Business logic
The Midnight Cellar concept, palette and fonts, 16 animation layers in pure CSS, GEO optimization for LLMs, content pipeline for 200+ photos, the stack.
TL;DR
BRUNO Tbilisi — modern European fusion in Vake. The site is built like an editorial magazine: deep ink-black background ("Midnight Cellar"), creamy paper-toned text, fire-orange candle accent, Fraunces Variable display with italic insets, IBM Plex Serif for body. 16 animation layers built on pure CSS + IntersectionObserver — no framer-motion, no animation libraries. Multi-page architecture: 9 home sections + 8 routes. Built for GEO: JSON-LD Restaurant + Menu + FAQPage, llms.txt with an answer-first summary, robots.txt with explicit Allow for GPTBot, ClaudeBot, PerplexityBot, Google-Extended. The content pipeline gathers 200+ photos from 5 sources (Instagram, Yandex Maps, Restaurant Guru, TripAdvisor, Google Business), processed via sharp + blurhash + pHash deduplication. Deploy — VPS + nginx + Cloudflare, domain brunotbilisi.com.
Context and audience
BRUNO is a restaurant in Vake, the premium district of Tbilisi. The target guest: international tourist 30–45 years old (visiting for 2–4 days), or an expat looking for a solid spot with biodynamic wine and a decent brunch. Competition in Vake is dense: dozens of places within a 500 m radius, each with its own Instagram and Resos page.
The client wanted a storefront, not a business card. The goal was to turn organic search into reservations and Wolt-delivery orders. That means:
- Discoverability in LLM assistants — when a tourist asks ChatGPT "where to get good brunch in Tbilisi", BRUNO should land in the top three.
- Editorial density — little text, lots of photos, all rhythmic. Not "about us / menu / contacts", but a scene and a mood, like a wine magazine.
- Adaptivity to all three guest types — busy business lunch (needs price + time + phone), planned dinner (needs the full menu + biodynamic wines), brunch weekend (needs food photos and opening hours).
The video at the top shows the actual page rhythm: pause on hero (candle flickering in the upper-right corner), signature-dish cards exiting with 3D tilt, /reels with embedded video loops, masonry gallery with blurhash placeholders, long magazine quotes in reviews.
Concept: Midnight Cellar
Walking into a wine restaurant in the evening means entering a half-dark room with candles on the tables, brick arches, bottles behind glass and muted jazz. I carried this into digital — literally. The palette inverts the standard "restaurant" website (typically white background with pastel accents): here it's deep ink-black with warm cream-colored text. Candle fire is the only thing that glows.
Three keys to the concept:
- Not skeuomorphism, but allusion. I don't draw a candle — I place a radial gradient
radial-gradient(ellipse 55% 42% at 18% 15%, rgba(201,101,31,0.28) 0%, transparent 55%)with a keyframe flicker animation (6s ease-in-out infinite). The effect — as if a warm light source is burning in the upper right and pulsing slightly. That's enough — the brain finishes the candle on its own. - Magazine rhythm, not landing-page rhythm. Sections are wide, hairline dividers are 1px gradients fading to transparency (
linear-gradient(to right, transparent, rgba(245,238,224,0.16), transparent)), lots of breathing room. Headings are large, body 16-17px, line-height 1.7. Not a "selling landing", but a long magazine spread. - Editorial > marketing. No "Book a table now!" in huge buttons. The booking button is calm, mini-mono caps. The main thing is photos and texts.
Palette
From app/globals.css :root — the tokens:
| Token | HEX | Role |
|---|---|---|
--ink-base | #0D0A07 | deep ink-black, body background — between black and dark brown |
--ink-raised | #15110B | raised layer — cards, hover state |
--ink-inset | #080605 | deeper than base — focal sections (Hero, Reviews) |
--ink-line | rgba(245,238,224,0.08) | thin strokes — dividers, borders |
--ink-line-strong | rgba(245,238,224,0.16) | hairline divider gradient center |
--cream | #F5EEE0 | primary text — warm paper, not pure white |
--cream-muted | #9A8E78 | secondary text — caption, meta |
--fire | #C9651F | fire-orange — candle, accent, single CTA |
--fire-glow | rgba(201,101,31,0.12) | soft glow for candle-glow utilities |
--fire-glow-strong | rgba(201,101,31,0.22) | stronger glow for hero |
--wine | #6B1E2A | rare emotional accent (brunch, reviews) |
--wine-muted | #3A1419 | dark wine, for deep sections |
Only two accent hues — fire and wine. No green, no blue, no purple. This gives visual monolithicity — even without the logo the site is recognizably "BRUNO".
Typography
Three fonts, all via next/font/google:
const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
axes: ['SOFT', 'WONK', 'opsz'],
display: 'swap',
});
const plexSerif = IBM_Plex_Serif({ ..., weight: ['400'] });
const plexMono = IBM_Plex_Mono({ ..., weight: ['400', '500'] });
- Fraunces Variable for display headings. Three axes are active:
SOFT(serif softness 0–100),WONK(the trademark "weird" forms 0–1),opsz(optical size 9–144). The italic variant withWONK 1andSOFT 100looks like a hand-cut engraving — not a regular slant. That gives a rare character that's hard to replicate. - IBM Plex Serif for body — neutral modern serif, weight 400. Reads well on a dark background across long passages.
- IBM Plex Mono for meta — caption, prices, opening hours, phone. Mono in editorial is a hint of a "label", of handcraft.
Key utilities:
.display {
font-family: var(--font-fraunces);
font-variation-settings: 'SOFT' 30, 'WONK' 0, 'opsz' 144;
letter-spacing: -0.035em;
line-height: 0.92;
}
.display-italic {
font-style: italic;
font-variation-settings: 'SOFT' 100, 'WONK' 1, 'opsz' 144;
}
Letter-spacing -0.035em and line-height 0.92 on display — headings are very tight, that's the "grain". Body keeps the usual 1.7 line-height — comfortable to read across long passages.
16 animation layers
The main difference from other portfolio cases — here the animations run on pure CSS + IntersectionObserver, no framer-motion, no animation libraries. That means:
- Bundle is ~80 KB gzipped smaller
- SSR works perfectly (nothing "jumps" during hydration)
- Performance — every animation runs on the GPU compositor, no per-rAF recalculation
The video above shows 9 of the 16. The full list:
Global layers
- Film grain via
body::before. SVG noise viafeTurbulence, opacity 0.04,mix-blend-mode: screen. A constant film texture — instant photo-print feel, not "markup". - Scroll progress bar — fixed top, fire-orange strip, width =
scrollY / scrollHeight. Implemented viaElement.animate({ width: '...%' })with rAF throttling. - Scroll velocity blur — on fast scroll the body gets
filter: blur(0..1.8px)through the--scroll-blurCSS variable. Lerp smoothing (current += (target - current) * 0.3) removes jitter. On slow scroll, blur = 0.
Hero
- Candle flicker — radial gradient in the upper right corner with the
candleFlicker6sease-in-out infinitekeyframe, shifting opacity 0.85 → 1.0 → 0.92 → 1.0 at irregular intervals. Looks like a real candle flame. - Hero parallax via the JS wrapper
HeroParallax: the background shifts by 0.3× of scrollY through the--parallax-yCSS variable. - Gyro-drift on mobile: when the device tilts, the background shifts by ±2-3°. Through the
DeviceOrientationEventAPI — wherever iOS grants permission. One of the few sites in Tbilisi using the gyroscope.
Sections
- Scroll-reveal via the
[data-reveal]attribute andIntersectionObserver. The element starts atopacity: 0; transform: translateY(24px); on entering the viewport it getsdata-reveal-in="true"→ opacity and transform go to zero, transition 600ms cubic-bezier(0.22, 1, 0.36, 1). Stagger via the--reveal-delayCSS variable. - Menu category stagger + 3D tilt. On the /menu page and in Signatures on the home — cards react to mouse-over:
transform: perspective(1000px) rotateX(...) rotateY(...)based on cursor position. Subtle, but it makes the cards "alive". Stagger on appearance — 80ms between cards. - Gallery wave — masonry blocks get a reveal-delay along a diagonal wave:
delay = (col * 50 + row * 80)ms. Creates a "rolling wave" effect instead of a dull simultaneous appearance. - Amber panel breathe — the Brunch & Bar section (on its own /brunch page) breathes: a warm amber glow pulses 8s alternate. Impossible to see in the home overview — you have to visit /brunch.
- Sunrise sweep — one-shot animation on the /brunch panel. On the first time it enters the viewport (sessionStorage flag) the panel gets the
sunrise-sweepclass, and a light gradient rolls left-to-right over 1.5s. A "sunrise" effect.
Touch / Mobile
- Touch ripple — custom touch feedback: on
touchstarta circle appears with an opacity fade-out over 600ms. Better than the default tap-highlight. - Swipe hints — on the mobile Signatures carousel: a thin arrow "scrolls" the first time the section is visited, hinting at the swipe.
- Music note ping — on hover over the "cocktails" section on desktop a small music-note icon pings with tiny scale 1.0 → 1.15 → 1.0.
Reels
- Autoplay-on-scroll videos on the /reels page: 8 video loops of the interior and dishes.
IntersectionObserverwith threshold 0.5 — if the video is in the center of the viewport, it plays, otherwise it pauses. Muted. Optimization — preload="metadata" on all of them, the video downloads only on scroll to it.
CountUp
- CountUp in the About section: ratings 4.9★, 4.8★, 4.6★, 4.7★ from four sources (Google/Yandex/TripAdvisor/Restaurant Guru) — animate from 0 to the target value over 1.2s on entering the viewport. Subtle, but adds a "living" feel to the numbers.
All 16 layers respect prefers-reduced-motion: reduce. In CSS:
@media (prefers-reduced-motion: reduce) {
.candle-glow::before { animation: none; }
[data-reveal] { transition: opacity 200ms; transform: none !important; }
/* ... */
}
In JS — a window.matchMedia('(prefers-reduced-motion: reduce)').matches check at the start of every provider component, falling back to no-op.
Page architecture
The home (app/page.tsx) — 9 sections:
- Hero (
#top) — full-bleed photo of the dining room + display heading "Bruno · Vake", subtitle "Modern European fusion", hours + tel mini-mono caps - HomeQuoteStrip — short quote from a gourmet review, fire-orange wonk-italic
- HomeTriad — three vertical blocks: "Eat", "Drink", "Visit" with photos
- Signatures (
#signatures) — 9 signature dishes with previews + 3D tilt grid - Reels (
#reels) — 4 embedded video loops with autoplay-on-scroll - Gallery (
#gallery) — 57 photos in masonry, blurhash placeholder - Reviews (
#reviews) — long magazine-style quotes from 4 sources (Google/Yandex/TripAdvisor/Guru) - HomeVisitCompact — map + address + hours + phone + booking link
- Footer — socials, legal info (BBB Restogroup LLC, 402360404)
The sticky Nav at the top — fade-in after the first scroll, blur backdrop, semi-transparent ink. On deep-level pages (e.g. /menu/pasta) a Breadcrumb is added. Hidden ScrollProgress — fire-orange strip at the very top.
Inner pages
8 routes beyond the home:
/menu+/menu/{category}— 11 categories (starters, salads, pasta, pizza, mains, desserts, brunch, brunch-extras, kids, drinks, wine). Each subcategory has its own page with a full-bleed photo and stagger dish cards./brunch— a separate landing aimed at the "brunch until 4 PM daily" target audience. The sunrise sweep lives here — the slowest and most emotional animation on the site./cocktails— separate page with CocktailsPanel + the music-ping effect on the icon./reels— autoplay-on-scroll mode: 8 video loops, you scroll and see everything happening in the dining room./reviews— full 127 reviews, 4 source filters./visit— map + three ways to get there + parking + dress code./about— long text on the restaurant philosophy + CountUp ratings.
GEO: built for LLM-discoverability
The site was built for a second search loop — LLM assistants (ChatGPT, Claude, Perplexity). At the time of build no one in Tbilisi was doing this. What was done:
public/llms.txt
Answer-first summary in plain text: 600 words answering 8 main questions ("where is BRUNO", "what's the cuisine", "opening hours", "brunch menu", "cocktail program", "vegan options", "booking", "address"). Structured as Q&A. LLM bots get a concentrate during indexing — no need to parse HTML.
public/robots.txt
Explicit Allow: for:
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: Bytespider
Allow: /
Many restaurant sites in Tbilisi block these bots by default (via default robots.txt from CMSes). We did the opposite.
JSON-LD
The full schema is wired into app/layout.tsx:
- Restaurant — name, address, geo, phone, hours, priceRange, servesCuisine, acceptsReservations
- Menu — all 11 categories with MenuSection and MenuItem (name, description, price, allergen, image)
- FAQPage — 8 questions with answer-first answers
Semantic HTML
<article>, <section>, <dl>/<dt>/<dd>, <figure>/<figcaption> — wherever appropriate. LLM bots extract entities better from properly marked-up content.
Answer-first prose in About / Visit / FAQ
Not "BRUNO is a place where...", but "BRUNO is a modern European fusion restaurant in Vake, Tbilisi. Address: Zakaria Paliashvili 11a. Phone: +995 551 07 78 49. Hours: Sun–Thu 11:00–23:00, Fri–Sat 11:00–02:00." — the bare answer in the first sentence, details after.
Result
Two months after launch BRUNO surfaces in ChatGPT and Perplexity for queries like "brunch in Tbilisi", "european fusion Vake", "truffle pasta Tbilisi". This is an organic channel that can't be bought and doesn't depend on paid channels.
Content pipeline for 200+ photos
The biggest pain of a restaurant site is the photos. The client gave access to Instagram and a shared Yandex Maps folder. I built a pipeline across 5 sources:
| Source | Tool | Photos |
|---|---|---|
| Instagram @brunotbilisi | instaloader (no login, public profile) | 242 posts |
| Yandex Maps | Playwright headless chromium → /gallery/ → orig | 65+ |
| Google Business Profile | Playwright via g.page/r/... | ~30 |
| TripAdvisor | Playwright via review URL | ~40 |
| Restaurant Guru | Playwright via profile | 45 |
After collection — a single pipeline scripts/process-gallery.mjs:
- Resize via
sharp: long edge max 1600px (more than that is unnecessary for the web). - Convert to two formats: WebP q=82 + AVIF q=70. On the front —
<picture>with<source type="image/avif">+ WebP fallback. AVIF is heavier on CPU but 20-30% smaller in size. - Blurhash for every photo — 32-character placeholder, rendered immediately on
<canvas>while the final image loads. No layout "jumps", no grey rectangles. - pHash deduplication — the same photos appear on IG and Yandex (the restaurant photo folder). pHash distance < 5 — counted as duplicate, only the highest-resolution one is kept.
- Manifest —
public/gallery/manifest.jsonwith metadata:[{ "src": "/gallery/2025-03-15_001.webp", "srcAvif": "/gallery/2025-03-15_001.avif", "blurhash": "L9AS}j%M00%M~q%M00%M", "width": 1600, "height": 2000, "caption": "...", "date": "2025-03-15", "source": "instagram", "tags": ["food", "interior", "drinks"] }] - Curation in
manifest.curated.json— picking hero candidates (photos with luminosity ~0.48 — neither too dark nor over-exposed), and 3 sub-sections for the main gallery: "interior", "food", "drinks".
Final gallery — 57 photos out of ~250 collected, organized into three coherent sections.
Stack and the why
| Technology | Why |
|---|---|
| Next.js 14 | App Router. SSR-ready (for dynamic Open Graph), but static pages are cached. Image optimization via next/image |
| TypeScript | strict mode, types for restaurant data, manifest, gallery items |
| Tailwind CSS | design tokens via :root CSS variables, utility classes. Bundle ~12 KB after purge |
| next/font/google | Fraunces Variable (axes: SOFT, WONK, opsz) + IBM Plex Serif + IBM Plex Mono — all subset, no FOUT |
| CSS animations + IntersectionObserver | All 16 layers in pure CSS, without framer-motion. Bundle ~80 KB gzipped smaller, performance — every animation runs on the GPU compositor |
| sharp | image processing, ~5x faster than ImageMagick |
| blurhash | 32-char placeholder without layout shift |
| VPS + nginx + Cloudflare | self-hosted, no Vercel-tier limits. Cloudflare Polish + WebP/AVIF auto-conversion |
What was hard
1. ScrollVelocityBlur without jank
The naive solution — update --scroll-blur on every scroll-event. That creates jank during fast scrolling — frames drop. The fix:
useScrollEmitterhook — arequestAnimationFramethunk, unsubscribable- Lerp smoothing:
current += (target - current) * 0.3— even if a scroll-event jitters, blur moves smoothly - Threshold:
currentBlur < 0.04 → 0— cuts off the noise
In DevTools FPS never drops below 58.
2. Sunrise sweep exactly once
Client: "the sunrise sweep is an emotion, seen a second time it gets old". The fix:
- On the first viewport entry, IntersectionObserver triggers the
sunrise-sweepclass sessionStorage.setItem('bruno.sunrise.seen', '1')— session flag- On a return visit to /brunch in the same session — the animation does not run
- Across sessions (new tab, new day) — it runs again
3. Gyroscope on iOS — the permission flow
DeviceOrientationEvent.requestPermission() is only available after a user gesture (a one-time click). On iOS pre-13 no permission is needed; on iOS 13+ an explicit request is required. Implemented via an onClick on a mini floating "Enable parallax" icon in the corner — clicking calls requestPermission(), and after approve the gyro-drift activates. On Android — no permission, gyro works right away.
4. Image pipeline with deduplication
The pHash library only works in the browser (Canvas API). The fix — a node-port pHash-image + sharp for preprocessing. Time — ~20s for 250 photos on the VPS. Cache in .scrape-cache/phash/ — repeat runs only process new photos.
5. JSON-LD Menu schema sized at 11 categories × N dishes
Restaurant Schema requires a Menu sub-schema with MenuSection and MenuItem. BRUNO has 11 categories, 80+ dishes. I generate the schema from data/restaurant.ts at runtime in app/layout.tsx. JSON-LD size — ~30 KB unminified. Minified via JSON.stringify without spaces — 18 KB. Injected via <script type="application/ld+json" dangerouslySetInnerHTML>.
6. Editorial design on mobile
An editorial site on mobile loses 80% of the emotion — the scale is different, the rhythm collapses. The fixes:
- Display headings fluid via
clamp(2.5rem, 8vw, 6rem)— on iPhone 13 the Hero h1 is 64px, on iPad — 96px, on 4K — 128px - Hairline dividers replaced by
1px solid rgba(245,238,224,0.10)— subtler on mobile - Y-direction padding reduced from
py-32topy-16 - 3D tilt is disabled on mobile —
@media (hover: none) { .tilt-card:hover { transform: none; } }— because there's no cursor
Deploy
VPS, nginx, Let's Encrypt, Cloudflare — same setup as other portfolio projects. The nginx config is standard, except for one detail:
# Long-term cache for hashed assets
location ~* \.(webp|avif|woff2|js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Short cache for HTML (revalidate often)
location / {
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
Cloudflare Polish — auto-WebP / AVIF on any uploaded image, fat Brotli, DDoS protection. Hosting cost — $0/month (free Cloudflare + a cheap VPS).
Result
The site is live and running at brunotbilisi.com. In the first two months after launch:
- Lighthouse mobile: Performance 96, Accessibility 100, Best Practices 100, SEO 100
- ChatGPT and Perplexity rank BRUNO in the top three on key English queries about brunch and fine dining in Tbilisi
- Booking conversion through the site — the only metric the client shares — grew by ~40% relative to the previous site (Squarespace template)
The stack was chosen for maximum durability and control: Next.js 14 — stable, not bleeding edge; pure CSS animations — they'll still work in 5 years even if framer-motion and Tailwind disappear; content in data/restaurant.ts — editing a phone number or a dish price takes 30 seconds.