#stage {
  position: fixed;
  inset: 0;
  overflow: hidden;
  cursor: grab;
  touch-action: none;            /* иначе Safari перехватит жесты на свой зум */
  /* Фона здесь нет намеренно: карточки получают отрицательный z-index
     (см. render в scene.js), и подложка на самом #stage перекрыла бы их.
     Чёрный приходит от body — он же и есть «пустота» сцены. */
  /* Свой слой обязателен: сцена становится одним слоем, а её внутренний
     порядок остаётся внутренним делом.
     Меню, контакты и слой ярлыков лежат ВНУТРИ #stage (см. index.html) —
     иначе им не с чем смешиваться — и живут без своего z-index, то есть
     на «нуле». Поэтому карточки получают отрицательный zIndex (render в
     scene.js): работы обязаны быть ниже интерфейса, иначе работа наезжает
     на контакты и перехватывает клик.
     ВАЖНО: isolation здесь ставить нельзя. Меню и контакты инвертируются
     относительно работ (mix-blend-mode в base/menu.css); изоляция
     отрезала бы их от сцены, и смешиваться стало бы не с чем — остался
     бы чёрный фон body. */
  z-index: 0;
}
#stage:active { cursor: grabbing; }

#world {
  position: absolute;
  inset: 0;
  transform-style: flat;         /* проекцию считаем сами, не через CSS 3D */
  /* z-index не нужен: над лучами #world оказывается по месту в разметке
     (canvas идёт первым). Поставить его здесь нельзя — работы всплыли бы
     над слоем подписей, а те инвертируются относительно работ. */
  /* Прозрачен для указателя целиком: жесты слушает #stage (input.js),
     попадание в работу считает pick() обратной проекцией — ни одной
     карточке события не нужны. А вот перехватывать он не должен:
     позиционированный #world рисуется ПОВЕРХ статичных пунктов меню
     (меню absolute без z-index — ради mix-blend-mode), и без этого
     правила прозрачный лист забирал бы их наведение и клики. */
  pointer-events: none;
}

/* Слой ярлыков — сестра #world, а не часть его: подписи обязаны лежать
   поверх всех работ, а внутри #world карточка с меньшей глубиной
   накрывала бы чужой ярлык. Изначально причина была строже — инверсия
   (mix-blend-mode на .label-name) требовала, чтобы между текстом и
   сценой не было ни одного стекающего контекста. Инверсию у подписей
   сняли (см. .label-name), но слой остался: порядок отрисовки нужен
   сам по себе. */
.label-layer {
  position: absolute;
  inset: 0;
  /* z-index НЕ ставить: слой встал бы над меню и контактами, а те
     инвертируются относительно работ (base/menu.css) и живут в #stage
     без своего z-index. Поверх работ слой оказывается по месту
     в разметке — он добавляется после #world (см. scene.js). */
}
/* Слой и ярлыки прозрачны для указателя целиком. Попадание считает
   pick() обратной проекцией — он и так знает про ярлыки. А вот перехват
   события ломает всё: pointerdown на ярлыке уводит setPointerCapture
   с #stage, pointerup туда уже не приходит, и клик не засчитывается. */
.label-layer, .label-layer .card-label { pointer-events: none; }

.card {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;         /* матрица остаётся тривиально аффинной */
  will-change: auto;             /* включаем адресно, см. .has-video */
  contain: layout paint style;
  background: #16161a;
  overflow: hidden;
  border-radius: 2px;
  opacity: 0;
  backface-visibility: hidden;
}

/* Пока постер не пришёл — карточка не пустая дырка, а тихий прямоугольник.
   На чистом чёрном фоне заглушка обязана быть чуть светлее, чем раньше:
   #16161a на #0c0c0e ещё читался, на #000 незагруженная работа исчезала
   вовсе, и сцена выглядела наполовину пустой, пока грузятся постеры. */
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #232329, #16161b);
  opacity: 1;
  transition: opacity .45s ease;
  pointer-events: none;
}
.card.is-loaded::after { opacity: 0; }

