/* design-system.css — единственный источник токенов и фирменных примитивов.

   Подключается через @import из style.css и project-page.css: одну из этих двух
   таблиц загружает каждая из 123 страниц портфолио, поэтому двух строк хватает,
   чтобы система дошла везде, и ни один HTML-файл править не пришлось.
   Отдельные мини-сайты продуктов (Alarmun, plannera2, theletters, popogode)
   этих таблиц не подключают и живут своей жизнью — так и задумано.

   Основа — дизайн-код «Фирменный стиль с изометрией». Приём один: изометрия
   как чертёжная проекция. Она показывает объём, но не врёт про размеры. */

/* Наборный и сериф грузятся отсюда: их подхватывают все 123 страницы через те
   же два @import, и править 123 шапки не пришлось. Моноширинный остаётся
   Roboto Mono — он уже стоит везде, и ради оттенка тащить ещё одну гарнитуру
   на каждую загрузку незачем. */
@import url("https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700;800&family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap");

:root {
  /* ── Нейтральная шкала (Tailwind neutral) ─────────────────────────────── */
  --bg-white: #ffffff;
  --bg-neutral-100: #f5f5f5;
  --bg-neutral-200: #e5e5e5;
  --border-neutral-200: #e5e5e5;
  --text-black: #000000;
  --text-neutral-300: #d4d4d4;
  --text-neutral-400: #a3a3a3;
  --text-neutral-500: #737373;
  --text-neutral-600: #525252;
  --text-neutral-700: #404040;

  /* ── Бренд ────────────────────────────────────────────────────────────────
     Терракота — единственный акцент, один на экран: либо заголовок проекта,
     либо правая грань куба, не оба сразу. Красный, синий и жёлтый ниже —
     только служебные состояния. */
  --terra: #ff5a1f;
  --terra-line: rgba(255, 90, 31, 0.42);   /* подчёркивание ссылок в покое */
  --terra-deep: #e8460a;                   /* нижняя грань плинта у чёрной кнопки */

  /* Солнечный жёлтый. Он и раньше жил в системе — вторым прогоном под мазком,
     — но только внутри data-URI, куда переменная не дотягивается. Стикерам,
     принтам и знакам он нужен как цвет, а не как строка в ссылке. Акцентом
     не бывает: подложка, второй прогон, заливка знака — и всё. */
  --sun: #ffc814;

  /* Грани плинта: правая светлее нижней — свет всегда сверху. */
  --face-r: #d8d3cb;
  --face-b: #bfb8ad;

  /* ── Паттерн LE ───────────────────────────────────────────────────────────
     Буквы L и E, выдавленные в изометрии, чередуются в шахматном порядке.
     Внешним файлом, а не data-URI: инлайном плитка весит ~6 КБ и легла бы
     в каждую загрузку CSS. Тон 0.09 вшит в сам файл — фон читается вблизи
     и не спорит с текстом. Живёт под контентом, поверх карточек — никогда. */
  --le-soft: url("/images/pattern-le.svg");
  --le-e-soft: url("/images/pattern-e.svg");
  --le-l-soft: url("/images/pattern-l.svg");
  --le-tile: 120px 196.3px;

  /* ── Растр ────────────────────────────────────────────────────────────────
     Терракота почти нигде не заливается сплошняком — вместо неё типографский
     растр. Шаг 6 px не меняется никогда: масштабировать сетку нельзя, меняется
     только диаметр точки или направление линий. Шесть ступеней, шесть имён. */
  --raster-step: 6px;
  --raster-90: radial-gradient(var(--terra) 2.6px, transparent 2.7px);
  --raster-70: radial-gradient(var(--terra) 2.1px, transparent 2.2px);
  --raster-55: radial-gradient(var(--terra) 1.6px, transparent 1.7px);
  --raster-40: radial-gradient(var(--terra) 1.15px, transparent 1.25px);
  --raster-25: radial-gradient(var(--terra) 0.75px, transparent 0.85px);
  --raster-12: radial-gradient(var(--terra) 0.45px, transparent 0.55px);
  --raster-gray: radial-gradient(var(--text-neutral-400) 1.15px, transparent 1.25px);

  /* Текстура закреплена за разделом: по фону шапки видно, где ты находишься,
     даже без заголовка. Шаг у всех трёх один, меняется только направление. */
  --tex-projects: var(--raster-55);
  --tex-recruiters: repeating-linear-gradient(0deg, var(--terra) 0 1px, transparent 1px 6px);
  --tex-notes: repeating-linear-gradient(90deg, var(--text-black) 0 1px, transparent 1px 6px);

  /* Те же три фактуры, но вывернутым цветом — под пятна на странице, которые
     смешиваются режимом difference.

     difference на белом просто выворачивает то, что на него положили. Значит
     чтобы пятно на белой странице читалось фирменной терракотой, класть надо
     её инверсию: 255 минус каждый канал от #ff5a1f даёт #00a5e0. Проверено
     счётом — на белом выходит ровно rgb(255,90,31), знак в знак.
     У заметок фактура чёрная, её инверсия — белая.

     Смысл выворота в том, что на белом (а это почти весь сайт) пятно
     выглядит обычной терракотовой фактурой, а стоит ему наехать на фото или
     на тёмное хиро — цвет уходит в бирюзу и пятно оживает. */
  --terra-invert: #00a5e0;
  --tex-projects-inv: radial-gradient(var(--terra-invert) 1.6px, transparent 1.7px);
  --tex-recruiters-inv: repeating-linear-gradient(0deg, var(--terra-invert) 0 1px, transparent 1px 6px);
  --tex-notes-inv: repeating-linear-gradient(90deg, #ffffff 0 1px, transparent 1px 6px);

  /* Два разделителя вместо серых черт. Штриховка — грань плинта, развёрнутая
     в линию; ряд точек — там, где сплошная черта была бы слишком громкой.
     Оба лежат значениями, а не классами: вешать их приходится на элементы,
     имена которых уже заняты. */
  --hatch: repeating-linear-gradient(-45deg, var(--text-black) 0 1px, transparent 1px 9px);
  --hatch-height: 14px;
  --dot-rule: radial-gradient(var(--text-black) 1.4px, transparent 1.5px);

  /* ── Геометрия ────────────────────────────────────────────────────────────
     Угол один и тот же везде: 45° у плинта и штриховки, 30° у изо-сетки.
     Других наклонов не бывает. Мягких теней нет: объём даёт плинт,
     глубину — кромка страниц и обводка. */
  --edge-gap: 4px;      /* просвет между линиями кромки страниц */
  --plinth-depth: 8px;  /* глубина граней у кнопки */

  /* ── Бумага ───────────────────────────────────────────────────────────────
     Тёплый газетный тон вместо белого и зерно отдельным слоем фона. Пятна
     фактуры разделов сидят на body::before/::after, поэтому зерно кладётся
     в стопку фонов, а не в псевдоэлемент. */
  --paper: #f6f1e6;
  --paper-0: rgba(246, 241, 230, 0);   /* та же бумага прозрачной — для роспусков */
  --paper-deep: #ede7da;               /* та же бумага на тон темнее — лист под образцами */
  --paper-veil: rgba(246, 241, 230, 0.92); /* липкая шапка поверх бумаги */
  --pg-rule: #a99e88;                  /* волосяная черта, видимая на бумаге */
  --ink-body: #242424;
  --grain: radial-gradient(rgba(26, 23, 20, 0.34) 0.5px, transparent 0.6px);
  --grain-tile: 3px 3px;

  /* ── Шрифты ───────────────────────────────────────────────────────────────
     Три голоса: наборный ведёт заголовки и выноску, сериф — всё, что читают
     подряд, моноширинный — служебное. Четвёртого не бывает. */
  --font-display: 'Unbounded', 'Geologica', sans-serif;
  --font-body: 'Inter', sans-serif;
  --font-serif: 'Lora', 'Georgia', serif;
  --font-mono: 'Roboto Mono', monospace;

  /* ── Служебные состояния ──────────────────────────────────────────────── */
  --red-600: #dc2626;
  --blue-600: #2563eb;
  --yellow-400: #facc15;

  /* ── Движение ─────────────────────────────────────────────────────────────
     Без пружин и перелётов: нажатие — 120 мс, наведение — 200 мс. */
  --transition-press: 0.12s ease;
  --transition-fast: 0.2s ease-out;
  --transition-normal: 0.3s ease;

  /* ── Фокус ────────────────────────────────────────────────────────────── */
  --focus-ring-color: #2563eb;
  --focus-ring-width: 2px;
  --focus-ring-offset: 2px;
}

/* Высокий контраст: тексту нужен запас, поэтому нейтрали темнеют.
   Терракота не трогается — она и так проходит по контрасту на белом. */
@media (prefers-contrast: more) {
  :root {
    --text-neutral-400: #5a5a5a;
    --text-neutral-500: #464646;
    --text-neutral-600: #363636;
    --text-neutral-700: #2b2b2b;
  }
}

/* ══════════════════════════════════════════════════════════════════════════
   ФИРМЕННЫЕ ПРИМИТИВЫ

   Приёмы системы применяются к именам, которые уже стоят в разметке, —
   переименовать их нельзя: на .case-screenshot, .project-preview,
   .gallery-item и .project-gallery-item завязан zoom.js, он вешает клик-зум
   по этим селекторам и инжектит свои стили в рантайме. Поэтому повторяемые
   приёмы живут значениями в токенах выше, а не классами-обёртками, и
   раздаются в блоке привязки ниже.
   ══════════════════════════════════════════════════════════════════════════ */

/* Моно-кикер — единственный примитив, который вешается классом: он нужен
   в разметке кейса, где своего имени под эту строку не было. */
.le-kicker {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-neutral-400);
}

