[object Object]

← 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

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
   .replace(/"/g, '&quot;');
 
+// 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 →