* { box-sizing: border-box; }
:root {
  /* отступ содержимого от краёв центрального листа — то же число, что SHEET_PAD_PX
     в gallery-viewer.js (счётчик, подпись, логотип, текст About) */
  --sheet-pad: 16px;
  /* логотип: ширина (на телефоне переопределена ниже) и высота по пропорции PNG
     (772×149) — по ним About отсчитывает, где кончается логотип */
  --logo-w: clamp(96px, 15vw, 172px);
  --logo-h: calc(var(--logo-w) * 149 / 772);
}
html, body {
  margin: 0; padding: 0;
  width: 100%; height: 100%;
  overflow: hidden;
  background: #f7f6f4;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /* pan-y (не auto/manipulation) — специально ИСКЛЮЧАЕТ pinch-zoom и
     double-tap-zoom: на мобильном (баг замечен на телефоне) их сочетание с
     position:fixed на весь экран у #stage (см. ниже) ломает масштаб/позицию
     3D-сцены — canvas остаётся в размере layout viewport, а не visual
     viewport, и после щипка "уезжает" в угол экрана. iOS Safari игнорирует
     user-scalable=no из meta viewport (см. ниже), но уважает touch-action —
     поэтому основной фикс здесь, а не в meta. pan-y (не none) — вертикальный
     скролл длинной карточки (#detail-content) должен продолжать работать. */
  touch-action: pan-y;
}

#stage {
  position: fixed; inset: 0;
  perspective: 1300px;
  perspective-origin: 50% 50%;
  overflow: hidden;
  background: #f7f6f4;
}

#bg-parallax {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  will-change: transform;
}

#room {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
}

.wall {
  position: absolute;
  top: 10%;
  height: 80%;
  border-bottom: 1px solid rgba(72,72,72,0.32);
  border-top: 1px solid rgba(72,72,72,0.32);
  /* бумага в клетку: почти нейтральный белый лист + приглушённая графитовая миллиметровка */
  background-color: #fbfaf7;
  background-image:
    repeating-linear-gradient(to right, rgba(72,72,72,0.16) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(to bottom, rgba(72,72,72,0.16) 0 1px, transparent 1px 22px);
}

.wall.center {
  left: 18%;
  width: 64%;
  transform: translateZ(0px);
  border-left: 1px solid rgba(72,72,72,0.32);
  border-right: 1px solid rgba(72,72,72,0.32);
}

/* камера внутри многогранника: боковые стены разворачиваются к зрителю */
.wall.left {
  left: -140%;
  width: 158%;
  transform-origin: right center;
  transform: rotateY(50deg);
  border-left: 1px solid rgba(72,72,72,0.32);
}

.wall.right {
  left: 82%;
  width: 158%;
  transform-origin: left center;
  transform: rotateY(-50deg);
  border-right: 1px solid rgba(72,72,72,0.32);
}

.seam {
  position: absolute;
  top: 10%;
  height: 80%;
  width: 1px;
  background: rgba(72,72,72,0.32);
  z-index: 3;
}
.seam.seam-left { left: 18%; }
.seam.seam-right { left: 82%; }

.base {
  position: absolute;
  left: -70%; width: 240%;
  background-color: #ffffff;
  backface-visibility: hidden;
  overflow: hidden;
}
.base-dots {
  position: absolute;
  top: 50%; left: 50%;
  width: 400vmax; height: 400vmax;
  margin: -200vmax 0 0 -200vmax;
  background-image: radial-gradient(circle, rgba(72,72,72,0.16) 3.5px, transparent 3.5px);
  background-size: 16px 16px;
  transform: rotate(45deg);
  /* точки едут по локальной оси X — после rotate(45deg) контейнера это и есть
     "под 45° к центральному листу"; ось X не зависит от rotateX родителя
     (.base-floor/.base-ceiling) — вправо/влево пол и потолок всегда едут в
     одну сторону. Но та же самая диагональ даёт разный знак по оси Z (к
     экрану/от экрана) при rotateX(90deg) (пол) и rotateX(-90deg) (потолок) —
     пол и потолок "разъезжались" по глубине (см. .base-ceiling .base-dots
     ниже, где это скомпенсировано поворотом на 135° вместо 45°: для
     квадратной сетки точек это визуально та же ориентация — 90°-симметрия —
     но даёт нужный знак по Z). Скорость — 1s (было 2s, вдвое быстрее по
     просьбе 2026-09-16).
     ⚠️ Двигаем transform (translateY в повёрнутых координатах — то же, что
     сдвиг background-position на период 16px), а НЕ background-position: тот
     перерисовывал два слоя по 400vmax каждый кадр на главном потоке (на
     телефоне ~3300×3300px) — PageSpeed: «non-composited animation», лаги на
     iPhone. transform анимирует компоновщик без перерисовки. */
  will-change: transform;
  animation: dots-drift 1s linear infinite;
}
@keyframes dots-drift {
  from { transform: rotate(45deg) translateY(0); }
  to   { transform: rotate(45deg) translateY(-16px); }
}
@keyframes dots-drift-ceiling {
  from { transform: rotate(135deg) translateY(0); }
  to   { transform: rotate(135deg) translateY(-16px); }
}
.base-floor {
  top: 90%; height: 45%;
  transform-origin: top center;
  transform: rotateX(90deg);
}
.base-ceiling {
  top: -35%; height: 45%;
  transform-origin: bottom center;
  transform: rotateX(-90deg);
}
/* компенсация знака по оси Z (см. комментарий в .base-dots) — без этого
   потолок ехал бы "на экран", а пол "от экрана"; теперь оба едут от экрана
   (и вправо, вместе с полом) */
.base-ceiling .base-dots {
  transform: rotate(135deg);
  animation-name: dots-drift-ceiling;
}
/* На телефонах и планшетах (узкий экран или сенсорный ввод) точки стоят: лишняя
   постоянная работа видеокарты ради декора не нужна; отдельный слой под анимацию
   (will-change) тоже не создаём — экономия памяти. На десктопе ползут. */
@media (max-width: 720px), (pointer: coarse) {
  .base-dots { animation: none; will-change: auto; }
}

/* при открытой карточке декорации комнаты уходят, чтобы не мешать детальному просмотру */
.chrome-fade {
  transition: opacity 0.4s ease;
}
body.detail-open .chrome-fade {
  opacity: 0;
  pointer-events: none;
}

/* счётчик карточек + название проекта — плавное затемнение (0.5s ease),
   напр. пока грузится новый раздел в goToSection(). Переключение СОСЕДНЕЙ
   карточки (стрелки/клавиши, stepProject() в app.js) сюда больше не
   попадает — по фидбоку счётчик/подпись/объект должны меняться МГНОВЕННО,
   точно так же, как переключение разделов меню, без какого-либо fade;
   раньше здесь был отдельный "карусельный" режим (body.carousel-
   transitioning — прятал объект/подпись, пока на стенах доигрывал декоративный
   свуш клетки), убран целиком, а сама анимация клетки на стенах позже
   убрана и вовсе (см. docs/status.md). */
.label-fade {
  transition: opacity 0.5s ease;
}

#scene-container {
  position: fixed; inset: 0;
  z-index: 5;
  transition: opacity 0.5s ease;
}
/* сцена остаётся видна за карточкой (просто не реагирует на драг, пока карточка открыта) —
   и притушена (см. "Объект тусклее при открытой карточке/лайтбоксе" ниже). */
body.detail-open #scene-container { pointer-events: none; }

/* Объект тусклее, пока открыта карточка (см. body.detail-open выше) — и ещё
   тусклее, пока поверх карточки открыт лайтбокс с фото (см. #lightbox ниже) —
   по фидбоку, "объект, поверх которого открыта галерея, должен быть тусклее"
   / "всё, что находится за открытым фото, должно быть чуть более прозрачным".
   Не трогает pointer-events/transition выше — просто наслаивается через
   отдельные селекторы. */
body.detail-open #scene-container {
  opacity: 0.4;
}
body.lightbox-open #scene-container {
  opacity: 0.18;
}

canvas { display: block; }

/* left — выровнен по тому же краю, что название/год проекта (getSheetRect()
   в gallery-viewer.js: левый край листа vw*0.18 + SHEET_PAD_PX 16px) — по
   фидбоку, счётчик "portfolio · N / M" и подпись проекта должны делить один
   левый край, а не жить на двух разных, никак не связанных отступах
   (было left:24%, произвольное число). */