.card-poster,
.card-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  user-select: none;
  -webkit-user-drag: none;
}

/* Слайд поста, чья форма расходится с формой карточки: вписываем целиком
   вместо среза по краям. Форму карточки задаёт обложка и менять её на
   каждом слайде нельзя — сдвинулась бы вся раскладка. */
.card.fit-contain .card-poster,
.card.fit-contain .card-video { object-fit: contain; }

.card-video {
  opacity: 0;
  transition: opacity .18s ease;
  pointer-events: none;
}
/* кроссфейд по событию playing: постер остаётся под видео,
   иначе первый кадр — чёрный прямоугольник */
.card-video.is-playing { opacity: 1; }

/* will-change только там, где реально крутится видео (максимум 3-6 карточек):
   поставить его на все — сотни МБ GPU-текстур и падение Safari на iPhone */
.card.has-video { will-change: transform; }

/* ── ярлык заказчика ─────────────────────────────────────────────── */
/* Ярлык — не карточка, а подпись в пространстве: ни плашки, ни рамки,
   только текст на чёрном. Плашка спорила с работами за внимание и делала
   вид, что имя заказчика — это тоже экспонат. Имя переносится по словам
   («Viner / Design / Group»), выключка влево. */
.card-label {
  background: none;
  border: 0;
  overflow: visible;             /* текст длиннее бокса — не режем его */
  display: flex;
  align-items: center;
  justify-content: flex-start;
  contain: none;                 /* иначе paint отсечёт вылезающий текст */
  /* База кегля 1px: тогда «17em» внутри читается как «17px в натуральную
     величину» — ровно то, что labelSize() заложил в baseW/baseH
     (см. layout.js). Масштаб и позицию даёт transform на самом ярлыке,
     прозрачность — opacity (см. render в scene.js); раньше и то и другое
     было запрещено ради mix-blend-mode текста, теперь инверсии нет. */
  font-size: 1px;
  /* Предел ширины строки описания. В тех же условных пикселях, что и
     кегли выше (база 1px), и обязан совпадать с LABEL_WRAP_W в
     config.js: по нему labelSize() считает, на сколько строк ломается
     текст, и закладывает высоту ярлыка. Разъедутся — раскладка отведёт
     ярлыку не ту площадь, и соседние облака налезут на подпись. */
  --label-wrap: 240em;
}

/* У ярлыка автора строка длиннее: он один в центре сцены, соседей рядом
   нет, и жать его до ширины подписи заказчика незачем. */
.card-label.is-root { --label-wrap: 340em; }
/* у ярлыка нет постера — гасим заглушку, иначе она перекроет текст */
.card-label::after { content: none; }

.label-inner {
  display: flex;
  flex-direction: column;
  gap: 4em;                      /* в em — масштабируется вместе с кеглем */
  text-align: left;
  /* Размеры в em: масштаб ярлыка задан font-size на .card-label, и всё
     внутри домножается само (см. render в scene.js). */
  white-space: pre-line;         /* перенос по словам задаётся в разметке */
}

