← back to Interiordesignershowroom
Frontend refinement: consistency + edge/empty states + mobile + a11y + loading states across storefront and Room Builder
8d27aa683ee87e7270a82300a5148681c3a2ff0d · 2026-08-01 22:02:20 -0700 · Steve Abrams
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
M lib/render.jsM public/css/site.cssM public/js/build.jsM server.js
Diff
commit 8d27aa683ee87e7270a82300a5148681c3a2ff0d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 22:02:20 2026 -0700
Frontend refinement: consistency + edge/empty states + mobile + a11y + loading states across storefront and Room Builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
lib/render.js | 49 ++--
public/css/site.css | 643 ++++++++++++++++++++++++++++++++++++++++------------
public/js/build.js | 64 ++++--
server.js | 151 ++++++++----
4 files changed, 688 insertions(+), 219 deletions(-)
diff --git a/lib/render.js b/lib/render.js
index f5be283..a848144 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -3,7 +3,7 @@
// <title>/meta/canonical/JSON-LD over JS-hydrated shells.
const SITE = {
- name: 'Interior Designer’s Showroom',
+ name: 'Interior Designer\'s Showroom',
tagline: 'A curated showroom of designer-worthy pieces — shop the look, room by room.',
url: process.env.PUBLIC_URL || 'https://interiordesignershowroom.com',
};
@@ -12,6 +12,8 @@ const esc = (s = '') => String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"');
+// Returns a formatted "$X,XXX.XX" string or empty string if value is null/undefined.
+// Callers MUST check for empty string and hide the price element when blank.
const money = (n) => (n == null ? '' : '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
// FTC 16 CFR Part 255 requires a clear, conspicuous disclosure. This is not optional.
@@ -23,19 +25,35 @@ function disclosureBar() {
}
function productCard(p) {
- const price = p.sale_price
- ? `<span class="price sale">${money(p.sale_price)}</span> <span class="price was">${money(p.price)}</span>`
- : `<span class="price">${money(p.price)}</span>`;
+ // Price block: hide entirely when there's no price (not "$"), sale shows red + struck through original
+ let priceHtml = '';
+ if (p.sale_price != null) {
+ priceHtml = `<span class="price sale">${money(p.sale_price)}</span><span class="price was">${money(p.price)}</span>`;
+ } else if (p.price != null) {
+ priceHtml = `<span class="price">${money(p.price)}</span>`;
+ }
+ // card-meta is only rendered when there is a price; empty div suppressed by CSS display:none
+ const metaHtml = priceHtml
+ ? `<div class="card-meta">${priceHtml}</div>`
+ : '';
+
+ // Image: use img with explicit width/height for aspect-ratio + graceful broken-img handling
+ const imgHtml = p.image_url
+ ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}" onerror="this.style.display='none';this.parentElement.classList.add('noimg-fallback')">`
+ : '';
+
return `
<article class="card" data-room="${esc(p.room)}" data-style="${esc(p.style)}" data-color="${esc(p.color)}"
- data-price="${p.sale_price ?? p.price ?? 0}" data-title="${esc(p.title)}" data-network="${esc(p.network)}">
- <a class="card-img" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">
- ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : `<div class="noimg">${esc(p.advertiser || '')}</div>`}
+ data-price="${p.sale_price ?? p.price ?? 0}" data-title="${esc(p.title)}" data-network="${esc(p.network)}"
+ data-id="${p.id}">
+ <a class="card-img" href="/go/${p.id}" rel="nofollow sponsored" target="_blank" tabindex="0"
+ aria-label="${esc(p.title)} — shop at ${esc(p.advertiser || p.network || '')}">
+ ${imgHtml || `<div class="noimg">${esc(p.advertiser || '')}</div>`}
</a>
<div class="card-body">
- <div class="card-brand">${esc(p.brand || p.advertiser || '')}</div>
+ ${p.brand || p.advertiser ? `<div class="card-brand">${esc(p.brand || p.advertiser || '')}</div>` : ''}
<h3 class="card-title"><a href="/go/${p.id}" rel="nofollow sponsored" target="_blank">${esc(p.title)}</a></h3>
- <div class="card-meta">${price}</div>
+ ${metaHtml}
<a class="buy" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(p.advertiser || p.network)} →</a>
</div>
</article>`;
@@ -48,7 +66,7 @@ function layout({ title, description, canonical, jsonld, body, activeNav }) {
['/build', 'Build a Room'],
['/guides', 'Guides'],
].map(([href, label]) =>
- `<a href="${href}"${activeNav === href ? ' class="active"' : ''}>${label}</a>`).join('');
+ `<a href="${href}"${activeNav === href ? ' class="active" aria-current="page"' : ''}>${label}</a>`).join('');
return `<!doctype html>
<html lang="en">
@@ -75,12 +93,15 @@ ${jsonld ? `<script type="application/ld+json">${JSON.stringify(jsonld)}</script
<body>
${disclosureBar()}
<header class="site-header">
- <a class="brand" href="/"><span class="brand-mark">IDS</span><span class="brand-word">Interior Designer’s Showroom</span></a>
- <nav class="site-nav">${nav}</nav>
+ <a class="brand" href="/" aria-label="${esc(SITE.name)} — home">
+ <span class="brand-mark" aria-hidden="true">IDS</span>
+ <span class="brand-word">${esc(SITE.name)}</span>
+ </a>
+ <nav class="site-nav" aria-label="Main navigation">${nav}</nav>
</header>
-<main>${body}</main>
+<main id="main-content">${body}</main>
<footer class="site-footer">
- <nav style="display:flex;justify-content:center;gap:32px;margin-bottom:24px;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase">
+ <nav aria-label="Footer navigation">
<a href="/shop">Shop</a>
<a href="/looks">Rooms</a>
<a href="/build">Build</a>
diff --git a/public/css/site.css b/public/css/site.css
index 39816c5..a0419f4 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -1,6 +1,7 @@
/* =========================================================
Interior Designer's Showroom — Editorial Design System
Target: steelcase.com caliber — clean, confident, premium
+ Refinement pass: consistency + edge states + mobile + a11y
========================================================= */
/* ---- Google Fonts (preconnect in layout, one family max) ---- */
@@ -47,9 +48,16 @@ body {
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
-button { font-family: var(--sans); }
+button { font-family: var(--sans); cursor: pointer; }
h1, h2, h3 { line-height: 1.2; }
+/* a11y: visible focus rings on all interactive elements */
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+ border-radius: 2px;
+}
+
/* ---- Main container ---- */
main { max-width: var(--max-w); margin: 0 auto; padding: 0 var(--pad) 96px; }
section { margin: 64px 0; }
@@ -105,6 +113,7 @@ section { margin: 64px 0; }
display: grid;
place-items: center;
border-radius: 2px;
+ flex-shrink: 0;
}
.brand-word {
font-family: var(--serif);
@@ -112,6 +121,10 @@ section { margin: 64px 0; }
font-weight: 400;
letter-spacing: .01em;
color: var(--ink);
+ /* prevent long name from crushing nav */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.site-nav {
@@ -121,12 +134,14 @@ section { margin: 64px 0; }
font-size: .84rem;
letter-spacing: .06em;
text-transform: uppercase;
+ flex-shrink: 0;
}
.site-nav a {
color: var(--muted);
transition: color var(--dur);
padding-bottom: 2px;
border-bottom: 1px solid transparent;
+ white-space: nowrap;
}
.site-nav a:hover { color: var(--ink); border-bottom-color: var(--ink); }
.site-nav a.active { color: var(--ink); border-bottom-color: var(--accent); }
@@ -177,8 +192,17 @@ section { margin: 64px 0; }
text-transform: uppercase;
font-weight: 500;
transition: background var(--dur) var(--ease), color var(--dur);
+ border: none;
+ cursor: pointer;
+}
+.cta:hover { background: var(--accent); color: #faf8f5; }
+.cta:disabled,
+.cta[disabled] {
+ background: var(--muted-lt);
+ color: var(--bg);
+ cursor: not-allowed;
+ pointer-events: none;
}
-.cta:hover { background: var(--accent); }
/* =========================================================
SECTION HEADINGS — refined editorial style
@@ -246,6 +270,7 @@ h2.ruled::after {
background: var(--ink);
color: #faf8f5;
}
+.room-tile span { pointer-events: none; }
/* =========================================================
GRID CONTROLS — refined
@@ -286,6 +311,14 @@ h2.ruled::after {
background-position: right 10px center;
padding-right: 28px;
}
+/* Count badge for grid results */
+.grid-count {
+ margin-left: auto;
+ font-size: .72rem;
+ color: var(--muted-lt);
+ letter-spacing: .04em;
+ text-transform: none;
+}
/* =========================================================
PRODUCT GRID — image-forward, no chrome
@@ -295,6 +328,23 @@ h2.ruled::after {
grid-template-columns: repeat(auto-fill, minmax(var(--cols), 1fr));
gap: 2px;
}
+/* Empty grid state */
+.grid-empty {
+ grid-column: 1/-1;
+ padding: 64px 0;
+ text-align: center;
+ color: var(--muted);
+ font-size: .96rem;
+}
+.grid-empty strong {
+ display: block;
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 1.4rem;
+ margin-bottom: 10px;
+ color: var(--ink-80);
+}
+
.card {
background: var(--card);
overflow: hidden;
@@ -309,17 +359,26 @@ h2.ruled::after {
box-shadow: 0 12px 40px rgba(22, 19, 15, .09);
z-index: 1;
}
+/* Consistent 4/5 aspect, no layout shift */
.card-img {
aspect-ratio: 4/5;
background: var(--bg-warm);
display: block;
overflow: hidden;
+ position: relative;
}
.card-img img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .5s var(--ease);
+ /* prevent broken-img icon from showing a tiny broken box */
+ min-height: 100%;
+}
+/* Broken image fallback: hide the broken icon, show warm bg */
+.card-img img[src=""],
+.card-img img:not([src]) {
+ visibility: hidden;
}
.card:hover .card-img img { transform: scale(1.03); }
.noimg {
@@ -331,6 +390,7 @@ h2.ruled::after {
font-size: .75rem;
letter-spacing: .06em;
text-transform: uppercase;
+ background: var(--bg-warm);
}
.card-body {
padding: 16px 18px 20px;
@@ -351,6 +411,11 @@ h2.ruled::after {
font-size: .9rem;
font-weight: 400;
margin: 0;
+ /* clamp to 2 lines — no overflow on long titles */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
line-height: 1.4;
color: var(--ink-80);
}
@@ -358,7 +423,11 @@ h2.ruled::after {
.card-meta {
margin-top: auto;
padding-top: 8px;
+ /* hide entire price block when empty — no bare "$" */
+ min-height: 1.4em;
}
+/* null price: .card-meta stays collapsed, not blank line */
+.card-meta:empty { display: none; }
.price {
font-size: .88rem;
font-weight: 500;
@@ -371,6 +440,7 @@ h2.ruled::after {
color: var(--muted-lt);
font-weight: 400;
font-size: .8rem;
+ margin-left: 4px;
}
.buy {
display: inline-block;
@@ -425,6 +495,13 @@ h2.ruled::after {
font-size: .88rem;
line-height: 1.6;
}
+/* Guides empty state */
+.guide-grid > p {
+ padding: 48px 0;
+ color: var(--muted);
+ font-size: .96rem;
+ grid-column: 1/-1;
+}
/* =========================================================
GUIDE ARTICLE
@@ -447,7 +524,6 @@ h2.ruled::after {
margin: 40px 0 12px;
letter-spacing: -.01em;
text-transform: none;
- letter-spacing: normal;
}
.guide-body h2::after { display: none; }
.guide-body h3 { font-family: var(--sans); font-weight: 600; font-size: 1rem; margin: 28px 0 8px; }
@@ -516,14 +592,10 @@ h2.ruled::after {
text-transform: uppercase;
color: var(--ink-80);
transition: border-color var(--dur), color var(--dur);
+ cursor: pointer;
}
.chip:hover { border-color: var(--accent); color: var(--accent); }
.chip.clear { font-weight: 600; }
-@media (max-width: 840px) {
- .catalog { grid-template-columns: 1fr; }
- .facets { position: static; flex-flow: row wrap; }
- .facet-group { min-width: 140px; }
-}
/* =========================================================
FOOTER — quiet editorial
@@ -537,6 +609,16 @@ h2.ruled::after {
line-height: 1.7;
letter-spacing: .02em;
}
+.site-footer nav {
+ display: flex;
+ justify-content: center;
+ gap: 32px;
+ margin-bottom: 24px;
+ font-size: .72rem;
+ letter-spacing: .1em;
+ text-transform: uppercase;
+ flex-wrap: wrap;
+}
.site-footer a { border-bottom: 1px solid var(--line); transition: border-color var(--dur), color var(--dur); padding-bottom: 1px; }
.site-footer a:hover { color: var(--ink); border-bottom-color: var(--ink); }
.site-footer .fine {
@@ -548,6 +630,141 @@ h2.ruled::after {
color: var(--muted-lt);
}
+/* =========================================================
+ LOOKS GALLERY + ROOM PAGE
+ ========================================================= */
+.look-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 2px;
+}
+/* Empty looks state */
+.look-grid > p {
+ padding: 64px 0;
+ color: var(--muted);
+ grid-column: 1/-1;
+}
+.look-card {
+ background: var(--card);
+ border: 1px solid var(--line-lt);
+ overflow: hidden;
+ display: block;
+ transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
+}
+.look-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 32px rgba(22,19,15,.08);
+}
+.look-thumbs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ aspect-ratio: 1/1;
+ background: var(--line);
+ overflow: hidden;
+}
+.look-thumbs img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ /* no layout shift: grid already constrains size */
+}
+/* Look card with only 1 thumb */
+.look-thumbs img:only-child { grid-column: 1/-1; }
+/* Look card with no thumbs */
+.look-thumbs .noimg {
+ grid-column: 1/-1;
+ aspect-ratio: 1/1;
+ height: 100%;
+ min-height: 140px;
+}
+.look-card h3 {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 1.05rem;
+ margin: 14px 16px 4px;
+ /* clamp long room titles */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.look-card p { margin: 0 16px 16px; font-size: .82rem; color: var(--muted); }
+
+/* =========================================================
+ ROOM PAGE — editorial "shop this room"
+ ========================================================= */
+.room-page {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding-top: 24px;
+}
+.room-head {
+ margin-bottom: 4px;
+}
+.room-head h1 {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(2rem, 3.5vw, 3rem);
+ letter-spacing: -.02em;
+ margin: 0 0 6px;
+ color: var(--ink);
+}
+.room-scene {
+ width: 100%;
+ border-radius: 3px;
+ margin: 20px 0;
+ /* hero-caliber display for the AI render */
+ aspect-ratio: 16/9;
+ object-fit: cover;
+ display: block;
+}
+.room-paint {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ background: var(--bg-warm);
+ border: 1px solid var(--line);
+ border-radius: 3px;
+ padding: 14px 18px;
+ margin: 20px 0;
+ max-width: 440px;
+}
+.room-paint img {
+ width: 72px;
+ height: 72px;
+ object-fit: cover;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+.room-paint-info { display: flex; flex-direction: column; gap: 4px; }
+.room-paint-label { font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
+.room-paint-name { font-size: .9rem; font-weight: 500; color: var(--ink-80); }
+/* Shop this room heading — use editorial serif */
+.room-page > h2.shop-heading {
+ font-family: var(--sans);
+ font-size: .72rem;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin: 32px 0 20px;
+}
+.room-page > h2.shop-heading::after {
+ content: '';
+ display: block;
+ height: 1px;
+ background: var(--line);
+ margin-top: 12px;
+}
+/* 0 pieces state */
+.room-empty {
+ padding: 48px 0;
+ text-align: center;
+ color: var(--muted);
+}
+.room-cta { text-align: center; margin: 48px 0 24px; }
+.room-cta .cta { display: inline-block; }
+
/* =========================================================
ROOM BUILDER — tray / left tabs / mid gallery / right panel
Preserve all existing layout logic; only elevate aesthetics
@@ -616,6 +833,7 @@ h2.ruled::after {
gap: 6px;
max-height: 150px;
overflow: auto;
+ padding: 10px 12px 12px;
}
.paint {
width: 34px;
@@ -626,7 +844,9 @@ h2.ruled::after {
overflow: hidden;
cursor: pointer;
background: none;
+ transition: border-color var(--dur), transform var(--dur);
}
+.paint:hover { transform: scale(1.08); }
.paint img { width: 100%; height: 100%; object-fit: cover; }
.paint.sel { border-color: var(--ink); }
.builder-save {
@@ -685,56 +905,6 @@ h2.ruled::after {
}
.bitem-btn:hover { border-color: var(--accent); color: var(--accent); }
-/* =========================================================
- LOOKS GALLERY + ROOM PAGE
- ========================================================= */
-.look-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- gap: 2px;
-}
-.look-card {
- background: var(--card);
- border: 1px solid var(--line-lt);
- overflow: hidden;
- display: block;
- transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
-}
-.look-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 32px rgba(22,19,15,.08);
-}
-.look-thumbs {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 1px;
- aspect-ratio: 1/1;
- background: var(--line);
-}
-.look-thumbs img { width: 100%; height: 100%; object-fit: cover; }
-.look-card h3 {
- font-family: var(--serif);
- font-weight: 400;
- font-size: 1.05rem;
- margin: 14px 16px 4px;
-}
-.look-card p { margin: 0 16px 16px; font-size: .82rem; color: var(--muted); }
-.room-page { max-width: 1100px; margin: 0 auto; }
-.room-scene { width: 100%; border-radius: 3px; margin: 16px 0; }
-.room-paint {
- display: flex;
- gap: 16px;
- align-items: center;
- background: var(--bg-warm);
- border: 1px solid var(--line);
- border-radius: 3px;
- padding: 14px;
- margin: 16px 0;
- max-width: 440px;
-}
-.room-paint img { width: 72px; height: 72px; object-fit: cover; border-radius: 3px; }
-.room-cta { text-align: center; margin: 40px 0; }
-
/* =========================================================
ROOM BUILDER v2 — tray + left tabs + mid render + right products
Preserve all layout / js hooks; polish aesthetics
@@ -753,15 +923,26 @@ h2.ruled::after {
border-radius: 3px;
flex-wrap: wrap;
}
-.rb-tray select, .rb-tray input {
+.rb-tray select, .rb-tray input[type="text"] {
padding: 7px 11px;
- border: 1px solid rgba(255,255,255,.12);
+ border: 1px solid rgba(255,255,255,.18);
border-radius: 2px;
- background: rgba(255,255,255,.07);
+ background: rgba(255,255,255,.08);
color: #f0ebe3;
font-size: .84rem;
font-family: var(--sans);
-}
+ transition: border-color var(--dur);
+}
+.rb-tray select:hover,
+.rb-tray input[type="text"]:hover { border-color: rgba(255,255,255,.32); }
+.rb-tray select:focus,
+.rb-tray input[type="text"]:focus {
+ outline: none;
+ border-color: #c9a96e;
+ box-shadow: 0 0 0 2px rgba(201,169,110,.25);
+}
+/* Style the select option dropdown text for readability */
+.rb-tray select option { background: var(--ink-80); color: #f0ebe3; }
.rb-tray.drop { outline: 2px dashed #c9a96e; }
.rb-chips {
flex: 1;
@@ -771,7 +952,7 @@ h2.ruled::after {
min-height: 30px;
align-items: center;
}
-.rb-hint { color: rgba(240,235,227,.5); font-size: .78rem; }
+.rb-hint { color: rgba(240,235,227,.5); font-size: .78rem; font-style: italic; }
.rb-chip {
display: inline-flex;
align-items: center;
@@ -784,7 +965,9 @@ h2.ruled::after {
cursor: pointer;
text-transform: capitalize;
letter-spacing: .03em;
+ transition: background var(--dur);
}
+.rb-chip:hover { background: rgba(255,255,255,.18); }
.rb-chip.prod {
background: rgba(255,255,255,.14);
padding: 3px 10px 3px 3px;
@@ -797,6 +980,7 @@ h2.ruled::after {
object-fit: cover;
border-radius: 3px;
}
+/* GO button in tray */
.rb-tray .cta {
background: #c9a96e;
color: var(--ink);
@@ -804,8 +988,18 @@ h2.ruled::after {
padding: 9px 24px;
letter-spacing: .08em;
border-radius: 2px;
+ white-space: nowrap;
}
.rb-tray .cta:hover { background: #d4b87e; }
+.rb-tray .cta:disabled { background: rgba(201,169,110,.5); cursor: not-allowed; pointer-events: none; }
+
+/* Title input in tray */
+.rb-tray input#rbTitle {
+ min-width: 160px;
+ max-width: 240px;
+}
+/* Room select */
+.rb-tray select#rbRoom { min-width: 120px; }
.rb-body {
display: grid;
@@ -833,7 +1027,10 @@ h2.ruled::after {
justify-content: space-between;
align-items: center;
letter-spacing: .02em;
+ user-select: none;
+ transition: background var(--dur);
}
+.rb-tab > summary:hover { background: var(--bg-warm); }
.rb-tab > summary::-webkit-details-marker { display: none; }
.rb-tab > summary::after { content: '+'; color: var(--muted-lt); font-size: 1rem; font-weight: 300; }
.rb-tab[open] > summary::after { content: '–'; }
@@ -854,8 +1051,18 @@ h2.ruled::after {
.rb-opt:hover { border-color: var(--accent); }
.rb-opt.sel { background: var(--ink); color: #faf8f5; border-color: var(--ink); }
.rb-opt.trend { background: var(--bg-warm); font-weight: 500; }
+.rb-opt.trend:hover { border-color: var(--accent); background: #ede8e0; }
.rb-mid { min-height: 320px; }
+/* Render bar: Generate button + cost */
+.rb-render-bar {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+.rb-cost { font-size: .72rem; color: var(--muted); letter-spacing: .03em; font-style: italic; }
.rb-render {
background: var(--bg-warm);
border: 1px solid var(--line);
@@ -874,6 +1081,88 @@ h2.ruled::after {
font-size: 1.05rem;
line-height: 1.7;
}
+
+/* ---- rb-renders: responsive gallery grid for AI room cards ---- */
+.rb-renders {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 12px;
+ min-height: 260px;
+}
+/* Shared placeholder — shown before any generation AND in .rb-renders */
+.rb-renders > .rb-render-ph,
+.rb-render-ph {
+ grid-column: 1/-1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 260px;
+ color: var(--muted);
+ text-align: center;
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 1.05rem;
+ background: var(--bg-warm);
+ border: 1px solid var(--line);
+ border-radius: 3px;
+ padding: 28px;
+ line-height: 1.7;
+}
+
+/* ---- rb-gen: individual AI room card ---- */
+.rb-gen {
+ aspect-ratio: 4/3;
+ border-radius: 3px;
+ overflow: hidden;
+ border: 2px solid transparent;
+ background: var(--bg-warm);
+ position: relative;
+ cursor: pointer;
+ transition: border-color .18s, box-shadow .18s;
+}
+.rb-gen img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.rb-gen:hover { box-shadow: 0 8px 28px rgba(22,19,15,.14); }
+.rb-gen.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
+/* Loading state: pulsing warm bg + spinner text */
+.rb-gen.loading {
+ background: var(--bg-warm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: pulse-bg 2s ease-in-out infinite;
+}
+@keyframes pulse-bg {
+ 0%, 100% { background: var(--bg-warm); }
+ 50% { background: #ebe5da; }
+}
+
+.rb-gen-spin {
+ text-align: center;
+ color: var(--muted);
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: .92rem;
+ line-height: 1.7;
+}
+.rb-gen-spin small { font-size: .76rem; display: block; margin-top: 4px; }
+
+/* Spinner ring for "Painting…" */
+.rb-gen-spin::before {
+ content: '';
+ display: block;
+ width: 28px;
+ height: 28px;
+ border: 2px solid var(--line);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ margin: 0 auto 12px;
+ animation: spin .8s linear infinite;
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.rb-scene-img { width: 100%; border-radius: 3px; display: block; }
.rb-scene { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 8px; width: 100%; }
.rb-scene.painted { padding: 16px; border-radius: 3px; }
.rb-scene-item {
@@ -895,7 +1184,7 @@ h2.ruled::after {
right: 0;
height: 100vh;
width: 340px;
- z-index: 200;
+ z-index: 200; /* above sticky header z-100 */
background: var(--card);
border-left: 1px solid var(--line);
box-shadow: -8px 0 40px rgba(22, 19, 15, .12);
@@ -919,6 +1208,7 @@ h2.ruled::after {
text-transform: uppercase;
flex-shrink: 0;
}
+/* Close button — ✕ icon, needs aria-label in HTML */
.rb-ham-close {
background: none;
border: none;
@@ -929,9 +1219,15 @@ h2.ruled::after {
padding: 5px 7px;
border-radius: 2px;
transition: background var(--dur), color var(--dur);
+ /* ensure tappable on mobile */
+ min-width: 32px;
+ min-height: 32px;
+ display: grid;
+ place-items: center;
}
.rb-ham-close:hover { color: var(--ink); background: var(--bg-warm); }
-.rb-right > input {
+/* Search input inside panel */
+.rb-right > input[type="search"] {
width: calc(100% - 32px);
margin: 13px 16px 10px;
padding: 9px 12px;
@@ -942,15 +1238,28 @@ h2.ruled::after {
flex-shrink: 0;
color: var(--ink);
background: var(--bg);
+ transition: border-color var(--dur);
+}
+.rb-right > input[type="search"]:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(155,123,80,.15);
}
.rb-products {
display: grid;
- grid-template-columns: 1fr; /* single image-forward column */
+ grid-template-columns: 1fr;
gap: 14px;
flex: 1;
overflow-y: auto;
padding: 0 16px 16px;
}
+/* Loading state inside products panel */
+.rb-products > .empty {
+ padding: 32px 0;
+ text-align: center;
+ font-style: italic;
+ color: var(--muted);
+}
.rbp {
background: var(--card);
border: 1px solid var(--line-lt);
@@ -965,15 +1274,24 @@ h2.ruled::after {
.rbp:hover { box-shadow: 0 6px 20px rgba(22,19,15,.12); transform: translateY(-2px); }
.rbp:active { cursor: grabbing; }
.rbp > img, .rbp .noimg {
- aspect-ratio: 4/3; /* big product photo */
+ aspect-ratio: 4/3;
object-fit: cover;
width: 100%;
background: var(--bg-warm);
display: block;
}
-/* the whole card is clickable to add; the button just labels it */
-.rbp::after { content: ''; }
-.rbp-t { font-size: .74rem; font-weight: 500; line-height: 1.25; padding: 7px 8px 3px; color: var(--ink-80); }
+.rbp-t {
+ font-size: .74rem;
+ font-weight: 500;
+ line-height: 1.25;
+ padding: 7px 8px 3px;
+ color: var(--ink-80);
+ /* clamp to 2 lines in the narrow panel */
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
.rbp-m { font-size: .68rem; color: var(--muted); padding: 0 8px; }
.rbp-add {
margin: 6px 8px 8px;
@@ -985,9 +1303,16 @@ h2.ruled::after {
cursor: pointer;
font-family: var(--sans);
letter-spacing: .04em;
- transition: border-color var(--dur), color var(--dur);
+ transition: border-color var(--dur), color var(--dur), background var(--dur);
}
.rbp-add:hover { border-color: var(--accent); color: var(--accent); }
+/* "Added" state — confirmed green-tinted */
+.rbp-add[data-added="true"],
+.rbp-add:contains("✓ Added") {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: rgba(155,123,80,.07);
+}
/* ---- rb-ham hamburger pill ---- */
.rb-ham {
@@ -1011,71 +1336,16 @@ h2.ruled::after {
box-shadow: 0 4px 20px rgba(22,19,15,.22);
transition: opacity .2s, background .2s;
font-family: var(--sans);
+ white-space: nowrap;
}
.rb-ham:hover { background: var(--accent); }
.rb-right:not(.collapsed) ~ .rb-ham,
body:has(.rb-right:not(.collapsed)) .rb-ham { opacity: 0; pointer-events: none; }
-/* ---- rb-renders: responsive gallery grid for AI room cards ---- */
-.rb-renders {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- gap: 12px;
- min-height: 260px;
-}
-.rb-render-ph {
- grid-column: 1/-1;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 260px;
- color: var(--muted);
- text-align: center;
- font-family: var(--serif);
- font-weight: 400;
- font-size: 1.05rem;
- background: var(--bg-warm);
- border: 1px solid var(--line);
- border-radius: 3px;
- padding: 28px;
- line-height: 1.7;
-}
-
-/* ---- rb-gen: individual AI room card ---- */
-.rb-gen {
- aspect-ratio: 4/3;
- border-radius: 3px;
- overflow: hidden;
- border: 2px solid transparent;
- background: var(--bg-warm);
- position: relative;
- cursor: pointer;
- transition: border-color .18s, box-shadow .18s;
-}
-.rb-gen img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.rb-gen:hover { box-shadow: 0 8px 28px rgba(22,19,15,.14); }
-.rb-gen.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
-.rb-gen.loading { background: var(--bg-warm); display: flex; align-items: center; justify-content: center; }
-
-.rb-gen-spin {
- text-align: center;
- color: var(--muted);
- font-family: var(--serif);
- font-weight: 400;
- font-size: .92rem;
- line-height: 1.7;
-}
-.rb-gen-spin small { font-size: .76rem; }
-
-/* ---- render bar ---- */
-.rb-render-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
-.rb-cost { font-size: .72rem; color: var(--muted); letter-spacing: .03em; }
-.rb-scene-img { width: 100%; border-radius: 3px; display: block; }
-
/* ---- product popup ---- */
.rb-pop {
position: fixed;
- z-index: 100;
+ z-index: 300; /* above rb-right */
width: 252px;
background: var(--card);
border: 1px solid var(--line);
@@ -1101,9 +1371,9 @@ body:has(.rb-right:not(.collapsed)) .rb-ham { opacity: 0; pointer-events: none;
cursor: pointer;
padding: 0;
position: relative;
- transition: transform var(--dur);
+ transition: transform var(--dur), box-shadow var(--dur);
}
-.rb-swatch:hover { transform: scale(1.1); }
+.rb-swatch:hover { transform: scale(1.15); }
.rb-swatch.sel { box-shadow: 0 0 0 2px var(--ink); }
.rb-swatch.sel::after {
content: '✓';
@@ -1121,41 +1391,126 @@ body:has(.rb-right:not(.collapsed)) .rb-ham { opacity: 0; pointer-events: none;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 1px rgba(255,255,255,.35);
+ flex-shrink: 0;
+}
+
+/* Room pieces grid (below the generated render) */
+.rb-pieces-h {
+ margin: 26px 0 12px;
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 1.05rem;
+}
+.rb-pieces {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 14px;
+}
+.rb-piece {
+ background: var(--card);
+ border: 1px solid var(--line-lt);
+ border-radius: 3px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition: box-shadow .2s, transform .2s;
+ text-decoration: none;
+ color: inherit;
+}
+.rb-piece:hover { box-shadow: 0 6px 20px rgba(22,19,15,.1); transform: translateY(-2px); }
+.rb-piece img {
+ aspect-ratio: 1/1;
+ object-fit: cover;
+ width: 100%;
+ background: var(--bg-warm);
+}
+.rb-piece-b { padding: 8px 9px 10px; }
+.rb-piece-t {
+ font-size: .78rem;
+ font-weight: 500;
+ line-height: 1.25;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
+.rb-piece-m { font-size: .72rem; color: var(--muted); margin-top: 3px; }
/* =========================================================
RESPONSIVE
========================================================= */
+
+/* 980px: builder collapses to single column */
@media (max-width: 980px) {
.rb-body { grid-template-columns: 1fr; }
- .rb-left { position: static; }
- .rb-right { width: 100%; border-left: none; border-top: 1px solid var(--line); }
- .rb-products { grid-template-columns: 1fr; }
+ .rb-left { position: static; flex-direction: row; flex-wrap: wrap; gap: 8px; }
+ .rb-tab { min-width: 140px; flex: 1 1 140px; }
+ /* Products panel goes full-width overlay on mobile */
+ .rb-right {
+ width: 100%;
+ border-left: none;
+ border-top: 1px solid var(--line);
+ }
+ .rb-products { grid-template-columns: 1fr 1fr; }
.rb-ham { top: 14px; right: 14px; padding: 9px 16px; font-size: .74rem; }
}
+
+/* 840px: catalog facets become horizontal scroll */
@media (max-width: 840px) {
.catalog { grid-template-columns: 1fr; }
.facets { position: static; flex-flow: row wrap; }
.facet-group { min-width: 140px; }
}
+
+/* 820px: header & main layout */
+@media (max-width: 820px) {
+ :root { --pad: 18px; }
+ .site-header { padding: 0 var(--pad); }
+ /* On small screens, brand word can shrink but not hide */
+ .brand-word { font-size: .92rem; }
+ /* Tray wraps — each row stays legible */
+ .rb-tray { gap: 8px; }
+ .rb-tray select#rbRoom { min-width: 100px; }
+ .rb-tray input#rbTitle { min-width: 110px; max-width: 180px; }
+}
+
+/* 760px: builder side-by-side collapses */
@media (max-width: 760px) {
.builder-grid { grid-template-columns: 1fr; }
.builder-controls { position: static; top: auto; }
:root { --pad: 16px; }
}
+
+/* 640px: mobile header, hero */
@media (max-width: 640px) {
- .site-nav { gap: 18px; font-size: .76rem; }
+ .site-nav { gap: 16px; font-size: .75rem; }
.brand-word { display: none; }
- .hero { padding: 72px 24px 64px; }
+ .hero { padding: 64px 20px 56px; }
.hero h1 { font-size: 2.2rem; }
+ .tagline { font-size: .96rem; }
+ .cta { padding: 12px 24px; }
+ /* Room page single-column */
+ .room-page { padding-top: 16px; }
+ .room-paint { flex-direction: column; align-items: flex-start; max-width: 100%; }
+ .room-paint img { width: 100%; height: 160px; }
+ /* Looks grid single column */
+ .look-grid { grid-template-columns: 1fr; }
+ /* Pieces grid in room builder */
+ .rb-pieces { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
+ /* Products panel 1 col */
+ .rb-products { grid-template-columns: 1fr; }
+ /* Tray stacks */
+ .rb-tray { flex-direction: column; align-items: stretch; }
+ .rb-tray select,
+ .rb-tray input { width: 100%; }
+ .rb-tray .cta { width: 100%; text-align: center; }
}
-/* Room pieces grid (below the generated render) */
-.rb-pieces-h{margin:26px 0 12px;font-family:var(--serif);font-weight:400;font-size:1.05rem}
-.rb-pieces{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
-.rb-piece{background:var(--card);border:1px solid var(--line-lt);border-radius:3px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .2s,transform .2s}
-.rb-piece:hover{box-shadow:0 6px 20px rgba(22,19,15,.1);transform:translateY(-2px)}
-.rb-piece img{aspect-ratio:1/1;object-fit:cover;width:100%;background:var(--bg-warm)}
-.rb-piece-b{padding:8px 9px 10px}
-.rb-piece-t{font-size:.78rem;font-weight:500;line-height:1.25}
-.rb-piece-m{font-size:.72rem;color:var(--muted);margin-top:3px}
+/* 480px: very small phones */
+@media (max-width: 480px) {
+ section { margin: 40px 0; }
+ .room-grid { grid-template-columns: 1fr 1fr; }
+ .rb-renders { grid-template-columns: 1fr; }
+ /* Keep guide grid readable */
+ .guide-grid { grid-template-columns: 1fr; }
+}
diff --git a/public/js/build.js b/public/js/build.js
index 465daf1..1892f18 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -19,11 +19,16 @@
function card(p) {
lastRows[p.id] = p;
- return '<div class="rbp" data-id="' + p.id + '" draggable="true">' +
- (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="">' : '<div class="noimg"></div>') +
- '<div class="rbp-t">' + (p.title || '').slice(0, 40) + '</div>' +
- '<div class="rbp-m">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div>' +
- '<button class="rbp-add" data-id="' + p.id + '">' + (board[p.id] ? '✓ Added' : '+ Add') + '</button></div>';
+ var priceStr = (p.sale_price != null ? money(p.sale_price) : money(p.price));
+ var metaStr = [priceStr, (p.advertiser || p.brand || '')].filter(Boolean).join(' · ');
+ var imgHtml = p.image_url
+ ? '<img loading="lazy" src="' + p.image_url + '" alt="' + (p.title || '').replace(/"/g, '') + '" onerror="this.style.display=\'none\'">'
+ : '<div class="noimg"></div>';
+ return '<div class="rbp" data-id="' + p.id + '" draggable="true" tabindex="0" role="button" aria-label="Add ' + (p.title || '').slice(0, 40).replace(/"/g, '') + '">' +
+ imgHtml +
+ '<div class="rbp-t">' + (p.title || '').slice(0, 50) + '</div>' +
+ '<div class="rbp-m">' + metaStr + '</div>' +
+ '<button class="rbp-add" data-id="' + p.id + '" type="button" aria-label="' + (board[p.id] ? 'Added to room' : 'Add to room') + '">' + (board[p.id] ? '✓ Added' : '+ Add') + '</button></div>';
}
function chips() {
@@ -90,9 +95,21 @@
var pt = e.target.closest('.paint'); if (pt) { Array.prototype.forEach.call(document.querySelectorAll('.paint'), function (x) { x.classList.remove('sel'); }); pt.classList.add('sel'); wallPaint = { id: pt.getAttribute('data-id') }; return; }
});
- // hamburger: open/close the right products panel
- $('rbHam').addEventListener('click', function () { $('rbRight').classList.remove('collapsed'); });
- $('rbHamClose').addEventListener('click', function () { $('rbRight').classList.add('collapsed'); });
+ // hamburger: open/close the right products panel; maintain aria-expanded state
+ $('rbHam').addEventListener('click', function () {
+ $('rbRight').classList.remove('collapsed');
+ $('rbHam').setAttribute('aria-expanded', 'true');
+ $('rbHamClose').setAttribute('aria-expanded', 'true');
+ // Move focus into panel for a11y
+ var si = $('rbSearch'); if (si) si.focus();
+ });
+ $('rbHamClose').addEventListener('click', function () {
+ $('rbRight').classList.add('collapsed');
+ $('rbHam').setAttribute('aria-expanded', 'false');
+ $('rbHamClose').setAttribute('aria-expanded', 'false');
+ // Return focus to the hamburger trigger
+ $('rbHam').focus();
+ });
// drag products into the tray
document.addEventListener('dragstart', function (e) { var c = e.target.closest('.rbp'); if (c) e.dataTransfer.setData('text/id', c.getAttribute('data-id')); });
@@ -108,15 +125,25 @@
$('rbRenderBtn').addEventListener('click', function () {
var btn = this, cost = $('rbCost'), g = $('rbRenders');
if (g.querySelector('.rb-render-ph')) g.innerHTML = '';
- var ph = document.createElement('div'); ph.className = 'rb-gen loading'; ph.innerHTML = '<div class="rb-gen-spin">🎨 Painting…<br><small>~10–20s</small></div>'; g.insertBefore(ph, g.firstChild);
- btn.disabled = true; btn.textContent = 'Generating…';
+ var ph = document.createElement('div');
+ ph.className = 'rb-gen loading';
+ ph.setAttribute('role', 'status');
+ ph.setAttribute('aria-label', 'Generating room setting…');
+ ph.innerHTML = '<div class="rb-gen-spin">Painting…<small>~10–20 seconds</small></div>';
+ g.insertBefore(ph, g.firstChild);
+ btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Generating…';
fetch('/api/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style: sel.style, color: Object.keys(swatches).join(' and '), theme: sel.theme, period: sel.period, room_type: room, wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
.then(function (r) { return r.json(); }).then(function (d) {
- btn.disabled = false; btn.textContent = '✨ Generate room setting';
- if (d.url) { Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (x) { x.classList.remove('sel'); }); ph.className = 'rb-gen sel'; ph.dataset.url = d.url; ph.innerHTML = '<img src="' + d.url + '" alt="room setting">'; lastScene = d.url; cost.textContent = 'last render $' + Number(d.cost).toFixed(3); }
- else { ph.remove(); cost.textContent = 'render failed: ' + (d.error || ''); }
- }).catch(function () { btn.disabled = false; btn.textContent = '✨ Generate room setting'; ph.remove(); });
+ btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Generate room setting';
+ if (d.url) {
+ Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (x) { x.classList.remove('sel'); });
+ ph.className = 'rb-gen sel'; ph.dataset.url = d.url;
+ ph.removeAttribute('role'); ph.removeAttribute('aria-label');
+ ph.innerHTML = '<img src="' + d.url + '" alt="AI-generated room setting">';
+ lastScene = d.url; cost.textContent = 'last render $' + Number(d.cost).toFixed(3);
+ } else { ph.remove(); cost.textContent = 'render failed: ' + (d.error || ''); }
+ }).catch(function () { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Generate room setting'; ph.remove(); });
});
// hover popup with item details
@@ -127,12 +154,15 @@
document.addEventListener('mouseout', function (e) { if (e.target.closest('.rbp,.card')) pop.style.display = 'none'; });
$('rbGo').addEventListener('click', function () {
- var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true;
+ var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true; go.setAttribute('aria-busy', 'true');
fetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: $('rbTitle').value.trim(), room_type: room, style: sel.style, color: (buckets()[0] || ''),
note: [sel.hue, sel.theme, sel.period].concat(Object.keys(swatches)).filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, product_ids: Object.keys(board).map(Number) }) })
- .then(function (r) { return r.json(); }).then(function (d) { if (d.url) window.location = d.url; else { go.textContent = 'GO ▶'; go.disabled = false; alert(d.error || 'Error'); } })
- .catch(function () { go.textContent = 'GO ▶'; go.disabled = false; });
+ .then(function (r) { return r.json(); }).then(function (d) {
+ if (d.url) window.location = d.url;
+ else { go.textContent = 'GO ▶'; go.disabled = false; go.removeAttribute('aria-busy'); alert(d.error || 'Error'); }
+ })
+ .catch(function () { go.textContent = 'GO ▶'; go.disabled = false; go.removeAttribute('aria-busy'); });
});
loadPaints(); loadBrands(); loadProducts(); chips();
diff --git a/server.js b/server.js
index b6dab21..6fa7bb8 100644
--- a/server.js
+++ b/server.js
@@ -32,11 +32,14 @@ function md(src = '') {
}
// ---- Sort + density controls (Steve's standing rule for every product grid) ----
-function gridControls() {
+function gridControls(productCount) {
+ const countBadge = productCount != null
+ ? `<span class="grid-count">${productCount} piece${productCount === 1 ? '' : 's'}</span>`
+ : '';
return `
- <div class="grid-controls">
+ <div class="grid-controls" role="toolbar" aria-label="Grid controls">
<label>Sort
- <select id="sortSel">
+ <select id="sortSel" aria-label="Sort products">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="style">Style</option>
@@ -47,12 +50,16 @@ function gridControls() {
</select>
</label>
<label class="density">Density
- <input id="densitySel" type="range" min="140" max="360" step="20" value="240">
+ <input id="densitySel" type="range" min="140" max="360" step="20" value="240" aria-label="Grid density">
</label>
+ ${countBadge}
</div>`;
}
function grid(products) {
+ if (!products.length) {
+ return `<div class="grid" id="grid"><div class="grid-empty"><strong>No pieces found</strong>Try adjusting your filters or <a href="/shop">browse everything</a>.</div></div>`;
+ }
return `<div class="grid" id="grid">${products.map(productCard).join('')}</div>`;
}
@@ -96,11 +103,10 @@ async function renderCatalog(res, { f, basePath, title, description, canonical,
const [products, counts] = await Promise.all([catalog.fetchProducts(f), catalog.facetCounts(f)]);
const body = `<section>
<h1>${esc(heading)}</h1>
- <p class="subtle">${products.length} piece${products.length === 1 ? '' : 's'}</p>
${catalog.activeChips(f, basePath)}
<div class="catalog">
${catalog.facetRail(f, counts, basePath)}
- <div class="catalog-main">${gridControls()}${grid(products)}</div>
+ <div class="catalog-main">${gridControls(products.length)}${grid(products)}</div>
</div>
</section><script src="/js/grid.js"></script>`;
res.send(layout({ title, description, canonical, body, activeNav }));
@@ -303,42 +309,57 @@ app.get('/build', (_req, res) => {
const cap = s => s.replace(/\b\w/g, c => c.toUpperCase());
const TRENDING = ['Quiet Luxury', 'Japandi', 'Mid-Century Modern', 'Modern Farmhouse', 'Coastal', 'Dark & Moody', 'Boho', 'Scandinavian', 'Maximalist'];
const CATS = ['Sofas', 'Sectionals', 'Chairs', 'Desks', 'Tables', 'Beds', 'Dressers', 'Bookcases', 'Rugs', 'Lighting', 'Mirrors', 'Wall Art', 'Vases', 'Bar Stools', 'Appliances'];
- const tab = (title, inner) => `<details class="rb-tab"><summary>${title}</summary><div class="rb-opts">${inner}</div></details>`;
- // left panel: ALL tabs start collapsed
+ const tab = (title, inner, id) =>
+ `<details class="rb-tab"${id ? ` id="${id}"` : ''}><summary>${title}</summary><div class="rb-opts">${inner}</div></details>`;
+ // left panel: ALL tabs start collapsed; Color tab has the paint-strip inline
const tabs =
- tab('🔥 Trending', TRENDING.map(t => `<button class="rb-opt trend" data-trend="${t}">${t}</button>`).join('')) +
+ tab('Trending', TRENDING.map(t => `<button class="rb-opt trend" data-trend="${t}" type="button">${t}</button>`).join('')) +
Object.entries(DIMENSIONS).map(([dim, vals]) => dim === 'color'
- ? tab('Color <span class="subtle">pick 1+</span>', COLORS_12.map(c => `<button class="rb-swatch" data-color="${c.bucket}" data-name="${c.name}" title="${c.name}" style="background:${c.hex}"></button>`).join(''))
- : tab(cap(dim), vals.map(v => `<button class="rb-opt" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
- tab('Categories', CATS.map(c => `<button class="rb-opt" data-cat="${c}">${c}</button>`).join('')) +
+ ? tab('Color <span class="subtle" aria-label="pick one or more">pick 1+</span>',
+ COLORS_12.map(c => `<button class="rb-swatch" type="button" data-color="${c.bucket}" data-name="${c.name}" title="${c.name}" aria-label="${c.name}" style="background:${c.hex}"></button>`).join(''))
+ : tab(cap(dim), vals.map(v => `<button class="rb-opt" type="button" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
+ tab('Categories', CATS.map(c => `<button class="rb-opt" type="button" data-cat="${c}">${c}</button>`).join('')) +
`<details class="rb-tab"><summary>Brand</summary><div class="rb-opts" id="rbBrands"><span class="empty">Loading…</span></div></details>`;
const body = `<section class="rb">
- <div class="rb-tray" id="rbTray">
- <select id="rbRoom" title="Room type"><option value="">Room…</option>${roomOpts}</select>
- <div class="rb-chips" id="rbChips"><span class="rb-hint">Pick a vibe on the left, drag products from the right →</span></div>
- <input id="rbTitle" type="text" placeholder="Name (optional)">
- <button id="rbGo" class="cta">GO ▶</button>
+ <div class="rb-tray" id="rbTray" role="toolbar" aria-label="Room builder controls">
+ <select id="rbRoom" aria-label="Room type"><option value="">Room…</option>${roomOpts}</select>
+ <div class="rb-chips" id="rbChips" role="list" aria-label="Selected filters and pieces">
+ <span class="rb-hint">Pick a vibe on the left — open <strong>Products</strong> to add pieces →</span>
+ </div>
+ <input id="rbTitle" type="text" placeholder="Room name (optional)" aria-label="Room name">
+ <button id="rbGo" class="cta" type="button" aria-label="Save and view this room">GO ▶</button>
</div>
<div class="rb-body">
- <aside class="rb-left">${tabs}
+ <aside class="rb-left" aria-label="Style filters">${tabs}
<details class="rb-tab"><summary>Wall color <span class="subtle">Samplize</span></summary>
- <div class="paint-strip" id="rbPaints"></div></details>
+ <div class="paint-strip" id="rbPaints" role="list" aria-label="Wall paint options"></div>
+ </details>
</aside>
<div class="rb-mid" id="rbMid">
<div class="rb-render-bar">
- <button id="rbRenderBtn" class="cta">✨ Generate room setting</button>
- <span class="rb-cost" id="rbCost">~$0.039 each · uses your vibe + selected pieces</span>
+ <button id="rbRenderBtn" class="cta" type="button" aria-label="Generate an AI room setting image">Generate room setting</button>
+ <span class="rb-cost" id="rbCost" aria-live="polite">~$0.039 each · uses your vibe + selected pieces</span>
+ </div>
+ <div class="rb-renders" id="rbRenders" role="list" aria-label="Generated room settings" aria-live="polite">
+ <div class="rb-render-ph">
+ <span>Your generated room settings appear here.</span><br>
+ <small>Pick a vibe on the left, open <strong>Products</strong> to add pieces, then hit <strong>Generate</strong>. Make several, pick your favorite, and hit <strong>GO</strong> to save.</small>
+ </div>
</div>
- <div class="rb-renders" id="rbRenders"><div class="rb-render-ph">Your generated room settings appear here.<br><small>Pick a vibe on the left (open <b>☰ Products</b> to add pieces), then <b>Generate</b>. Make several, pick your favorite, and hit <b>GO</b> to save.</small></div></div>
<h4 class="rb-pieces-h" id="rbPiecesH" hidden>Shop the pieces in this room</h4>
- <div class="rb-pieces" id="rbPieces"></div>
+ <div class="rb-pieces" id="rbPieces" role="list" aria-label="Pieces added to this room"></div>
</div>
- <button class="rb-ham" id="rbHam" title="Show products">☰ Products</button>
- <aside class="rb-right collapsed" id="rbRight">
- <div class="rb-right-head"><span>Products</span><button class="rb-ham-close" id="rbHamClose">✕</button></div>
- <input id="rbSearch" type="search" placeholder="Search products…">
- <div class="rb-products" id="rbProducts"></div>
+ <button class="rb-ham" id="rbHam" type="button" aria-label="Open products panel" aria-expanded="false" aria-controls="rbRight">
+ <span aria-hidden="true">☰</span> Products
+ </button>
+ <aside class="rb-right collapsed" id="rbRight" aria-label="Products panel">
+ <div class="rb-right-head">
+ <span>Products</span>
+ <button class="rb-ham-close" id="rbHamClose" type="button" aria-label="Close products panel" aria-expanded="true" aria-controls="rbRight">✕</button>
+ </div>
+ <input id="rbSearch" type="search" placeholder="Search products…" aria-label="Search products">
+ <div class="rb-products" id="rbProducts" aria-live="polite" aria-label="Product results"><p class="empty">Loading…</p></div>
</aside>
</div>
</section><script src="/js/build.js"></script>`;
@@ -348,12 +369,28 @@ app.get('/build', (_req, res) => {
app.get('/looks', async (_req, res, next) => {
try {
const list = await rooms.listRooms({ limit: 60 });
- const cards = list.map(r => `<a class="look-card" href="/room/${esc(r.slug)}">
- <div class="look-thumbs">${(r.thumbs || []).slice(0, 4).map(t => `<img loading="lazy" src="${esc(t)}" alt="">`).join('') || '<div class="noimg"></div>'}</div>
- <h3>${esc(r.title)}</h3><p class="subtle">${esc(r.style || '')} ${esc(r.room_type || '')} · ${(r.product_ids || []).length} pieces</p>
- </a>`).join('');
- const body = `<section><div class="builder-head"><h1>Rooms</h1><a class="cta sm" href="/build">+ Build a room</a></div>
- <div class="look-grid">${cards || '<p class="subtle">No rooms yet — <a href="/build">build the first one</a>.</p>'}</div></section>`;
+ const cards = list.map(r => {
+ const thumbs = (r.thumbs || []).slice(0, 4);
+ const thumbHtml = thumbs.length
+ ? thumbs.map(t => `<img loading="lazy" src="${esc(t)}" alt="">`).join('')
+ : '<div class="noimg">No preview</div>';
+ const pieceCount = (r.product_ids || []).length;
+ const meta = [r.style, r.room_type].filter(Boolean).join(' ');
+ return `<a class="look-card" href="/room/${esc(r.slug)}" aria-label="${esc(r.title)}${meta ? ' — ' + meta : ''}">
+ <div class="look-thumbs">${thumbHtml}</div>
+ <h3>${esc(r.title)}</h3>
+ <p class="subtle">${esc(meta)}${meta && pieceCount ? ' · ' : ''}${pieceCount ? `${pieceCount} piece${pieceCount === 1 ? '' : 's'}` : ''}</p>
+ </a>`;
+ }).join('');
+
+ const emptyState = `<p class="subtle">No rooms yet — <a href="/build">be the first to build one</a>.</p>`;
+ const body = `<section>
+ <div class="builder-head">
+ <h1>Rooms</h1>
+ <a class="cta sm" href="/build" aria-label="Build a new room">+ Build a room</a>
+ </div>
+ <div class="look-grid">${cards || emptyState}</div>
+ </section>`;
res.send(layout({ title: 'Shop the Look — Curated Rooms', description: 'Browse shoppable rooms built from real designer pieces.', canonical: `${SITE.url}/looks`, body, activeNav: '/looks' }));
} catch (e) { next(e); }
});
@@ -364,20 +401,46 @@ app.get('/room/:slug', async (req, res, next) => {
if (!data) return next();
const { room, products, paint } = data;
const jsonld = { '@context': 'https://schema.org', '@type': 'ItemList', name: room.title, numberOfItems: products.length };
- const paintBlock = paint ? `<div class="room-paint">
- <a href="/go/${paint.id}" rel="nofollow sponsored" target="_blank"><img src="${esc(paint.image_url)}" alt="${esc(paint.title)}"></a>
- <div><span class="subtle">Wall color</span><br>${esc(paint.title)}<br><a class="buy" href="/go/${paint.id}" rel="nofollow sponsored" target="_blank">Get the sample →</a></div>
+
+ // Wall color (Samplize) block — editorial treatment
+ const paintBlock = paint ? `
+ <div class="room-paint">
+ <a href="/go/${paint.id}" rel="nofollow sponsored" target="_blank" aria-label="${esc(paint.title)} — get the paint sample">
+ <img src="${esc(paint.image_url)}" alt="${esc(paint.title)}" width="72" height="72">
+ </a>
+ <div class="room-paint-info">
+ <span class="room-paint-label">Wall color</span>
+ <span class="room-paint-name">${esc(paint.title)}</span>
+ <a class="buy" href="/go/${paint.id}" rel="nofollow sponsored" target="_blank">Get the sample →</a>
+ </div>
</div>` : '';
+
+ // 0 pieces state
+ const piecesGrid = products.length
+ ? `<div class="grid">${products.map(productCard).join('')}</div>`
+ : `<div class="room-empty"><p>No shoppable pieces in this room yet.</p></div>`;
+
+ const styleLabel = [room.style, room.room_type].filter(Boolean).join(' ');
const body = `<article class="room-page">
- <div class="room-head"><h1>${esc(room.title)}</h1>
- <p class="subtle">${esc(room.style || '')} ${esc(room.room_type || '')} · ${products.length} shoppable pieces</p></div>
- ${room.scene_image ? `<img class="room-scene" src="${esc(room.scene_image)}" alt="${esc(room.title)}">` : ''}
+ <div class="room-head">
+ <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>
+ ${room.scene_image ? `<img class="room-scene" src="${esc(room.scene_image)}" alt="AI-generated scene for ${esc(room.title)}">` : ''}
${paintBlock}
- <h2>Shop this room</h2>
- <div class="grid">${products.map(productCard).join('')}</div>
- <p class="room-cta"><a class="cta sm" href="/build">Build your own room →</a></p>
+ <h2 class="shop-heading">Shop this room</h2>
+ ${piecesGrid}
+ <div class="room-cta">
+ <a class="cta sm" href="/build">Build your own room →</a>
+ </div>
</article>`;
- res.send(layout({ title: room.title, description: room.note || `A shoppable ${room.style || ''} ${room.room_type || 'room'} — ${products.length} designer pieces.`, canonical: `${SITE.url}/room/${room.slug}`, jsonld, body, activeNav: '/looks' }));
+
+ res.send(layout({
+ title: room.title,
+ description: room.note || `A shoppable ${styleLabel || 'room'} — ${products.length} designer pieces.`,
+ canonical: `${SITE.url}/room/${room.slug}`,
+ jsonld, body, activeNav: '/looks'
+ }));
} catch (e) { next(e); }
});
← 96155e2 Data quality: dead-image guard (in_stock flag), image-based
·
back to Interiordesignershowroom
·
harden /api/render + admin: persistent restart-proof render 7f7de63 →