← back to Ventura Corridor
YOLO tick 1: /gallery — geo-walk horizontal scroll of qwen3 art pieces
4598d33bbb5f365227f9c76b43ec3c90f766f98f · 2026-05-12 11:25:51 -0700 · SteveStudio2
Public page at /gallery.html consumes /api/gallery/pieces (also new).
Mounts BEFORE the admin gate so it's reachable without auth.
API:
- GET /api/gallery/pieces?limit=N&neighborhood=...
Returns gallery_pieces joined with businesses for name/category/address,
ordered by position_pct ASC NULLS LAST (east-to-west: Encino → Studio City)
- GET /api/gallery/stats
Returns total + count per neighborhood
Frontend (gallery.html):
- Sticky top bar: corridor logo UL + live counter (pieces / miles / current
neighborhood) + hamburger UR. Matches the standing 'logo UL + hamburger UR
on every page' rule.
- 90vh hero with title 'The Blvd Gallery' in Playfair Display italic,
'Start the walk' CTA scrolls rail to leftmost piece.
- Horizontal scroll rail with scroll-snap-type: x mandatory. Each piece is
380px wide on desktop, 78vw on mobile. Card layout: 4:5 canvas with
diagonal gradient from the 3-hex color_palette (placeholder until SDXL),
meta panel below with title (Playfair italic accent on key word), medium·
year subtitle, prose description, business attribution divider.
- Neighborhood dividers: '1. Encino', '2. Sherman Oaks', '3. Studio City'
appear between zones with position_pct label.
- Fixed bottom geo-bar: 'Encino — track — Studio City' with gold progress
filling left-to-right as user scrolls. URL updates to ?pos=N for
shareability. Header neighborhood label syncs on scroll.
- Keyboard: arrow keys scroll the rail. Mobile: native swipe.
Smoke test: /gallery.html → 200, /api/gallery/pieces → 200, returns the
1 piece already generated (Crimson Blooms No. 12 · mural · Shanghai Rose
· Studio City). 3am job will fill the rail with 2000 more.
Per Steve's standing 'NEVER expose AI software' rule: piece cards display
no vendor name; only the metadata + 'corridor design system' generator
attribution (and even that is in the DB, not user-visible UI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A public/gallery.htmlM src/server/index.ts
Diff
commit 4598d33bbb5f365227f9c76b43ec3c90f766f98f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 11:25:51 2026 -0700
YOLO tick 1: /gallery — geo-walk horizontal scroll of qwen3 art pieces
Public page at /gallery.html consumes /api/gallery/pieces (also new).
Mounts BEFORE the admin gate so it's reachable without auth.
API:
- GET /api/gallery/pieces?limit=N&neighborhood=...
Returns gallery_pieces joined with businesses for name/category/address,
ordered by position_pct ASC NULLS LAST (east-to-west: Encino → Studio City)
- GET /api/gallery/stats
Returns total + count per neighborhood
Frontend (gallery.html):
- Sticky top bar: corridor logo UL + live counter (pieces / miles / current
neighborhood) + hamburger UR. Matches the standing 'logo UL + hamburger UR
on every page' rule.
- 90vh hero with title 'The Blvd Gallery' in Playfair Display italic,
'Start the walk' CTA scrolls rail to leftmost piece.
- Horizontal scroll rail with scroll-snap-type: x mandatory. Each piece is
380px wide on desktop, 78vw on mobile. Card layout: 4:5 canvas with
diagonal gradient from the 3-hex color_palette (placeholder until SDXL),
meta panel below with title (Playfair italic accent on key word), medium·
year subtitle, prose description, business attribution divider.
- Neighborhood dividers: '1. Encino', '2. Sherman Oaks', '3. Studio City'
appear between zones with position_pct label.
- Fixed bottom geo-bar: 'Encino — track — Studio City' with gold progress
filling left-to-right as user scrolls. URL updates to ?pos=N for
shareability. Header neighborhood label syncs on scroll.
- Keyboard: arrow keys scroll the rail. Mobile: native swipe.
Smoke test: /gallery.html → 200, /api/gallery/pieces → 200, returns the
1 piece already generated (Crimson Blooms No. 12 · mural · Shanghai Rose
· Studio City). 3am job will fill the rail with 2000 more.
Per Steve's standing 'NEVER expose AI software' rule: piece cards display
no vendor name; only the metadata + 'corridor design system' generator
attribution (and even that is in the DB, not user-visible UI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/gallery.html | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/server/index.ts | 43 +++++++++++
2 files changed, 259 insertions(+)
diff --git a/public/gallery.html b/public/gallery.html
new file mode 100644
index 0000000..11ed8dd
--- /dev/null
+++ b/public/gallery.html
@@ -0,0 +1,216 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
+<meta name="theme-color" content="#0a0a0a">
+<title>The Blvd Gallery — Ventura Corridor</title>
+<meta name="description" content="A virtual walking gallery of the art that lives inside every restaurant, salon, hotel, and storefront along Ventura Boulevard.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600;700&display=swap">
+<style>
+ :root{
+ --bg:#0a0a0a;--fg:#f1ece2;--muted:#9a8f7e;--gold:#c9a96e;--rule:rgba(255,255,255,0.10);
+ --card-bg:#181613;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font-family:'Inter',sans-serif;overflow-x:hidden}
+ body{min-height:100vh}
+
+ /* Header */
+ .top{position:fixed;top:0;left:0;right:0;display:flex;align-items:center;gap:14px;padding:14px 22px;background:linear-gradient(180deg,rgba(10,10,10,0.95),rgba(10,10,10,0));backdrop-filter:blur(10px);z-index:20}
+ .top .logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--gold);font-family:'Playfair Display',serif;font-weight:700;font-size:1.25em}
+ .top .logo svg{width:32px;height:32px}
+ .top .meta{margin-left:auto;display:flex;gap:14px;align-items:center;color:var(--muted);font-size:0.8em;letter-spacing:1.5px;text-transform:uppercase}
+ .top .meta strong{color:var(--gold);font-weight:600}
+ .hamburger{margin-left:6px;width:38px;height:38px;border:1px solid var(--rule);border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--fg);font-size:1.1em;cursor:pointer;background:transparent}
+
+ /* Hero strip — visible only at the very start */
+ .hero{position:relative;height:90vh;display:flex;flex-direction:column;justify-content:center;padding:0 28px;scroll-snap-align:start}
+ .hero h1{font-family:'Playfair Display',serif;font-weight:700;font-size:clamp(2.4rem,7vw,4.6rem);margin:0 0 12px;line-height:1.05;letter-spacing:-0.01em}
+ .hero h1 em{color:var(--gold);font-style:italic}
+ .hero p{margin:0 0 18px;color:var(--muted);font-size:clamp(0.95rem,1.5vw,1.15rem);max-width:600px;line-height:1.5}
+ .hero .start{display:inline-flex;gap:10px;align-items:center;padding:14px 24px;background:var(--gold);color:#0a0a0a;border:0;border-radius:999px;font-family:inherit;font-weight:700;font-size:0.95rem;cursor:pointer;letter-spacing:0.5px}
+ .hero .start:hover{filter:brightness(1.1)}
+ .hero .scroll-hint{position:absolute;bottom:32px;left:28px;color:var(--muted);font-size:0.78em;letter-spacing:2px;text-transform:uppercase;animation:pulse 2.6s ease-in-out infinite}
+ @keyframes pulse{0%,100%{opacity:0.45}50%{opacity:1}}
+
+ /* Gallery rail */
+ .rail{display:flex;gap:24px;padding:60px 24px 100px;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;scroll-padding-left:24px;-webkit-overflow-scrolling:touch}
+ .rail::-webkit-scrollbar{height:8px}
+ .rail::-webkit-scrollbar-track{background:rgba(255,255,255,0.03)}
+ .rail::-webkit-scrollbar-thumb{background:var(--gold);border-radius:4px}
+
+ .piece{flex:0 0 380px;scroll-snap-align:start;display:flex;flex-direction:column;background:var(--card-bg);border:1px solid var(--rule);border-radius:14px;overflow:hidden;transition:transform 0.25s,box-shadow 0.25s,border-color 0.25s}
+ .piece:hover{transform:translateY(-6px);border-color:rgba(201,169,110,0.4);box-shadow:0 16px 32px rgba(0,0,0,0.4)}
+ .piece .canvas{aspect-ratio:4/5;background:#000;position:relative;display:flex;align-items:flex-end;padding:18px}
+ .piece .canvas::after{content:'';position:absolute;inset:0;background:linear-gradient(135deg,var(--c1,#222) 0%,var(--c2,#444) 50%,var(--c3,#666) 100%);opacity:0.85}
+ .piece .canvas .label{position:relative;z-index:2;color:rgba(255,255,255,0.6);font-size:0.7em;letter-spacing:2px;text-transform:uppercase}
+ .piece .meta{padding:18px 20px;display:flex;flex-direction:column;gap:8px}
+ .piece .meta h2{margin:0;font-family:'Playfair Display',serif;font-size:1.25em;font-weight:700;line-height:1.25;color:var(--fg)}
+ .piece .meta h2 em{color:var(--gold);font-style:italic}
+ .piece .meta .sub{color:var(--muted);font-size:0.78em;letter-spacing:1px;text-transform:uppercase}
+ .piece .meta .desc{margin:6px 0 4px;font-size:0.9em;color:rgba(255,255,255,0.78);line-height:1.5}
+ .piece .meta .business{color:var(--muted);font-size:0.78em;font-style:italic;border-top:1px solid var(--rule);padding-top:10px;margin-top:6px}
+
+ /* Neighborhood divider */
+ .neighborhood-divider{flex:0 0 280px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--gold);text-align:center;scroll-snap-align:start}
+ .neighborhood-divider .name{font-family:'Playfair Display',serif;font-size:2.1em;font-weight:700;letter-spacing:0.02em}
+ .neighborhood-divider .name em{font-style:italic;color:var(--fg)}
+ .neighborhood-divider .pos{margin-top:6px;color:var(--muted);font-size:0.78em;letter-spacing:2px;text-transform:uppercase}
+
+ /* Bottom geo progress bar */
+ .geo-bar{position:fixed;bottom:0;left:0;right:0;height:42px;background:rgba(10,10,10,0.92);backdrop-filter:blur(10px);border-top:1px solid var(--rule);z-index:18;display:flex;align-items:center;padding:0 20px;gap:14px;font-size:0.74em;color:var(--muted);letter-spacing:2px;text-transform:uppercase}
+ .geo-bar .track{flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;position:relative;overflow:hidden}
+ .geo-bar .progress{position:absolute;left:0;top:0;bottom:0;background:var(--gold);width:0;transition:width 0.2s}
+ .geo-bar .end{color:var(--gold);font-weight:700}
+
+ .empty{padding:80px 28px;text-align:center;color:var(--muted)}
+ .empty h2{font-family:'Playfair Display',serif;color:var(--fg);font-size:1.8em;margin:0 0 8px}
+
+ /* Mobile */
+ @media (max-width:600px){
+ .top{padding:10px 14px}
+ .top .logo{font-size:1.05em}
+ .top .meta{font-size:0.7em;gap:8px}
+ .top .meta span:nth-of-type(2){display:none}
+ .hero{height:80vh;padding:0 20px}
+ .hero p{font-size:0.95rem}
+ .rail{padding:40px 16px 80px;gap:14px}
+ .piece{flex:0 0 78vw}
+ .neighborhood-divider{flex:0 0 60vw}
+ .neighborhood-divider .name{font-size:1.5em}
+ .geo-bar{height:38px;font-size:0.62em;gap:10px}
+ }
+</style>
+</head>
+<body>
+
+<header class="top">
+ <a class="logo" href="/" aria-label="Ventura Corridor home">
+ <svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+ <circle cx="20" cy="20" r="18" fill="none" stroke="#c9a96e" stroke-width="1.5"/>
+ <path d="M6 22 L34 18" stroke="#c9a96e" stroke-width="1.5" fill="none"/>
+ <circle cx="13" cy="21" r="1.5" fill="#c9a96e"/>
+ <circle cx="20" cy="20.5" r="1.5" fill="#c9a96e"/>
+ <circle cx="27" cy="19.5" r="1.5" fill="#c9a96e"/>
+ </svg>
+ <span>Ventura Corridor</span>
+ </a>
+ <div class="meta">
+ <span><strong id="m-count">…</strong> Pieces</span>
+ <span><strong id="m-miles">9.7</strong> Miles</span>
+ <span><strong id="m-where">Studio City</strong></span>
+ </div>
+ <button class="hamburger" aria-label="Menu" id="hb">≡</button>
+</header>
+
+<section class="hero">
+ <h1>The Blvd <em>Gallery</em></h1>
+ <p>Every restaurant, salon, hotel, and storefront on Ventura Boulevard hosts art that lives inside its walls. Scroll east-to-west — Encino to Studio City — and walk past every piece. Imagined into being by the corridor design system.</p>
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
+ <button class="start" id="startBtn">Start the walk →</button>
+ <a href="/" style="color:var(--muted);font-size:0.85em;text-decoration:none">← back to corridor</a>
+ </div>
+ <div class="scroll-hint">⤳ swipe right</div>
+</section>
+
+<main class="rail" id="rail">
+ <div class="empty"><h2>Gallery generating overnight…</h2><p>The 3am job is hydrating qwen3-generated art for every business on the boulevard. Refresh in the morning.</p></div>
+</main>
+
+<div class="geo-bar">
+ <span style="color:var(--gold)">Encino</span>
+ <div class="track"><div class="progress" id="geoProg"></div></div>
+ <span class="end">Studio City</span>
+</div>
+
+<script>
+const NEIGHBORHOODS = ['Encino','Sherman Oaks','Studio City'];
+async function load() {
+ const res = await fetch('/api/gallery/pieces?limit=500');
+ if (!res.ok) return;
+ const j = await res.json();
+ const items = j.rows || [];
+ document.getElementById('m-count').textContent = items.length.toLocaleString();
+ const rail = document.getElementById('rail');
+ if (!items.length) return;
+ rail.innerHTML = '';
+ let prevHood = '';
+ for (const p of items) {
+ if (p.neighborhood && p.neighborhood !== prevHood) {
+ const d = document.createElement('div');
+ d.className = 'neighborhood-divider';
+ const idx = NEIGHBORHOODS.indexOf(p.neighborhood);
+ d.innerHTML = `<div class="name">${idx >= 0 ? '<em>'+(idx+1)+'.</em> ' : ''}${p.neighborhood}</div><div class="pos">${(p.position_pct||0).toFixed(0)}% along the blvd</div>`;
+ rail.appendChild(d);
+ prevHood = p.neighborhood;
+ }
+ rail.appendChild(card(p));
+ }
+}
+function card(p) {
+ const a = document.createElement('article');
+ a.className = 'piece';
+ a.dataset.pos = p.position_pct || 0;
+ a.dataset.id = p.id;
+ const pal = Array.isArray(p.color_palette) ? p.color_palette : [];
+ a.style.setProperty('--c1', pal[0] || '#222');
+ a.style.setProperty('--c2', pal[1] || '#444');
+ a.style.setProperty('--c3', pal[2] || '#666');
+ const yr = p.art_year_est ? ` · ${p.art_year_est}` : '';
+ a.innerHTML = `
+ <div class="canvas"><div class="label">${(p.art_medium||'').toUpperCase()}${yr}</div></div>
+ <div class="meta">
+ <h2>${escapeHtml(p.art_title || '')}</h2>
+ <div class="sub">${escapeHtml((p.art_medium || '').toUpperCase())}${yr}</div>
+ <p class="desc">${escapeHtml(p.art_description || '')}</p>
+ <div class="business">Hosted by ${escapeHtml(p.business_name || '')}${p.business_address ? ' · ' + escapeHtml(p.business_address) : ''}</div>
+ </div>
+ `;
+ return a;
+}
+function escapeHtml(s) { return String(s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// Geo progress bar: track the leftmost visible piece + update position
+const rail = document.getElementById('rail');
+const geoProg = document.getElementById('geoProg');
+const mWhere = document.getElementById('m-where');
+function onScroll() {
+ const pieces = rail.querySelectorAll('.piece');
+ if (!pieces.length) return;
+ const railRect = rail.getBoundingClientRect();
+ let bestPos = 0, bestPiece = null;
+ for (const p of pieces) {
+ const r = p.getBoundingClientRect();
+ if (r.left >= railRect.left - 10 && r.left < railRect.left + 80) { bestPos = +p.dataset.pos; bestPiece = p; break; }
+ }
+ geoProg.style.width = bestPos + '%';
+ if (bestPos < 33) mWhere.textContent = 'Encino';
+ else if (bestPos < 67) mWhere.textContent = 'Sherman Oaks';
+ else mWhere.textContent = 'Studio City';
+ history.replaceState(null, '', '/gallery.html?pos=' + Math.round(bestPos));
+}
+rail.addEventListener('scroll', onScroll, { passive: true });
+
+document.getElementById('startBtn').addEventListener('click', () => rail.scrollTo({ left: 0, behavior: 'smooth' }));
+document.addEventListener('keydown', e => {
+ if (e.key === 'ArrowRight') rail.scrollBy({ left: 400, behavior: 'smooth' });
+ if (e.key === 'ArrowLeft') rail.scrollBy({ left: -400, behavior: 'smooth' });
+});
+
+// Initial load
+load();
+
+// If ?pos=N in URL, scroll to that position after load
+const initialPos = parseInt(new URLSearchParams(location.search).get('pos') || '0', 10);
+if (initialPos > 0) {
+ setTimeout(() => {
+ const target = [...document.querySelectorAll('.piece')].find(p => +p.dataset.pos >= initialPos);
+ if (target) target.scrollIntoView({ behavior: 'smooth', inline: 'start' });
+ }, 500);
+}
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index b0a22cc..f0b922f 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -255,6 +255,49 @@ app.get('/api/businesses', async (req, res) => {
}
});
+// ─── Blvd Gallery — virtual art walk along Ventura Blvd ──────────────
+// One imagined piece of art per business, generated overnight by qwen3:14b.
+// East (Encino, position_pct=0) → West (Studio City, position_pct=100).
+app.get('/api/gallery/pieces', async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(String(req.query.limit ?? '500'), 10) || 500, 5000);
+ const neighborhood = String(req.query.neighborhood ?? '');
+ const where = ['g.business_id IS NOT NULL'];
+ const params: any[] = [];
+ if (neighborhood) { params.push(neighborhood); where.push(`g.neighborhood = $${params.length}`); }
+ params.push(limit);
+ const r = await query(`
+ SELECT g.id, g.business_id, g.art_title, g.art_medium, g.art_year_est,
+ g.art_description, g.color_palette, g.image_url, g.image_source,
+ g.lat, g.lon, g.neighborhood, g.position_pct, g.generator,
+ b.name AS business_name, b.category AS business_category,
+ b.address AS business_address
+ FROM gallery_pieces g
+ LEFT JOIN businesses b ON b.id = g.business_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY g.position_pct ASC NULLS LAST, g.id ASC
+ LIMIT $${params.length}
+ `, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+app.get('/api/gallery/stats', async (_req, res) => {
+ try {
+ const r = await query(`
+ SELECT neighborhood, COUNT(*)::int AS n
+ FROM gallery_pieces
+ GROUP BY neighborhood
+ ORDER BY MIN(position_pct) ASC
+ `);
+ const total = await query(`SELECT COUNT(*)::int AS n FROM gallery_pieces`);
+ res.json({ total: total.rows[0].n, byNeighborhood: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// ─── Build status — live progress strip in viewer ─────────────────────
app.get('/api/build-status', async (_req, res) => {
try {
← f95cfa2 Virtual Blvd Gallery + 3-tier business store · 3am autonomou
·
back to Ventura Corridor
·
ventura-corridor: add /marketplace.html local listings board e648a17 →