.label-name {
  font-family: var(--font);
  /* в em, а не в px: масштаб ярлыка задаёт font-size родителя.
     ВАЖНО: базовые кегли обязаны совпадать с LABEL_*_SIZE в config.js —
     по ним labelSize() считает габарит под текст (см. layout.js). */
  font-size: 17em;
  font-weight: 400;
  letter-spacing: -.005em;
  line-height: 1.18;
  /* Прозрачность держит opacity самого бокса (см. render в scene.js),
     а не альфа цвета: инверсии у ярлыков больше нет ни на одном экране,
     и стекающего контекста можно не бояться. */
  color: #fff;
  /* ИНВЕРСИИ У ЯРЛЫКОВ НЕТ — ни на телефоне, ни на десктопе.
     Раньше здесь стоял mix-blend-mode: exclusion: ярлык нарисован поверх
     работ (см. zIndex в scene.js) и попадает на светлые кадры, а инверсия
     держала его читаемым без плашки. Плата оказалась слишком велика.
     Во-первых, blend запрещает композитору переиспользовать готовые слои:
     пиксель под подписью зависит от того, что под ним оказалось сейчас,
     значит при каждом движении камеры регион пересобирается заново —
     а подписей в кадре под десяток, и каждая обязана быть отдельным
     composited-слоем. Во-вторых и главное, ради инверсии ярлык нельзя
     было двигать через transform (стекающий контекст отрезал бы бленд
     от работ), и render() гонял его через left/top + font-size —
     то есть переразбивал весь текст ярлыка каждый кадр.
     Сначала это сняли на телефоне (там сцена шла ~30 fps вместо 60),
     теперь и на десктопе: лаги были заметны и там, а целиться надо
     в машину работодателя, а не в свою. Ярлыки идут по мобильному
     пути рендера — одним transform (см. render в scene.js).

     Что взамен: белый текст и text-shadow — но не та слабая, что раньше
     подстраховывала инверсию на среднесером. Там она была ореолом, а
     здесь обязана держать белый текст на белой работе одна, и ореол
     этого не делает: размытая полупрозрачная тень даёт на белом серую
     дымку, буквы в ней тонут (проверено попиксельно на фальш-карточке,
     рецепт в tools/SAFARI-BLEND.md). Поэтому тень двухсоставная:
     плотный контур в 1px почти без размытия обводит глиф и отделяет его
     от любой подложки, мягкий слой сажает ярлык на неё, чтобы обводка
     не читалась наклейкой. Ярлыки часто ложатся прямо на светлую
     работу — на десктопе чаще, чем на телефоне: работы крупнее.

     Инверсию сохранили меню (menu.css) и строка контактов (base.css):
     они неподвижны, их не пересобирают на каждом кадре камеры. */
  text-shadow:
    0 0 1px rgba(0, 0, 0, .92),
    0 1px 2px rgba(0, 0, 0, .85),
    0 0 12px rgba(0, 0, 0, .55);
}

.label-desc {
  display: block;
  font-size: 10em;               /* в em — см. .card-label */
  font-weight: 400;
  line-height: 1.35;
  /* приглушение постоянное: общая прозрачность ярлыка живёт в opacity
     бокса (см. render в scene.js), --label-a больше не нужна */
  color: rgba(255, 255, 255, .82);
  letter-spacing: 0;
  /* Строка с циклом не переносится — перенос сдвинул бы призрака и фразу
     на разное число строк, и высота бокса поехала бы на каждой смене
     (запрет стоит на самом .cyc-box, ниже). А вот статичный текст
     переноситься обязан: описания работодателей — это предложения
     («Тысяча подписчиков с нуля. Из инстаграма пришли заказы: два
     застройщика...»), и в одну строку они растягивали ярлык через всю
     сцену, отчего доска и выглядела разъехавшейся.
     Ширину держит max-width в тех же em, что и кегль: строка ломается
     по словам, а раскладка знает, на сколько строк (см. labelSize
     в layout.js — константы обязаны совпадать). */
  max-width: var(--label-wrap);
  overflow-wrap: break-word;
  /* Инверсии нет, тень держит читаемость одна — контур плюс мягкий слой,
     как у имени (см. .label-name). Кегль здесь мельче, и контур тем
     важнее: тонкие штрихи на белом пропадают первыми. */
  text-shadow:
    0 0 1px rgba(0, 0, 0, .95),
    0 1px 2px rgba(0, 0, 0, .85),
    0 0 10px rgba(0, 0, 0, .5);
}

/* Пометка при имени: «Aviasales case study». Мельче и приглушена —
   это уточнение жанра, а не часть названия. ВАЖНО: множитель кегля
   совпадает с tagFs в labelSize (layout.js). */
.label-tag {
  font-size: .62em;
  opacity: .55;
  letter-spacing: .02em;
  white-space: nowrap;
}

/* Подпись перед циклящимся перечнем: «Продуктовый маркетинг: A/B Testing».
   Приглушена, потому что меняется как раз то, что после двоеточия —
   взгляд должен держаться на нём, а не на неподвижной части. */
