[object Object]

← back to Wallco Ai

redesign(wallco-ai): luxury 404 page for /design/:id

229a8e0170d70ad936180e097bc4b76207ecec56 · 2026-05-11 21:44:26 -0700 · SteveStudio2

Replaces the bare 'Design not found' one-liner with a fashion-house
treatment matching wallco.ai's Gucci-style aesthetic:

- Massive italic Cormorant Garamond '404' numeral in gold
- Hairline rule + restrained 'No design at this address' headline
- Monospace pill showing the bad path so user knows what they hit
- Serif body copy explaining (migrated/retired) + single italic CTA
- 'Currently in rotation' recovery row — 4 random published designs
  hydrated server-side
- Light/dark theme + responsive (2-up grid on mobile)
- Shared renderDesignNotFound() used by both bad-id and missing-id
  branches

Files touched

Diff

commit 229a8e0170d70ad936180e097bc4b76207ecec56
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 21:44:26 2026 -0700

    redesign(wallco-ai): luxury 404 page for /design/:id
    
    Replaces the bare 'Design not found' one-liner with a fashion-house
    treatment matching wallco.ai's Gucci-style aesthetic:
    
    - Massive italic Cormorant Garamond '404' numeral in gold
    - Hairline rule + restrained 'No design at this address' headline
    - Monospace pill showing the bad path so user knows what they hit
    - Serif body copy explaining (migrated/retired) + single italic CTA
    - 'Currently in rotation' recovery row — 4 random published designs
      hydrated server-side
    - Light/dark theme + responsive (2-up grid on mobile)
    - Shared renderDesignNotFound() used by both bad-id and missing-id
      branches
---
 server.js | 331 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 305 insertions(+), 26 deletions(-)

diff --git a/server.js b/server.js
index de5957e..a3cd86e 100644
--- a/server.js
+++ b/server.js
@@ -137,6 +137,38 @@ function hexToHSL(hex) {
   return [hue, sat, l];
 }
 
