гора.
BRUNO Tbilisi

Бизнес-логика

Концепт «Midnight Cellar», палитра и шрифты, 16 анимационных слоёв на чистом CSS, GEO-оптимизация под LLM, контент-пайплайн на 200+ фото, стек.

~15 мин чтения · 2904 слов
Полный обзор: главная (Hero → About → Signatures → Reels → Gallery → Reviews → Visit → Footer) + страница /reels с autoplay-видео. Десктоп и мобильный обзоры — браузер выбирает источник по ширине viewport.

TL;DR

BRUNO Tbilisi — modern European fusion в Vake. Сайт сделан как editorial-журнал: глубокий ink-чёрный фон («Midnight Cellar»), кремовая бумага текста, fire-orange акцент свечи, Fraunces Variable display с italic-вставками, IBM Plex Serif для body. 16 анимационных слоёв реализованы на чистом CSS + IntersectionObserver — без framer-motion, без анимационных библиотек. Multi-page архитектура: 9 секций главной + 8 routes. Built for GEO: JSON-LD Restaurant + Menu + FAQPage, llms.txt с answer-first summary, robots.txt с эксплицитным Allow для GPTBot, ClaudeBot, PerplexityBot, Google-Extended. Контент-пайплайн собирает 200+ фото из 5 источников (Instagram, Yandex Maps, Restaurant Guru, TripAdvisor, Google Business), prepare через sharp + blurhash + pHash-дедупликацию. Деплой — VPS + nginx + Cloudflare, домен brunotbilisi.com.

Контекст и аудитория

BRUNO — ресторан в Vake, премиум-район Тбилиси. Целевой гость: международный турист 30–45 лет (визит на 2–4 дня) или экспат, ищущий хорошее место с biodynamic wine и приличным brunch. Конкуренция в Vake плотная: десятки мест в радиусе 500 м, каждое со своим инстаграмом и Resos-страницей.

Заказчик хотел сайт-магазин, не сайт-визитку. Цель — превратить органический поиск в брони и заказы Wolt-доставки. Это значит:

  1. Дискаверабельность в LLM-ассистентах — когда турист спрашивает у ChatGPT «where to get good brunch in Tbilisi», BRUNO должен попадать в топ-3.
  2. Editorial-плотность — мало текста, много фото, всё ритмично. Не «о нас / меню / контакты», а сцена-настроение, как в журнале о вине.
  3. Адаптивность под все три типа гостей — занятый бизнес-обед (нужны цена + время + телефон), запланированный ужин (нужно меню целиком + биодинамические вина), брунч-выходные (нужны фото блюд и часы работы).

Видео-обзор сверху показывает реальный ритм страницы: пауза на hero (свеча мерцает в правом верхнем углу), выходные карточки signature-блюд с 3D tilt, /reels со встроенными видео-петлями, masonry-галерея с blurhash placeholder, длинные журнальные цитаты в reviews.

Концепция: Midnight Cellar

Зайти в винный ресторан вечером — это войти в полу-тёмный зал, где свечи на столах, кирпичные арки, бутылки за стеклом и приглушённый jazz. Я перенёс это в digital — буквально. Палитра обратная стандартному «ресторанному» сайту (где обычно белый фон с пастельными акцентами): тут глубокий ink-чёрный с тёплой кремовой бумагой текста. Огонь свечи — единственное что светится.

Три ключа концепта:

  1. Не скевоморфизм, а аллюзия. Я не рисую свечу — я ставлю радиальный градиент radial-gradient(ellipse 55% 42% at 18% 15%, rgba(201,101,31,0.28) 0%, transparent 55%) с keyframe-анимацией мерцания (6s ease-in-out infinite). Эффект — будто справа сверху горит источник тёплого света, и он немного пульсирует. Этого достаточно — мозг достраивает свечу сам.
  2. Журнальный ритм, не лендинговый. Секции широкие, hairline-разделители 1px градиент к прозрачности (linear-gradient(to right, transparent, rgba(245,238,224,0.16), transparent)), много воздуха. Заголовки крупные, body 16-17px, line-height 1.7. Не «продающий лендинг», а длинный разворот в журнале.
  3. Editorial > marketing. Никаких «Закажи стол прямо сейчас!» в huge-buttons. Кнопка бронирования спокойная, mini-mono caps. Главное — фото и тексты.