.card-fake-nav {
  position: fixed;
  top: 12%; left: calc(18% + 16px);
  color: rgba(20,20,20,0.6);
  font-size: 13px; letter-spacing: 0.08em;
  text-transform: uppercase;
  z-index: 10;
}

/* Логотип (images/qozlov.png — прозрачный PNG цвета графита) — в правом
   верхнем углу центрального листа, зеркально счётчику слева: те же 16px
   (SHEET_PAD_PX) от краёв листа. Виден во всех разделах; вместе с остальным
   "хромом" гаснет под открытой карточкой (.chrome-fade). Не ловит клики
   (pointer-events:none — драг под ним должен доходить до canvas). В
   stageBoundEls (gallery-viewer.js) участвует ОБЁРТКА #site-logo (не
   картинка) — id перенесён на неё намеренно, наклоняется вслед за листом, как
   счётчик.
   Точка после знака (`.site-logo-dot`) — отдельный элемент, не часть PNG:
   цвет задаётся CSS (точное совпадение с графитом текста при любом масштабе,
   в отличие от растра), и её проще подвинуть/поменять размер. Стоит внутри
   той же обёртки, поэтому наклоняется вместе с картинкой как одно целое.
   Обёртка выровнена по ПРАВОМУ краю (right, см. ниже) — правый край самой
   картинки (знак "D") почти вплотную к правому краю PNG (~2px запаса), точка
   пририсована СНАРУЖИ, поэтому именно она, а не картинка, теперь касается
   правого края отступа. Размер точки и зазор — в долях --logo-w (не px), как
   и сама картинка, — чтобы пропорция сохранялась и на самом узком мобильном
   (см. медиа-запрос переопределения --logo-w ниже по файлу). */
#site-logo {
  position: fixed;
  top: calc(10% + var(--sheet-pad));
  right: calc(18% + var(--sheet-pad));
  z-index: 10;
  display: flex;
  align-items: flex-end;
  gap: calc(var(--logo-w) * 0.01);
  pointer-events: none;
  user-select: none;
}
#site-logo-img {
  display: block;
  width: var(--logo-w);
  height: auto;
  aspect-ratio: 772 / 149;
  -webkit-user-drag: none;
}
/* 2026-10-01 по фидбоку — втрое мельче и ближе к знаку, чем было (0.032→0.0107,
   зазор 0.02→0.01 от --logo-w); margin-bottom уменьшен той же пропорцией, чтобы
   точка осталась у той же условной "базовой линии" знака, а не уехала вверх. */
.site-logo-dot {
  flex: none;
  width: calc(var(--logo-w) * 0.0107);
  height: calc(var(--logo-w) * 0.0107);
  margin-bottom: calc(var(--logo-w) * 0.002);
  border-radius: 50%;
  background: #484848;
}

/* Группа переключателей режимов (версии + x-ray + "без крышки") — общий
   контейнер вместо позиционирования каждой кнопки отдельно. Каждая кнопка —
   символ режима + свитч в стиле iOS, это <button role="switch"> (не
   текстовая кнопка), видимость которой ПЕРЕКЛЮЧАЕТСЯ ОТДЕЛЬНО (не через
   контейнер): x-ray виден во всех разделах галереи (см. goToSection() в
   app.js), "без крышки" — ТОЛЬКО у проектов с projects/<slug>/3d/
   no_lid.json (см. setNoLidAvailable() в app.js) — соответственно кнопки
   могут показываться независимо друг от друга.
   Позиционирование/наклон живут на обёртках вокруг #mode-toggles (см.
   ниже) — ОТНОСИТЕЛЬНО ПРАВОГО ЛИСТА (.wall.right — transform-origin left
   center, т.е. его левый/нижний угол остаётся на месте шва .seam-right —
   left:82% — даже после rotateY(-50deg), в отличие от дальнего правого
   края, уходящего в перспективу), а не центрального — по фидбоку, группу
   переместили с плоскости центрального листа на плоскость правого, в его
   левый нижний угол. calc(82% + Npx)/calc(10% + Npx) — тот же приём, что и
   у getSheetRect() в gallery-viewer.js — процентная граница + небольшой
   фиксированный отступ внутрь, здесь от шва вправо и от низа листа вверх.
   Сам #mode-toggles — просто flex-контейнер, без собственного position или
   transform (обёртки делают всё это за него). flex-direction: column —
   версии/свитчи режимов друг над другом (по фидбоку), а не в ряд;
   align-items: flex-start — все элементы группы делят один левый край
   (угол листа). */
#mode-toggles {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 14px;
}

/* Наклон листа — ЧЕТЫРЕ вложенных уровня (см. ниже за тем, почему).
   Первая попытка (perspective() ФУНКЦИЕЙ прямо в transform группы, с
   transform-origin на её же левом крае) давала СЖАТИЕ вширь (похоже на
   наклон), но не давала того же НАКЛОНА/схождения, что у настоящего листа —
   потому что "точка схода" перспективы у perspective()-функции берётся из
   ТОГО ЖЕ transform-origin, что и у поворота (свой левый край группы), а у
   настоящей комнаты (#stage) камера/точка схода — центр ЭКРАНА
   (perspective-origin: 50% 50%), это ДРУГАЯ точка. Замерено в живом DOM
   (временные маркеры внутри .wall.right + getBoundingClientRect): её левый/
   правый край (фиксированный локальный x) остаются строго вертикальными на
   экране, а её НИЖНИЙ край (фиксированный локальный y, разный x) — прямая
   линия под ~16° к горизонтали, тот самый ориентир с скриншота фидбека.
   Через одну perspective()-функцию с "неправильной" точкой схода этот наклон
   не воспроизвести — нужна НАСТОЯЩАЯ перспектива (CSS-свойство `perspective`
   на предке ВО ВЕСЬ ЭКРАН, дефолтная perspective-origin 50% 50% которого
   САМА ПО СЕБЕ и есть центр экрана) — тогда наклон её потомка получается тем
   же способом, что и у самого листа, и совпадает с ним.
   ⚠️ Второй баг (по фидбоку — "свитчи вылезают за пределы листа, когда
   смотришь на объект с нижнего ракурса"): динамический "живой" наклон
   камеры (bgLean/bgPitchLean в gallery-viewer.js) раньше применялся к
   #mode-toggles ТОЧКОЙ-ПРИБЛИЖЕНИЕМ (через stageBoundEls/
   applyStageBoundTransform — тот же приём, что у плоских подписей типа
   #project-title: считает transform-origin так, будто группа — плоская
   точка где-то на экране, вращающаяся вокруг центра вьюпорта). Для плоских
   подписей это нормально, но #mode-toggles уже лежит ВНУТРИ статично
   наклонённой плоскости (следующий уровень ниже) — при большом наклоне
   камеры (смотришь снизу вверх) эта точка-аппроксимация расходится с тем,
   как ПО-НАСТОЯЩЕМУ двигается сам лист (`.wall.right`, у которого
   динамический наклон комнаты приходит через #bg-parallax — НАСТОЯЩИЙ
   родительский transform, не аппроксимация), и группу свитчей уносит за
   пределы листа. Фикс — перестать аппроксимировать: #mode-toggles-room
   (новый уровень) каждый кадр получает ТОЧНО ТОТ ЖЕ transform, что и
   #bg-parallax (см. animate() в gallery-viewer.js — одна и та же строка
   присваивается обоим) — тогда у #mode-toggles-plane (статичный наклон
   листа, следующий уровень внутри) ровно та же родительская 3D-цепочка, что
   и у `.wall.right` (#stage → #bg-parallax → #room → .wall.right), просто
   параллельная, а не буквально общая — тот же perspective, те же углы,
   тот же порядок вложенности, значит и то же поведение при любом наклоне
   камеры. #mode-toggles больше НЕ в stageBoundEls (точка-аппроксимация ему
   больше не нужна).
   Уровни (снаружи внутрь):
   1) #mode-toggles-perspective — на весь экран, задаёт ТОЛЬКО перспективу
      (те же 1300px, что у #stage) с её настоящей (дефолтной) точкой схода в
      центре экрана — сама не двигается и не поворачивается (аналог #stage);
   2) #mode-toggles-room — на весь экран, ДИНАМИЧЕСКИЙ "живой" наклон камеры
      — JS копирует сюда ТОТ ЖЕ transform, что получает #bg-parallax, каждый
      кадр (аналог #bg-parallax, тот же приём преломления перспективы,
      transform-style:preserve-3d, чтобы наклон следующего уровня остался
      в общем 3D-пространстве, а не расплющился);
   3) #mode-toggles-plane — СТАТИЧНЫЙ наклон, тот же угол rotateY(-50deg),
      что у `.wall.right`, вокруг СВОЕГО левого края (transform-origin: left
      center — тот край стоит у шва, левый край правого листа) — аналог
      `.wall.right`;
   4) #mode-toggles (см. выше) — просто содержимое, больше без своего
      transform.
   z-index: 10 на #mode-toggles-perspective — ОБЯЗАТЕЛЕН (баг при первой
   версии этой обёртки): `.wall` (внутри #stage) имеет сплошной непрозрачный
   `background-color` и создаёт свой стек (у #stage задан `perspective`, а
   это тоже создаёт stacking context, как и `transform`), а #stage стоит в
   разметке index.html ПОСЛЕ #mode-toggles-perspective — без явного z-index
   обёртка красилась РАНЬШЕ (ниже) стены и была полностью перекрыта
   непрозрачным листом, свитчи пропадали целиком. 10 — тот же уровень, что у
   card-label/menu-leaf (.card-fake-nav), класс которого раньше держал
   группу выше стен неявно. */
