гора.
BRUNO Tbilisi

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.

~16 min read · 3238 words
Full overview: home (Hero → About → Signatures → Reels → Gallery → Reviews → Visit → Footer) plus the /reels page with autoplay videos. Desktop and mobile walkthroughs — the browser picks the source by viewport width.

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:

  1. Discoverability in LLM assistants — when a tourist asks ChatGPT "where to get good brunch in Tbilisi", BRUNO should land in the top three.
  2. Editorial density — little text, lots of photos, all rhythmic. Not "about us / menu / contacts", but a scene and a mood, like a wine magazine.
  3. 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:

  1. 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.
  2. 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.
  3. 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:

TokenHEXRole
--ink-base#0D0A07deep ink-black, body background — between black and dark brown
--ink-raised#15110Braised layer — cards, hover state
--ink-inset#080605deeper than base — focal sections (Hero, Reviews)
--ink-linergba(245,238,224,0.08)thin strokes — dividers, borders
--ink-line-strongrgba(245,238,224,0.16)hairline divider gradient center
--cream#F5EEE0primary text — warm paper, not pure white
--cream-muted#9A8E78secondary text — caption, meta
--fire#C9651Ffire-orange — candle, accent, single CTA
--fire-glowrgba(201,101,31,0.12)soft glow for candle-glow utilities
--fire-glow-strongrgba(201,101,31,0.22)stronger glow for hero
--wine#6B1E2Arare emotional accent (brunch, reviews)
--wine-muted#3A1419dark 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 with WONK 1 and SOFT 100 looks 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

  1. Film grain via body::before. SVG noise via feTurbulence, opacity 0.04, mix-blend-mode: screen. A constant film texture — instant photo-print feel, not "markup".
  2. Scroll progress bar — fixed top, fire-orange strip, width = scrollY / scrollHeight. Implemented via Element.animate({ width: '...%' }) with rAF throttling.
  3. Scroll velocity blur — on fast scroll the body gets filter: blur(0..1.8px) through the --scroll-blur CSS variable. Lerp smoothing (current += (target - current) * 0.3) removes jitter. On slow scroll, blur = 0.

Hero

  1. Candle flicker — radial gradient in the upper right corner with the candleFlicker 6s ease-in-out infinite keyframe, shifting opacity 0.85 → 1.0 → 0.92 → 1.0 at irregular intervals. Looks like a real candle flame.
  2. Hero parallax via the JS wrapper HeroParallax: the background shifts by 0.3× of scrollY through the --parallax-y CSS variable.
  3. Gyro-drift on mobile: when the device tilts, the background shifts by ±2-3°. Through the DeviceOrientationEvent API — wherever iOS grants permission. One of the few sites in Tbilisi using the gyroscope.

Sections

  1. Scroll-reveal via the [data-reveal] attribute and IntersectionObserver. The element starts at opacity: 0; transform: translateY(24px); on entering the viewport it gets data-reveal-in="true" → opacity and transform go to zero, transition 600ms cubic-bezier(0.22, 1, 0.36, 1). Stagger via the --reveal-delay CSS variable.
  2. 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.
  3. 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.
  4. 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.
  5. Sunrise sweep — one-shot animation on the /brunch panel. On the first time it enters the viewport (sessionStorage flag) the panel gets the sunrise-sweep class, and a light gradient rolls left-to-right over 1.5s. A "sunrise" effect.

Touch / Mobile

  1. Touch ripple — custom touch feedback: on touchstart a circle appears with an opacity fade-out over 600ms. Better than the default tap-highlight.
  2. Swipe hints — on the mobile Signatures carousel: a thin arrow "scrolls" the first time the section is visited, hinting at the swipe.
  3. 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

  1. Autoplay-on-scroll videos on the /reels page: 8 video loops of the interior and dishes. IntersectionObserver with 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

  1. 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:

  1. Hero (#top) — full-bleed photo of the dining room + display heading "Bruno · Vake", subtitle "Modern European fusion", hours + tel mini-mono caps
  2. HomeQuoteStrip — short quote from a gourmet review, fire-orange wonk-italic
  3. HomeTriad — three vertical blocks: "Eat", "Drink", "Visit" with photos
  4. Signatures (#signatures) — 9 signature dishes with previews + 3D tilt grid
  5. Reels (#reels) — 4 embedded video loops with autoplay-on-scroll
  6. Gallery (#gallery) — 57 photos in masonry, blurhash placeholder
  7. Reviews (#reviews) — long magazine-style quotes from 4 sources (Google/Yandex/TripAdvisor/Guru)
  8. HomeVisitCompact — map + address + hours + phone + booking link
  9. 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:

SourceToolPhotos
Instagram @brunotbilisiinstaloader (no login, public profile)242 posts
Yandex MapsPlaywright headless chromium → /gallery/ → orig65+
Google Business ProfilePlaywright via g.page/r/...~30
TripAdvisorPlaywright via review URL~40
Restaurant GuruPlaywright via profile45

After collection — a single pipeline scripts/process-gallery.mjs:

  1. Resize via sharp: long edge max 1600px (more than that is unnecessary for the web).
  2. 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.
  3. Blurhash for every photo — 32-character placeholder, rendered immediately on <canvas> while the final image loads. No layout "jumps", no grey rectangles.
  4. 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.
  5. Manifestpublic/gallery/manifest.json with 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"]
    }]
    
  6. 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

TechnologyWhy
Next.js 14App Router. SSR-ready (for dynamic Open Graph), but static pages are cached. Image optimization via next/image
TypeScriptstrict mode, types for restaurant data, manifest, gallery items
Tailwind CSSdesign tokens via :root CSS variables, utility classes. Bundle ~12 KB after purge
next/font/googleFraunces Variable (axes: SOFT, WONK, opsz) + IBM Plex Serif + IBM Plex Mono — all subset, no FOUT
CSS animations + IntersectionObserverAll 16 layers in pure CSS, without framer-motion. Bundle ~80 KB gzipped smaller, performance — every animation runs on the GPU compositor
sharpimage processing, ~5x faster than ImageMagick
blurhash32-char placeholder without layout shift
VPS + nginx + Cloudflareself-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:

  • useScrollEmitter hook — a requestAnimationFrame thunk, 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-sweep class
  • 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-32 to py-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.

Business and design · hiregora.com