Палитра

Из app/globals.css :root — токены:

ТокенHEXРоль
--ink-base#0D0A07глубокий ink-чёрный, фон body — между чёрным и тёмно-коричневым
--ink-raised#15110Bприподнятый слой — карточки, hover-state
--ink-inset#080605глубже base — фокусные секции (Hero, Reviews)
--ink-linergba(245,238,224,0.08)тонкие штрихи — разделители, borders
--ink-line-strongrgba(245,238,224,0.16)hairline divider центр градиента
--cream#F5EEE0основной текст — тёплая бумага, не чистый белый
--cream-muted#9A8E78вторичный текст — caption, meta
--fire#C9651Fогненно-оранжевый — свеча, акцент, single CTA
--fire-glowrgba(201,101,31,0.12)мягкая подсветка для candle-glow утилит
--fire-glow-strongrgba(201,101,31,0.22)усиленная подсветка для hero
--wine#6B1E2Aредкий эмоциональный акцент (brunch, отзывы)
--wine-muted#3A1419вино тёмное, для глубоких секций

Только две акцент-цветности — fire и wine. Зелёного нет, синего нет, фиолетового нет. Это даёт визуальную монолитность — даже без логотипа сайт узнаваемо «BRUNO».

Типографика

Три шрифта, все через 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 для display-заголовков. Активны три оси: SOFT (мягкость серифов 0–100), WONK (характерные «странные» формы 0–1), opsz (оптический размер 9–144). Italic-вариант с WONK 1 и SOFT 100 выглядит как ручная гравюра — не как обычный наклон. Это даёт редкий характер, который сложно повторить.
  • IBM Plex Serif для body — нейтральный современный serif, weight 400. Хорошо читается на тёмном фоне на длинных отрывках.
  • IBM Plex Mono для meta — caption, цены, часы работы, телефон. Mono в editorial — это намёк на «этикетку», на ручную работу.

Ключевые утилиты:

.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 и line-height 0.92 на display — заголовки очень плотные, это «зерно». На body — обычные 1.7 line-height, читать комфортно на длинных отрывках.

16 анимационных слоёв

Главное отличие от других кейсов в портфолио — здесь анимации на чистом CSS + IntersectionObserver, без framer-motion, без анимационных библиотек. Это значит:

  • Bundle меньше на ~80 KB gzipped
  • SSR работает идеально (ничего не «прыгает» при гидрации)
  • Performance — все анимации на GPU compositor, не пересчитываются на каждый rAF

В видео сверху видны 9 из 16. Полный список:

Глобальные слои

  1. Film grain через body::before. SVG-noise через feTurbulence, opacity 0.04, mix-blend-mode: screen. Постоянная плёночная фактура — мгновенное ощущение фото-печати, не «вёрстки».
  2. Scroll progress bar — fixed top, fire-orange polosa, ширина = scrollY / scrollHeight. Реализована через Element.animate({ width: '...%' }) с rAF-throttle.
  3. Scroll velocity blur — на быстром scroll body получает filter: blur(0..1.8px) через --scroll-blur CSS-переменную. Lerp-сглаживание (current += (target - current) * 0.3) убирает дёрганье. На медленном скролле blur = 0.

Hero

  1. Candle flicker — radial-gradient в правом верхнем углу с keyframe candleFlicker 6s ease-in-out infinite, изменяющим opacity на 0.85 → 1.0 → 0.92 → 1.0 в нерегулярных интервалах. Похоже на настоящий свечной огонь.
  2. Hero parallax через JS-обёртку HeroParallax: фон смещается на 0.3× от scrollY за счёт CSS-переменной --parallax-y.
  3. Gyro-drift на mobile: при наклоне устройства фон смещается на ±2-3° по углу. Через DeviceOrientationEvent API — там, где iOS даёт permission. Один из немногих сайтов в Тбилиси, использующих gyroscope.