#mode-toggles-perspective {
  position: fixed;
  inset: 0;
  z-index: 10;
  perspective: 1300px;
  pointer-events: none;
}
#mode-toggles-room {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}
#mode-toggles-plane {
  position: absolute;
  bottom: calc(10% + 20px); left: calc(82% + 20px);
  transform: rotateY(-50deg);
  transform-origin: left center;
  pointer-events: auto;
}

/* row-reverse — трек свитча слева, символ режима справа (по фидбоку; было
   наоборот, символ слева) — .mode-icon идёт первым в разметке (index.html),
   row-reverse просто зеркалит видимый порядок, не трогая DOM/aria. */
.mode-switch {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  gap: 10px;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  /* без явного значения наследовался бы UA-дефолт (чистый чёрный) — иконка
     "без крышки" рисуется через stroke="currentColor" (index.html) и брала
     бы именно его. Графитовый — как на контурах 3D-моделей, по фидбоку. */
  color: #484848;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.mode-switch[hidden] { display: none; }
/* У проекта с кнопками версий свитч «без крышки» есть не у каждого вида (у
   «сборки» нет, у «механизма» есть). Пока его нет, под него остаётся пустой
   слот: visibility:hidden держит место, но убирает свитч из кликов, фокуса и
   дерева доступности. Колонка привязана к низу листа, и без слота при переключении
   вида кнопки версий прыгали бы вверх-вниз на его высоту (у vots то же самое
   случалось в момент загрузки модели, пока свитч ещё не появился). У проектов без
   кнопок версий слот не резервируется. */
#mode-toggles:has(#version-switch:not([hidden])) #no-lid-toggle[hidden] {
  display: flex;
  visibility: hidden;
}

/* Размер свитчей/символов/кнопок версий увеличен ещё раз по фидбоку (было
   24/44×24/34×24) — трек стал ДЛИННЕЕ (шире пилюля) и крупнее в целом.
   Символы теперь яркие ДАЖЕ В ВЫКЛЮЧЕННОМ состоянии (было 0.45 — по фидбоку
   плохо читались) — толщина самих линий увеличена в разметке (index.html,
   stroke-width 1.6→2.1). */
.mode-icon {
  width: 27px; height: 27px;
  opacity: 0.85;
  transition: opacity 0.15s ease;
}
.mode-switch:hover .mode-icon { opacity: 0.95; }
.mode-switch.active .mode-icon { opacity: 1; }

/* трек свитча — как в iOS: пилюля 54×27, кружок-ручка 21px, сдвигается
   вправо и трек темнеет, когда режим включён (.active, см. app.js). */
.mode-switch-track {
  position: relative;
  width: 54px; height: 27px;
  border-radius: 13.5px;
  background: rgba(20,20,20,0.18);
  flex-shrink: 0;
  transition: background 0.2s ease;
}
.mode-switch:hover .mode-switch-track { background: rgba(20,20,20,0.28); }
.mode-switch.active .mode-switch-track { background: #484848; }
.mode-switch-knob {
  position: absolute;
  top: 3px; left: 3px;
  width: 21px; height: 21px;
  border-radius: 50%;
  background: #fbfaf7;
  box-shadow: 0 1px 2px rgba(0,0,0,0.25);
  transition: transform 0.2s ease;
}
.mode-switch.active .mode-switch-knob { transform: translateX(27px); }

/* Переключатель версий модели (V1/V2/…, см. #version-switch в index.html +
   setVersionsAvailable() в app.js) — опционален, как #no-lid-toggle выше,
   показывается только у проектов с data.json.versions. В отличие от
   мод-свитчей это не булев тумблер, а ряд именованных кнопок — столько,
   сколько версий задано (задел под V3/V4/V5 без изменений в разметке, JS
   рендерит кнопки по данным проекта). Друг над другом, а не в ряд (по
   фидбоку) — и сама группа, как один блок, стоит НАД свитчами режимов
   (порядок в разметке index.html — version-switch первый ребёнок
   #mode-toggles, тот теперь column, см. выше). */
.version-switch {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.version-switch[hidden] { display: none; }
/* height/строка — совпадают с .mode-switch-track (27px, см. выше), чтобы
   кнопки версий были того же размера, что и свитчи режимов под ними. */
.version-btn {
  min-width: 38px;
  height: 27px;
  padding: 0 15px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 13.5px;
  border: 1px solid rgba(20,20,20,0.2);
  background: none;
  font: inherit;
  font-size: 14px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: rgba(20,20,20,0.6);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.version-btn:hover { border-color: rgba(20,20,20,0.4); color: #484848; }
.version-btn.active { background: #484848; border-color: #484848; color: #fbfaf7; }

/* Кнопка версии со значком вместо текста (data.json.versions[i].icon, набор —
   VERSION_ICONS в app.js). Та же пилюля, что у текстовых V1/V2: высота, рамка,
   цвета и .active общие, меняются только отступы под значок. Значок — контур
   currentColor, поэтому цвет (серый / графит при наведении / светлый у
   .active) достаётся ему от кнопки. */
.version-btn--icon { padding: 0 11px; }
.version-icon {
  width: 18px; height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* название проекта + год на центральном листе, позади объекта. Позиция
   (left/top в px) и масштаб (--title-scale) считаются в JS при каждой смене
   проекта — см. layoutProjectTitle() в gallery-viewer.js. "Не выходит за
   пределы листа" (.wall.center, left:18%/width:64% × top:10%/height:80%) —
   жёсткое требование, поэтому не чистый CSS: длина названия/года у разных
   проектов сильно отличается ("ctm 1" vs "victory over the sun v2"), а лист
   не во весь экран. JS меряет реальный размер подписи через
   getBoundingClientRect, выбирает ориентацию (гориз./верт. — верт.
   предпочтительна для "высоких" моделей, см. recomputeFraming) и, если и
   так не помещается, уменьшает масштаб — но никогда не двигает подпись за
   границу листа. left/top задают положение ЦЕНТРА через её же натуральный
   (нетрансформированный) размер w/2,h/2 — см. layoutProjectTitle().
   Поворот/масштаб — на .title-rotate (внутренний div), не на самом
   #project-title: JS-параллакс (gallery-viewer.js, applyStageBoundTransform)
   каждый кадр перезаписывает inline style.transform ВНЕШНЕГО элемента, так
   что стационарный rotate/scale туда ставить нельзя — затрётся. */
/* z-index/pointer-events — название кликабельно (клик открывает карточку,
   см. app.js), должно ловить клик ДО canvas сцены (#scene-container, z-index
   5), иначе клик по тексту улетал бы на canvas позади него. text-align:left
   (было center) — по фидбоку название и год выровнены по левому краю (якорь
   titleX=0/titleY=1 в manifest.json, "левый нижний угол листа"), а не
   центрированы друг относительно друга (при разной ширине строк "title"/
   "year" центровка визуально не даёт единого левого края). */
#project-title {
  position: fixed;
  text-align: left;
  color: rgba(20,20,20,0.6);
  z-index: 6;
  cursor: pointer;
}
#project-title:focus { outline: none; }
/* рамка фокуса — только при навигации клавишей Tab (body.kbd-nav, app.js): после
   закрытия карточки по Esc фокус возвращается на название, и :focus-visible сам по
   себе рисовал бы рамку, хотя посетитель Tab-ом не пользовался */
body.kbd-nav #project-title:focus-visible { outline: 1px solid rgba(20,20,20,0.45); outline-offset: 4px; }
#project-title .title-rotate {
  display: inline-block;
  --title-scale: 1;
  transform: scale(var(--title-scale));
  transform-origin: center center;
}
#project-title.vertical .title-rotate {
  transform: rotate(-90deg) scale(var(--title-scale));
}
#project-title .title {
  display: block;
  font-size: 22px;
  font-weight: 300;
  letter-spacing: 0.03em;
  white-space: nowrap;
}
#project-title .year {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(20,20,20,0.6);
  white-space: nowrap;
}

/* Текст страниц About/Contact привязан к центральному листу так же, как подписи
   галереи (счётчик, название проекта, логотип): лежит на его плоскости и
   наклоняется вместе с ним — обёртки тем же приёмом, что у свитчей (см.
   #mode-toggles-perspective выше): #page-text-perspective — только перспектива
   (те же 1300px, что у #stage), #page-text-room — динамический наклон комнаты
   (JS копирует сюда тот же transform, что у #bg-parallax, каждый кадр, см.
   animate() в gallery-viewer.js), внутри — сам блок #page-text, положение
   которого задано в координатах листа: left/right/top/bottom = его края
   (.wall.center: 18% / 64% × 10% / 80%) минус --sheet-pad. Так текст не
   пересекается с логотипом и не уходит за лист на любом экране, а лишнее
   прокручивается внутри листа (см. .about).
   z-index 7 у обёртки — ВЫШЕ canvas сцены (#scene-container, z-index 5): canvas
   на весь экран и ловит любой клик, при z-index 4 ссылки Contact и копирование
   почты не срабатывали (клик уходил в canvas). Сама обёртка и #page-text
   кликов не ловят (pointer-events:none) — только блоки внутри (см. ниже): драг
   по пустому месту листа доходит до сцены и наклоняет комнату, как в галерее. */
#page-text-perspective {
  position: fixed;
  inset: 0;
  z-index: 7;
  perspective: 1300px;
  pointer-events: none;
}
#page-text-room {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}
#page-text {
  position: absolute;
  left: calc(18% + var(--sheet-pad));
  right: calc(18% + var(--sheet-pad));
  top: calc(10% + var(--sheet-pad));
  bottom: calc(10% + var(--sheet-pad));
  color: #484848;
  font-size: 15px;
  line-height: 1.7;
  letter-spacing: 0.01em;
  pointer-events: none;
}
#page-text > * { pointer-events: auto; }
#page-text p { margin: 0; }
/* About — от логотипа вниз (логотип + 20px), у левого края листа, как подпись
   проекта в галерее */