@media (prefers-reduced-motion: reduce) {
  .cta-button,
  .cta-button::before,
  .cta-button::after,
  .hire-cta-button,
  .hire-cta-button::before,
  .hire-cta-button::after {
    transition: none;
  }
}

/* ── Мазок ────────────────────────────────────────────────────────────────
   Длинная линия с круглым концом, проведённая по фактуре. Один прогон и
   один цвет. Было два — жёлтый со сдвигом вниз и оранжевый умножением
   поверх, — и на бумаге это читалось не промахом печати, а двумя полосами:
   жёлтая вылезала снизу отдельной лентой и спорила с мазком за внимание.
   В артефакте мазок всегда был один, оранжевый, и он был прав.

   Кривая идёт строго от края до края: путь начинается на x=0 и кончается
   на x=360, то есть на самых границах viewBox. Круглые концы при этом
   срезаются рамкой пополам — так и задумано, мазок не висит в воздухе,
   а входит в кадр и выходит из него.

   Толщину держит vector-effect: без него растяжка по ширине расплющивала
   перо вместе с геометрией. Сдвиг второго прогона только вертикальный —
   горизонтальный растянулся бы вместе с кривой.

   Перо 14 при высоте 26: мазок должен читаться жестом, а не колбасой.
   Правило одно — один мазок на разворот. */