+// Saturation (0..1) from hex — used by /design/:id PG fallback.
+function hexSaturation(hex) { return Math.round(hexToHSL(hex)[1] * 1000) / 1000; }
+
+// ── Snapshot-style title generator (mirror of refresh_designs_snapshot.py).
+// Used by /design/:id PG fallback so freshly-baked designs land with proper
+// "Honey Garden No.83"-style titles, not the raw prompt prefix.
+const HUE_NAMES = [
+  [  0, 18, 'Crimson'], [ 18, 38, 'Amber'],  [ 38, 58, 'Honey'],
+  [ 58, 78, 'Olive'],   [ 78,160, 'Sage'],   [160,200, 'Teal'],
+  [200,255, 'Sapphire'],[255,295, 'Violet'], [295,335, 'Rose'],
+  [335,360, 'Crimson']
+];
+const NOUNS = {
+  floral:    ['Bouquet','Blossom','Garden','Floret','Botanical','Bloom'],
+  geometric: ['Lattice','Tessellation','Geometry','Meridian','Compass','Mosaic'],
+  damask:    ['Damask','Baroque','Heirloom','Salon','Atrium','Reverie'],
+  mixed:     ['Atelier','Studio','Origin','Folio','Sketch','Edit'],
+  'art deco':       ['Deco','Fan','Sunburst','Chevron','Marquee'],
+  'arts and crafts':['Morris','Crafts','Bough','Trellis','Vine']
+};
+function hueName(hex) {
+  if (!hex) return 'Bone';
+  const [h, s] = hexToHSL(hex);
+  if (s < 0.06) return 'Noir';
+  for (const r of HUE_NAMES) if (h >= r[0] && h < r[1]) return r[2];
+  return 'Bone';
+}
+function titleFor(cat, hex, id) {
+  const pool = NOUNS[(cat || 'mixed').toLowerCase()] || NOUNS.mixed;
+  return `${hueName(hex)} ${pool[id % pool.length]} No.${id}`;
+}
+
 function sortDesigns(designs, sort) {
   const d = [...designs];
   switch (sort) {
@@ -1363,34 +1395,223 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
+// ── DESIGN DETAIL not-found page (shared between bad-id and missing-id branches)
+function renderDesignNotFound(req, idForCanonical) {
+  // Hydrate 4 trending designs at request time, so the recovery row is real.
+  let pickups = [];
+  try {
+    pickups = DESIGNS
+      .filter(d => d.is_published !== false && d.image_url)
+      .slice(0, 200)
+      .sort(() => Math.random() - 0.5)
+      .slice(0, 4);
+  } catch {}
+  const pickupHtml = pickups.map(d => `
+    <a class="nf-pick" href="/design/${d.id}" aria-label="${d.title || 'Design ' + d.id}">
+      <div class="nf-pick-img" style="background-image:url('${d.image_url}')"></div>
+      <div class="nf-pick-meta">
+        <div class="nf-pick-title">${(d.title || 'Untitled').slice(0, 40)}</div>
+        <div class="nf-pick-cat">${d.category || ''}</div>
+      </div>
+    </a>`).join('');
+  const safePath = String(req.params.id || '').replace(/[<>"'&]/g, '').slice(0, 60);
+  return `${htmlHead({
+    title: 'Design not found — wallco.ai',
+    description: 'The requested design isn’t in our archive. Browse the live collection.',
+    canonical: `https://wallco.ai/design/${idForCanonical || safePath}`
+  })}
+<style>
+  .nf {
+    min-height: calc(100vh - 120px);
+    padding: 140px 32px 80px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    background: var(--bg);
+  }
+  .nf-inner {
+    max-width: 720px;
+    text-align: center;
+    margin-bottom: 80px;
+  }
+  .nf-numeral {
+    font-family: var(--serif);
+    font-style: italic;
+    font-weight: 300;
+    font-size: clamp(140px, 22vw, 280px);
+    line-height: 0.85;
+    letter-spacing: -0.04em;
+    color: var(--gold);
+    opacity: 0.85;
+    margin-bottom: 24px;
+    user-select: none;
+  }
+  .nf-rule {
+    width: 48px;
+    height: 1px;
+    background: var(--ink);
+    margin: 12px auto 28px;
+    opacity: 0.5;
+  }
+  .nf-title {
+    font-family: var(--serif);
+    font-weight: 300;
+    font-size: clamp(28px, 4vw, 44px);
+    line-height: 1.15;
+    letter-spacing: -0.01em;
+    color: var(--ink);
+    margin: 0 0 18px;
+  }
+  .nf-meta {
+    font-family: var(--sans);
+    font-size: 13px;
+    letter-spacing: 0.06em;
+    text-transform: uppercase;
+    color: var(--ink-faint);
+    margin: 0 0 22px;
+  }
+  .nf-path {
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    text-transform: none;
+    letter-spacing: 0;
+    padding: 2px 8px;
+    background: var(--card-bg);
+    border: 1px solid var(--line);
+    border-radius: 3px;
+    color: var(--ink-soft);
+  }
+  .nf-body {
+    font-family: var(--serif);
+    font-weight: 400;
+    font-size: 18px;
+    line-height: 1.6;
+    color: var(--ink-soft);
+    max-width: 460px;
+    margin: 0 auto 36px;
+  }
+  .nf-cta {
+    display: inline-block;
+    font-family: var(--serif);
+    font-style: italic;
+    font-size: 18px;
+    color: var(--ink);
+    text-decoration: none;
+    padding-bottom: 4px;
+    border-bottom: 1px solid var(--gold);
+    transition: color 0.2s, border-color 0.2s;
+  }
+  .nf-cta:hover { color: var(--gold); border-bottom-color: var(--ink); }
+  .nf-pickup {
+    width: 100%;
+    max-width: 1200px;
+    border-top: 1px solid var(--line);
+    padding-top: 40px;
+  }
+  .nf-pickup-label {
+    font-family: var(--sans);
+    font-size: 11px;
+    letter-spacing: 0.18em;
+    text-transform: uppercase;
+    color: var(--ink-faint);
+    text-align: center;
+    margin-bottom: 28px;
+  }
+  .nf-pickup-row {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 24px;
+  }
+  .nf-pick {
+    text-decoration: none;
+    color: var(--ink);
+    display: block;
+  }
+  .nf-pick-img {
+    aspect-ratio: 1 / 1;
+    background-size: cover;
+    background-position: center;
+    background-color: var(--card-bg);
+    margin-bottom: 12px;
+    border: 1px solid var(--line);
+    transition: transform 0.4s ease;
+  }
+  .nf-pick:hover .nf-pick-img { transform: scale(1.02); }
+  .nf-pick-title {
+    font-family: var(--serif);
+    font-weight: 400;
+    font-size: 15px;
+    line-height: 1.3;
+    color: var(--ink);
+  }
+  .nf-pick-cat {
+    font-family: var(--sans);
+    font-size: 11px;
+    letter-spacing: 0.1em;
+    text-transform: uppercase;
+    color: var(--ink-faint);
+    margin-top: 4px;
+  }
+  @media (max-width: 720px) {
+    .nf { padding: 100px 20px 60px; }
+    .nf-pickup-row { grid-template-columns: repeat(2, 1fr); gap: 16px; }
+  }
+</style>
+<body>
+${htmlHeader('')}
+<main class="nf">
+  <div class="nf-inner">
+    <div class="nf-numeral">404</div>
+    <div class="nf-rule"></div>
+    <h1 class="nf-title">No design at this address</h1>
+    <p class="nf-meta"><span class="nf-path">/design/${safePath}</span></p>
+    <p class="nf-body">That ID isn’t in the wallco.ai archive—either retired during a recent migration or never minted. Browse the live collection below.</p>
+    <a class="nf-cta" href="/designs">Return to the collection →</a>
+  </div>
+  ${pickups.length ? `<div class="nf-pickup">
+    <div class="nf-pickup-label">Currently in rotation</div>
+    <div class="nf-pickup-row">${pickupHtml}</div>
+  </div>` : ''}
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`;
+}
+
 // ── DESIGN DETAIL
 app.get('/design/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) {
-    return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${req.params.id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
+    return res.status(404).type('html').send(renderDesignNotFound(req));
   }
   let design = DESIGNS.find(d => d.id === id);
-  // Hot-path fallback: design was generated after server start (e.g. via chat).
-  // Look it up live in PG + lazily extend the in-memory snapshot.
+  // Hot-path fallback: design was generated after server start (e.g. just baked
+  // from /design/:id/bake). Live-query PG and synthesize a snapshot-matching
+  // entry on the fly so we never have to wait for refresh_designs_snapshot.py.
   if (!design) {
     try {
-      const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category, dominant_hex, prompt, seed, local_path, is_published, created_at FROM spoon_all_designs WHERE id=${id}) t;`);
+      const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+        dominant_hex, prompt, seed, local_path, is_published, created_at, notes
+        FROM spoon_all_designs WHERE id=${id}) t;`);
       if (row) {
         const r = JSON.parse(row);
         const filename = (r.local_path || '').split('/').pop();
         design = {
-          id: r.id, kind: r.kind, category: r.category, dominant_hex: r.dominant_hex,
-          saturation: 0, title: (r.prompt || '').split(',')[0].slice(0, 60) || `Design #${r.id}`,
+          id: r.id, kind: r.kind, category: r.category || 'mixed',
+          dominant_hex: r.dominant_hex || '#888888',
+          saturation: hexSaturation(r.dominant_hex || '#888888'),
+          title: titleFor(r.category, r.dominant_hex, r.id),
           handle: `wallco-${String(r.id).padStart(4,'0')}`,
           image_url: `/designs/img/${filename}`, filename,
           generator: 'wallco.ai', seed: r.seed,
