tatarverse.cc - каталог татарских, башкирских и крымскотатарских центров, сообществ и инициатив: 420 карточек в 53 странах. Код открыт целиком, включая дизайн-систему и инструкции для ИИ-агентов. Ниже разбор того, из чего он собран, где были грабли и куда идти, если захотите помочь.

proxima812/tatarverseИсходники: Astro 7, MDX, Tailwind v4, Cloudflare Pages.
Главная tatarverse.cc: заголовок «Найди своих рядом», кнопка на 420 центров и блок статистики
Главная tatarverse.cc: заголовок «Найди своих рядом», кнопка на 420 центров и блок статистики

Почему статика

У каталога нет личных кабинетов, корзины и ленты. Есть данные, которые меняются редко, и страницы, которые надо быстро отдавать по всему миру. Сервер тут не нужен: output: "static", весь сайт превращается в набор HTML-файлов и раздается с CDN.

Отдельное решение - никаких UI-фреймворков. Ни React, ни Vue, ни Svelte, ни библиотеки анимаций. Только Astro-компоненты и CSS-кейфреймы. В цифрах это 54 компонента и 19 страниц, и ни одного острова гидратации.

JSON
{
	"astro": "7.0.3",
	"@astrojs/mdx": "7.0.0",
	"tailwindcss": "^4.0.9",
	"fuse.js": "^7.3.0",
	"wrangler": "^4.92.0"
}

Сверху - @astrojs/sitemap, @astrojs/rss, astro-icon, @dualmark/astro для markdown-двойников страниц и @vite-pwa/astro. Пакетный менеджер - Bun, деплой - Cloudflare Pages через Wrangler.

Контент: четыре коллекции и строгие схемы

Ни одной базы данных. Все лежит файлами и описано в src/content.config.ts:

Коллекция Папка Что внутри
centers src/data/centers_formatted 420 карточек на русском
centersEn src/data/centers_i18n/en 363 перевода на английский
posts src/data/posts редакционные заметки
thanks src/data/thanks благодарности контрибьюторам

Все схемы помечены .strict(). Это значит, что лишнее поле во frontmatter не игнорируется, а роняет сборку. Строго - намеренно: данные приносят разные люди, и молчаливо проглоченная опечатка в имени поля хуже красной сборки.

География не входит в схему. src/data/geo (ruRegions, macroRegions, places) нормализует поле location уже на рендере - для фасетов, поиска и карточек. Флаг страны тоже не пишется в MDX руками: он выводится из названия страны через countryFlagsByRu в src/data/worldCountries.ts.

Слаги карточек стабильные, вида tbk-389. Менять их нельзя: на них завязаны ссылки, которые уже разошлись по чатам и сайтам самих центров.

Каталог центров: поиск, фасеты по стране и региону, счетчики у каждого фильтра
Каталог центров: поиск, фасеты по стране и региону, счетчики у каждого фильтра

Дизайн-система: два файла, которые обязаны совпадать

Дизайн-система вынесена в DESIGN.md - 566 строк рассуждений, почему сделано именно так. Рядом лежит машинный сайдкар .impeccable/design.json. Правило жесткое: меняешь одно - меняешь второе.