.le-stroke {
  display: block;
  height: 26px;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 100%;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23FF5A1F' stroke-width='14' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E");
}

/* Жирный мазок. Перо не тянется вместе с блоком — non-scaling-stroke держит
   его в пикселях, — поэтому на высокой плашке тонкое перо читается волной, а
   не жестом. Два начертания вместо резинового: 14 при 26 и 26 при 52. */
.le-stroke--bold {
  height: 52px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23FF5A1F' stroke-width='26' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E");
}

/* ── Мазок в другом цвете ─────────────────────────────────────────────────
   Цвет мазка лежал внутри data-URI, куда переменная не дотягивается: чтобы
   провести жёлтый по тёмному, пришлось бы держать вторую копию картинки, а
   потом третью и четвёртую. Поэтому картинка уходит в маску, а цветом
   становится обычный фон — один примитив закрывает все начертания.

   Фон-картинка остаётся запасным вариантом намеренно: где масок нет, мазок
   должен остаться оранжевым мазком, а не сплошной плашкой во весь блок.
   Поэтому цвет включается только внутри @supports, вместе с маской. */
@supports ((-webkit-mask-image: url("#m")) or (mask-image: url("#m"))) {
  .le-stroke {
    background-image: none;
    background-color: var(--stroke-ink, var(--terra));
    -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23000' stroke-width='14' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23000' stroke-width='14' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
  }

  .le-stroke--bold {
    background-image: none;
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23000' stroke-width='26' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 30' preserveAspectRatio='none'%3E%3Cpath d='M0 22 C72 6 148 26 226 14 C286 5 322 16 360 8' fill='none' stroke='%23000' stroke-width='26' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E");
  }
}

/* Четыре чернила, больше не бывает: те же, что у подложек стикеров. */
.le-stroke--sun { --stroke-ink: var(--sun); }
.le-stroke--ink { --stroke-ink: var(--text-black); }
.le-stroke--paper { --stroke-ink: var(--paper); }

/* ── Фактура в другом цвете ───────────────────────────────────────────────
   Токены --raster-* прибиты к терракоте, и подменить цвет ниже по дереву
   нельзя: var() внутри самого токена разрешается там, где токен объявлен,
   то есть на :root. Поэтому цвет вынесен в свойство, а не в токен — класс
   красит фактуру тем, что стоит в --raster-ink на самом элементе.

   Шаг при этом не меняется никогда: 6 px и точка по центру ячейки. Меняются
   только чернила и диаметр — жёлтый растр это тот же растр, а не другой. */