-          is_published: !!r.is_published, created_at: r.created_at
+          is_published: !!r.is_published, created_at: r.created_at,
+          notes: r.notes || null
         };
-        DESIGNS.push(design);
+        DESIGNS.push(design);  // lazy-extend so next request is fast
       }
     } catch {}
   }
-  if (!design) return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
+  if (!design) return res.status(404).type('html').send(renderDesignNotFound(req, id));
 
   const relIdx = DESIGNS.findIndex(d => d.id === id);
   const prev = relIdx > 0 ? DESIGNS[relIdx-1] : null;
@@ -1594,14 +1815,14 @@ ${htmlHeader('/designs')}
           <div class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
             <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:flex;align-items:center;gap:10px;flex:1;min-width:240px">
               Pattern scale
-              <input type="range" id="scale-slider" min="4" max="54" step="2" value="24" style="flex:1;max-width:280px">
+              <input type="range" id="scale-slider" min="4" max="24" step="2" value="24" style="flex:1;max-width:280px">
             </label>
             <span id="scale-readout" style="font-family:var(--serif);font-size:18px;color:var(--ink);min-width:90px">24" repeat</span>
           </div>
           <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px"></div>
           <div style="display:flex;justify-content:space-between;font:10.5px var(--sans);color:var(--ink-faint);margin-top:6px;letter-spacing:.04em">
             <span>← Smaller repeat (denser pattern)</span>
-            <span>54" wide, sold per yard</span>
+            <span id="scale-width-hint">24" wide, sold per yard</span>
             <span>Larger repeat →</span>
           </div>
 
