Бизнес-логика
Концепт «Midnight Cellar», палитра и шрифты, 16 анимационных слоёв на чистом CSS, GEO-оптимизация под LLM, контент-пайплайн на 200+ фото, стек.
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-доставки. Это значит:
- Дискаверабельность в LLM-ассистентах — когда турист спрашивает у ChatGPT «where to get good brunch in Tbilisi», BRUNO должен попадать в топ-3.
- Editorial-плотность — мало текста, много фото, всё ритмично. Не «о нас / меню / контакты», а сцена-настроение, как в журнале о вине.
- Адаптивность под все три типа гостей — занятый бизнес-обед (нужны цена + время + телефон), запланированный ужин (нужно меню целиком + биодинамические вина), брунч-выходные (нужны фото блюд и часы работы).
Видео-обзор сверху показывает реальный ритм страницы: пауза на hero (свеча мерцает в правом верхнем углу), выходные карточки signature-блюд с 3D tilt, /reels со встроенными видео-петлями, masonry-галерея с blurhash placeholder, длинные журнальные цитаты в reviews.
Концепция: Midnight Cellar
Зайти в винный ресторан вечером — это войти в полу-тёмный зал, где свечи на столах, кирпичные арки, бутылки за стеклом и приглушённый jazz. Я перенёс это в digital — буквально. Палитра обратная стандартному «ресторанному» сайту (где обычно белый фон с пастельными акцентами): тут глубокий ink-чёрный с тёплой кремовой бумагой текста. Огонь свечи — единственное что светится.
Три ключа концепта:
- Не скевоморфизм, а аллюзия. Я не рисую свечу — я ставлю радиальный градиент
radial-gradient(ellipse 55% 42% at 18% 15%, rgba(201,101,31,0.28) 0%, transparent 55%)с keyframe-анимацией мерцания (6s ease-in-out infinite). Эффект — будто справа сверху горит источник тёплого света, и он немного пульсирует. Этого достаточно — мозг достраивает свечу сам. - Журнальный ритм, не лендинговый. Секции широкие, hairline-разделители 1px градиент к прозрачности (
linear-gradient(to right, transparent, rgba(245,238,224,0.16), transparent)), много воздуха. Заголовки крупные, body 16-17px, line-height 1.7. Не «продающий лендинг», а длинный разворот в журнале. - 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-line | rgba(245,238,224,0.08) | тонкие штрихи — разделители, borders |
--ink-line-strong | rgba(245,238,224,0.16) | hairline divider центр градиента |
--cream | #F5EEE0 | основной текст — тёплая бумага, не чистый белый |
--cream-muted | #9A8E78 | вторичный текст — caption, meta |
--fire | #C9651F | огненно-оранжевый — свеча, акцент, single CTA |
--fire-glow | rgba(201,101,31,0.12) | мягкая подсветка для candle-glow утилит |
--fire-glow-strong | rgba(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. Полный список:
Глобальные слои
- Film grain через
body::before. SVG-noise черезfeTurbulence, opacity 0.04,mix-blend-mode: screen. Постоянная плёночная фактура — мгновенное ощущение фото-печати, не «вёрстки». - Scroll progress bar — fixed top, fire-orange polosa, ширина =
scrollY / scrollHeight. Реализована черезElement.animate({ width: '...%' })с rAF-throttle. - Scroll velocity blur — на быстром scroll body получает
filter: blur(0..1.8px)через--scroll-blurCSS-переменную. Lerp-сглаживание (current += (target - current) * 0.3) убирает дёрганье. На медленном скролле blur = 0.
Hero
- Candle flicker — radial-gradient в правом верхнем углу с keyframe
candleFlicker6sease-in-out infinite, изменяющим opacity на 0.85 → 1.0 → 0.92 → 1.0 в нерегулярных интервалах. Похоже на настоящий свечной огонь. - Hero parallax через JS-обёртку
HeroParallax: фон смещается на 0.3× от scrollY за счёт CSS-переменной--parallax-y. - Gyro-drift на mobile: при наклоне устройства фон смещается на ±2-3° по углу. Через
DeviceOrientationEventAPI — там, где iOS даёт permission. Один из немногих сайтов в Тбилиси, использующих gyroscope.
Секции
- 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-delayCSS-переменную. - Menu category stagger + 3D tilt. На странице /menu и в Signatures на главной — карточки реагируют на mouse-over:
transform: perspective(1000px) rotateX(...) rotateY(...)зависимости от позиции курсора. Subtle, но делает карточки «живыми». Stagger при появлении — 80ms между карточками. - Gallery wave — masonry-блоки получают reveal-delay по diagonal-волне:
delay = (col * 50 + row * 80)ms. Создаёт эффект «накатывающей волны» вместо уныло одновременного появления. - Amber panel breathe — секция Brunch & Bar (на отдельной странице /brunch) дышит: тёплое янтарное свечение пульсирует 8s alternate. Невозможно увидеть в обзоре главной — нужно зайти на /brunch.
- Sunrise sweep — one-shot анимация на /brunch панели. При первом попадании в viewport (sessionStorage flag) панель получает class
sunrise-sweep, и градиент света «прокатывается» слева направо за 1.5s. Эффект «рассвета».
Touch / Mobile
- Touch ripple — кастомный feedback на касание: при
touchstartпоявляется кружок с opacity-fade-out за 600ms. Лучше чем дефолтный tap-highlight. - Swipe hints — на mobile-карусели Signatures: тонкая стрелка «прокручивается» в первый раз посещения секции, намекая на свайп.
- Music note ping — при hover на «cocktails»-секции на десктопе мелкая music-note иконка пингует с tiny scale 1.0 → 1.15 → 1.0.
Reels
- Autoplay-on-scroll videos на странице /reels: 8 видео-петель с интерьером и блюдами. Используется
IntersectionObserverс threshold 0.5 — если видео в центре viewport, оно играет, иначе паузится. Без звука. Оптимизация — preload="metadata" на всех, видео скачивается только при скролле к нему.
CountUp
- 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 секций:
- Hero (
#top) — full-bleed фото зала + display-заголовок «Bruno · Vake», подзаголовок «Modern European fusion», hours + tel mini-mono caps - HomeQuoteStrip — короткая цитата из gourmet-обзора, fire-orange wonk-italic
- HomeTriad — три vertical-блока: «Eat», «Drink», «Visit» с фото
- Signatures (
#signatures) — 9 signature блюд с превью + 3D tilt grid - Reels (
#reels) — 4 встроенных видео-петли с autoplay-on-scroll - Gallery (
#gallery) — 57 фото в masonry, blurhash placeholder - Reviews (
#reviews) — длинные журнальные цитаты из 4 источников (Google/Yandex/TripAdvisor/Guru) - HomeVisitCompact — карта + адрес + часы + телефон + booking link
- 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 @brunotbilisi | instaloader (без логина, публичный профиль) | 242 поста |
| Yandex Maps | Playwright headless chromium → /gallery/ → orig | 65+ |
| Google Business Profile | Playwright по g.page/r/... | ~30 |
| TripAdvisor | Playwright по review URL | ~40 |
| Restaurant Guru | Playwright по profile | 45 |
После сбора — единый pipeline scripts/process-gallery.mjs:
- Resize через
sharp: long edge max 1600px (больше не нужно для веба). - Convert в два формата: WebP q=82 + AVIF q=70. На фронте —
<picture>с<source type="image/avif">+ WebP fallback. AVIF тяжелее по CPU, но размер меньше на 20-30%. - Blurhash для каждого фото — 32-character placeholder, рендерится сразу в
<canvas>пока грузится финальное изображение. Никаких «прыжков» layout, никаких grey rectangles. - pHash дедупликация — одни и те же фото есть на IG и Yandex (фото-фолдер ресторана). pHash distance < 5 — считается дубликатом, оставляется только лучшая по разрешению.
- Manifest —
public/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"] }] - Curation в
manifest.curated.json— отбор hero-кандидатов (фото с luminosity ~0.48 — не слишком тёмные, не пересвеченные), и 3 sub-секции для main gallery: «interior», «food», «drinks».
Final gallery — 57 фото из ~250 собранных, организованы в три цельных раздела.
Стек и почему так
| Технология | Почему |
|---|---|
| Next.js 14 | App Router. SSR-ready (для динамических Open Graph), но статические страницы кэшируются. Image optimization через next/image |
| TypeScript | strict-mode, types для restaurant data, manifest, gallery items |
| Tailwind CSS | дизайн-токены через :root CSS-переменные, утилитарные классы. Bundle ~12 KB после purge |
| next/font/google | Fraunces 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 |
| sharp | image processing, ~5x быстрее ImageMagick |
| blurhash | 32-char placeholder без layout shift |
| VPS + nginx + Cloudflare | self-hosted, никакого Vercel-tier ограничения. Cloudflare Polish + WebP/AVIF auto-conversion |
Что было сложным
1. ScrollVelocityBlur без джанков
Прямое решение — на каждый scroll-event обновлять --scroll-blur. Это создаёт jank при быстром скролле — фрейм пропускается. Решение:
useScrollEmitterхук —requestAnimationFramethunk, отписываемый- 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 секунд.