.le-raster {
  background-image: radial-gradient(
    var(--raster-ink, var(--terra)) var(--raster-dot, 1.6px),
    transparent calc(var(--raster-dot, 1.6px) + 0.1px));
  background-size: var(--raster-step) var(--raster-step);
}

/* Мазок под заголовком: подчёркивание, которое не линия. */
.le-stroke--under {
  height: 18px;
  margin-top: -0.25em;
}

/* ── Стикер-тихоходка ─────────────────────────────────────────────────────
   Картинка из /images/stickers/tardigrade/, вырезанная по контуру: кант и
   прозрачность уже в файле, поэтому здесь только размер, наклон и тень.
   Тень drop-shadow, а не box-shadow: она ложится по контуру наклейки,
   а не по прямоугольнику файла.

   Ставится одной строкой куда угодно — в кейс, на превью, в заметку:
     <img class="le-sticker" src="/images/stickers/tardigrade/yay.webp" alt="Yay!">
   Размер --sticker-size, наклон --sticker-tilt; модификаторы на частые случаи. */
.le-sticker {
  display: inline-block;
  width: var(--sticker-size, 140px);
  height: auto;
  max-width: 100%;
  rotate: var(--sticker-tilt, -4deg);
  filter: drop-shadow(0 6px 10px rgba(30, 20, 10, 0.22))
          drop-shadow(0 1px 2px rgba(30, 20, 10, 0.18));
  transition: rotate 220ms ease, translate 220ms ease;
}
.le-sticker:hover { rotate: 0deg; translate: 0 -3px; }
.le-sticker--s { --sticker-size: 96px; }
.le-sticker--l { --sticker-size: 200px; }
.le-sticker--tilt-right { --sticker-tilt: 5deg; }
.le-sticker--flat { --sticker-tilt: 0deg; }

/* На полях статьи: справа от абзаца, текст обтекает. Ставится прямо перед
   первым <p> раздела, к которому стикер — реплика. На узком экране меньше,
   чтобы рядом оставалась колонка текста, а не два слова в строке. */
.le-sticker--margin {
  float: right;
  margin: 0.25rem 0 1rem 1.5rem;
  --sticker-size: clamp(108px, 26vw, 170px);
}

/* Поверх кадра статьи (figure.case-screenshot): на углу картинки, за край.
   Кадр с кромкой — сетка, где картинка и обе черты кромки лежат в одной
   ячейке 1/1. Стикер ложится туда же и прижимается к углу отрицательным
   полем, поэтому ни position у фигуры, ни пересчёта под подпись не нужно:
   подпись во второй строке, и нижние углы — это углы картинки, а не подписи.
   Селектор длиннее обычного: .case-screenshot img в project-page.css тянет
   любую картинку в фигуре во всю ширину и обводит рамкой. */
.case-screenshot > img.le-sticker--on {
  grid-area: 1 / 1;
  position: relative;
  z-index: 3;
  width: var(--sticker-size);
  max-width: 45%;
  height: auto;
  border: 0;
  --sticker-size: clamp(110px, 17vw, 190px);
  align-self: start;
  justify-self: start;
  margin: -22px 0 0 -16px;
}
.case-screenshot > img.le-sticker--tr { justify-self: end; margin: -22px -16px 0 0; }
.case-screenshot > img.le-sticker--bl { align-self: end; margin: 0 0 -20px -16px; }
.case-screenshot > img.le-sticker--br { align-self: end; justify-self: end; margin: 0 -16px -20px 0; }
@media (max-width: 560px) {
  .case-screenshot > img.le-sticker--on { margin-left: -8px; }
  .case-screenshot > img.le-sticker--tr,
  .case-screenshot > img.le-sticker--br { margin-left: 0; margin-right: -8px; }
}

/* Поверх карточки или кадра: угол, чуть за край, над контентом. Родителю
   нужен position: relative — у превью и кадров он уже есть.
   pointer-events не гасим: стикер без наведения не шевелится, а именно этим
   он и отличается от картинки. Если под ним ссылка — клади стикер в неё. */
.le-sticker--corner {
  position: absolute;
  z-index: 3;
  top: -18px;
  right: -14px;
}

@media (prefers-reduced-motion: reduce) {
  .le-sticker { transition: none; }
  .le-sticker:hover { translate: none; }
}