.label-cap { opacity: .62; }

/* ── циклящаяся строка ───────────────────────────────────────────── */
/* Одна фраза уходит вниз и растворяется, следующая приходит сверху.
   Место под фразу держит невидимый .cyc-ghost с самой длинной из них:
   сами фразы лежат в потоке absolute (иначе уходящая и приходящая
   встали бы в столбик и строка прыгала бы на каждой смене), а absolute
   высоты не занимает — без призрака строка схлопнулась бы в ноль.
   Ghost задаёт и ширину: ярлык не должен менять габарит по обороту,
   раскладка считает его один раз (см. labelSize в layout.js). */
.cyc-box {
  position: relative;
  display: inline-block;
  vertical-align: top;
  overflow: hidden;
  /* Здесь перенос запрещён именно потому, что фраза меняется: уходящая
     и приходящая сломались бы на разном числе строк, и высота бокса
     прыгала бы на каждой смене. Фразы цикла короткие — это пункты
     перечисления, а не предложения, так что запрет ничего не стоит. */
  white-space: nowrap;
  /* Высота — ровно строка. Без этого inline-block берёт высоту по глифам
     (8 px при line-height 14.85), overflow режет по ним, и уходящая фраза
     вылезает в следующую строку описания вместо того, чтобы скрыться. */
  height: 1.35em;
  line-height: 1.35;
}

.cyc-ghost {
  visibility: hidden;
  pointer-events: none;
  display: block;
  line-height: 1.35;
}

.cyc-item {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  line-height: 1.35;
  transition: transform .46s cubic-bezier(.4, 0, .2, 1), opacity .46s ease;
  /* will-change здесь НЕ постоянный: строк с циклом в сцене 13, и хинт
     на всех сразу держал бы столько же GPU-текстур круглые сутки — в том
     числе у ярлыков за краем экрана. Ставится на время самой смены и
     снимается по её окончании (см. advance в textCycle.js) — та же
     логика, что у .card.has-video. */
}

/* приходит сверху */
.cyc-item.is-enter { transform: translateY(-.55em); opacity: 0; }
.cyc-item.is-in    { transform: none;               opacity: 1; }
/* уходит вниз */
.cyc-item.is-out   { transform: translateY(.55em);  opacity: 0; }

/* Ярлык автора — вершина иерархии: крупное имя, описание мелко под ним.
   Разница кегля здесь и есть иерархия, никаких рамок и подложек. */
.card-label.is-root { justify-content: center; }
.card-label.is-root .label-inner { gap: 2em; }
.card-label.is-root .label-name {
  font-size: 46em;               /* в em — см. .card-label */
  font-weight: 400;
  letter-spacing: -.02em;
  line-height: 1.05;
}
.card-label.is-root .label-desc { font-size: 11em; }

/* Мобильные кегли — те же, что LABEL_*_SIZE у MOBILE в config.js */
@media (max-width: 760px) {
  .label-name { font-size: 14em; }
  .label-desc { font-size: 9em; }
  .card-label.is-root .label-name { font-size: 32em; }
  .card-label.is-root .label-desc { font-size: 10em; }
  /* Инверсия и цвета — общие с десктопом (см. .label-name):
     ярлыки не блендятся нигде. */
}

/* ── лучи ────────────────────────────────────────────────────────── */
/* Canvas под карточками: связи не должны перекрывать работы.
   Карточки живут в отрицательном z-index — минус глубина, до -FAR
   (см. render в scene.js), поэтому «под ними» — это ещё ниже. Запас
   намеренно большой: при FAR=2600 канвас не догнать. */
#rays {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: -100000;
}

@media (prefers-reduced-motion: reduce) {
  .card::after, .card-video { transition-duration: .01ms; }
  /* Смена фразы остаётся (иначе половина текста не будет прочитана
     никогда), но без движения — только проявление. */
  .cyc-item { transition: opacity .3s ease; }
  .cyc-item.is-enter, .cyc-item.is-out { transform: none; }
}