#page-text[data-page="about"] {
  top: calc(10% + var(--sheet-pad) + var(--logo-h) + 20px);
}
/* Contact — по центру листа; поля 11px (не 16) — так на самом узком телефоне
   самая длинная строка (почта) остаётся в одну строку */
#page-text[data-page="contact"] {
  left: calc(18% + 11px);
  right: calc(18% + 11px);
  display: flex;
  align-items: center;
  justify-content: center;
}
#page-text[hidden] { display: none; }

/* ---------- About (см. PAGE_TEXT.about в app.js) ----------
   Блок у левого края листа (тот же левый край, что у счётчика и подписи
   проекта), текст внутри — по левому краю. Иерархия сверху вниз (текст —
   PAGE_TEXT.about в app.js, версия 2026-10-01): лид (что делаю; перечень
   направлений жирным, вводная часть — бледнее) → основной абзац спокойным
   серым (место/таймзона — графитом жирным, как и любой <strong> внутри .about,
   см. общее правило ниже) → трек-рекорд (.about-stats + .about-current —
   цифры и текущее место работы, отступ МЕЖДУ ними меньше, чем перед блоком:
   читаются как одна мысль) → три строки «что предлагаю» (.about-offer, та же
   логика плотной группы) → кому подходит (переиспользует .about-body) →
   финальная фраза под тонкой чертой (.about-close) → призыв к действию
   (.about-cta). Кнопки «Download CV» больше нет — убрана по просьбе. */
.about {
  /* --pad — запас под outline у ссылок/кнопки, который иначе срезался бы при
     прокрутке (overflow ниже); отрицательный margin и "+ 2 pad" в размерах
     возвращают его обратно, так что сам ТЕКСТ лежит ровно по краю области.
     Колонка не шире 640px (было 470 — по фидбоку 2026-10-01: с более длинным
     текстом About на широких десктопах справа оставалось слишком много
     пустого места; min(…,100%) сам сжимает её на узких экранах — сеть не
     меняли). 640px — не «сколько влезет», а сознательный потолок читаемости
     строки (иначе на сверхширoких мониторах строка вытягивается слишком
     длинной); до этого потолка колонка растягивается на всю ширину листа. */
  --pad: 6px;
  width: calc(min(640px, 100%) + var(--pad) * 2);
  margin: calc(var(--pad) * -1);
  padding: var(--pad);
  text-align: left;
  /* Если текст не помещается между логотипом (сверху) и низом листа — низкий
     экран/телефон/больше текста — блок прокручивается внутри листа, а не
     вылезает на логотип или под меню; на обычных экранах прокрутки нет. */
  max-height: calc(100% + var(--pad) * 2);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}
/* подсказка прокрутки: пока внизу есть непоказанный текст (класс ставит
   updateAboutScrollCue() в app.js), нижний край блока плавно гаснет */