/* ══════════════════════════════════════════════════════════════════════════
   ПРИВЯЗКА К СУЩЕСТВУЮЩЕЙ РАЗМЕТКЕ

   Классы ниже уже стоят в 123 страницах, и переименовать их нельзя. Поэтому
   примитив не «надевается» через class в разметке, а выдаётся здесь по имени.
   Геометрия при этом остаётся в одном месте — правится только выше.
   ══════════════════════════════════════════════════════════════════════════ */

/* Кадр внутри статьи стоит на кромке. Хиро (--showcase) — единственный кадр
   без неё: он и так во всю ширину и ничем не поддержан. */
.case-screenshot:not(.case-screenshot--showcase) {
  position: static;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  overflow: visible;
}

.case-screenshot:not(.case-screenshot--showcase) > img,
.case-screenshot:not(.case-screenshot--showcase) > video,
.case-screenshot:not(.case-screenshot--showcase) > picture,
.case-screenshot:not(.case-screenshot--showcase)::before,
.case-screenshot:not(.case-screenshot--showcase)::after {
  grid-area: 1 / 1;
}

.case-screenshot:not(.case-screenshot--showcase) > figcaption {
  grid-area: 2 / 1;
}

.case-screenshot:not(.case-screenshot--showcase)::before,
.case-screenshot:not(.case-screenshot--showcase)::after {
  content: "";
  position: static;
  border-right: 1px solid var(--text-black);
  border-bottom: 1px solid var(--text-black);
  pointer-events: none;
}

.case-screenshot:not(.case-screenshot--showcase)::before {
  margin: var(--edge-gap) calc(-1 * var(--edge-gap)) calc(-1 * var(--edge-gap)) var(--edge-gap);
}

.case-screenshot:not(.case-screenshot--showcase)::after {
  margin: calc(2 * var(--edge-gap)) calc(-2 * var(--edge-gap)) calc(-2 * var(--edge-gap)) calc(2 * var(--edge-gap));
}

/* Превью проекта на главной. Здесь подписи нет, поэтому кромка вешается
   на сам кадр обычным абсолютом. */
.project-preview {
  position: relative;
}

.project-preview::before,
.project-preview::after {
  content: "";
  position: absolute;
  border-right: 1px solid var(--text-black);
  border-bottom: 1px solid var(--text-black);
  pointer-events: none;
}

.project-preview::before {
  top: var(--edge-gap);
  left: var(--edge-gap);
  right: calc(-1 * var(--edge-gap));
  bottom: calc(-1 * var(--edge-gap));
}

.project-preview::after {
  top: calc(2 * var(--edge-gap));
  left: calc(2 * var(--edge-gap));
  right: calc(-2 * var(--edge-gap));
  bottom: calc(-2 * var(--edge-gap));
}

/* Главная кнопка страницы стоит на плинте. Отключённая — нет: подниматься
   ей некуда. */
.cta-button:not(.disabled),
.hire-cta-button {
  position: relative;
  display: inline-block;
  transition: transform var(--transition-press);
}

/* Плинт целиком одним псевдоэлементом. Обе грани — это Г-образная область
   вокруг правого и нижнего края, её вырезает clip-path; конический градиент
   из внутреннего угла делит её ровно по диагонали, поэтому правая грань
   светлее нижней — свет всегда сверху. Одним элементом, а не двумя, чтобы
   второй остался свободен под брызги. */
.cta-button:not(.disabled)::before,
.hire-cta-button::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: calc(100% + var(--plinth-depth));
  height: calc(100% + var(--plinth-depth));
  pointer-events: none;
  transition: inherit;
  background: conic-gradient(from 135deg at
      calc(100% - var(--plinth-depth)) calc(100% - var(--plinth-depth)),
      var(--terra-deep) 0deg 180deg,
      var(--terra) 180deg 360deg);
  clip-path: polygon(
      calc(100% - var(--plinth-depth)) 0,
      100% var(--plinth-depth),
      100% 100%,
      var(--plinth-depth) 100%,
      0 calc(100% - var(--plinth-depth)),
      calc(100% - var(--plinth-depth)) calc(100% - var(--plinth-depth)));
}

/* Плинт ловит курсор наравне с кнопкой. Без этого наведение на видимую
   терракотовую часть ничего не делало: глаз считает её кнопкой, а попадал
   курсор мимо. clip-path режет и область попадания, так что ловится ровно
   нарисованная Г, а не весь прямоугольник. */
.cta-button:not(.disabled)::before,
.hire-cta-button::before {
  pointer-events: auto;
}

