Лендинг мне прислали одним HTML-файлом на 545 КБ. Открываешь - работает: анимации, тёмные плиты стека, всё крутится. Заглядываешь внутрь - React-обёртка, ресурсы в base64 и шаблон, упакованный в JSON-строку. Копированием такое в проект не переносится.
Что было внутри бандла
Файл оказался самораспаковывающимся архивом. Разметка лежала в <script type="__bundler/template"> как JSON-строка, а рядом - манифест ресурсов: каждый со своим UUID, mime-типом и флагом сжатия. Картинки и шрифты в base64, скрипты дополнительно пожаты gzip.
Разбирать вручную бессмысленно, поэтому я написал короткий скрипт: он читает манифест, распаковывает нужное и раскладывает по папкам. На выходе получились 11 файлов woff2, дизайн-система в CSS и веб-компонент на three.js.
Шрифты: 39 правил на 11 файлов
Прототип тянул Nunito и Manrope с Google Fonts, а в бандле они уже лежали разбитыми на сабсеты: латиница, кириллица, греческий, вьетнамский. Правил @font-face было 39 - по одному на каждую пару «сабсет + начертание», хотя файлов всего 11.
Дело в том, что оба шрифта вариативные: один файл покрывает весь диапазон весов. Я схлопнул правила, указав диапазон вместо конкретного значения:
@font-face {
font-family: 'Manrope';
font-weight: 400 700;
font-display: swap;
src: url('../fonts/manrope-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
unicode-range здесь важнее, чем кажется: браузер скачивает только те сабсеты, символы из которых реально встретились на странице. Русская версия не грузит вьетнамский, английская - кириллицу. Плюс два самых нужных сабсета я подтянул через <link rel="preload">, чтобы текст не перерисовывался после загрузки.
Google Fonts в итоге не подключается вообще - все 240 КБ шрифтов отдаются со своего домена.
Инлайновые стили я оставил как есть
Вся разметка прототипа держалась на атрибутах style. Первым порывом было вынести это в классы - «сделать по-человечески». Я этого не сделал, и вот почему.
Размеры, сетки и отступы в такой вёрстке лежат ровно там, где их правят: открыл блок, поменял число. Перекладывание в классы даёт красивый CSS и потерянную связь между разметкой и стилем - чтобы поправить отступ, нужно искать правило в другом файле. Для лендинга, который меняется целыми блоками, это проигрыш.
Забегая вперёд: позже я это решение пересмотрел, и почему - в постскриптуме.
В CSS уехало только то, что инлайном не выражается: @font-face, токены палитры, компоненты вроде кнопок и карточек, анимации появления и медиазапросы. Адаптив тоже завязан на data-атрибуты разметки, а не на классы:
@media (max-width: 900px) {
[data-grid] { grid-template-columns: minmax(0, 1fr) !important; }
[data-hero-art] { width: min(260px, 100%) !important; }
}
Тёмная тема, которой в прототипе не было
Прототип был только светлым, а на сайте уже работал переключатель. Спасло то, что разметка почти везде ссылается на переменные: var(--color-neutral-800) вместо конкретного серого. Значит, достаточно переопределить палитру.
Нейтральную рампу я перевернул: в тёмной теме neutral-100 стал самым тёмным, neutral-900 - самым светлым. Карточки, фоны секций и текст встали на место сами.
А вот акцентную рампу переворачивать нельзя, и это было неочевидно. Блок контактов - тёмно-синий прямоугольник, собранный из шагов accent-800 и accent-900. Он должен выглядеть одинаково в обеих темах. Перевернёшь рампу - и в тёмной теме он станет светло-голубым, а текст на нём исчезнет. Поэтому в тёмной палитре я переопределил только два самых светлых шага акцента, которые используются как фоны плашек.
Мест, которым рампы не хватило, оказалось пять - их я вынес отдельными правилами. Ещё пришлось подложить белый фон под иконки технологий: это тёмные SVG, на тёмном фоне они просто пропадали.
3D-блок без сборщика
В секции стека прототип рисовал стопку слоёв на WebGL. Это был кастомный элемент на three.js, который тянул библиотеку с внешнего CDN прямо в рантайме.
Библиотеку я положил локально и передал путь атрибутом, а сам импорт оставил динамическим - 660 КБ грузятся только когда блок доходит до отрисовки:
const THREE = await import(this.dataset.three || "/js/vendor/three.module.min.js");
Названия слоёв компонент читает из data-layers, чтобы блок переводился вместе с остальным сайтом. На мобильных, без WebGL2 и при prefers-reduced-motion он сам рисует плоский список слоёв вместо трёхмерной сцены.
Две ловушки, на которые ушло больше всего времени
Стопка уезжала за кадр. В компоненте была логика: подписи слоёв стоят справа, и если геометрия на них наползает - сдвинуть её левее. Проблема в том, что повёрнутая стопка занимает по горизонтали заметно больше своей ширины: не W, а W·cos + D·sin. В отведённые 60% холста она не влезала, сдвиг упирался в жёсткий предел, и левый край плит оказывался вне пирамиды видимости камеры. На экране это выглядело как ровный вертикальный срез. Уменьшил масштаб стопки и добавил проверку левого края - она идёт первой, до сдвига ради подписей.
Горизонтальный скролл на телефонах. В hero есть декоративное свечение - круг 560 пикселей, вынесенный на 180 пикселей правее края секции. На них страница и растягивалась. Казалось бы, есть body { overflow-x: hidden } - но он не помогает: по спецификации это значение уходит на вьюпорт, а сам body остаётся visible и ничего не обрезает.
Помогла обёртка вокруг содержимого с overflow-x: clip. Шапку я оставил снаружи намеренно: обёртка с overflow становится скролл-контейнером и ломает position: sticky.
Что получилось
Лендинг рендерится на сервере из массива переводов, ассеты отдаются напрямую из public мимо Vite, внешних зависимостей у фронта не осталось. Тёмная тема, два языка, работающий 3D-блок и никакого горизонтального скролла на 320 пикселях.
Главный вывод не про код: перенос прототипа - это не копирование разметки, а решение, что из чужих договорённостей оставить, а что переписать под свой проект.
Постскриптум: и всё-таки я их убрал
Через некоторое время после запуска я перенёс всю вёрстку из атрибутов в классы. Аргументы из раздела выше никуда не делись, просто перестали перевешивать.
Что изменилось. Инлайн выигрывает, пока блоки разные: открыл, поправил число, готово. Но по мере роста страницы одни и те же наборы правил начали повторяться. Пять секций с одинаковым заголовком - это пять одинаковых строк по семьдесят символов. Правка размера заголовка превратилась из «поправить в одном месте» в «не забыть про пятое».
Добила ситуацию вторая часть сайта. Когда появился блог со своими страницами и общей шапкой, повторяющиеся куски пришлось бы дублировать уже в третий раз. Это ровно тот момент, когда «связь разметки и стиля» превращается в «копипаста в трёх файлах».
Отдельно решился вопрос с динамикой, который меня и держал. Цвет маркера в карточке опыта разный, и подставлять его в атрибут казалось единственным вариантом. На деле хватило модификатора с локальной переменной:
.exp-card--accent { --job-color: var(--color-accent); }
.exp-card--accent-2 { --job-color: var(--color-accent-2-500); }
.exp-points .bullet { background: var(--job-color); }
В разметке остаётся класс, вычисляемых стилей нет вообще.
Переносил не глазами. Снял полностраничные скриншоты до правок - три ширины, светлая и тёмная тема - и сравнил попиксельно после. Это сразу поймало потерю: скриншоты проектов лишились скругления и тени, потому что я убрал их из разметки и забыл добавить в класс. На картинке различий это выглядело как красный контур ровно вокруг двух изображений.
Остаточные расхождения нашлись только в полосе с трёхмерным блоком. Проверил гипотезу просто: снял два кадра одного и того же кода. Разошлись на ту же величину - значит дело в WebGL, который крутится по времени, а не в рефакторинге.
Вывод, который мне кажется важнее самого решения: оба состояния были правильными в своё время. Прототип не знал, что вырастет в сайт с блогом, и для прототипа инлайн был честным выбором. Ошибкой было бы не менять решение, когда изменились условия.