.about.about--more {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), transparent);
}
.about-lead {
  font-size: 22px;
  line-height: 1.4;
  font-weight: 300;
  letter-spacing: 0.005em;
  color: #484848;
  text-wrap: balance;
}
.about-lead-dim { color: rgba(20,20,20,0.6); }
.about-lead-strong { font-weight: 600; }
.about-body {
  margin-top: 24px !important;
  font-size: 15px;
  line-height: 1.75;
  color: rgba(30,30,30,0.66);
  text-wrap: pretty;
}
.about-place { color: #484848; font-weight: 500; white-space: nowrap; }
/* любое инлайновое выделение в About — та же логика, что у .about-place
   (графит, чуть плотнее обычного): числа трек-рекорда, "Cyland Media Art
   Lab", заголовки "Remote engineering."/"In my workshop."/"On site." */
.about strong { color: #484848; font-weight: 600; }
/* Трек-рекорд: цифры в одну строку через "·" + вторая строка "текущее место
   работы" — визуально одна мысль, поэтому между ними отступ МЕНЬШЕ, чем у
   .about-body перед блоком (24px). Шрифт — тот же, что у .about-body. */
.about-stats {
  margin-top: 24px;
  font-size: 15px;
  line-height: 1.75;
  color: rgba(30,30,30,0.66);
}
.about-current {
  margin-top: 8px;
  font-size: 15px;
  line-height: 1.75;
  color: rgba(30,30,30,0.66);
}
/* Три коротких предложения (remote engineering / workshop / on site) — тоже
   плотная группа, отступ МЕЖДУ ними меньше межблочного; сама группа отбита
   от соседних блоков как обычный .about-body. */
.about-offer { margin-top: 24px; }
.about-offer p {
  font-size: 15px;
  line-height: 1.75;
  color: rgba(30,30,30,0.66);
}
.about-offer p + p { margin-top: 8px; }
.about-close {
  margin-top: 26px !important;
  padding-top: 20px;
  border-top: 1px solid rgba(20,20,20,0.16);
  font-size: 15px;
  line-height: 1.65;
  color: #484848;
  text-wrap: balance;
}

/* Призыв к действию — последнее, что читают на странице (About должен
   приводить к заказам/предложениям): под финальной фразой, выделен вертикальной
   графитовой чертой слева (как выноска на чертеже), текст крупнее основного
   абзаца. Единственная интерактивная ссылка в тексте — "Let's talk" (ведёт на
   Contact, data-goto в app.js) — подчёркнута плотнее и со стрелкой (сама
   стрелка — ::after ниже, в тексте её нет). Кнопки "Download CV" под текстом
   больше нет — убрана по просьбе. */
.about-cta {
  margin-top: 30px;
  padding-left: 18px;
  border-left: 2px solid #484848;
}
.about-cta-text {
  font-size: 16px;
  line-height: 1.6;
  color: #484848;
  text-wrap: pretty;
}
.about-link {
  color: inherit;
  font-weight: 500;
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
}
/* стрелка — только оформление (inline-block: подчёркивание на неё не тянется;
   alt-текст "" прячет её от скринридеров там, где это поддерживается) */
.about-link::after {
  content: '\2192';
  content: '\2192' / '';
  display: inline-block;
  margin-left: 0.28em;
  transition: transform 0.15s ease;
}
.about-link:hover::after { transform: translateX(3px); }
.about-link:focus-visible {
  outline: 1px solid rgba(20,20,20,0.45);
  outline-offset: 4px;
}

/* ---------- Contact (см. PAGE_TEXT.contact в app.js) ----------
   Блок в целом по центру (inline-block внутри text-align:center у
   #page-text), строки внутри — по левому краю: значок + ник. Значки — контурные,
   один размер, цвет = цвет текста (stroke: currentColor). Шрифт —
   моноширинный: читается как техническая подпись, и ширина строки
   предсказуема — по ней подобраны размеры на мобильном (см. ниже), самая
   длинная строка — почта. */
.contact-block {
  position: relative;
  display: inline-block;
  max-width: 100%;
  text-align: left;
}
.contact-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
  font-family: 'SF Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
  font-size: 17px;
  line-height: 1.4;
  color: #484848;
}
.contact-row {
  display: flex;
  align-items: center;
  gap: 14px;
  color: inherit;
  text-decoration: none;
  white-space: nowrap;
  transition: opacity 0.15s ease;
}
.contact-row:hover { opacity: 0.65; }
.contact-icon {
  flex: none;
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* ник/почта подчёркнуты; у почты подчёркивание пунктирное — так она
   отличается от ссылок: это не переход, а копирование (см. .contact-toast) */
.contact-value {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
}
.contact-copy { cursor: pointer; }
.contact-copy .contact-value { text-decoration-style: dashed; }
.contact-copy:focus-visible, a.contact-row:focus-visible {
  outline: 1px solid rgba(20,20,20,0.45);
  outline-offset: 4px;
}

/* Короткое уведомление над блоком контактов ("Email copied to clipboard"):
   показывается на пару секунд после клика по почте, см. showContactToast()
   в app.js. Тёмная плашка — той же графитовой заливки, что у включённых
   свитчей. */
.contact-toast {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 20px);
  transform: translate(-50%, 6px);
  padding: 7px 14px;
  border-radius: 3px;
  background: #484848;
  color: #fbfaf7;
  font-size: 12px;
  line-height: 1.3;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.contact-toast.visible {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* Узкий экран: центральный лист — 64% ширины (240px при 375px), текст
   Contact/About обязан в нём уместиться (границы задаёт #page-text, см. выше).
   Самая длинная строка — почта: 18 знаков моноширинного шрифта (0.6em на знак)
   + значок + зазор. При 14px это ~181px, при 13px (≤340px) ~170px — влезает и
   в 320px-экран (лист 205px, область Contact 183px). Если совсем не влезет
   (системный шрифт шире) — строка переносится (anywhere), а не вылезает за лист. */
@media (max-width: 720px) {
  /* About на телефоне: в лист (64% ширины) влезало слишком мало текста — блок
     выходит за лист почти на всю ширину экрана (16px от краёв, как карточка
     #detail-view), под ним — полупрозрачная бумажная подложка в цвет
     карточки, но плотнее (0.82, а не 0.5: при 0.5 сквозь мелкий текст
     просвечивали края листа и клетки). Сверху — по-прежнему
     под логотипом, снизу — по низ листа (над меню). */
  #page-text[data-page="about"] {
    left: 16px;
    right: 16px;
    bottom: 10%;
    padding: 16px 18px;
    background: rgba(251, 250, 247, 0.82);
    border-radius: 3px;
  }
  .about-lead { font-size: 16px; line-height: 1.38; }
  .about-body { margin-top: 14px !important; font-size: 13px; line-height: 1.6; }
  .about-stats, .about-current, .about-offer p { font-size: 13px; line-height: 1.6; }
  .about-stats { margin-top: 14px; }
  .about-current { margin-top: 6px; }
  .about-offer { margin-top: 14px; }
  .about-offer p + p { margin-top: 6px; }
  .about { --pad: 3px; }
  .about-close { margin-top: 14px !important; padding-top: 12px; font-size: 13px; line-height: 1.55; }
  .about-cta { margin-top: 18px; padding-left: 12px; }
  .about-cta-text { font-size: 13px; line-height: 1.55; }
  .contact-list { gap: 13px; font-size: 14px; }
  .contact-row { gap: 11px; white-space: normal; overflow-wrap: anywhere; }
  .contact-icon { width: 19px; height: 19px; }
  .contact-toast { bottom: calc(100% + 16px); padding: 6px 12px; font-size: 11px; }
}
@media (max-width: 340px) {
  .contact-list { font-size: 13px; }
}
/* невысокий телефон (360×740 и ниже): About не должен требовать прокрутки без
   необходимости, поэтому ужимаем отступы */
@media (max-width: 720px) and (max-height: 780px) {
  .about-lead { font-size: 15px; line-height: 1.36; }
  .about-body { margin-top: 12px !important; font-size: 12.5px; }
  .about-stats, .about-current, .about-offer p { font-size: 12.5px; }
  .about-stats { margin-top: 12px; }
  .about-current { margin-top: 5px; }
  .about-offer { margin-top: 12px; }
  .about-offer p + p { margin-top: 5px; }
  .about-close { margin-top: 12px !important; padding-top: 10px; font-size: 12.5px; }
  .about-cta { margin-top: 14px; }
  .about-cta-text { font-size: 12.5px; }
}
/* низкий экран (телефон в альбомной ориентации): лист всего ~300px высотой */
@media (max-height: 560px) {
  .about-lead { font-size: 17px; }
  .about-body { margin-top: 14px !important; font-size: 13px; line-height: 1.6; }
  .about-stats, .about-current, .about-offer p { font-size: 13px; line-height: 1.6; }
  .about-stats { margin-top: 14px; }
  .about-current { margin-top: 6px; }
  .about-offer { margin-top: 14px; }
  .about-offer p + p { margin-top: 6px; }
  .about-close { margin-top: 14px !important; padding-top: 12px; font-size: 13px; line-height: 1.55; }
  .about-cta { margin-top: 16px; }
  .about-cta-text { font-size: 13px; }
  .contact-list { gap: 10px; }
}

.nav-arrow {
  position: fixed;
  top: 50%;
  /* кликабельная область (width/height) увеличена с 40 до 48px по фидбоку —
     сама стрелка (svg 26×26, задан в index.html) не меняется, flex-центровка
     держит её ровно посередине увеличенного поля; margin — минус половина
     width/height, чтобы центр поля остался на тех же top:50%/left:8%|92% */
  margin-top: -24px;
  /* z-index ВЫШЕ #detail-view (25, см. ниже) — по фидбоку кнопки
     переключения проекта должны оставаться поверх открытой карточки и
     оставаться кликабельными (раньше были в списке .chrome-fade и гасли
     вместе с меню/декором комнаты при body.detail-open — убрано из разметки
     в index.html, см. stepProject() в app.js за тем, как переключение
     проекта теперь работает и при открытой карточке). */
  z-index: 30;
  width: 48px; height: 48px;
  display: flex; align-items: center; justify-content: center;
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  opacity: 0.5;
  transition: opacity 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}
.nav-arrow:hover { opacity: 0.85; }
.nav-arrow:active { opacity: 1; }
.nav-arrow svg { display: block; }
/* Фиксированный отступ от истинного края экрана (было left:8%/92% —
   по фидбоку, в открытой карточке это попадало на сам текст на узких/
   средних экранах; фиксированные px ближе к краю и работают одинаково на
   любой ширине, без риска уехать за экран, как было бы с процентом на
   узком мобильном). */
.nav-arrow-left { left: 12px; }
.nav-arrow-right { right: 12px; }
/* На десктопе (по фидбоку) стрелки чуть ближе к центру: у самого края экрана
   при сильном развороте объекта одна из них (по направлению разворота)
   попадала под наклонённый край комнаты/листа и переставала быть заметна.
   Мобильный экран уже, там 12px от края не трогаем — 64px откусили бы
   заметную часть ширины. */
@media (min-width: 721px) {
  .nav-arrow-left { left: 64px; }
  .nav-arrow-right { right: 64px; }
}
.nav-arrow[hidden] { display: none; }
/* стрелки переключения проекта остаются поверх открытой карточки (см.
   z-index/комментарий выше), но должны пропадать, пока поверх карточки
   открыт лайтбокс с фото (по фидбоку) — иначе они лежат поверх крупного
   фото и путаются с его собственными стрелками (.lightbox-nav). */
body.lightbox-open .nav-arrow {
  opacity: 0;
  pointer-events: none;
}

/* тонкий листок меню — отдельная "страница", лежит параллельно центральной,
   визуально в той же комнате, но технически поверх canvas (иначе клики
   перехватывал бы WebGL-канвас). Горизонтальная полоска-пилюля СНИЗУ ПО
   ЦЕНТРУ — один и тот же вид и позиция на десктопе и на мобильном (прошла
   путь верхний левый угол/колонка на десктопе → нижний левый угол → верхний
   центр → снова нижний центр за несколько правок одного дня 2026-09-16 —
   в итоге вернулись к варианту "снизу по центру", который и был исходно на
   мобильном). На десктопе полоска сидит по размеру своего содержимого (нет
   явного width — задаёт padding), на мобильном — явная ширина (см.
   медиа-запрос ниже) — иначе 5 пунктам меню не хватает места на узком
   экране без переноса строки. */
#menu-leaf {
  position: fixed;
  bottom: 24px; left: 50%; transform: translateX(-50%); top: auto;
  height: 45px;
  z-index: 10;
  background-color: #fbfaf7;
  border: 1px solid rgba(72,72,72,0.32);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 20px;
}
#menu-leaf nav {
  display: flex;
  flex-direction: row;
  gap: 20px;
  align-items: center;
  flex-wrap: nowrap;
}
#menu-leaf button {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: rgba(20,20,20,0.6);
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  white-space: nowrap;
}
#menu-leaf button:hover { color: rgba(20,20,20,0.8); }
#menu-leaf button.active { color: #484848; }