Секции

  1. Scroll-reveal через [data-reveal]-attribute и IntersectionObserver. Element имеет opacity: 0; transform: translateY(24px), при попадании в viewport получает data-reveal-in="true" → opacity и transform к нулю, transition 600ms cubic-bezier(0.22, 1, 0.36, 1). Stagger через --reveal-delay CSS-переменную.
  2. Menu category stagger + 3D tilt. На странице /menu и в Signatures на главной — карточки реагируют на mouse-over: transform: perspective(1000px) rotateX(...) rotateY(...) зависимости от позиции курсора. Subtle, но делает карточки «живыми». Stagger при появлении — 80ms между карточками.
  3. Gallery wave — masonry-блоки получают reveal-delay по diagonal-волне: delay = (col * 50 + row * 80)ms. Создаёт эффект «накатывающей волны» вместо уныло одновременного появления.
  4. Amber panel breathe — секция Brunch & Bar (на отдельной странице /brunch) дышит: тёплое янтарное свечение пульсирует 8s alternate. Невозможно увидеть в обзоре главной — нужно зайти на /brunch.
  5. Sunrise sweep — one-shot анимация на /brunch панели. При первом попадании в viewport (sessionStorage flag) панель получает class sunrise-sweep, и градиент света «прокатывается» слева направо за 1.5s. Эффект «рассвета».

Touch / Mobile

  1. Touch ripple — кастомный feedback на касание: при touchstart появляется кружок с opacity-fade-out за 600ms. Лучше чем дефолтный tap-highlight.
  2. Swipe hints — на mobile-карусели Signatures: тонкая стрелка «прокручивается» в первый раз посещения секции, намекая на свайп.
  3. Music note ping — при hover на «cocktails»-секции на десктопе мелкая music-note иконка пингует с tiny scale 1.0 → 1.15 → 1.0.

Reels

  1. Autoplay-on-scroll videos на странице /reels: 8 видео-петель с интерьером и блюдами. Используется IntersectionObserver с threshold 0.5 — если видео в центре viewport, оно играет, иначе паузится. Без звука. Оптимизация — preload="metadata" на всех, видео скачивается только при скролле к нему.

CountUp

  1. CountUp на About-секции: рейтинги 4.9★, 4.8★, 4.6★, 4.7★ из четырёх источников (Google/Yandex/TripAdvisor/Restaurant Guru) — анимируются от 0 до целевого значения за 1.2s при попадании в viewport. Subtle, но добавляет «живой» оттенок цифрам.

Все 16 слоёв уважают prefers-reduced-motion: reduce. В CSS:

@media (prefers-reduced-motion: reduce) {
  .candle-glow::before { animation: none; }
  [data-reveal] { transition: opacity 200ms; transform: none !important; }
  /* ... */
}

В JS — window.matchMedia('(prefers-reduced-motion: reduce)').matches чек на старте каждого компонента-провайдера, fallback к no-op.

Архитектура страницы