/* Наведение: сама кнопка остаётся на месте, глубже становится только тень.
   Двигать плоскость нельзя — она уезжает из-под курсора, наведение слетает
   и кнопка начинает дрожать на границе. Подъём и так читается: предмет встал
   выше — тень отошла дальше. */
.cta-button:not(.disabled):hover,
.hire-cta-button:hover {
  --plinth-depth: 11px;
}

/* Нажатие: тень схлопывается, плоскость садится вниз-вправо — нижний правый
   угол остаётся на месте. */
.cta-button:not(.disabled):active,
.hire-cta-button:active {
  --plinth-depth: 2px;
  transform: translate(6px, 6px);
}

/* Брызги: пять чёрточек, разлетающихся от кнопки. Чистое баловство, поэтому
   только там, где есть настоящий курсор, — на тапе они бы мигнули и пропали.
   Каждая чёрточка — отдельный слой фона; разлёт даёт масштаб всего слоя. */
@media (hover: hover) and (pointer: fine) {
  .cta-button:not(.disabled)::after,
  .hire-cta-button::after {
    content: "";
    position: absolute;
    inset: -34px;
    pointer-events: none;
    opacity: 0;
    --spark: 1;
    transform: scale(0.55);
    transition: opacity 0.16s ease,
                transform 0.26s cubic-bezier(0.18, 0.9, 0.32, 1.2),
                background-size 0.16s cubic-bezier(0.2, 0.8, 0.3, 1);
    background-repeat: no-repeat;
    background-size:
      calc(22px * var(--spark)) calc(22px * var(--spark)),
      calc(19px * var(--spark)) calc(19px * var(--spark)),
      calc(22px * var(--spark)) calc(22px * var(--spark)),
      calc(20px * var(--spark)) calc(20px * var(--spark)),
      calc(17px * var(--spark)) calc(17px * var(--spark));
    /* Разлёт идёт вверх и в стороны, мимо правого нижнего угла: там плинт,
       и чёрточка на нём читалась бы сколом, а не брызгой. */
    background-position: 3% 2%, 33% 0%, 64% 1%, 0% 42%, 98% 12%;
    /* Каждая чёрточка — настоящий прямоугольник в SVG, а не полоса градиента:
       у градиента концы срезаются углами плитки и выходят заострёнными. */
    background-image:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect x='10.8' y='6' width='2.4' height='12' fill='%23000000' transform='rotate(-45 12 12)'/%3E%3C/svg%3E"),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect x='10.8' y='6' width='2.4' height='12' fill='%23000000'/%3E%3C/svg%3E"),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect x='10.8' y='6' width='2.4' height='12' fill='%23000000' transform='rotate(45 12 12)'/%3E%3C/svg%3E"),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect x='10.8' y='6' width='2.4' height='12' fill='%23000000' transform='rotate(90 12 12)'/%3E%3C/svg%3E"),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Crect x='10.8' y='6' width='2.4' height='12' fill='%23000000' transform='rotate(45 12 12)'/%3E%3C/svg%3E");
  }

  .cta-button:not(.disabled):hover::after,
  .hire-cta-button:hover::after {
    opacity: 1;
    transform: scale(1);
  }

  /* Нажали — брызги вытягиваются и уходят дальше: удар отдаёт в стороны. */
  .cta-button:not(.disabled):active::after,
  .hire-cta-button:active::after {
    opacity: 1;
    --spark: 2;
    transform: scale(1.4);
  }
}

/* ── Пятна фактуры раздела ───────────────────────────────────────────────── */

/* Два квадрата фирменной фактуры, разложенные по полям страницы. Раздел им
   даёт атрибут data-section на <html> — его проставляет scripts/stamp-section.py,
   там же объяснение, почему в разметке, а не по дорожке в шапке.

   Фактура берётся вывернутая, а смешивается режимом difference: на белом
   пятно выходит ровно фирменной терракотой, а наехав на фото или на тёмное
   хиро — выворачивается в бирюзу. Подробности у токенов в design-system.css.

   Размер, место и небольшой сдвиг при прокрутке ставит section-tex.js через
   переменные ниже. Значения по умолчанию — на случай, если скрипт не дошёл:
   пятна тогда просто стоят на месте. */
html[data-section="projects"]   { --section-tex: var(--tex-projects-inv) }
html[data-section="recruiters"] { --section-tex: var(--tex-recruiters-inv) }
html[data-section="notes"]      { --section-tex: var(--tex-notes-inv) }