#loading-indicator {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f7f6f4;
  color: rgba(20,20,20,0.6);
  font-size: 13px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  z-index: 50;
  transition: opacity 0.35s ease;
}
#loading-indicator[hidden] { opacity: 0; pointer-events: none; display: flex; }

/* Шкала загрузки конкретной 3D-модели карточки (в отличие от
   #loading-indicator выше — тот только на самый первый заход на сайт) — по
   фидбоку, чтобы тяжёлые модели (десятки МБ, напр. memory_chopper — 13 МБ)
   не оставляли лист пустым на время загрузки. Показывается/скрывается и
   двигается через app.js (showModelLoading()/setModelLoadingProgress()/
   hideModelLoading()) по прогрессу самого fetch() в data-loader.js
   (loadPartsBin() читает поток чанками, зная Content-Length). На месте
   объекта — центр экрана, как и сам объект/фото (camera.lookAt смотрит
   ровно в центр вьюпорта). */
#model-loading {
  position: fixed;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 160px;
  z-index: 4;
  pointer-events: none;
}
#model-loading[hidden] { display: none; }
.model-loading-track {
  width: 100%;
  height: 2px;
  background: rgba(20,20,20,0.12);
  border-radius: 1px;
  overflow: hidden;
}
.model-loading-fill {
  width: 0%;
  height: 100%;
  background: rgba(20,20,20,0.45);
  transition: width 0.15s linear;
}

/* Плашка-подсказка "Click the model for details" (на тач-экране — "Tap") —
   поверх объекта, в центре вьюпорта (центр модели всегда там, см.
   getTapObjectRect() во вьюере). Когда показывается — блок «плашка-подсказка»
   в app.js. Плашка — в стиле меню (бумага + тонкая графитовая рамка); клик по
   ней открывает карточку. Под надписью — статичный светлый круг, который
   градиентом уходит в прозрачность к краям (--glow-size ставит app.js); без
   анимации — анимированное кольцо тормозило на iPhone 12.
   Пока плашка видна, сайт «модальный»: #click-hint-blocker (прозрачный, на весь
   экран) лежит поверх ВСЕГО интерфейса (стрелки 30, меню/свитчи 10, сцена 5) и
   перехватывает касания — работает только тап по плашке или по объекту (app.js).
   Сама плашка — над блокером. Карточка (25) открывается уже после того, как
   плашка и блокер убраны. */
#click-hint-blocker {
  position: fixed;
  inset: 0;
  z-index: 35;
  touch-action: none;
}
#click-hint-blocker[hidden] { display: none; }
/* Пока плашка видна (body.click-hint-open, app.js): кнопки, свитчи, стрелки,
   меню и кликабельное название проекта гаснут — остаётся только модель и плашка. Комната чуть забеляется
   прозрачным белым слоем #click-hint-scrim: он лежит над листами, но ПОД
   сценой (z-index 4 < 5), поэтому сама модель остаётся чёткой. */
#click-hint-scrim {
  position: fixed;
  inset: 0;
  z-index: 4;
  background: rgba(255,255,255,0.45);
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}
body.click-hint-open #click-hint-scrim { opacity: 1; }
body.click-hint-open #menu-leaf,
body.click-hint-open #project-title,
body.click-hint-open #site-logo,
body.click-hint-open #card-label,
body.click-hint-open .nav-arrow,
body.click-hint-open #mode-toggles {
  opacity: 0 !important;
  pointer-events: none;
}
#click-hint {
  position: fixed;
  top: 50%; left: 50%;
  width: 0; height: 0;
  z-index: 36;
  opacity: 0;
  transition: opacity 0.4s ease;
  pointer-events: none;
}
#click-hint[hidden] { display: none; }
#click-hint.click-hint--visible { opacity: 1; }
.click-hint-text {
  position: absolute;
  left: 0; top: 0;
  transform: translate(-50%, -50%);
  padding: 10px 16px;
  background-color: rgba(251,250,247,0.94);
  border: 1px solid rgba(72,72,72,0.32);
  color: #484848;
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  outline: none;
}
.click-hint-text:focus-visible { outline: 1px solid #484848; outline-offset: 2px; }
#click-hint.click-hint--visible .click-hint-text { pointer-events: auto; }
.click-hint-glow {
  position: absolute;
  left: 0; top: 0;
  width: var(--glow-size, 360px);
  height: var(--glow-size, 360px);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(circle closest-side, rgba(255,255,255,0.85) 0%, rgba(255,255,255,0.6) 45%, rgba(255,255,255,0) 100%);
  pointer-events: none;
}
@media (max-width: 720px) {
  .click-hint-text { font-size: 11px; padding: 9px 14px; }
}

/* Нет WebGL (body.no-webgl, см. showsModel() в app.js): на листе вместо модели —
   пояснение; переключатели режимов модели (x-ray, крышка, версии) не нужны. */
#no-webgl-note {
  display: none;
  position: fixed;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: min(320px, 80vw);
  text-align: center;
  color: rgba(20,20,20,0.6);
  font-size: 14px;
  line-height: 1.5;
  z-index: 6;
  pointer-events: none;
}
body.no-webgl[data-view="gallery"]:not(.detail-open) #no-webgl-note { display: block; }
body.no-webgl #mode-toggles { display: none; }

/* About/Contact: canvas сцены пуст — курсор "рука" (gallery-viewer.js) там не к месту */
body[data-view="page"] #scene-container canvas { cursor: default !important; }

/* «Уменьшить движение» в системе: без плавных переходов (автовращение
   модели гасит gallery-viewer.js, setAutoRotate()) */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

/* ---------- детальный просмотр карточки (клик по объекту/листку) ----------
   не на весь экран — со всех сторон виден краешек сцены; простое всплытие
   (масштаб + прозрачность), без разворота листа и без анимации ухода объекта.
   Закрытие — крестик (#detail-close, см. ниже), Esc или клик по пустому
   полю внутри самой карточки (см. обработчик на el.detailView в app.js). */
#detail-view {
  position: fixed;
  inset: 40px;
  z-index: 25;
  background: rgba(251, 250, 247, 0.5);
  border-radius: 3px;
  overflow-y: auto;
  opacity: 0;
  pointer-events: none;
  transform: scale(0.97);
  transition: opacity 0.3s ease, transform 0.3s ease;
}
body.detail-open #detail-view {
  opacity: 1;
  pointer-events: auto;
  transform: scale(1);
}
/* Кнопка закрытия карточки — СОСЕД #detail-view в разметке (index.html), а
   не потомок (по фидбоку: крестик "уезжал" вместе со скроллом длинной
   карточки и пропадал с экрана — оказалось, `transform` на #detail-view
   (scale выше) делает его containing block’ом для СВОИХ position:fixed
   потомков по спецификации CSS, так что вложенный туда #detail-close на
   самом деле вёл себя как position:absolute ОТНОСИТЕЛЬНО скроллящегося
   #detail-view, а вовсе не был закреплён на экране). Как сосед — честно
   привязан к вьюпорту, виден на экране независимо от скролла содержимого
   карточки. Видимость (opacity/pointer-events) раньше наследовалась от
   родителя автоматически — теперь задаётся отдельно, через тот же
   body.detail-open. z-index выше #detail-view (25), но ниже стрелок
   навигации (30) и лайтбокса (40). */