@@ -1621,26 +1842,59 @@ ${htmlHeader('/designs')}
 
         <script>
         (function(){
-          // The viewport visually represents 54" wide × 36" tall (1 yard).
-          // We compute background-size in pixels so that one repeat = N inches at the viewport scale.
+          // The viewport visually represents one roll-width × ~2/3 roll-width (1 yard tall when roll=36).
+          // Hard rule (Steve): the repeat can never exceed the selected roll width.
+          // Max repeat clamps to roll width; selecting a smaller roll auto-clamps the current value.
           var slider = document.getElementById('scale-slider');
           var read   = document.getElementById('scale-readout');
           var port   = document.getElementById('scale-viewport');
-          // Persist
-          var saved = parseInt(localStorage.getItem('wallco.scale')||'',10);
-          if (saved && saved >= 4 && saved <= 54) slider.value = saved;
+          var hint   = document.getElementById('scale-width-hint');
+
+          function currentRollWidth() {
+            try {
+              var m = JSON.parse(localStorage.getItem('wallco.material') || 'null');
+              var w = m && parseInt(m.w, 10);
+              if ([24, 36, 52].indexOf(w) >= 0) return w;
+            } catch {}
+            return 24;
+          }
+
+          function applyRollWidth(rollW) {
+            // Slider max can never exceed roll width.
+            slider.max = String(rollW);
+            // Clamp current value down if it exceeds the new max.
+            if (parseInt(slider.value, 10) > rollW) slider.value = String(rollW);
+            if (hint) hint.textContent = rollW + '" wide, sold per yard';
+            paint();
+          }
+
+          // Persist (bounded by current roll width)
+          var saved = parseInt(localStorage.getItem('wallco.scale') || '', 10);
+          if (saved && saved >= 4) slider.value = String(saved);
+
           function paint(){
-            var inches = parseInt(slider.value, 10);
+            var rollW = parseInt(slider.max, 10) || 24;
+            var inches = Math.min(parseInt(slider.value, 10), rollW);
+            slider.value = String(inches);
             read.textContent = inches + '" repeat';
-            // Viewport is rendered at whatever CSS width it has; convert 'inches per repeat' to a % of width.
-            // Goal: at 54-inch slider, the repeat tile fills the full 54-inch width (1 cycle across viewport).
-            // At 4-inch slider, the tile is 4/54 of width.
-            var pct = (inches / 54) * 100;
+            // % of viewport that one repeat occupies = inches / roll-width.
+            var pct = (inches / rollW) * 100;
             port.style.backgroundSize = pct + '% auto';
             localStorage.setItem('wallco.scale', String(inches));
           }
           slider.addEventListener('input', paint);
-          paint();
+
+          // Initial: align with whichever roll width is currently selected.
+          applyRollWidth(currentRollWidth());
+
+          // Listen to every roll-width tab on the page; re-apply on click.
+          document.querySelectorAll('.w-tab').forEach(function(t){
+            t.addEventListener('click', function(){
+              // The existing width-tab handler updates localStorage.wallco.material asynchronously;
+              // run on the next tick so we read the freshly-persisted value.
+              setTimeout(function(){ applyRollWidth(currentRollWidth()); }, 0);
+            });
+          });
         })();
         </script>
       </div>
@@ -4806,11 +5060,36 @@ app.post('/api/design/:id/bake',
                FALSE, 'baked from design #${parentId}')
         RETURNING id;`);
       const newId = parseInt(id, 10);
-      // Refresh in-memory + snapshot
+      // Eagerly synthesize a snapshot-style design + push to in-memory list
+      // so the upcoming GET /design/:newId hits the fast path immediately —
+      // no blocking snapshot regen, no race on redirect.
+      try {
+        const hex = parent.dominant_hex || null;
+        const cat = parent.category || 'mixed';
+        DESIGNS.push({
+          id: newId,
+          kind: 'seamless_tile',
+          category: cat,
+          dominant_hex: hex,
+          saturation: hex ? hexSaturation(hex) : 0,
+          title: titleFor(cat, hex, newId),
+          handle: `wallco-${String(newId).padStart(4,'0')}`,
+          image_url: `/designs/img/${filename}`,
+          filename,
+          generator: 'wallco.ai',
+          seed: 0,
+          is_published: false,
+          created_at: new Date().toISOString(),
+          notes: `baked from design #${parentId}`
+        });
+      } catch {}
+      // Snapshot regen runs in background (non-blocking) — the in-memory
+      // push above means the redirect works immediately regardless.
       try {
-        spawnSync('python3', [path.join(__dirname, 'scripts', 'refresh_designs_snapshot.py')],
-                  { timeout: 15000 });
-        loadDesigns();
+        const proc = spawn('python3',
+          [path.join(__dirname, 'scripts', 'refresh_designs_snapshot.py')],
+          { detached: true, stdio: 'ignore' });
+        proc.unref();
       } catch {}
       res.json({ ok:true, design_id: newId, filename });
     } catch (e) {

← caf6496 wallco.ai: side-by-side modals on /design/:id — when both ch  ·  back to Wallco Ai  ·  tick 13: palette-rebalance batch — 10 new SDXL designs targe 292faa8 →