/* Только там, где под пятно есть поле. Колонка текста — 896 точек; на экране
   уже 1200 поля по бокам тоньше полутора сотен, и пятно лезет в текст,
   а не стоит рядом с ним. */
/* Пятна выключены. Задумывались фактурной подсказкой раздела, но на деле
   стоят у самых краёв окна, к содержанию не привязаны и пользы не несут —
   глаз читает их как обрезки, залипшие к бокам.

   Правило оставлено целиком: приём рабочий, вернуть его — снять display:none,
   и всё, что ниже, снова заработает. Скрипт section-tex.js трогать не нужно,
   он просто заполняет переменные, которые никто не читает. */
@media (min-width: 1200px) {
  html[data-section] body::before,
  html[data-section] body::after {
    display: none;
    content: "";
    position: fixed;
    pointer-events: none;
    background-image: var(--section-tex);
    background-size: var(--raster-step) var(--raster-step);
    /* Ниже липкой шапки: пятно, размазанное difference по навигации,
       читается грязью на ней, а не фактурой на странице. */
    z-index: 5;
    mix-blend-mode: difference;
    /* Сдвиг пишет скрипт одним числом — так же, как в мозаике: у пятна могут
       быть и свои трансформации, а собирает их все таблица стилей. */
    transform: translateY(var(--tex-shift, 0px));
    will-change: transform;
  }

  html[data-section] body::before {
    top: var(--tex-a-top, 96px);
    left: var(--tex-a-left, 0px);
    width: var(--tex-a-size, 132px);
    height: var(--tex-a-size, 132px);
    --tex-shift: var(--tex-a-shift, 0px);
  }

  html[data-section] body::after {
    top: var(--tex-b-top, 58%);
    right: var(--tex-b-right, 0px);
    width: var(--tex-b-size, 84px);
    height: var(--tex-b-size, 84px);
    --tex-shift: var(--tex-b-shift, 0px);
  }
}

/* ── Ролик, который ещё едет ─────────────────────────────────────────────── */

/* Плитка с роликом занимает своё место в ленте сразу, а файл к ней приезжает
   потом — и до этого момента в кадре не должно быть пусто.

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

   Поверх постера — тонкая полоса внизу плитки. Без неё непонятно, будет тут
   движение или это просто картинка: постер от остановленного ролика не
   отличить. Полоса уходит, как только показывать есть что.

   Состояние ставит lazy-video.js на сам элемент: queued — стоит в очереди,
   loading — едет, ready — приехал. Контейнер узнаёт о нём через :has, чтобы
   скрипту не приходилось знать, во что его плитку завернули. */

/* Ролику без постера показывать до приезда нечего — под ним фирменный растр,
   тот же, что у плиток без кадра вовсе. Серый прямоугольник читался бы дырой
   в ленте, растр читается паузой.

   Условие про :not([poster]) обязательно: у ролика с постером фон плитки —
   это и есть постер, и заливка растром просто закрасила бы его. */
:where(.bento-card, .project-gallery-item, .reel):has(
    > video[data-load="queued"]:not([poster]),
    > video[data-load="loading"]:not([poster])) {
  position: relative;
  background-color: var(--bg-neutral-100);
  background-image: var(--raster-25);
  background-size: var(--raster-step) var(--raster-step);
}

video[data-load="queued"]:not([poster]),
video[data-load="loading"]:not([poster]) {
  opacity: 0;
  transition: opacity 0.45s ease;
}

video[data-load="ready"] {
  opacity: 1;
}

:where(.bento-card, .project-gallery-item, .reel):has(> video[data-load="queued"],
                                                      > video[data-load="loading"])::after {
  content: "";
  position: absolute;
  z-index: 6;
  left: 0;
  right: 0;
  /* Поверху, а не понизу: внизу плитки лежит подпись со своим затемнением, и
     полоса там читалась подчёркиванием к названию работы. */
  top: 0;
  height: 3px;
  pointer-events: none;
  background: linear-gradient(90deg,
    transparent 0%, var(--terra) 45%, var(--terra) 55%, transparent 100%);
  /* Полоса вдвое уже плитки и ходит по ней из края в край: это не проценты
     загрузки — сколько осталось, браузер не сообщает, — а признак работы. */
  transform: scaleX(0.5);
  transform-origin: left center;
  animation: video-lane 1.6s ease-in-out infinite;
}

@keyframes video-lane {
  0%   { transform: translateX(-50%) scaleX(0.5) }
  100% { transform: translateX(100%) scaleX(0.5) }
}