#detail-close {
  position: fixed;
  top: 56px; right: 56px;
  z-index: 26;
  width: 40px; height: 40px;
  display: flex; align-items: center; justify-content: center;
  background: none;
  border: none;
  border-radius: 50%;
  color: rgba(20,20,20,0.6);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: background 0.15s ease, color 0.15s ease, opacity 0.3s ease;
  -webkit-tap-highlight-color: transparent;
}
body.detail-open #detail-close {
  opacity: 1;
  pointer-events: auto;
}
#detail-close:hover { background: rgba(20,20,20,0.08); color: #484848; }
#detail-close:focus { outline: none; }
body.kbd-nav #detail-close:focus-visible { outline: 1px solid rgba(20,20,20,0.45); outline-offset: 3px; }
#detail-close svg { display: block; }

/* padding — он же кликабельное "пустое поле", закрывающее карточку (см.
   обработчик на el.detailView в app.js) — увеличен по фидбоку ЕЩЁ РАЗ
   (90/32/100 → 120/56/140 → 170/90/190 → это). */
#detail-content {
  max-width: 760px;
  margin: 0 auto;
  padding: 200px 115px 220px;
  transition: opacity 0.25s ease;
}
/* Пока открыт лайтбокс (крупное фото поверх карточки, см. #lightbox ниже) —
   текст и фото самой карточки притушены (тот же коэффициент 0.25, что у
   контура 3D-модели при открытии карточки, см. body.detail-open выше по
   файлу) — по фидбоку 2026-09-16, чтобы крупное фото лучше читалось на фоне
   БЕЗ собственной подложки/тени (см. #lightbox ниже) — раньше подложка сама
   притушала карточку визуально, теперь этот эффект вернули явно, отдельно
   от самой подложки. */
body.lightbox-open #detail-content {
  opacity: 0.25;
}
#detail-content h1 {
  font-size: 28px;
  font-weight: 500;
  margin: 0 0 4px;
  letter-spacing: 0.01em;
  /* без явного color наследовался бы UA-дефолт (чистый чёрный) — нигде
     выше по дереву color не задан. Графитовый — как на контурах 3D-моделей
     (CONTOUR_COLOR в gallery-viewer.js), по фидбоку. */
  color: #484848;
}
#detail-content .detail-subtitle {
  color: rgba(20,20,20,0.6);
  font-size: 14px;
  margin: 0 0 32px;
  letter-spacing: 0.02em;
}
.detail-meta {
  display: grid;
  grid-template-columns: 140px 1fr;
  row-gap: 10px;
  column-gap: 16px;
  font-size: 14px;
  margin-bottom: 40px;
}
.detail-meta dt {
  color: rgba(20,20,20,0.6);
  text-transform: uppercase;
  font-size: 11px;
  letter-spacing: 0.08em;
  padding-top: 2px;
}
.detail-meta dd {
  margin: 0;
  color: #484848;
  line-height: 1.5;
}
.detail-meta a { color: inherit; }

/* Ссылка скачивания STEP-файла — только у раздела Models, см. renderDetail()
   в app.js. Показана ВСЕГДА для этого раздела, даже без файла (assets.step
   пуст) — тогда --empty (тусклее + курсор "запрещено", клик проглатывается
   в app.js) держит место под будущую рабочую ссылку без правок вёрстки. */
.detail-step-link {
  display: inline-block;
  margin: 0 0 36px;
  padding: 9px 18px;
  border: 1px solid rgba(20,20,20,0.3);
  border-radius: 3px;
  color: #484848;
  font-size: 13px;
  letter-spacing: 0.03em;
  text-decoration: none;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.detail-step-link:hover { background: #484848; color: #fbfaf7; }
.detail-step-link--empty {
  color: rgba(20,20,20,0.35);
  border-color: rgba(20,20,20,0.15);
  cursor: not-allowed;
}
.detail-step-link--empty:hover { background: none; color: rgba(20,20,20,0.35); }

.detail-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 10px;
  margin-bottom: 36px;
}
.detail-gallery img, .detail-gallery video {
  width: 100%;
  display: block;
  border-radius: 2px;
}
/* Плитка видео (см. videoTileHtml() в app.js) — того же размера, что фото в этой
   же сетке: квадрат на всю ширину колонки (эталон — 1200×1200 рендеры вроде
   seventh_heaven/photo_1.png). Кадр-превью с CDN bunny обрезается по центру
   (object-fit: cover), без превью — нейтральная подложка. Клик открывает видео в
   лайтбоксе вместе с фото и запускает его (см. app.js). */
.detail-video-tile {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  padding: 0;
  border: 0;
  border-radius: 2px;
  overflow: hidden;
  background: rgba(20,20,20,0.07);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.detail-gallery .detail-video-tile img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  cursor: inherit;
}
.detail-gallery .detail-video-tile img:hover { opacity: 1; }
/* Плитка, с которой начинается новый ряд (data.json: у элемента assets.video
   "newRow": true). grid-column-start: 1 не даёт ей встать в хвост предыдущего ряда —
   она уходит в следующий, а следующие плитки идут за ней. Размер плитки не меняется.
   Когда колонка в сетке одна (узкий экран), правило ничего не меняет. */
.detail-video-tile--newrow { grid-column-start: 1; }
/* кнопка play — в графитовой гамме сайта: бумажный круг с тонкой графитовой
   рамкой, на hover заливка графитом (как у .detail-step-link) */
.detail-video-play {
  position: absolute;
  left: 50%; top: 50%;
  width: 52px; height: 52px;
  margin: -26px 0 0 -26px;
  display: flex; align-items: center; justify-content: center;
  border: 1.5px solid #484848;
  border-radius: 50%;
  background: rgba(251,250,247,0.88);
  color: #484848;
  transition: background 0.15s ease, color 0.15s ease;
}
.detail-video-play svg { width: 24px; height: 24px; margin-left: 2px; fill: currentColor; }
.detail-video-tile:hover .detail-video-play,
.detail-video-tile:focus-visible .detail-video-play { background: #484848; color: #fbfaf7; }
.detail-video-tile:focus-visible { outline: 1px solid rgba(20,20,20,0.45); outline-offset: 3px; }
/* фото кликабельны (открывают лайтбокс, см. app.js) */
.detail-gallery img {
  cursor: pointer;
  transition: opacity 0.15s ease;
}
.detail-gallery img:hover { opacity: 0.82; }
.detail-section-label {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: rgba(20,20,20,0.6);
  margin: 0 0 12px;
}
/* Текст вместо сетки Video/Photo, когда раздел ещё пуст (data.json
   "photo_note"/"video_note", см. gallerySection() в app.js) — тот же
   нижний отступ, что у .detail-gallery, чтобы следующая секция не прыгала. */
.detail-placeholder {
  margin: 0 0 36px;
  color: rgba(20,20,20,0.6);
  font-size: 14px;
  font-style: italic;
  line-height: 1.5;
}

/* ---------- мини-галерея (лайтбокс) — крупный просмотр фото карточки,
   поверх #detail-view (z-index 25) — клик по превью в .detail-gallery, см.
   app.js openLightbox(). Без собственного фона (по фидбоку — с тёмной, а
   затем со сплошной светлой подложкой у многих рендеров с прозрачным
   PNG-фоном тень + подложка визуально сливались в "белую карточку с тенью"
   вокруг фото) — карточка позади остаётся видна (хоть и притушенная, см.
   body.lightbox-open #detail-content выше), фото просто всплывает поверх
   неё. #lightbox остаётся full-viewport элементом ТОЛЬКО чтобы ловить клик
   "мимо фото" для закрытия (см. обработчик клика в app.js). Отдельного
   крестика больше нет (убран по фидбоку 2026-09-16, как и у карточки выше)
   — закрытие: Esc или клик по фону. ---------- */
#lightbox {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
body.lightbox-open #lightbox {
  opacity: 1;
  pointer-events: auto;
}
#lightbox-img {
  max-width: 86vw;
  max-height: 84vh;
  object-fit: contain;
}
/* Видео в лайтбоксе (#lightbox-video заполняет app.js, mountLightboxVideo()): рамка
   bunny-плеера вписывается в тот же прямоугольник, что и фото (86vw × 84vh; на
   телефоне 92vw × 76vh), с сохранением пропорций кадра из data.json (--ar).
   Локальный <video> — ровно как фото, по естественному размеру. */
