// xyjear.md | 27.09.2026

Xyjear. Сайт-портфолио

Сайт-портфолио на своей теме WordPress с нуля: живой терминал, скрамбл-заголовок, CSS одним ответом.

Задача

Собственный сайт-портфолио на теме WordPress, написанной с нуля: своя структура, своя вёрстка, своё поведение, свой вес. Сайт-визитка, который описывает сам себя — эта статья лежит на том же домене, о котором в ней идёт речь.

Два требования тянут в разные стороны. Первый экран должен выглядеть живым терминалом: печатающийся boot, скрамбящийся заголовок, тёмная IDE-тема. И Lighthouse не должен от этого умереть, а он наказывает именно за анимацию первого экрана.

Как делал

Главный экран — не блок, а окно ~/xyjear, которое печатает содержимое строками. Перед стартом размер резервируется через min-height и min-width по getBoundingClientRect(): иначе колонки max-content схлопывались бы в процессе набора и весь текст прыгал бы вверх. Резерв снимается в onDone.

const rect = term.getBoundingClientRect();
if (!term.style.minHeight && rect.height) {
    term.style.minHeight = rect.height + "px";
}
if (!term.style.minWidth && rect.width) {
    term.style.minWidth = rect.width + "px";
}

const typist = new TheTypist(term);
typist.type(lines, {
  stepMs,
  onDone: () => {
    term.style.minHeight = "";
    term.style.minWidth = "";
  },
});

Скрамбл заголовка — вторая причина позднего полного кадра, и там была неочевидная проблема: при посимвольном наборе text-wrap: balance пережёвывает переносы на каждой букве, и слово в конце строки скачет. Решение — переносы ставит сам JS. Фраза замеряется скрытым зондом по реальной ширине заголовка, режется на строки заранее и печатается с жёсткими \n; CSS переключается на white-space: pre-line. Высота под самую длинную фразу тоже зарезервирована зондом, чтобы блок под заголовком не дёргался при смене фразы.

function wrapPhrase(el, phrase) {
    const width = el.clientWidth;
    if (!width || !phrase || !String(phrase).includes(" ")) {
        return String(phrase ?? "");
    }
    const probe = document.createElement("span");
    probe.setAttribute("aria-hidden", "true");
    probe.style.cssText =
        "position:absolute;visibility:hidden;pointer-events:none;" +
        "left:-100000px;top:0;white-space:pre;";
    el.appendChild(probe);

    const lines = [];
    let cur = "";
    for (const w of String(phrase).split(" ")) {
        const cand = cur ? cur + " " + w : w;
        probe.textContent = cand;
        if (cur && probe.getBoundingClientRect().width > width) {
            lines.push(cur);
            cur = w;
        } else {
            cur = cand;
        }
    }
    if (cur) lines.push(cur);
    probe.remove();
    return lines.join("\n");
}

CSS отдаётся одним ответом через core.css.php?set=…. На мобильной сети каждый отдельный <link> стоит примерно один RTT до первого кадра, поэтому вместо пяти-шести файлов набор зависит от типа страницы: пустое значение даёт базовый комплект везде, blocks — синглы, blocks,profile — страницу профиля, 404 — ошибку. Сборщик минифицирует на лету, отдаёт ETag и max-age на год, а URL версионируется по максимальному mtime задействованных файлов — правка любого из них даёт новый URL и старый кэш просто не возвращается.

$set_key = '';
if (is_singular(['post', 'project', 'page'])) {
    $set_key = 'blocks';
}
$profile_page_id = xyjear_page_id_by_slug('profile');
if ($profile_page_id && is_page($profile_page_id)) {
    $set_key = 'blocks,profile';
}
if (is_404()) {
    $set_key = '404';
}

$set_files = ['tokens.css', 'base.css', 'components.css'];
if (strpos($set_key, 'blocks') !== false)  $set_files[] = 'blocks.css';
if (strpos($set_key, 'profile') !== false) $set_files[] = 'profile.css';
if ($set_key === '404')                    $set_files[] = '404.css';