@media (prefers-reduced-motion: reduce) {
  /* Бегущей полосы нет, но и молчать нельзя: остаётся неподвижная, приглушённая
     на всю ширину — та же мысль без движения. */
  :where(.bento-card, .project-gallery-item, .reel):has(> video[data-load="queued"],
                                                        > video[data-load="loading"])::after {
    animation: none;
    transform: none;
    opacity: 0.5;
    background: var(--terra);
  }

  video[data-load]:not([poster]) { transition: none }
}

/* ── Проявка постера вместо полосы ───────────────────────────────────────────
   На reel, в отличие от плиток, файл тяжёлый (12 МБ) и грузится заметно.

   Прежде здесь была нитка по краю секции, потом — блок с полосой посреди кадра.
   Обе штуки сообщали «идёт загрузка» рядом с картинкой и обе были посторонним
   предметом поверх неё. Теперь показывает сама картинка: постер проявляется,
   как встарь проявлялся прогрессивный джипег — сперва десяток огромных клеток,
   потом всё мельче, пока не станет собой. Чем чётче кадр, тем ближе конец, и
   объяснять это не нужно никому.

   Рисует холст reel.js, ступени клеток там же. Здесь только место и уход. */

/* Нитку по краю секции гасим: указателей на одно ожидание должно быть один. */
.reel--progress::after {
  display: none;
}

.reel__pixels {
  position: absolute;
  z-index: 5;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Холст появляется только когда постер прочитан и первая ступень нарисована:
     класс ставит скрипт. Иначе на долю секунды мелькнул бы пустой прямоугольник
     поверх кадра. */
  opacity: 0;
  transition: opacity 0.35s ease;
}

.reel--pixels .reel__pixels { opacity: 1 }

/* Ролик поехал — клетки уходят, под ними уже настоящее видео.

   Условие тут только на data-load-done и намеренно не включает .reel--progress.
   Раньше включало, и это ломало всё разом: скрипт снимает .reel--progress через
   400 мс после готовности, правило переставало совпадать, холст возвращался к
   непрозрачности 1 и намертво накрывал кадр. Видео при этом честно играло —
   просто под слоем клеток, и выглядело так, будто оно не запустилось вовсе. */
.reel[data-load-done] .reel__pixels { opacity: 0 }

/* Проценты. По клеткам видно направление, но не остаток, — цифра досказывает.
   Подложка молочная с размытием, та же, что под сердечками на плитках: клетки
   бывают любого цвета, и без неё цифра тонула бы то в светлом, то в тёмном. */
.reel__load {
  position: absolute;
  z-index: 7;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  padding: 0.5rem 0.875rem;
  border-radius: 999px;
  pointer-events: none;
  background: rgb(255 255 255 / 76%);
  backdrop-filter: blur(8px) saturate(1.2);
  -webkit-backdrop-filter: blur(8px) saturate(1.2);
  opacity: 0;
  transition: opacity 0.3s ease;
}

.reel--progress .reel__load { opacity: 1 }
.reel[data-load-done] .reel__load { opacity: 0 }

.reel__pct {
  font-family: var(--font-mono);
  font-size: 1.125rem;
  font-weight: 500;
  /* Табличные цифры: иначе строка дёргается на каждом десятке. */
  font-variant-numeric: tabular-nums;
  color: var(--text-black);
}

@media (prefers-reduced-motion: reduce) {
  .reel__pixels,
  .reel__load {
    transition: none;
  }
}

/* ── Фоновый изометрический параллакс LE ────────────────────────────────────
   Разделяет фоновый паттерн на два слоя (буквы L на дальнем плане, E на ближнем).
   При движении мыши слои скользят с разной скоростью на GPU-трансформах. */
.bg-le {
  position: fixed;
  inset: -80px;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  background-color: var(--bg-white);
}

.bg-le__layer {
  position: absolute;
  inset: 0;
  background-size: var(--le-tile);
  background-repeat: repeat;
  will-change: transform;
}

.bg-le__layer--l {
  background-image: var(--le-l-soft);
}

.bg-le__layer--e {
  background-image: var(--le-e-soft);
}

body.has-bg-le {
  /* Паттерн уезжает на слои паралакса, а зерно остаётся: оно к движению
     не привязано и должно лежать на бумаге всегда. */
  background-image: var(--grain) !important;
  background-size: var(--grain-tile) !important;
}

@media (prefers-reduced-motion: reduce) {
  .bg-le__layer {
    transform: none !important;
  }
}