#lightbox-video[hidden] { display: none; }
.lightbox-embed {
  aspect-ratio: var(--ar, 1.7778);
  width: min(86vw, calc(84vh * var(--ar, 1.7778)));
  background: #000;
}
.lightbox-embed iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}
#lightbox-video video {
  display: block;
  max-width: 86vw;
  max-height: 84vh;
}
.lightbox-nav {
  position: fixed;
  top: 50%;
  margin-top: -24px;
  width: 48px; height: 48px;
  display: flex; align-items: center; justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(0,0,0,0.06);
  color: rgba(20,20,20,0.7);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
  -webkit-tap-highlight-color: transparent;
}
.lightbox-nav:hover { background: rgba(0,0,0,0.12); color: #484848; }
.lightbox-nav[hidden] { display: none; }
/* тот же отступ от края (12px), что и у .nav-arrow-left/-right выше — по
   фидбоку, стрелки лайтбокса должны стоять на том же месте, что и стрелки
   переключения проекта. Они и так не показываются одновременно (см.
   body.lightbox-open .nav-arrow — те гаснут, пока лайтбокс открыт), так что
   переиспользование места не даёт визуального наложения. */
.lightbox-prev { left: 12px; }
.lightbox-next { right: 12px; left: auto; }

@media (max-width: 720px) {
  /* Ширина/гап/паддинг по фидбоку 2026-09-16: при 5 пунктах меню
     ("Commiss…Contact", ~269px текста + 4 гапа) на десктопной ширине "по
     содержимому" всё легко умещается в одну строку, но на узком мобильном
     экране той же логике не хватает места без явной ширины — 90% вьюпорта
     (337.5px на типичных 375px) + гап 14px укладывают все 5 пунктов в одну
     строку (269+4×14=325px — на ≤400px уже впритык, см. отдельный блок
     ≤400px в конце файла). Раскладка (строка,
     nowrap), высота (45px) и позиция (по центру снизу) те же, что и на
     десктопе (см. базовое #menu-leaf выше) — только чуть ближе к краю
     экрана (bottom меньше) и явная ширина вместо сжатия по контенту. */
  #menu-leaf { bottom: 14px; width: 90%; padding: 0 16px; }
  /* Логотип делит верхнюю строку листа со счётчиком ("portfolio · 1 / 7" — самая
     длинная подпись, ~109px при 11px — раздел переименован из "commiss", слово
     на 2 буквы длиннее, поэтому и счётчик на мобильном уменьшен с 12 до 11px, и
     константа в формуле логотипа ниже пересчитана: без этого на 320–414px
     логотип с новой точкой на конце (.site-logo-dot, см. #site-logo выше) налезал
     на счётчик). Ширина логотипа = что остаётся от листа (64vw − 2×16px отступов
     − счётчик − зазор), но не больше 110px. Точка добавляет к видимой ширине ещё
     ~5% от --logo-w (гэп + сама точка, см. #site-logo) — учтено константой.
     Проверено измерением getBoundingClientRect на 320/360/375/414px: зазор
     держится ≥8px по всему диапазону. */
  #card-label { font-size: 11px; }
  /* Номер проекта — второй строкой под названием раздела, по тому же левому краю
     (по фидбоку); разделитель « · » на мобильном не нужен. */
  #card-label .card-label-sep { display: none; }
  #card-label .card-label-count { display: block; margin-top: 4px; }
  :root { --logo-w: clamp(52px, calc(64vw - 155px), 110px); }
  #menu-leaf nav { gap: 14px; }
  #project-title .title { font-size: 15px; }
  #project-title .year { font-size: 10px; }
  #detail-view { inset: 16px; }
  #detail-close { top: 28px; right: 28px; width: 36px; height: 36px; }
  /* Десктопный паддинг (200/115/220) на узком мобильном экране съел бы
     слишком много ширины под текст — своя, скромнее, но всё равно заметно
     больше исходной (90/32/100), версия. */
  #detail-content { padding: 130px 38px 150px; }
  .detail-meta { grid-template-columns: 1fr; row-gap: 4px; }
  .detail-meta dt { margin-top: 8px; }
  /* Свитчи режимов — компактная версия для мобильного, тот же угол правого
     листа/тот же статичный наклон, что и на десктопе (см.
     #mode-toggles-plane выше), просто с меньшим отступом от шва и чуть
     меньшим размером самих элементов. row-reverse (символ справа от трека)
     и column (версии/свитчи друг над другом) наследуются с десктопной
     раскладки без переопределения. */
  /* +6px (не +20, как выше) — угол между швом (82%) и краем узкого
     мобильного экрана заметно теснее, чем на десктопе. */
  #mode-toggles-plane { bottom: calc(10% + 16px); left: calc(82% + 6px); }
  #mode-toggles { gap: 9px; }
  .mode-switch { gap: 6px; }
  .mode-icon { width: 19px; height: 19px; }
  .mode-switch-track { width: 40px; height: 21px; }
  .mode-switch-knob { width: 17px; height: 17px; top: 2px; left: 2px; }
  .mode-switch.active .mode-switch-knob { transform: translateX(19px); }
  .version-switch { gap: 6px; }
  /* height совпадает с .mode-switch-track на мобильном (21px) — та же логика
     выравнивания размера, что и на десктопе выше. */
  .version-btn { height: 21px; padding: 0 10px; font-size: 11px; }
  .version-btn--icon { padding: 0 8px; }
  .version-icon { width: 14px; height: 14px; }
  #lightbox-img { max-width: 92vw; max-height: 76vh; }
  .lightbox-embed { width: min(92vw, calc(76vh * var(--ar, 1.7778))); }
  #lightbox-video video { max-width: 92vw; max-height: 76vh; }
  /* тот же отступ 12px от края, что и у .nav-arrow (тот не меняет позицию
     на мобильном вообще) — размер кнопки меньше (40px вместо 48px), но
     левый/правый край держится на том же 12px. */
  .lightbox-nav { width: 40px; height: 40px; }
}

/* Узкие телефоны: было откалибровано под пять пунктов меню и слово «Commiss» (длиннее
   прежнего «Work») — при размере 12px/gap 14px не помещались в пилюлю 90% ширины (на
   360px вылезали за рамку, на 375px съедали внутренний отступ). С 2026-09-30 пунктов
   четыре («Portfolio Art About Contact», Models скрыт, Commiss→Portfolio) — с запасом
   должно хватать места и без сжатия, блок не пересчитывался под новый набор (не мешает,
   просто чуть мельче, чем строго необходимо). Шрифт плавно уменьшается от 12px (≥ ~390px)
   до 10.5px (320px), плюс теснее гап и трекинг. Выше 400px — прежний вид. */
@media (max-width: 400px) {
  #menu-leaf { padding: 0 12px; }
  #menu-leaf nav { gap: 8px; }
  #menu-leaf button { font-size: clamp(10.5px, 3.1vw, 12px); letter-spacing: 0.08em; }
}

/* ---------- кастомный тултипс (см. «кастомные тултипсы» в app.js) ----------
   Один плоский бейдж на ЦЕНТРАЛЬНОМ листе (по фидбоку): свитчи и кнопки версий
   стоят на наклонённом правом листе у самого шва, а подсказка — слева от шва, без
   наклона. Средняя линия бейджа — продолжение горизонтали кнопки: та линия клетки
   правого листа, на которой лежит середина кнопки, доходит до шва и переходит в
   линию клетки центрального листа — на ней бейдж и лежит. left/top задаёт JS — это
   ПРАВЫЙ край бейджа и его середина по высоте (отсюда translate(-100%, -50%)). Цвет — тот
   же графит, что у .mode-switch.active и у .contact-toast; текст всегда со
   строчной буквы (по фидбоку). */
#tooltip-bubble {
  position: fixed;
  left: 0; top: 0;
  padding: 5px 9px;
  border-radius: 3px;
  background: #484848;
  color: #fbfaf7;
  font-size: 11px;
  line-height: 1.3;
  letter-spacing: 0.03em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translate(-100%, -50%) translateX(2px);
  transition: opacity 0.12s ease, transform 0.12s ease;
  z-index: 60;
}
/* Невидимая точка на шве (левый край правого листа) — по ней JS ставит бейдж, см.
   positionTooltip() в app.js. */
#tooltip-seam-anchor {
  position: absolute;
  left: 0; top: 0;
  width: 0; height: 0;
  pointer-events: none;
}
#tooltip-bubble.visible {
  opacity: 1;
  transform: translate(-100%, -50%);
}