Единственные источники правды: акценты в src/utils/accents.ts, палитры в src/styles/palettes/*.css, токены в src/styles/tailwind.css. Литеральных hex-значений и сырых палитр Tailwind в компонентах нет - только семантические токены.

Лестница поверхностей

Несущее правило всей системы: muted стоит между background и surface.

TXT
темная тема:  background < muted < surface < surface-muted < subtle
светлая:      surface > background > surface-muted > muted > subtle

Полоса секции берет bg-muted и утоплена относительно страницы, карточка на этой полосе берет bg-surface и приподнята над ней. Переставьте местами - и каталог схлопнется.

Именно это и произошло однажды. В темной теме muted оказался выше surface, а на трех пресетах они и вовсе совпали: orange 20%/20%, pink 15%/15%, red 13%/12%. Несколько сотен карточек рисовались тем же цветом, что и полоса под ними. В светлой теме surface и background были оба чисто белыми, так что каждая панель bg-surface была белым по белому.

Производные токены

Четыре токена не пишутся руками для каждой палитры, а вычисляются из своей же поверхности:

токен формула
surface-muted color-mix(in oklab, surface 94.5%, surface-foreground)
border-muted color-mix(in oklab, surface 86%, surface-foreground)
border color-mix(in oklab, surface 80%, surface-foreground)
depth-100 color-mix(in oklab, surface 84%, surface-foreground)

Смысл не в экономии строк. Написанная руками граница может уехать - и уезжала: border-muted давал контраст 1.10:1 на surface при акцентах red и pink, то есть его просто не было видно. Производное значение так сломаться не может: оно всегда на фиксированном перцептивном расстоянии от своей поверхности.

При этом muted, subtle и два цвета текста остаются авторскими для каждой палитры. Общая пропорция сплющила бы пресеты друг в друга.

Скругления едут вместе с акцентом

Радиус - часть пресета, а не константа. Шесть пресетов разложены в три регистра формы: шесть разных геометрий превратили бы шкалу в набор случайностей.

регистр пресеты micro control card catalog
default green, orange 8 16 24 32
medium violet, pink 8 12 18 24
small blue, red 6 8 12 16

red попал в мелкий регистр специально: это самый громкий акцент в наборе, и строгая форма его уравновешивает. Шаги сжимаются неравномерно - большие радиусы отдают пропорционально больше, иначе micro упал бы до 4px и стал неотличим от прямого угла.

Печатная версия каталога (/centers/print) держит сырые радиусы: бумага не должна зависеть от экранного пресета.

Подъем

Две утилиты вместо ручных пар dark:shadow-*:

  • surface-lift - для того, что в потоке: карточки, секции. Мягкая тень в светлой теме и никакой тени в темной, где слои и так разделены шагом светлоты.
  • overlay-lift - для того, что висит над страницей: меню, поповеры. В темной теме вместо тени снизу - светлая линия 1px по верхней кромке.

Черная тень на почти черной странице не рисует ничего, зато стоит композитинга.

Поверхности, которые надо пережить

Правка не готова, пока она не выжила на всех поверхностях сразу. Одна карточка каталога живет под всеми ними одновременно:

поверхность что проверять
Акцент 7 пресетов через [data-accent]: default (монохром), green, blue, violet, red, orange, pink
Тема светлая, темная, системная - контраст держится в обеих
Движение [data-motion="off"] и prefers-reduced-motion
Локаль ru без префикса, en под /en/
Печать /centers/print со своим лейаутом
Фильтры тулбар читает data-* прямо с DOM

«Собралось без ошибок» проверкой не считается.

Клиентские скрипты

JS на сайте немного, и он весь про интерфейс, а не про данные.

centersToolbar.ts - движок каталога: поиск, фасеты, активные фильтры. Оси фильтров не захардкожены: группа объявляет себя data-filter-group, и набор осей читается из разметки. Раньше имя каждой группы было вписано в тип состояния, в карту query-параметров и в четыре ветки применения фильтров - добавить пятую ось означало правку в восьми местах.

Три вещи, без которых иерархия фильтров не читалась:

  1. Счетчики живые. До этого они приезжали с сервера один раз, и после выбора «Казахстан» рядом с «Пермский край» продолжала висеть цифра 17.
  2. Опции с нулем прячутся. Это же согласовывает уровни: выбрал Украину - регионы и города других стран обнулились и ушли сами.
  3. Секции появляются по условию (data-filter-gate): город не показывается, пока не выбрана страна или регион, иначе это список на 122 строки.

savedCenters.ts - сохраненные карточки. Сервера нет и не будет, поэтому список живет в localStorage. Хранится не только id, а снимок карточки: страница /saved рисуется сразу из хранилища, без запроса к каталогу. Снимок берется с уже существующих data-атрибутов карточки - второй копии тех же строк в разметке не заводят.

appearance.ts - тема и акцент. Первичное применение делает инлайн-скрипт в <head>, до первой отрисовки: иначе вспышка светлой темы. Слушатели навешаны на document, поэтому число переключателей на странице роли не играет.

Скрипты проекта

Bash
bun run dev
bun run build
bun run cf:preview     # прогнать собранный dist через Cloudflare локально
bun run cf:deploy      # сборка и публикация
bun run release:bump   # версия в package.json и src/data/release.json
bun run brand-kit      # PDF со всеми пресетами

release:bump - единственный способ поменять версию. Руками package.json и src/data/release.json не правят. Поверх висит git-хук pre-push: он проверяет, что в пуше есть новые коммиты, что дерево чистое, бампает версию, коммитит и прерывает текущий пуш, чтобы релизный коммит уехал следующим. В форке он не нужен: RELEASE_BUMP_SKIP=1 git push.

brand-kit собирает одну HTML-страницу со всеми семью пресетами в обеих темах и печатает ее в PDF через headless Chrome. Значения он не дублирует, а читает tailwind.css и palettes/*.css и переписывает селекторы, чтобы четырнадцать состояний ужились на одной странице. Копия палитры в генераторе означала бы, что бренд-кит разойдется с сайтом в тот же день, когда кто-то поправит токен.

Markdown-двойники и Cloudflare

Сайт статический, поэтому эндпоинты @dualmark/astro до прода не доезжают: на диск попадает только тело .md-файла. Заголовки, которые считаются на каждый запрос, и согласование по Accept/User-Agent в статическом _headers невыразимы.

Поэтому в functions/_middleware.js живет функция Cloudflare Pages: она определяет ИИ-ботов, договаривается о формате и отдает markdown-двойник вместо HTML. Логика намеренно консервативная - если двойника у страницы нет, отдается обычный HTML, а не 404.

Агентский слой

Проект открыто устроен под работу с ИИ-агентами, и этот слой тоже лежит в git.

В .agents/skills/ - 13 скиллов. Восемь своих, с префиксом tatarverse-: astro-content, posts, ui-tailwind, brand, motion, i18n, page-weight, collab. Пять сторонних: impeccable, design-taste-frontend, high-end-visual-design, redesign-existing-projects, full-output-enforcement. Сторонние закреплены в skills-lock.json и обновляются через bunx skills add, а не правкой SKILL.md руками.

.claude/skills/ - симлинки на те же папки, чтобы Codex и Claude видели один набор и поддерживать надо было одну копию.

Хуков три:

  • posts_style_check.py висит на PostToolUse и после каждой правки в src/data/posts возвращает нарушения стиля с номерами строк.
  • Хук impeccable проверяет UI-файлы после правки и делает глубокий проход на Stop.
  • pre_tool_use_guard.py и session_start.py - на входе.

В AGENTS.md зафиксированы три жестких запрета для постов, и проверяет их машина, а не память: никаких длинных тире, никакой буквы «е» с точками и никакого написания бренда с заглавной V в середине. Правильно - Tatarverse, а в теле поста строчное tatarverse.

Оттуда же два правила, за которые заплачено:

Подбирай хвосты. Удалил файл, экспорт, маршрут или константу - прогони rg по всему репозиторию, включая .md и скиллы. Удаление скриптов контраст-аудита оставило DESIGN.md со ссылками на них, а снос карты оставил в CONTRIBUTING описание полей geo, которых уже нет.

export * не вносит имя в область видимости самого реэкспортирующего модуля. Переносишь общую константу в лист - барелю, который ее использует, нужен явный import. astro check при этом проходит, а падает сборка.

Нюансы и баги

Про инверсию лестницы поверхностей и невидимую границу уже было выше. Еще два.

Тумблер движения вешает компоненты. Переключатель в шапке пишет data-motion="off" на <html> и ставит animation: none и transition: none всему подряд. Любой компонент, чья логика ждет animationend или transitionend, после этого зависает навсегда. Такие места вырезают отдельно и дают им animation-duration: 1ms вместо none - событие все равно должно выстрелить. Прецедент - мобильный drawer.

Локали расходятся, и это нормально. Русских карточек 420, английских 363. Маршруты без английской версии перечислены в ruOnlyRoutes в src/i18n/index.ts вместе с поведением переключателя языка: stay - остаться, hub - увести на общую страницу.

Как контрибьютить

Самый ценный вклад - проверенные данные, и для него не нужно быть программистом.

Путь первый, целиком в браузере. Ни git, ни терминал, ни Node не нужны. Карточки лежат в src/data/centers_formatted/, каждый файл - один центр. На GitHub откройте папку и нажмите t - включится поиск по именам файлов. Если знаете только название, ищите по репозиторию с path:src/data. Дальше карандаш Edit this file, правка прямо в браузере, вкладка Preview покажет результат. Форк GitHub создаст сам.

Путь второй - форк и локальная разработка: для нескольких центров сразу, переводов и правок интерфейса.

Не уверены в данных? Все равно откройте issue со ссылкой на источник.

Схема карточки

Zod, .strict(), так что лишнее поле уронит сборку.

Поле Тип Обяз.
title строка да
type enum нет
category enum нет
source URL нет
summary строка нет
pubDate строка нет
location объект (тоже .strict()) нет

Внутри location: country, region, city, flag.

Что чаще всего ломается

  • Сборка падает на схеме. Лишнее поле во frontmatter или значение category/type не из enum.
  • source не проходит валидацию. Нужен полный URL со схемой: https://example.com/, а не example.com.
  • На английской странице русский текст. Ключ есть в ru.ts, но нет в en.ts - сработал фолбэк на локаль по умолчанию.
  • bun install падает. Нужен Bun 1.3+.
  • git push отклонен из-за релизного коммита. Это хук pre-push, в форке он не нужен: RELEASE_BUMP_SKIP=1 git push.

При добавлении строки интерфейса: добавьте ключ во все активные локали, используйте существующее пространство имен (nav.*, list.*, detail.*, copy.*, feedback.*) и не оставляйте захардкоженный текст в компонентах.

Что улучшать

Полезнее всего сейчас не рефакторинг, а данные: проверенные обновления карточек, английские переводы (отставание в 57 карточек), ссылки на источники, правки доступности и небольшие улучшения интерфейса без ломки визуального языка.

Гайды лежат в репозитории на двух языках: CONTRIBUTING.ru.md и CONTRIBUTING.en.md.

tatarverse.ccСам каталог: 420 центров в 53 странах.