← 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 →