$core_ver = max([
    ...array_map(fn($f) => (int) @filemtime(XYJEAR_DIR . '/style/' . $f), $set_files),
    (int) @filemtime(XYJEAR_DIR . '/style/core.css.php'),
]);
wp_enqueue_style('xyjear-core-style', XYJEAR_URI . '/style/core.css.php?set=' . $set_key, [], (string) $core_ver);

Минификатор пришлось писать посимвольным парсером. Наивная regex по комментариям ломала content-строки, где встречается /* и */ — в теме есть content: "/* " для вывода форм CF7.

Чужие скрипты выпилены. Contact Form 7 ставит в очередь свои скрипты, стили и капчу Turnstile на каждом фронт-запросе, хотя форма в проекте одна и живёт на странице контактов. wp_dequeue работает только на фронте — в админке хук не срабатывает, так что мёртвый редактор записей не ломается. Отдельно повозился с обязательным чекбоксом согласия в CF7 6.1.7: без класса optional он на лету отключает кнопку submit, клик молчит и сообщений нет. Лечится добавлением класса wpcf7-acceptance-as-validation через фильтр wpcf7_form_class_attr — согласие проверяется на сабмите и выводит статус unaccepted.

Все скрипты темы с defer и в правильном порядке (core → buttons → scramble → main → syntax → boot → slider). Метрика подключена через requestIdleCallback с таймаутом 2 секунды, без вебвизора и тепловых карт — грузится только tag.js. preconnect к mc.yandex.ru стоит всегда, к challenges.cloudflare.com — только на контактах.

Отдельно warm-start: блоки первого экрана получают класс is-in синхронным инлайн-скриптом в футере, не дожидаясь main.js. На холодную это минус 0,3–0,5 секунды до появления контента. Ниже фолда всё так же работает IntersectionObserver, плавный скролл-reveal не сломан.

(function () {
  if (!document.querySelector ||
      !('getBoundingClientRect' in document.documentElement)) return;
  if (window.matchMedia &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  var warm = function () {
    var els = document.querySelectorAll('.reveal:not(.is-in)');
    for (var i = 0; i < els.length; i++) {
      if (els[i].getBoundingClientRect().top <= (window.innerHeight || 1)) {
        els[i].classList.add('is-in');
      }
    }
  };
  warm();
  window.addEventListener('load', warm);
})();

Итог

Мобильный: Performance 95, FCP 1,4 с, Speed Index 4,3 с, TTI 2,5 с, TBT 0 мс, CLS 0.

Десктоп: Performance 100, FCP 0,3 с, Speed Index 0,8 с, CLS 0,001.

Замер мобильный идёт в эмуляции Slow-4G, где локальные woff2 и изображения выше фолда приходят кусками.

Speed Index 4,3 — это честная цена за печать и скрамбл: полный кадр физически не может собраться раньше, чем допечатается последний символ. При этом серверного кеша страниц у сайта нет — доступ только к теме, к конфигу хостинга доступа нет, поэтому каждый визит прогоняет PHP-рендер заново. Это то, что со стороны темы не чинится.

Шрифты локальные, но без preload для woff2 выше фолда — прелоад Manrope и JetBrains Mono под кириллицу скорее всего снял бы часть жёлтой зоны. Не проверено.

// похожие проекты

Ещё работы

// проект 2026
Нет изображения

Telegram парсер участников каналов

Консольный инструмент на Telethon: инвентаризация участников Telegram-групп с чекпоинтами и обходом FloodWait.

// проект 2026
Нет изображения

Бот для сбора лидов из Telegram-каналов и RSS

Бот для Telegram: читает каналы и RSS-ленты, фильтрует заказы по ключевым словам с весами и присылает подходящие в личку.

// проект 2026
Скриншот главной страницы лендинга ИГРАЙ-КА

«Играй-ка». Лендинг магазина игрушек

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

// проект 2026
Нет изображения

Парсер публикаций ВКонтакте

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

// проект 2026
Скриншот главной страницы лендинга Counter

Counter. Лендинг для типографии

Лендинг типографической студии: каскад размеров, живой образец шрифта и горизонтальная бегущая строка.

// проект 2026
Скриншот главной страницы лединга Salt and Cedar

Salt and cedar. Лендинг курорта у моря

Лендинг курорта у моря на WordPress и бесплатном Elementor: услуги, преимущества, бронирование и форма заявки.

> все проекты