Главная (app/page.tsx) — 9 секций:

  1. Hero (#top) — full-bleed фото зала + display-заголовок «Bruno · Vake», подзаголовок «Modern European fusion», hours + tel mini-mono caps
  2. HomeQuoteStrip — короткая цитата из gourmet-обзора, fire-orange wonk-italic
  3. HomeTriad — три vertical-блока: «Eat», «Drink», «Visit» с фото
  4. Signatures (#signatures) — 9 signature блюд с превью + 3D tilt grid
  5. Reels (#reels) — 4 встроенных видео-петли с autoplay-on-scroll
  6. Gallery (#gallery) — 57 фото в masonry, blurhash placeholder
  7. Reviews (#reviews) — длинные журнальные цитаты из 4 источников (Google/Yandex/TripAdvisor/Guru)
  8. HomeVisitCompact — карта + адрес + часы + телефон + booking link
  9. Footer — соцсети, юр.информация (BBB Restogroup LLC, 402360404)

Sticky Nav наверху — fade-in после первой прокрутки, blur backdrop, semi-transparent ink. На страницах глубокого уровня (например /menu/pasta) добавляется Breadcrumb. Скрытый ScrollProgress — fire-orange polosa на самой верхушке.

Внутренние страницы

8 routes за пределами главной:

  • /menu + /menu/{category} — 11 категорий (starters, salads, pasta, pizza, mains, desserts, brunch, brunch-extras, kids, drinks, wine). У каждой подкатегории — своя страница с full-bleed фото и stagger-карточками блюд.
  • /brunch — отдельная посадочная под целевую аудиторию «brunch до 16:00 ежедневно». На ней живёт sunrise sweep — самая медленная и эмоциональная анимация сайта.
  • /cocktails — отдельная страница с CocktailsPanel + музыкальный ping-эффект на иконке.
  • /reels — режим autoplay-on-scroll: 8 видео-петель, скроллишь и видишь всё что в зале происходит.
  • /reviews — полные 127 отзывов, 4 фильтра по источнику.
  • /visit — карта + три способа добраться + парковка + dress code.
  • /about — длинный текст о философии ресторана + рейтинги CountUp.

GEO: built for LLM-discoverability

Сайт делался под второй контур поиска — LLM-ассистенты (ChatGPT, Claude, Perplexity). На момент сборки в Тбилиси никто этим не занимался. Что сделано:

public/llms.txt

Answer-first summary в plain-text: 600 слов с ответами на 8 главных вопросов («where is BRUNO», «what's the cuisine», «opening hours», «brunch menu», «cocktail program», «vegan options», «booking», «address»). Структурирован как Q&A. LLM-боты при индексации получают концентрат — не нужно парсить HTML.

public/robots.txt

Эксплицитный Allow: для:

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: /

Многие ресторанные сайты в Тбилиси блокируют этих ботов по умолчанию (через дефолтные robots.txt от CMS). Мы — наоборот.

JSON-LD

В app/layout.tsx зашит полный schema:

  • Restaurant — name, address, geo, phone, hours, priceRange, servesCuisine, acceptsReservations
  • Menu — все 11 категорий с MenuSection и MenuItem (название, описание, цена, allergen, image)
  • FAQPage — 8 вопросов с answer-first ответами

Semantic HTML

<article>, <section>, <dl>/<dt>/<dd>, <figure>/<figcaption> — везде где это уместно. LLM-боты лучше извлекают сущности из правильно размеченного контента.

Answer-first prose в About / Visit / FAQ

Не «BRUNO — это место, где...», а «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.» — голый ответ в первом предложении, дальше детали.

Результат

Через 2 месяца после публикации BRUNO попадает в ChatGPT и Perplexity при запросах «brunch in Tbilisi», «european fusion Vake», «truffle pasta Tbilisi». Это органический канал, который не покупается и не зависит от платных каналов.

Контент-пайплайн на 200+ фото

Главная боль сайта-ресторана — фото. Заказчик дал доступ к Instagram и общую папку Yandex Maps. Я собрал пайплайн на 5 источниках:

ИсточникИнструментФото
Instagram @brunotbilisiinstaloader (без логина, публичный профиль)242 поста
Yandex MapsPlaywright headless chromium → /gallery/ → orig65+
Google Business ProfilePlaywright по g.page/r/...~30
TripAdvisorPlaywright по review URL~40
Restaurant GuruPlaywright по profile45

После сбора — единый pipeline scripts/process-gallery.mjs:

  1. Resize через sharp: long edge max 1600px (больше не нужно для веба).
  2. Convert в два формата: WebP q=82 + AVIF q=70. На фронте — <picture> с <source type="image/avif"> + WebP fallback. AVIF тяжелее по CPU, но размер меньше на 20-30%.
  3. Blurhash для каждого фото — 32-character placeholder, рендерится сразу в <canvas> пока грузится финальное изображение. Никаких «прыжков» layout, никаких grey rectangles.
  4. pHash дедупликация — одни и те же фото есть на IG и Yandex (фото-фолдер ресторана). pHash distance < 5 — считается дубликатом, оставляется только лучшая по разрешению.
  5. Manifestpublic/gallery/manifest.json с метаданными:
    [{
      "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 в manifest.curated.json — отбор hero-кандидатов (фото с luminosity ~0.48 — не слишком тёмные, не пересвеченные), и 3 sub-секции для main gallery: «interior», «food», «drinks».

Final gallery — 57 фото из ~250 собранных, организованы в три цельных раздела.

Стек и почему так

ТехнологияПочему
Next.js 14App Router. SSR-ready (для динамических Open Graph), но статические страницы кэшируются. Image optimization через next/image
TypeScriptstrict-mode, types для restaurant data, manifest, gallery items
Tailwind CSSдизайн-токены через :root CSS-переменные, утилитарные классы. Bundle ~12 KB после purge
next/font/googleFraunces Variable (axes: SOFT, WONK, opsz) + IBM Plex Serif + IBM Plex Mono — все сабсетятся, никакого FOUT
CSS animations + IntersectionObserverВсе 16 слоёв на чистом CSS, без framer-motion. Bundle меньше на ~80 KB gzipped, performance — все анимации на GPU compositor
sharpimage processing, ~5x быстрее ImageMagick
blurhash32-char placeholder без layout shift
VPS + nginx + Cloudflareself-hosted, никакого Vercel-tier ограничения. Cloudflare Polish + WebP/AVIF auto-conversion

Что было сложным

1. ScrollVelocityBlur без джанков

Прямое решение — на каждый scroll-event обновлять --scroll-blur. Это создаёт jank при быстром скролле — фрейм пропускается. Решение:

  • useScrollEmitter хук — requestAnimationFrame thunk, отписываемый
  • Lerp-сглаживание: current += (target - current) * 0.3 — даже если scroll-event дернулся, blur плавно движется
  • Threshold: currentBlur < 0.04 → 0 — отрезает мелочь

В DevTools FPS никогда не падает ниже 58.

2. Sunrise sweep ровно один раз

Заказчик: «sunrise sweep — это эмоция, увиденный второй раз он надоедает». Решение:

  • При первой просмотре в viewport — IntersectionObserver триггерит class sunrise-sweep
  • sessionStorage.setItem('bruno.sunrise.seen', '1') — флаг сессии
  • При перезаходе на /brunch в той же сессии — анимация не запускается
  • Между сессиями (новая вкладка, новый день) — снова работает

3. Gyroscope на iOS — permission flow

DeviceOrientationEvent.requestPermission() доступен только после user-gesture (кnonomik click). На iOS pre-13 permission не нужен, на iOS 13+ нужен явный запрос. Реализован через onClick на mini-floating иконке «Включить параллакс» в углу — при клике вызывается requestPermission(), после approve gyro-drift активируется. На Android — без всякого permission, gyro работает сразу.

4. Image pipeline с deduplication

pHash библиотека работает только на browser (Canvas API). Решение — node-port pHash-image + sharp для предобработки. Время — ~20s на 250 фото на VPS. Cache в .scrape-cache/phash/ — повторные запуски обрабатывают только новые фото.

5. JSON-LD Menu schema размером 11 категорий × N блюд

Restaurant Schema требует Menu подсхему с MenuSection и MenuItem. У BRUNO — 11 категорий, 80+ блюд. Я генерирую schema из data/restaurant.ts runtime в app/layout.tsx. Размер JSON-LD — ~30 KB unminified. Минификация через JSON.stringify без spaces — 18 KB. Внедряется через <script type="application/ld+json" dangerouslySetInnerHTML>.

6. Editorial design на mobile

Editorial-сайт на мобильном теряет 80% эмоции — масштаб другой, ритм рушится. Решения:

  • Display-заголовки fluid через clamp(2.5rem, 8vw, 6rem) — на iPhone 13 Hero h1 — 64px, на iPad — 96px, на 4K — 128px
  • Hairline-разделители заменены на 1px solid rgba(245,238,224,0.10) — на мобильном subtler
  • Padding y-direction уменьшен с py-32 на py-16
  • 3D tilt отключается на mobile — @media (hover: none) { .tilt-card:hover { transform: none; } } — потому что нет курсора

Деплой

VPS, nginx, Let's Encrypt, Cloudflare — same setup that другие проекты в портфолио. Конфиг nginx стандартный, кроме одной деталей:

# 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 на любом уплоадаемом изображении, пожирная Brotli, DDoS protection. Стоимость хостинга — $0/мес (free Cloudflare + копеечный VPS).

Результат

Сайт выкачен и работает на brunotbilisi.com. За первые два месяца после запуска:

  • Lighthouse mobile: Performance 96, Accessibility 100, Best Practices 100, SEO 100
  • ChatGPT и Perplexity ранжируют BRUNO в топ-3 по ключевым английским запросам про brunch и fine dining в Тбилиси
  • Conversion бронирования через сайт — единственная метрика, которую заказчик делит — выросла на ~40% относительно предыдущего сайта (Squarespace template)

Стек выбран под максимальную долговечность и контроль: Next.js 14 — стабильный, не bleeding-edge; чистые CSS-анимации — будут работать через 5 лет даже если сложить framer-motion и Tailwind; контент в data/restaurant.ts — править телефон или цену блюда занимает 30 секунд.