/* ═══════════════════ РАБОЧИЙ СТОЛ ═══════════════════
   Рабочий стол устроен как граф: папки/приложения — узлы,
   которые можно свободно таскать (как на маке), связаны
   линиями друг с другом и с лого-хабом в центре. Толщина
   линии зависит от её длины (короче — толще), а линии,
   идущие к лого, ещё и гаснут на подходе к нему — иначе
   в одной точке скапливается пучок одинаковых линий.

   Иконки — полноценные картинки (assets/icons/*.svg) как в
   настоящей ОС: у папки — свой рисунок, у Profile — свой,
   без схематичных плашек-заглушек. Подпись всегда под иконкой.

   Открытие папки/приложения — клик по иконке плавно перелетает
   и вырастает в центре (на месте лого), становясь хабом (см.
   "ЦЕНТР ЭКРАНА" в main.js).

   Левитация (--levitate-y) считается в JS покадрово (main.js,
   tickLevitation) и применяется через CSS-переменную, а не через
   @keyframes — потому что линии-связи в том же кадре перерисовываются
   от актуального getBoundingClientRect() узлов, и должны идеально
   следовать за покачивающимися иконками, а не отставать от них.

   Всё в относительных долях экрана и fluid-единицах (clamp),
   а не в фиксированных брейкпоинтах.
═══════════════════════════════════════════════════════ */

:root {

  /* Размер объектов на большом экране (решение владельца 10.10.2026) —
     иконки узлов (спутник, Profile на столе, знак сайта — от спутника),
     лого (boot.css), хаб папки и категории, работы категории. Те же рампы
     короткой стороны, что у --px-up (style.css), свои ступени — на 8 %
     крупнее, чем было до 10.10: на 1920 (940–1080) — чем на окне
     1920×960, где их смотрел владелец (не вровень с текстом: 1.38); на
     2560 (1340–1440) и 3840 (2060–2160) — чем во весь экран 2560×1440 и
     3840×2160. Хаб и работы — своими числами: раньше их на больших
     экранах ограничивала доля короткой стороны (18vmin, 8.8vmin), а не
     --px-up. Размер = значение при 900 × множитель; при короткой
     стороне ≤ 900 — 1px, ничего не меняется. */
  --icon-up: calc(1px
                 + var(--up-ramp-1920) * (1.2168 - 1)
                 + var(--up-ramp-2560) * (1.9872 - 1.2168)
                 + var(--up-ramp-3840) * (3.18 - 1.9872)
                 + var(--up-ramp-over) * 1.325);
  --hub-up: calc(1px
                + var(--up-ramp-1920) * (1.2168 - 1)
                + var(--up-ramp-2560) * (1.9713 - 1.2168)
                + var(--up-ramp-3840) * (2.957 - 1.9713)
                + var(--up-ramp-over) * 1.232);
  --work-up: calc(1px
                 + var(--up-ramp-1920) * (1.152 - 1)
                 + var(--up-ramp-2560) * (1.728 - 1.152)
                 + var(--up-ramp-3840) * (2.592 - 1.728)
                 + var(--up-ramp-over) * 1.08);

  /* Реакция хаба на наведение (.os-icon-wrap--hub) — заметно тише, чем
     у спутников (--hub-hover-scale 1.06 < 1.08 у спутников), потому что
     хаб и так самый крупный объект на экране. Реакции на клик нет —
     сжатие пробовали и убрали: перелёт хаба уже достаточная обратная
     связь, а сжатие рисковало сорвать замер FLIP (см. docs/CHANGELOG.md). */
  --hub-hover-scale: 1.06;

  /* Отступ подписи от иконки — папки-спутники и работы-спутники
     (.os-app, .os-subchild и хаб .os-subhub — везде, КРОМЕ Profile, см. --profile-label-gap
     ниже). Было 12px, по прямому запросу («подписи слишком далеко,
     уменьшить примерно вдвое») уменьшено до 6px. */
  --node-label-gap: calc(6 * var(--px-up));

  /* Подпись работы в категории (07.10.2026). Видна только у работы под
     курсором — то есть всегда при увеличенной картинке
     (--work-hover-scale, от центра), поэтому видимый отступ меряется от
     увеличенного края: тот же, что у подписей узлов. Раскладка колец
     меряет бокс работы (картинка + --work-layout-gap + подпись,
     buildCategoryWorks в main.js) — этот зазор не менять: раскладка
     «Плакатов» хаотически чувствительна к любому пикселю. Подпись
     доводится до видимого отступа сдвигом (translate) — вне раскладки. */
  --work-label-gap:   var(--node-label-gap);
  --work-layout-gap:  calc(2 * var(--px-up));
  --work-hover-scale: 1.08;
  /* Сторона бокса, в который вписана картинка работы в категории
     (07.10.2026, решение владельца): от короткой стороны экрана, минимум
     64 px — по плакату на телефоне удобно попадать пальцем (было
     clamp(56px, 5.5vw, 84px): на любом экране уже ~1020 px — 56, плакат
     ~40 px шириной). На 1440×900 8.8vmin = 5.5vw = 79,2 px — то же.
     Выше 900 — 79,2 px (8.8vmin при 900) × --work-up (10.10.2026; было
     clamp(64px, 8.8vmin, calc(84 * --px-up)) — на больших экранах
     работало только 8.8vmin).
     Раскладка колец берёт число отсюда (workIconFitBox в main.js —
     замером), своего в JS нет. */
  --work-icon-size: calc(max(64px, min(8.8vmin, 79.2px)) + 79.2 * (var(--work-up) - 1px));
  /* Приближение картинки открытой работы (07.10.2026): работает, только
     если картинка вырастет хотя бы во столько раз (меньше — курсор
     обычный, клик ничего не делает; toggleWorkZoom в main.js). */
  --work-zoom-min: 1.15;
  /* Открытая работа не растягивается сверх полного файла больше чем во
     столько раз — в CSS px, без DPR (08.10.2026, решение владельца): потолок
     бокса картинки в updateOpenWorkLayout и приближения «на весь экран»
     в refreshWorkZoom (main.js). */
  --work-upscale-max: 1.15;
  /* Листание открытой работы (10.10.2026, решение владельца): соседи —
     предыдущая у левого края, следующая у правого, частично за краем.
     Сосед вписан в квадрат --work-peek-size доли высоты экрана (вытянутая
     работа — не шире --work-peek-wide его стороны); на экране видно не
     больше --work-peek-show его ширины; если места у края меньше
     --work-peek-min и меньше --work-peek-show-min ширины — соседа нет
     (телефон), только шевроны. Свайп — сдвиг пальцем хотя бы на
     --work-swipe-min по горизонтали (layoutWorkStrip, pageWork в main.js). */
  --work-peek-size:     0.46;
  --work-peek-wide:     1.6;
  --work-peek-show:     0.62;
  --work-peek-show-min: 0.2;
  --work-peek-min:      var(--space-6);
  --work-swipe-min:     calc(40 * var(--px-up));
  /* Шевроны листания «‹ ›»: линия как у спиц (толщина — из диапазона
     linkWidth, цвет --text-muted, наведение --text-primary), без кругов и
     подложек. Видимая высота --pager-h, ширина — доля высоты; зона нажатия
     — невидимый квадрат --pager-hit (на таче --hint-ok-hit-touch). */
  --pager-h:      calc(28 * var(--px-up));
  --pager-w:      calc(var(--pager-h) * 0.43);
  --pager-line-w: calc(1.5 * var(--px-up));
  --pager-hit:    calc(32 * var(--px-up));

  /* Отступ подписи от иконки Profile отдельно — на рабочем столе
     (.os-app) и в открытом виде (.os-subhub, хаб-фото) — не общий
     токен с папками. Оставлен прежним (12px, подобран под увеличенное
     hover-состояние — Profile крупнейший безрамочный hover-хаб на
     сайте, самый жёсткий случай, см. прежнее обоснование в
     docs/CHANGELOG.md): тот же множитель ×2 для папок ужал бы подпись
     Profile под hover почти вплотную — реальный баг, найденный по
     фидбэку, когда --node-label-gap правился одним общим числом на
     всех. Применяется через `:has(.os-icon--photo)` (тот же приём,
     что уже используют --profile-icon-size/--drawer-hub-size
     ниже и .os-icon-wrap--hub:has(.os-icon--photo) в desktop.css) —
     Profile единственный узел с photoIcon в apps-data.js, отдельный
     класс не нужен. */
  --profile-label-gap: calc(12 * var(--px-up));

  /* Размер иконки спутника (папка/категория/приложение) — один токен на
     всех, раньше вдобавок множился на --depth-scale (псевдослучайная
     "глубина" узла на столе или реальный вес папки по числу/объёму
     файлов внутри — оба убраны, см. docs/CHANGELOG.md: "все папки на
     уровне одного размера"). Наведение — тоже один токен, вместо
     прежнего "текущий --depth-scale × 1.16". 1.16 → 1.08 (24.09): по
     скиллу наведение не больше 1.08 (futuristic-os-design/SKILL.md,
     «Движение»). */
  /* От короткой стороны экрана (02.10.2026, было 15vw): от одной ширины на
     телефоне лёжа (844×390) спутник был 110 px, как на десктопе, и восемь
     категорий вокруг хаба не разводились. На 1440, 768, 390, 375 — прежние
     110 / 110 / 77 / 77 px. */
  --sat-icon-size:   calc(clamp(77px, 15vmin, 110px) + 110 * (var(--icon-up) - 1px));
  --sat-hover-scale: 1.08;

  /* Стопка работ — иконка категории на уровне папки (10.10.2026,
     buildCategoryStack в main.js): площадь верхней карточки — как у иконки
     спутника (--sat-icon-size²), вытянутая — не длиннее --stack-wide его
     стороны; каждая следующая — на --stack-step стороны вверх-вправо и в
     --stack-shrink раз меньше. JS читает числа один раз. */
  /* Сторона-единица стопки — сторона иконки спутника, но не больше
     12,5vmin (+ рост больших экранов, как у иконок): на тесном экране
     (телефон, 844×390) стопка с подписью в две строки иначе не помещается,
     и разведение перекидывало категории на другую сторону хаба —
     композиция по умолчанию важнее (на 1440 и больших — ровно иконка). */
  /* Телефон (13.10.2026, владелец: на 390×844 стопки ~31×44 рядом с хабом
     101 — мелко): потолок 12,5vmin × --stack-narrow при короткой стороне
     ≤ 430 px, плавно до ×1 к 600 (как --line-w-narrow) — без правил под
     ширину; с 600 и на десктопе — ровно как было. В тот же день ещё +10 %
     по стороне (владелец): 1,25 → 1,375. Хаб-призрак — --hub-icon-size,
     от этого множителя не зависит. */
  --stack-narrow: 1.375;
  --stack-cap:    clamp(12.5vmin, calc(12.5vmin * var(--stack-narrow) - (100vmin - 430px) * (var(--stack-narrow) - 1) * 75 / 170), calc(12.5vmin * var(--stack-narrow)));
  --stack-unit:   calc(min(var(--sat-icon-size), calc(var(--stack-cap) + 110 * (var(--icon-up) - 1px))) * var(--stack-scale));
  /* Стопки меньше иконки (10.10.2026, владелец: стопки перетягивали
     внимание с хаба): площадь верхней карточки −35 % (сторона × √0,65),
     в тот же день — ещё −20 % от этого (× √0,8): −48 % от иконки;
     11.10.2026 — ещё −15 % (× √0,85): −56 % от иконки.
     Хаб «Работы» — самый крупный на уровне. */
  --stack-scale:  0.665;
  --stack-step:   0.07;
  --stack-shrink: 0.86;
  --stack-wide:   1.4;
  /* Веер при наведении мышью (10.10.2026): верхняя на месте, вторая —
     влево, третья — вправо, вокруг своего нижнего края, как карты в
     руке; сдвиг — доля --stack-unit. Прогресс --fan (0…1) пишет цикл
     (updateNodeScale, --t-stack-fan, --ease-navigate). */
  --stack-fan-rot-2: -13deg;
  --stack-fan-rot-3:  12deg;
  --stack-fan-dx:     0.08;
  /* Листы стопки-спутника покачиваются каждый сам (13.10.2026, владелец:
     стопка левитировала одним целым, будто склеенная): поверх левитации
     узла и веера у каждой карточки свой сдвиг по x и y (до --stack-sway-shift)
     и поворот вокруг нижнего края (до --stack-sway-rot) — синусоиды со своей
     фазой и периодом (--t-stack-sway-*). Ведёт цикл (updateNodeScale); у
     хаба-призрака, в перелёте, раздаче, сборе и драге — нет. */
  --stack-sway-shift: calc(1.2 * var(--px-up));
  --stack-sway-rot:   0.9deg;
  /* Хабом категория — та же стопка, призраком (11.10.2026, решение
     владельца): вписана в квадрат хаба (--hub-icon-size, как папка
     раньше — зона хаба и кольца те же), непрозрачность — эта; громкий
     элемент на уровне — подпись под ней. С 13.10.2026 — у каждой карточки
     своя: карточка становится призраком в кадр, когда с неё снимается её
     лист (работа), и полной — в кадр, когда он лёг обратно (data-ghost
     пишет рендер-цикл, updateStackDeal в main.js). Только прозрачность — без
     контуров, подложек и фильтров. */
  --stack-ghost-opacity: 0.18;
  /* Лист, снимаемый со стопки, приподнимается: путь — дуга (квадратичная
     кривая, контрольная точка выше старта на эту долю высоты карточки),
     без перелёта за место. */
  --stack-sheet-lift: 0.35;

  /* Закрытый узел стола ("locked" в apps-data.js) — состояние
     «неактивен», а не глубина (яркостью глубину не показываем).
     Иконка — чёрно-белая, не полупрозрачная (решение владельца
     06.10.2026; было приглушение 0.5 с 04.10): --locked-icon-filter на
     самой картинке, --locked-icon-op — прозрачность иконки (1 — без
     приглушения, вариант «а»; вариант «б» — 0.75, один шаг текстовой
     шкалы --text-soft → --text-secondary на фоне; снимки в
     .claude/tmp/locked-gray/). Подпись — --locked-label-op, как с
     04.10 (--text-soft при ней ≈ --text-muted, снимки в
     .claude/tmp/locked-dim/). Одна степень для всех закрытых узлов. */
  --locked-icon-filter: grayscale(1);
  --locked-icon-op:     1;
  --locked-label-op:    0.5;

  /* Знак сайта (.os-site): видимая рамка иконки (или высота заглавной у
     временной буквы) вписывается в квадрат этой стороны — fitSiteGlyph
     в main.js. Сторона — видимая высота папки на уровне папки: корпус
     folder.svg по Y 58…206 из 256. Подпись — на том же расстоянии от
     видимого края знака, что у папки: зазор узла + пустое поле под
     корпусом папки (206…256 из 256). */
  --site-glyph-size: calc(var(--sat-icon-size) * 148 / 256);
  --site-label-gap:  calc(var(--node-label-gap) + var(--sat-icon-size) * 50 / 256);
  /* Допуск зоны наведения, клика и драга узла сайта вокруг контура знака
     (siteHitPolygon в main.js → clip-path обёртки иконки). Пустые углы
     квадрата знака — мимо. */
  --site-hit-pad:    var(--space-1);

  /* Превью сайта при наведении на узел «Сайтов» (updateSitePreview в
     main.js) — ширина, отступ от курсора, от края экрана, масштаб в
     начале роста и в конце сворачивания, доля появления, за которую
     прозрачность доходит до 1. JS читает один раз. */
  --site-preview-w:          calc(350 * var(--px-up));
  --site-preview-gap:        var(--space-3);
  --site-preview-edge:       var(--space-2);
  --site-preview-scale-from: 0.3;
  --site-preview-fade-in:    0.6;

  /* Иконка Profile на рабочем столе — НЕ --sat-icon-size, а собственный
     токен: assets/wdoyose.webp — прозрачный PNG с силуэтом, который
     реально закрашивает лишь часть своего квадратного холста (тонкие
     лучи/лепестки, много прозрачных полей по краям — тот же силуэт, что
     и у хаба Profile, см. --hub-shape), тогда как folder.svg заливает
     почти весь свой квадрат почти сплошной формой. При ОДИНАКОВОМ боксе
     Profile из-за этого читается заметно мельче папки — число подобрано
     глазом, не расчётом (см. docs/CHANGELOG.md), так, чтобы визуальная
     "масса" (не размер бокса) была сопоставима. */
  /* От короткой стороны экрана (02.10.2026, было 17.28vw), как --sat-icon-size:
     на 1440×900, 768×1024, 390×844, 375×667 — прежние 127 / 127 / 89 / 89 px,
     на телефоне лёжа 844×390 — 89 вместо 127. */
  --profile-icon-size: calc(clamp(89px, 17.28vmin, 127px) + 127 * (var(--icon-up) - 1px));

  /* Размер хаба (папка/категория открыты в центре) — тоже токеном, тем
     же множителем ×1.2, что и у спутников, чтобы хаб остался крупнее
     спутника ровно в той же пропорции, что и раньше (118/92 ≈ 142/110).
     Фото-хаб Profile лежит в ящике — его размер --drawer-hub-size ниже. */
  /* От короткой стороны экрана (02.10.2026, было 18vw): на 1440×900,
     768×1024, 390×844, 375×667 — прежние 142 / 138.24 / 101 / 101 px,
     на 844×390 — 101 вместо 142 (хаб был больше трети высоты экрана). */
  --hub-icon-size:         calc(clamp(101px, 18vmin, 142px) + 142 * (var(--hub-up) - 1px));

  /* Ящик-картотека уровня Profile — геометрия (layoutDrawer в main.js
     меряет эти токены, своих чисел не держит). Всё — от шкалы --space-*. */
  --drawer-max-w:    calc(var(--space-8) * 6 + var(--space-6)); /* 624: обод — не шире (передняя карточка — 576) */
  --drawer-gutter:   var(--space-3);                           /* поле от ящика (обода и стенки; сверху и снизу — от ящика) до края экрана */
  /* фото-хаб на задней карточке: 60 на телефоне … 76 с ширины ~500 (от ширины, не от высоты экрана) */
  --drawer-hub-size: clamp(calc(var(--space-7) - var(--space-1)), 15vw, calc(var(--space-7) + var(--space-3) - var(--space-1)));
  --drawer-pad:      var(--space-3);                           /* поле внутри карточки */
  --drawer-who-gap:  var(--space-2);                           /* хаб → имя и роль */
  --drawer-fog-scale: 0.97;                                   /* ящик «из тумана»: масштаб в начале входа и в конце ухода (вместе с прозрачностью 0) */
  --drawer-open-from: var(--space-6);                          /* ящик при входе стартует задвинутым: обод выше центра экрана на столько */
  --drawer-head-gap: var(--space-3);                           /* шапка: от последней строки подписи до верха самого высокого корешка */
  --drawer-hover-lift: var(--space-1);                         /* закрытый лист приподнимается под курсором корешка */
  --drawer-tab-h:    calc(var(--space-4) + var(--space-1));    /* 28: корешок */
  --drawer-tab-pad:  var(--space-2);                           /* поле корешка за скосом: ширина корешка = самая длинная подпись + 2 · (скос + поле) */
  --drawer-tab-skew: var(--space-2);                           /* скос бока корешка — около трети его высоты */
  --drawer-tab-radius: var(--space-1);                         /* верхние углы корешка и его стык с краем карточки */
  --drawer-radius:   calc(var(--drawer-step) / 2);             /* 12: верхние углы карточки — половина шага стопки, по краю стопки столбик скруглений */
  --drawer-tab-inset: var(--space-3);                          /* крайний корешок от края карточки — не меньше радиуса угла + скругления стыка корешка (12 + 4) */
  --drawer-tab-gap:  var(--space-2);                           /* между колонками корешков */
  --drawer-step:     var(--space-4);                           /* 24: шаг стопки — чуть меньше корешка */
  --drawer-tilt:     calc(var(--drawer-step) / 4);            /* 6: наклон боковин листов и скосов стенки — столько px внутрь на каждый --drawer-step вниз (угол 1:4, решение владельца) */
  --drawer-overhang: var(--space-4);                           /* обод выступает за переднюю карточку */
  --drawer-rim-h:    calc(var(--space-2) + var(--space-1));    /* 12: обод */
  --drawer-wall-h:   var(--space-7);                           /* передняя стенка, пустой контур (книзу сужается под углом --drawer-tilt) */
  --drawer-key-w:    calc(var(--space-8) + var(--space-3));    /* колонка ключей в строках карточки — потолок */
  /* На узкой карточке колонка ключей уже (09.10.2026): доля ширины
     содержимого карточки (cqi — от .os-drawer__body), не меньше
     --drawer-key-min (самое длинное слово ключа, «Telegram», ~47 px;
     «Telegram канал» переносится по пробелу). При содержимом от ~312 px
     (телефон 430 и шире) — ровно --drawer-key-w, как было. */
  --drawer-key-min:  var(--space-7);                           /* 64 */
  --drawer-key-share: 36cqi;
  --drawer-row-pad:  var(--space-2);
  --drawer-row-shift: var(--space-1);                          /* строка входит снизу вверх на столько и уходит вниз */                           /* поле строки над и под текстом */
  --drawer-compact-h: calc(520 * var(--px-up));                                   /* ниже этой высоты экрана — компактный ящик */

}

.desktop {
  position: relative;
  width: 100%;
  min-height: 100vh;
}

/* ── Левитация — см. пояснение в шапке файла. .os-app/.os-subchild
   позиционируются через left/top, поэтому transform тут свободен
   целиком под покачивание. ── */
.os-node { transform: translateY(var(--levitate-y, 0px)); }
.os-node.is-dragging { transform: none !important; }

/* Узлы стола и папок (и хаб — тот же элемент) — <button> (main.js,
   makeNodeButton). Сброс того, что браузер задаёт кнопке сверх reset.css
   (шрифт, цвет, выравнивание, трекинг, внешний вид), — через :where(),
   с нулевой специфичностью: любое правило классов .os-app/.os-subchild/
   .os-subhub и подписи ниже его перебивает, внешний вид прежний. */
:where(button.os-app, button.os-subchild, button.os-subhub) {
  -webkit-appearance: none;
  appearance: none;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  word-spacing: inherit;
  line-height: inherit;
  text-transform: inherit;
  text-indent: inherit;
  text-align: inherit;
}

/* ── Линии-связи между узлами и хабом-лого ── */
/* Под лого (.boot, boot.css): на рабочем столе лого — фон места, спицы
   и линия между спутниками уходят под его лепестки, а не рисуются
   поверх. */
.os-links {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: var(--z-lines);
  pointer-events: none;
  /* Видим всегда, кроме уровней ниже стола (.is-drilled ниже). На загрузке
     слой не проявляется целиком: линии растут по одной (bootTick). */
  transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.os-link { stroke: var(--line-color); stroke-opacity: var(--line-alpha); }

/* Импульс по спице — кружок (--pulse-r) в том же SVG, что и его линия;
   позицию, прозрачность и видимость пишет рендер-цикл (updatePulses в
   main.js). Без свечения и тени — просто точка основного цвета текста. */
.os-pulse { fill: var(--text-primary); }

/* ── Слой узлов ── */
/* Над лого: иконку стола можно положить поверх лого. Своей прозрачности
   у слоя нет (02.10.2026): узел, который летит между столом и уровнем,
   переезжает между #os-apps и #os-subgraph, и проступающий слой делал его
   полупрозрачным первые ~100 мс перелёта. Видимость узла — только его
   собственная: display (exit), --enter волны и загрузки, --label-in. */
.os-apps {
  position: fixed;
  inset: 0;
  z-index: var(--z-nodes);
  pointer-events: none;
}

/* Уход со стола вглубь (is-drilled ставит renderAppHub/renderCategoryHub
   в main.js) — каждая линия стола гаснет сама, в рендер-цикле
   (updateEdgeGeometry, --t-line-exit); слой выключается шагом, когда они
   уже погасли (11.10.2026: раньше слой гас transition-ом 0.22s, позже
   своих узлов). Возврат на стол — 0.6s из правила выше. */
.desktop.is-drilled .os-links { transition: opacity 0s linear var(--t-line-exit); }

.os-app {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--node-label-gap);
  cursor: grab;
  /* Хитбокс — не весь узел (иконка+подпись+зазор), а сама иконка (см.
     .os-icon-wrap ниже) — курсор рядом с папкой (над подписью, над
     зазором) не должен её подсвечивать. :hover/mouseenter/клик на
     .os-app всё равно срабатывают от наведения на дочерний .os-icon-wrap
     (всплытие к предку не требует pointer-events:auto у самого предка) —
     ни один обработчик в main.js трогать не нужно. */
  pointer-events: none;
  user-select: none;
  touch-action: none;
}
/* Profile на рабочем столе — свой, больший отступ (см. --profile-label-gap
   в :root), не общий с папками. */
.os-app:has(.os-icon--photo) { gap: var(--profile-label-gap); }

/* Появление узлов (иконок стола/категорий, работ, блоков Profile) ведёт
   рендер-цикл, а не CSS-анимация: прогресс волны — общий с прогрессом
   прорастания линии-спицы к этому же узлу (см. updateEdgeGeometry/
   beginWave в main.js) — одно поле на ребре, а не два отдельных числа
   в JS и CSS. opacity/scale — отдельные CSS-свойства (см. "один писатель
   на свойство" в скилле spatial-os-navigation), не конфликтуют ни с
   transform (левитация), ни с scale хаба/depth на .os-icon-wrap. */
/* --year-op/--hover-op — второй и третий писатели, но каждый своё
   СВОЙСТВО-ИСТОЧНИК (не opacity напрямую): фильтр по годам/сериям
   (updateLevelFilterAnim в main.js) пишет только --year-op, наведение на
   работу в плотной раскладке (updateHoverDimAnim, см. блок ГАШЕНИЕ
   СОСЕДЕЙ ПРИ НАВЕДЕНИИ) пишет только --hover-op — ни один не трогает
   --enter волны появления или друг друга, opacity комбинирует все три
   через calc(), как и требует "один писатель на одно свойство" в
   spatial-os-navigation/SKILL.md. Оба существуют только у .os-work-dot —
   для .os-app/.os-subchild они всегда не заданы и calc() тихо берёт
   запасное значение 1, ничего не меняя. */
/* Переменные, которые рендер-цикл пишет узлу каждый кадр (волна, раздача,
   левитация, фильтр, гашение соседей), — ненаследуемые (13.10.2026): читает
   их только сам узел (правила ниже и левитация .os-node, #logo-wrap,
   .os-subhub, .os-site). Наследуемая переменная при каждой записи
   пересчитывала всё поддерево узла — у работы картинка, подпись, год,
   описание, ссылки: в кадре раздачи «Плакатов» ~350 элементов, ~25 мс при
   CPU ×4. Без @property (старый Safari) — как раньше, через запасные
   значения var(). */
@property --enter       { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --enter-scale { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --year-op     { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --hover-op    { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --fly-x       { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --fly-y       { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --fly-r       { syntax: '<angle>';  inherits: false; initial-value: 0deg; }
@property --fly-sx      { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --fly-sy      { syntax: '<number>'; inherits: false; initial-value: 1; }
@property --levitate-y  { syntax: '<length>'; inherits: false; initial-value: 0px; }
.os-app, .os-subchild, .os-work-dot {
  opacity: calc(var(--enter, 1) * var(--year-op, 1) * var(--hover-op, 1));
  scale: var(--enter-scale, 1);
}
/* Вылет из стопки-призрака и сбор в неё (11.10.2026, updateStackDeal в
   main.js): работа (и узел сайта) летит от карточки стопки на своё место
   и обратно — свои свойства узла translate и множитель scale по осям
   (--fly-*; по осям — лист совпадает с карточкой по ширине и высоте),
   их пишет только рендер-цикл и снимает на месте; transform узла
   (левитация сайта), transform картинки (FLIP открытия, наведение) и
   left/top (место, драг) не трогаются. В полёте узел указатель не ловит —
   хаб (выход) ловит всегда. */
.os-work-dot {
  translate: var(--fly-x, 0px) var(--fly-y, 0px);
  rotate: var(--fly-r, 0deg); /* лист со стопки, раскрытой веером при клике, — с поворотом своей карточки */
  scale: calc(var(--enter-scale, 1) * var(--fly-sx, 1)) calc(var(--enter-scale, 1) * var(--fly-sy, 1));
}
.os-subgraph .os-work-dot.is-flying,
.os-subgraph .os-work-dot.is-flying * { pointer-events: none; }
/* Лист стопки (три работы, чьи карточки в ней, 13.10.2026) — в полёте над
   стопкой; картинка видна сразу (превью то же, что у карточки, уже
   декодировано) и без рамки — в кадр снятия и касания совпадает с
   карточкой. Рамка возвращается на месте за свои 0.22 с. */
.os-work-dot.is-sheet { z-index: var(--z-work-sheet); }
/* Сбор (13.10.2026): средний и нижний листы подкладываются под уже лёгшую
   обложку — ниже стопки (хаба), над остальными работами. */
.os-work-dot.is-sheet.is-sheet-under { z-index: var(--z-work-sheet-under); }
.os-work-dot.is-sheet img { opacity: 1; border-color: transparent; transition: none; }

/* Подписи узлов стола на загрузке проступают после замыкания треугольника
   (--label-in пишет bootTick и снимает в конце загрузки). У хаба класс
   узла уже os-subhub — правило не действует. */
.os-app > .os-app__label { opacity: var(--label-in, 1); }

.os-app__label {
  font-family: var(--font-sans);
  font-size: var(--text-node);
  font-weight: 700;
  color: var(--text-soft);
  text-align: center;
  max-width: calc(108 * var(--px-up));
  line-height: 1.25;
  /* Подпись в перелёте хаб ↔ спутник держит свой видимый размер — её
     `scale` пишет рендер-цикл (updateLabelFlights в main.js, 13.10.2026);
     от верхнего центра — подпись остаётся у иконки. */
  transform-origin: 50% 0;
  /* text-shadow убран 24.09: «тень для высоты» запрещена скиллом
     (futuristic-os-design/SKILL.md, «Поверхностей нет»), размытая тень
     подписи читалась как тёмный ореол под иконкой. */
}

/* Иконка — полноценная картинка (assets/icons/*.svg или .png), как в
   настоящей ОС: свой размер, свои цвета, без рамки, без подложки —
   ту, что несёт файл, ту и показываем. Заменить иконку = положить
   другой файл в assets/icons/ и поправить путь в apps-data.js.

   .os-icon-wrap не рисует ничего своего (без фона/тени) — это просто
   контейнер фиксированного размера, один и тот же для ЛЮБОГО спутника
   (папка, категория, приложение) — размер иконки не зависит от того,
   сколько и каких файлов внутри (было — вес папки задавал --depth-scale,
   убрано целиком по фидбэку "все папки одного размера", см.
   docs/CHANGELOG.md). Единственная разница в размере на этом сайте —
   хаб крупнее спутника (несёт смысл "ты здесь", см. .os-icon-wrap--hub
   ниже). На hover спутник ненадолго увеличивается — тот же смысл,
   что и у --hub-hover-scale хаба, отдельным фиксированным множителем. */
.os-icon-wrap {
  display: block;
  position: relative;
  width: var(--sat-icon-size);
  height: var(--sat-icon-size);
  /* Масштаб наведения/драга (`scale`) пишет общий цикл —
     updateNodeScales в main.js: спутник --sat-hover-scale, хаб
     --hub-hover-scale, за --t-hover-scale. */
}
/* Возвращает хитбокс на квадратную область самой иконки — родитель
   (.os-app/.os-subchild) выше сделан pointer-events:none целиком.
   Хаб (.os-subhub) сюда не попадает — при смене роли className узла
   меняется на "os-subhub" целиком (см. placeAsHub в main.js), это
   правило перестаёт совпадать, и .os-icon-wrap там наследует auto от
   .os-subhub как и раньше — хаб/Profile-хаб (clip-path на
   .os-icon-wrap--hub) не затронуты. */
.os-app .os-icon-wrap,
.os-subchild .os-icon-wrap {
  pointer-events: auto;
}
/* Растровая иконка стола с зоной по силуэту (setIconHit в main.js):
   указатель ловит только невидимый слой поверх неё, обрезанный по
   контуру силуэта (--icon-hit), — рисунок не обрезается (ореол иконки со
   светом остаётся целым). Событие на ::after приходит самой обёртке. */
.os-icon-wrap[data-hit] { pointer-events: none; }
.os-icon-wrap[data-hit]::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: auto;
  clip-path: var(--icon-hit);
}
/* Profile на рабочем столе — свой токен вместо --sat-icon-size, см.
   --profile-icon-size выше. Хаб Profile (.os-icon-wrap--hub, когда
   открыт) не затронут — отдельное правило ниже. */
.os-app .os-icon-wrap:has(.os-icon--photo) {
  width: var(--profile-icon-size);
  height: var(--profile-icon-size);
}
.os-icon {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  position: relative;
}

/* Иконка-фото (Profile) — в отличие от нарисованных иконок папок,
   реальную фотографию нужно КРОПНУТЬ в квадрат (cover), а не вписывать
   с полями (contain). filter убран 24.09: фильтр на перетаскиваемой
   картинке запрещён (Safari глючит при драге). Сама картинка и так
   ч/б (цветности 0), grayscale ничего не делал; оставалось ~5 %
   затемнения от contrast(1.08) brightness(0.95) — его на тёмном фоне
   повторяет opacity 0.95 (серый 119 → 112 с фильтром, → 114 так). */
.os-icon--photo {
  object-fit: cover;
  opacity: 0.95;
}

/* Лого гаснет вместе с рабочим столом, когда что-то открыто в центре —
   его место временно занимает открытая папка/Profile-хаб. Мгновенно, без
   перехода: объявленный здесь раньше transition 0.45s никогда не работал
   (его перебивал #hero-logo { transition: none } в style.css), оба сняты
   вместе 25.09. */
.boot.is-hub-hidden .hero-logo {
  opacity: 0;
}

/* ══════════════════════════════════════════
   ЦЕНТР ЭКРАНА — общий слой для открытой папки (хаб + категории),
   открытой категории (хаб + работы) и открытого Profile (хаб-фото +
   текстовые блоки). os-subgraph сам клики не перехватывает
   (pointer-events: none) — клик по пустому месту проваливается на
   os-scrim: он закрывает открытую работу (если открыта), а уровень
   навигации НЕ закрывает (выход наверх — только хаб, Esc/Backspace и
   «назад» браузера, см. CLAUDE.md).
═══════════════════════════════════════════ */
.os-sublinks {
  position: fixed;
  inset: 0;
  z-index: var(--z-sublinks);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.desktop.is-drilled .os-sublinks { opacity: 1; }

.os-subgraph {
  position: fixed;
  inset: 0;
  /* Выше os-scrim (--z-scrim): иначе клики по хабу внутри этого слоя
     перехватывались бы прозрачным scrim поверх него — контейнер с
     собственным z-index создаёт стек, внутри которого z-index детей
     сравнивается только друг с другом, а не с элементами снаружи. */
  z-index: var(--z-subgraph);
  pointer-events: none;
  /* Своей прозрачности нет — как у .os-apps (см. там): хаб, прилетевший
     со стола, непрозрачен с первого кадра, спутники и работы проступают
     своей волной (--enter, updateRingIntro). */
}

.desktop.is-drilled .os-links { opacity: 0; }
.desktop.is-drilled .os-apps { pointer-events: none; }

.os-subhub {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: var(--z-hub); /* поверх os-work-dot, чтобы работы, оказавшиеся рядом с хабом, его не закрывали */
  transform: translate(-50%, calc(-50% + var(--levitate-y, 0px)));
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Тот же отступ подписи, что у спутника (.os-app/.os-subchild) — не
     свой. FLIP при смене роли хаб↔спутник совмещает иконку (см.
     flipElementCentered в main.js), и разная геометрия подписи у хаба и
     спутника давала скачок подписи в первом кадре на 17–22px по Y. Отдельные
     --hub-label-gap и отрицательный margin-top подписи хаба (17.09)
     сняты сознательно, см. docs/CHANGELOG.md, 2026-09-23. */
  gap: var(--node-label-gap);
  cursor: pointer;
  pointer-events: auto;
  user-select: none;
}
.os-subhub .os-app__label {
  transition: color var(--t-hover) ease;
}
.os-subhub:hover .os-app__label {
  color: var(--text-primary);
}
/* Реакция на наведение — свойство `scale` на .os-icon-wrap (не transform
   узла: он занят FLIP-перелётом и левитацией), его пишет общий цикл —
   updateNodeScales в main.js (--hub-hover-scale у хаба). Масштаб — на
   иконке, а не на узле: у узла центр бокса лежит между иконкой и
   подписью, рост от него уводил бы иконку в сторону. */
.os-icon-wrap--hub {
  width: var(--hub-icon-size);
  height: var(--hub-icon-size);
}
/* Хаб Profile — фото, узнаётся по .os-icon--photo (единственный photoIcon
   в apps-data.js) — лежит на задней карточке ящика, размер от высоты
   экрана (--drawer-hub-size): ящик целиком в одном экране. */
.os-icon-wrap--hub:has(.os-icon--photo) {
  width: var(--drawer-hub-size);
  height: var(--drawer-hub-size);
  /* Хитбокс/hover — по грубому силуэту (--hub-shape, в % от бокса —
     масштабируется вместе с иконкой), а не по всему квадрату: прозрачные
     углы больше не реагируют на наведение. Сам клик обрабатывает внешний
     .os-subhub (см. makeNavNode), поэтому его JS-эквивалент того же
     контура (HUB_SHAPE_POLY в main.js) гейтит клик отдельно — clip-path
     сюда браузер это не передаёт. */
  clip-path: var(--hub-shape);
  cursor: pointer;
}
/* .os-subhub целиком (включая подпись "Profile" и прозрачные углы
   квадрата иконки) по умолчанию — pointer у всех типов хаба; для
   фото-хаба базовый курсор снят в пользу курсора на самой иконке выше —
   так вне силуэта (но внутри квадрата) курсор не обещает клика, которого
   не будет. */
.os-subhub:has(.os-icon-wrap--hub .os-icon--photo) {
  cursor: default;
  /* Тот же больший отступ, что и у Profile-иконки на рабочем столе
     (см. --profile-label-gap в :root) — открытый хаб Profile ощутимо
     крупнее хаба папки/категории (--drawer-hub-size), общий с
     папками --node-label-gap выглядел бы под ним теснее расчёта. */
  gap: var(--profile-label-gap);
}

/* Стопка работ вместо папки у категории (buildCategoryStack в main.js):
   бокс .os-icon-wrap — объединение карточек (в долях стороны иконки,
   --sw/--sh), карточки — настоящие превью в своих пропорциях, без рамок,
   теней и фильтров; проступают, когда декодированы. Папки (.os-icon) у
   такой категории не видно ни в какой роли. Хабом — та же стопка,
   призраком (11.10.2026): сторона-единица такая, что большая сторона
   стопки = квадрату хаба; перелёт в центр — FLIP того же узла (пропорции
   бокса те же, масштаб равномерный). */
.os-subchild[data-stack] .os-icon-wrap {
  width: calc(var(--stack-unit) * var(--sw, 1));
  height: calc(var(--stack-unit) * var(--sh, 1));
}
.os-subhub[data-stack] .os-icon-wrap {
  --stack-unit: calc(var(--hub-icon-size) / max(var(--sw, 1), var(--sh, 1)));
  width: calc(var(--stack-unit) * var(--sw, 1));
  height: calc(var(--stack-unit) * var(--sh, 1));
}
[data-stack] .os-icon { opacity: 0; }
/* Прозрачность карточки стопки — один писатель: правила .is-decoded ниже.
   У спутника карточка полная, у хаба — призрак (прямая ссылка,
   reduced-motion — сразу призрак), кроме data-ghost="0": его на время
   раздачи и сбора пишет рендер-цикл (updateStackDeal) — карточка, чей
   лист ещё (или уже) в стопке. Призрак виден только вне карточек выше
   (--ghost-clip, stackGhostClip в main.js): в покое стопка-призрак —
   как одна прозрачная стопка, сквозь верхнюю нижние не просвечивают. */
.os-stack {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
/* Подпись под призраком — громкий элемент уровня (сам призрак тихий):
   всегда --text-primary, наведение — масштаб иконки, как у хаба. */
.os-subhub[data-stack] .os-app__label { color: var(--text-primary); }
.os-stack__card {
  position: absolute;
  display: block;
  left: calc(var(--stack-unit) * var(--x));
  top: calc(var(--stack-unit) * var(--y));
  width: calc(var(--stack-unit) * var(--w));
  height: calc(var(--stack-unit) * var(--h));
  max-width: none;
  opacity: 0;
  transition: opacity var(--t-wave-node) var(--ease-out);
}
.os-stack__card.is-decoded { opacity: 1; }
.os-subhub .os-stack__card.is-decoded:not([data-ghost="0"]) {
  opacity: var(--stack-ghost-opacity);
  clip-path: var(--ghost-clip, none);
}
/* У хаба карточка меняется мгновенно — в кадр снятия и касания листа;
   проступание после декода — у спутника. Хаб → спутник (уход при открытой
   работе) — карточки-призраки наливаются им же, за --t-wave-node. */
.os-subhub .os-stack__card { transition: none; }
/* Веер и покачивание листа — свои свойства карточки (rotate, translate),
   одним правилом: левитация и FLIP пишут узел, наведение — scale обёртки;
   карточек они не касаются. Переменные пишет только рендер-цикл
   (updateNodeScale): --fan (0…1) на .os-stack, --sway-x/-y/-r (доли
   амплитуд, −1…1, уже с множителем включения) на карточке; ту же позу
   карточки в кадре читает раздача (stackCardPose). */
.os-stack__card {
  transform-origin: 50% 100%;
  rotate: calc(var(--fan, 0) * var(--fan-rot, 0deg) + var(--sway-r, 0) * var(--stack-sway-rot));
  translate: calc(var(--fan, 0) * var(--fan-dx, 0) * var(--stack-unit) + var(--sway-x, 0) * var(--stack-sway-shift)) calc(var(--sway-y, 0) * var(--stack-sway-shift));
}
.os-stack__card[data-k="1"] { --fan-rot: var(--stack-fan-rot-2); --fan-dx: calc(var(--stack-fan-dx) * -1); }
.os-stack__card[data-k="2"] { --fan-rot: var(--stack-fan-rot-3); --fan-dx: var(--stack-fan-dx); }
/* Подпись категории-спутника: имя (как было — не шире 108) и под ним
   строка данных «49 работ · 2023–2026» — роль «Данные» (400, --text-data,
   --tracking-data, --text-secondary), в одну строку. У хаба — только имя. */
.os-subchild[data-stack] .os-app__label { max-width: none; }
.os-app__title {
  display: block;
  max-width: calc(108 * var(--px-up));
  margin: 0 auto;
}
.os-app__data {
  display: block;
  margin-top: var(--space-1);
  font-weight: 400;
  font-size: var(--text-data);
  letter-spacing: var(--tracking-data);
  color: var(--text-secondary);
  white-space: nowrap;
}
/* У хаба строки данных нет — но в перелёте спутник → хаб она не пропадает
   в первый кадр, а гаснет (fadeCategoryData в main.js, 13.10.2026): в роли
   хаба остаётся в подписи вне потока, на том же месте под именем (на
   размер подписи, зону хаба и раскладку работ не влияет), в покое
   прозрачна. Прозрачность в перелёте пишет только рендер-цикл. */
.os-subhub[data-stack] .os-app__label { position: relative; }
.os-subhub .os-app__data {
  position: absolute;
  top: 100%;
  left: 50%;
  translate: -50% 0;
  opacity: 0;
  pointer-events: none;
}

.os-subchild {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--node-label-gap);
  cursor: grab;
  /* Хитбокс — сама иконка (.os-icon-wrap выше), не узел целиком; см.
     тот же приём и комментарий у .os-app. */
  pointer-events: none;
  user-select: none;
  touch-action: none;
}
/* ══════════════════════════════════════════
   РАБОТЫ ВНУТРИ КАТЕГОРИИ — вся категория видна разом, БЕЗ спиц (см.
   buildCategoryWorks в main.js — subWantedEdges() для этого уровня
   отдаёт [], линий тут нет вовсе). Раскладка — несколько полярных колец
   вокруг хаба: угол по хешу, радиус по кольцу (новые работы ближе к
   центру, старые дальше — единственный сигнал глубины, без разницы в
   размере: пробовали крупнее-у-центра/мельче-у-края, читалось как
   случайность, а не как глубина, и мелкие по краям было плохо видно).
   Каждая работа — в своём НАСТОЯЩЕМ формате (квадратная — квадратная
   карточка, прямоугольная — прямоугольная, без кропа: img вписывается
   по max-width/height с width/height:auto). Появляются волной от
   центра наружу, по кольцам (не все разом). ── */
.os-work-dot {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--work-layout-gap); /* бокс для раскладки колец; видимый отступ подписи — --work-label-gap (сдвиг подписи ниже) */
  cursor: grab;
  /* Хитбокс — сама картинка (.os-work-dot img ниже), не весь узел с
     подписью и годом: при 49 работах вплотную лишний бокс с подписью
     давал промахи мимо соседних работ (реальный баг, найденный по
     фидбэку). Подпись/год своего pointer-events не получают — наследуют
     none от этого правила, поэтому и не ловят курсор, даже когда видны
     по наведению (это и есть требуемое "область подписи не ловит") и
     даже когда остаются в потоке скрытыми (opacity:0 у всех, кроме
     подсвеченной работы — см. .os-work-dot__label ниже; opacity не
     убирает элемент из хит-теста сам по себе, только pointer-events). :hover/mouseenter/клик/драг — всё по-прежнему
     всплывает на этот узел от наведения на img, менять main.js не нужно. */
  pointer-events: none;
  user-select: none;
  touch-action: none;
  /* Наложение — по дате (см. --z-work, выставляется инлайн в
     buildCategoryWorks/main.js: год·100+месяц минус самая свежая работа
     категории). Всегда отрицательное значение — строго меньше z-index
     хаба (--z-hub у .os-subhub) — работы наслаиваются друг на
     друга по свежести, но хаб никогда не закрывают. Наведение (--z-work-raised) и
     открытый просмотр (--z-work-flying) — свои фиксированные значения
     поверх любого отрицательного ранга, не читают --z-work вовсе. */
  z-index: var(--z-work, auto);
}
.os-work-dot img {
  display: block;
  max-width: var(--work-icon-size);
  max-height: var(--work-icon-size);
  /* width/height читают инлайновые --work-fit-w/--work-fit-h (см.
     buildCategoryWorks в main.js) с запасным auto — НЕ голый инлайновый
     width/height на самом <img>: инлайновый style всегда перебивает
     ЛЮБОЙ селектор класса по specificity, и когда открытие работы
     (.os-work-dot.is-open-work img ниже) пытается вернуть картинку на
     весь экран через max-width:calc(100vw...)+width:auto, инлайновый
     px-размер (нужен, чтобы избежать бага "картинка ещё не начала
     грузиться → getBoundingClientRect ~2×2px", см. work-rings.md/
     docs/CHANGELOG.md) держал её приклеенной к размеру превью —
     реальный баг, найденный по скриншоту ("Это свага!" не открывалась
     крупно). Через CSS-переменную то же самое значение читается ТОЛЬКО
     этим правилом — более специфичный селектор `.is-open-work img`
     ниже побеждает через обычный каскад (2 класса против 1), а не
     проигрывает инлайну. */
  width: var(--work-fit-w, auto);
  height: var(--work-fit-h, auto);
  /* Хитбокс — см. комментарий у .os-work-dot выше. Бокс картинки и так
     совпадает с видимым изображением (width/height:auto + max-* даёт
     размер строго по пропорции, объявленной в thumb-meta.js, — здесь
     нет фиксированного бокса с object-fit:contain и невидимых полей по
     бокам; проверено на разных пропорциях, расхождение — только
     собственная рамка ниже, 1px, а не пустое поле). */
  pointer-events: auto;
  /* Явно (хотя это и умолчание) — просмотр работы (openWork/closeWork в
     main.js) летит FLIP-ом именно этой картинкой, от своего же центра;
     формула там (flipElementCentered) верна только при центровом origin. */
  transform-origin: center;
  border: 1px solid var(--line-img);
  /* filter убран 24.09 (фильтр на перетаскиваемой картинке запрещён —
     Safari глючит при драге). Был grayscale(0.3) contrast(1.05)
     brightness(0.92) — работы стали на ~8 % светлее и на 30 %
     насыщеннее. Затемнение НЕ перенесено в opacity сознательно: работы в
     плотной категории перекрывают друг друга, и при opacity 0.92 нижняя
     проступала сквозь верхнюю (проверено скриншотом «Плакатов»,
     docs/CHANGELOG.md, 2026-09-24). */
  /* Проступает уже декодированной (см. decodeWorkDotImage в main.js:
     img.decode() перед показом), а не дёргает кадром на медленной сети —
     opacity своя у картинки, отдельно от --enter волны на самом узле. */
  opacity: 0;
  transition: opacity 0.2s ease, transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.22s ease;
}
.os-work-dot.is-decoded img { opacity: 1; }
/* Видео-работа (см. openWork/closeWork в main.js) — скрыта, пока не
   .is-video-active (полный файл ещё не готов к показу или его вовсе не
   грузили — reduced-motion). Никаких значков/рамок "это видео" —
   поверхностей нет (см. futuristic-os-design/SKILL.md), в россыпи и на
   превью-кадре она неотличима от обычной картинки. Базовый размер —
   тот же clamp, что у img выше (сетка категории); внутри is-open-work
   он переопределяется ниже под полноэкранный просмотр. Оставлен видимым
   и играющим ВЕСЬ перелёт закрытия (is-video-active снимается только
   когда FLIP реально доиграл, см. closeWork) — поэтому базовый (grid)
   размер здесь нужен независимо от is-open-work: именно на разнице
   между открытым и этим размером и держится FLIP-коррекция "сворачивания". */
.os-work-dot video { display: none; pointer-events: none; }
.os-work-dot.is-video-active video {
  display: block;
  max-width: var(--work-icon-size);
  max-height: var(--work-icon-size);
  width: auto;
  height: auto;
  object-fit: contain;
  transform-origin: center;
}
/* Пока видео активно, превью-картинка под ним не нужна — видео и есть
   то, что видно (requirement "видео ведёт себя как живая картинка", без
   кросс-фейда между ними). */
.os-work-dot.is-video-active img { display: none; }
/* Выделение — увеличение и подъём над соседями (могут перекрываться
   после прохода разведения по радиусу), а не "проступающая" рамка. */
.os-work-dot:hover {
  z-index: var(--z-work-raised);
}
.os-work-dot:hover img {
  transform: scale(var(--work-hover-scale));
  border-color: var(--line-img-hover);
}
.os-work-dot.is-dragging img { border-color: var(--line-color); transform: none; }
/* Фильтр по годам/сериям (см. блок ФИЛЬТР ПО ГОДАМ/СЕРИЯМ в main.js) —
   работа вне выбранного значения гаснет через --year-op (opacity, см.
   calc() выше — плавно, ведёт рендер-цикл) и СРАЗУ, без перехода, уходит
   под всех остальных работ выбранной группы (плоское значение, а не calc
   от --z-work: порядок ВНУТРИ погашенной группы не важен, важен только
   порядок ВНУТРИ выбранной группы — та не трогается вовсе) и перестаёт
   ловить hover/клик
   (pointer-events на контейнере и повторно на img — та же причина
   дублирования, что и у .has-open-work выше: у img своё явное
   pointer-events:auto, оно сильнее наследования). */
.os-work-dot.is-year-dimmed {
  z-index: var(--z-work-dimmed);
  pointer-events: none;
}
.os-work-dot.is-year-dimmed img {
  pointer-events: none;
}
.os-work-dot.is-dragging { cursor: grabbing; z-index: var(--z-work-raised); }
/* Обёртка над подписью/годом/описанием/ссылками (см. buildCategoryWorks
   в main.js). В сетке категории — просто ещё один flex-item в колонке
   под картинкой, центрирует единственный видимый в этом режиме элемент
   (__label); __year/__desc/__links скрыты до is-open-work ниже, в
   раскладку сетки не вмешиваются. Открытый просмотр целиком
   переопределяет этот блок — см. .os-work-dot.is-open-work .os-work-dot__info. */
.os-work-dot__info {
  display: flex;
  flex-direction: column;
  align-items: center;
}
/* Роль "Проза", не "Имя объекта": в россыпи из полусотни работ жирная
   подпись у каждой спорила бы с картинками, а имя работы здесь —
   вторичная деталь, не главный элемент кадра (см. type-and-labels.md).
   400/--text-secondary, чуть мельче обычной прозы. Длинное название
   переносится на вторую строку по словам (как в файловых менеджерах),
   а не обрезается сразу же; если не влезло и в две строки — тогда
   многоточие. */
.os-work-dot__label {
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: var(--text-work);
  letter-spacing: var(--tracking-work);
  line-height: 1.3;
  color: var(--text-secondary);
  text-align: center;
  max-width: calc(140 * var(--px-up));
  overflow-wrap: break-word;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  /* Видна только у подсвеченной работы — под курсором,
     после той же задержки, что гасит соседей (--label-op пишет
     updateHoverDim в main.js, 07.10.2026); во всех категориях одинаково.
     Открытая работа показывает подпись своим правилом ниже. У узлов
     «Сайтов» подпись видна только в открытом виде — правило их не
     касается. */
  opacity: var(--label-op, 0);
  /* Видимый отступ --work-label-gap от края увеличенной картинки: сдвиг
     вниз на разницу с зазором раскладки и на прирост половины высоты
     картинки при наведении. translate не меняет ни бокс узла, ни
     offsetTop — раскладка колец та же. Открытая работа — без сдвига.
     По X — сдвиг внутрь экрана у работы с бокового края (--label-shift-x,
     fitWorkLabelX в main.js, 07.10.2026). */
  translate: var(--label-shift-x, 0px) calc(var(--work-label-gap) - var(--work-layout-gap) + var(--work-fit-h, 0px) * (var(--work-hover-scale) - 1) / 2);
}

.os-scrim {
  position: fixed;
  inset: 0;
  z-index: var(--z-scrim);
  background: transparent;
  pointer-events: none;
}
.os-scrim.is-open { pointer-events: auto; }

/* ══════════════════════════════════════════
   ФИЛЬТР ПО ГОДАМ/СЕРИЯМ — единственный элемент интерфейса на уровне
   работ (см. buildLevelFilterRow в main.js). Одна строка через запятую
   («ВСЕ, 2025, 2026» / «ВСЕ, KOLPAK.FM, MEDIABREAD»), в правом верхнем
   углу, выравнивание по правому краю — те же безопасные отступы от края,
   что у остальных плавающих элементов (clamp(16px, 3vw, 28px)). Вне
   .os-subgraph — не
   часть раскладки колец, не левитирует, не гасится разведением/резайзом.
   Поверхностей нет — сама строка лежит прямо на фоне, без рамки/фона/
   подчёркивания: единственный разделитель между пунктами — запятая
   (--text-muted, некликабельный <span>, ту же роль в системе уже играют
   угловые разрывы между годовыми секторами, см. work-rings.md). Мельче и
   тише самих работ — --text-data, не --text-name/--text-label — чтобы не
   спорить с картинками за внимание. Подписи серий (например «Kolpak.Fm»)
   длиннее подписей года — max-width с запасом от края экрана и flex-wrap
   на случай, если однажды строка не поместится в одну линию: перенос
   вправо-выровненным блоком, а не обрез или вылет за viewport (тот же
   принцип, что и адаптив везде на сайте — раскладка тянется сама, без
   брейкпоинтов под конкретную ширину). */
.os-year-filter {
  position: fixed;
  /* Отступ — от края safe-area (статус-бар и вырез iPhone, в приложении
     с экрана «Домой» страница под ними); без выреза env() = 0. */
  top: calc(clamp(16px, 3vw, calc(28 * var(--px-up))) + env(safe-area-inset-top, 0px));
  right: calc(clamp(16px, 3vw, calc(28 * var(--px-up))) + env(safe-area-inset-right, 0px));
  z-index: var(--z-filter); /* выше .os-subgraph и .os-scrim — кликабелен поверх обоих */
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: baseline;
  max-width: calc(100vw - clamp(16px, 3vw, calc(28 * var(--px-up))) * 2
    - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-navigate) var(--ease-navigate);
}
.os-year-filter.is-open {
  opacity: 1;
  pointer-events: auto;
}
/* Открытый просмотр работы (has-open-work на .os-subgraph, соседний
   элемент в разметке — см. index.html) гасит и фильтр тоже: пока одна
   работа развёрнута на весь экран, это и есть единственный громкий
   элемент, строке в углу рядом с ней делать нечего (те же причины, что
   гасят хаб и соседей). Сам фильтр при этом не сбрасывается —
   activeYearFilter и is-year-dimmed остаются как были, просто временно
   не видны и не кликабельны; после closeWork() возвращаются как есть. */
.os-subgraph.has-open-work ~ .os-year-filter {
  opacity: 0;
  pointer-events: none;
}
/* Запятая-разделитель — часть строки, не кнопка: тот же муted-тон
   всегда, независимо от соседних пунктов, некликабельна (это текст,
   а не элемент управления — pointer-events выключать незачем, у span
   и так нет своего hit-действия, а сам текст помечен aria-hidden в
   main.js, чтобы скринридер не зачитывал запятые отдельно от слов). */
.os-year-filter__comma {
  font-family: var(--font-sans);
  font-size: var(--text-label);
  letter-spacing: var(--tracking-data);
  color: var(--text-muted);
  white-space: pre;
}
/* Явно НЕ "Данные" из futuristic-os-design/SKILL.md (mixed case,
   без uppercase) — по прямому образцу задачи («ВСЕ, KOLPAK.FM,
   MEDIABREAD»): единая строка-переключатель читается как один ряд
   команд, не как значения из файла, и весь ряд, включая «ВСЕ», приведён
   к uppercase через text-transform (не через саму строку в DOM — textContent
   остаётся «Kolpak.Fm» как в data.js, реальным регистром для copy/paste
   и скринридера). Неактивный пункт — --text-muted, активный —
   --text-primary, тот же контраст, которым система показывает "главное"
   везде остальном (см. "Один громкий элемент"). button — сброс
   background/border/cursor уже в reset.css. Размер — var(--text-label)
   (12px), не var(--text-data) (11px, была слишком мелкой по фидбэку,
   13.09.2026): letter-spacing/tracking-data и цвета не тронуты, только
   кегль — и он даже точнее подходит этой строке, чем text-data, раз
   строка и так UPPERCASE, как обычная системная метка. */
.os-year-filter__btn {
  font-family: var(--font-sans);
  font-size: var(--text-label);
  letter-spacing: var(--tracking-data);
  text-transform: uppercase;
  color: var(--text-muted);
  transition: color var(--t-hover) ease;
}
.os-year-filter__btn:hover {
  color: var(--text-primary);
}
.os-year-filter__btn.is-active {
  color: var(--text-primary);
}

/* ══════════════════════════════════════════
   ПРОСМОТР РАБОТЫ — клик по работе внутри категории открывает её крупно
   (см. openWork/closeWork в main.js). Не новый слой: тот же .os-work-dot,
   просто is-open-work переключает его с position:absolute (место в
   раскладке категории) на position:fixed (центр экрана) — FLIP летит
   между этими двумя геометриями, точно как хаб между спутником и
   центром при обычном navigate(). Поверхностей нет — ни рамки, ни
   подложки под картинкой и под текстом; отделение от остальной категории —
   гашением соседей (has-open-work ниже), не оверлеем НАД картинкой.

   Раскладка — картинка слева, текстовая колонка (имя/год/описание/
   ссылки) справа, верх колонки на одной высоте с верхом картинки: весь
   ряд (.is-open-work — flex-row) центрируется как единое целое через
   translate(-50%,-50%), align-items:flex-start внутри ряда даёт общий
   верх картинке и колонке независимо от того, чья высота больше — без
   отдельного вычисления в JS (в кадре ничего не измеряется, см.
   инвариант 7 spatial-os-navigation/SKILL.md). Летит FLIP-ом по-прежнему
   только <img> (или <video>, см. openWork/closeWork) — колонка с текстом
   не участвует в перелёте, появляется/исчезает вместе с синхронным
   переключением is-open-work, как и раньше подпись с годом.
═══════════════════════════════════════════ */
.os-subgraph.has-open-work .os-work-dot:not(.is-open-work):not(.is-peek-shown),
.os-subgraph.has-open-work .os-subhub,
/* Спицы уровня «Сайты» (у категорий с работами линий нет) гаснут вместе
   с соседями, пока сайт открыт. */
.desktop:has(.os-subgraph.has-open-work) .os-sublinks {
  opacity: 0.10;
  /* pointer-events:none — не просто "не реагирует": клик по погашенному
     соседу или хабу должен провалиться сквозь них на os-scrim и закрыть
     просмотр (см. слушатель клика на osScrim в main.js) — тот же приём
     провала клика, что и у os-subgraph в целом (см. комментарий выше). */
  pointer-events: none;
  transition: opacity var(--t-navigate) var(--ease-navigate);
}
/* .os-work-dot img выше объявляет собственный pointer-events:auto —
   правило над этим блоком гасит его на КОНТЕЙНЕРЕ, но у картинки своё
   явное значение сильнее наследования, поэтому здесь нужно то же
   pointer-events:none ещё раз, уже адресно по img — иначе клик по
   притушенной соседней работе не проваливался бы на os-scrim. */
.os-subgraph.has-open-work .os-work-dot:not(.is-open-work):not(.is-peek-shown) img {
  pointer-events: none;
}

/* Сосед открытой работы у края экрана (листание, layoutWorkStrip в
   main.js) — тот же узел работы, что в россыпи (летит туда и обратно
   FLIP-ом своей картинки); место в россыпи — в dataset, как у открытой.
   Только картинка (подпись и колонка скрыты), в настоящих цветах — без
   приглушения, фильтров и прозрачности: гашение соседей (has-open-work)
   его не касается. Размер картинки и место — инлайн (--peek-w/--peek-h,
   left/top) от раскладки открытой работы. Не помещается у края (телефон)
   — стоит за краем экрана (is-peek без is-peek-shown): погашен, как
   россыпь, и не ловит указатель; при листании въезжает из-за края. */
.os-work-dot.is-peek {
  z-index: var(--z-work-peek);
}
.os-work-dot.is-peek .os-work-dot__info { display: none; }
.os-work-dot.is-peek img {
  width: var(--peek-w);
  height: var(--peek-h);
  max-width: none;
  max-height: none;
}
.os-subgraph.has-open-work .os-work-dot.is-peek-shown {
  cursor: pointer;
  transition: opacity var(--t-navigate) var(--ease-navigate);
}
/* Сосед, ушедший обратно в россыпь, — на высоте соседа, пока летит
   (снимается по концу перелёта, как .is-work-flying). */
.os-work-dot.is-peek-leaving {
  z-index: var(--z-work-peek);
}

/* Шевроны листания «‹ ›» — линия как у спиц, без кругов, рамок и
   подложек; место — от раскладки открытой работы (layoutWorkStrip):
   между соседом и работой, а без соседа — у края экрана, по высоте —
   центр картинки. Зона нажатия — невидимый квадрат --pager-hit. */
.os-pager {
  position: absolute;
  z-index: var(--z-work-pager);
  width: var(--pager-hit);
  height: var(--pager-hit);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
  cursor: pointer;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--t-navigate) var(--ease-navigate), color var(--t-hover) ease;
}
.os-subgraph.has-open-work .os-pager.is-on {
  opacity: 1;
  pointer-events: auto;
}
.os-pager:hover { color: var(--text-primary); }
.os-pager svg {
  display: block;
  width: var(--pager-w);
  height: var(--pager-h);
  overflow: visible;
}
.os-pager polyline {
  fill: none;
  stroke: currentColor;
  stroke-width: var(--pager-line-w);
  stroke-linejoin: round;
}
@media (pointer: coarse) {
  .os-pager { width: var(--hint-ok-hit-touch); height: var(--hint-ok-hit-touch); }
}

.os-work-dot.is-open-work {
  position: fixed;
  /* Центр области внутри safe-area (вырез, статус-бар, полоска «домой»);
     размер под неё считает updateOpenWorkLayout. Без выреза — центр экрана. */
  left: calc(50% + (env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)) / 2);
  top: calc(50% + (env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) / 2);
  transform: translate(-50%, -50%);
  /* z-index сюда НЕ ставим — см. .is-work-flying ниже: is-open-work
     снимается синхронно (конечное состояние применяется в DOM ДО FLIP,
     см. closeWork в main.js) раньше, чем долетает FLIP-коррекция при
     закрытии, и если z был бы привязан к этому классу, работа на время
     полёта обратно проваливалась бы под хаб (реальный баг, найденный по
     фидбэку — z-index класса гас раньше, чем картинка визуально
     уменьшалась). */
  flex-direction: row;
  /* stretch — верх колонки совпадает с верхом картинки (<img> —
     replaced-элемент со своим intrinsic-размером, stretch на неё не
     действует по спецификации flexbox, картинка не меняется). Строка
     ссылок больше НЕ привязана к нижнему краю картинки/панели (было в
     двух прошлых итерациях — выравнивание по базовой линии, потом по
     низу подчёркивания — отменено по прямому фидбэку: ссылки должны
     идти сразу за прозой, в потоке, а не быть прижаты к какому-либо
     краю). align-items здесь снова влияет только на .os-work-dot__info
     (растягивает её до высоты строки, см. ниже) — картинку, как и
     раньше, не трогает. */
  align-items: stretch;
  gap: var(--space-6);
  cursor: default;
}
/* Определённая (не auto) высота строки — ТОЛЬКО так .os-work-dot__info
   ниже может честно резолвить `height: 100%` в проценте от родителя.
   auto-высота flex-контейнера (какой была бы без этого правила) для
   процентного height детей считается НЕОПРЕДЕЛЁННОЙ по спецификации —
   align-items:stretch всё ещё растягивал бы .os-work-dot__info по факту,
   но сам `height:100%` внутри неё схлопывался бы в auto (реальный баг,
   пойманный здесь Playwright'ом: без этой строки `.os-work-dot__info`
   рендерился на высоту своего контента, а не строки, и в overflow:hidden
   уже не было смысла). Та же формула, что у max-height картинки ниже
   (.is-open-work img) — картинка коротким/широким кадром может быть
   МЕНЬШЕ этого бюджета по высоте, тогда просто останется вертикальным
   воздухом сверху/снизу неё внутри строки той же высоты, что и панель. */
.os-work-dot.is-open-work:not(.is-stacked) {
  height: calc(100vh - var(--space-5) * 2);
}
/* Приподнята НАД хабом (--z-hub у .os-subhub) и обычными точками (--z-work-raised на
   hover) весь перелёт — и открытие, и закрытие, — не только пока стоит
   is-open-work. Класс включается в openWork ДО первого кадра FLIP и
   снимается в closeWork только когда FLIP-коррекция закрытия реально
   доиграла (anim.finished), а не когда мгновенно снялся is-open-work —
   иначе на закрытии видна была ровно эта проблема: работа гасла под хаб
   раньше, чем визуально долетала до его места. Значение — между
   --z-nodes (обычные узлы графа) и --z-overlay (верхняя граница шкалы,
   в этой системе модального слоя нет) — см. tokens в style.css. */
.os-work-dot.is-work-flying {
  z-index: var(--z-work-flying);
}
.os-work-dot.is-open-work img {
  /* Отступ от краёв экрана — --space-5 с каждой стороны. Бюджет ширины
     делится с колонкой текста: --space-6 (тот же gap, что задан на
     .is-open-work выше) и 34ch на саму колонку (см. .os-work-dot__info
     ниже — то же значение, оба места держат одно число за счёт общего
     смысла "не шире 34 символов", не переменной, ch тут не читается
     через var() ни в одном calc()). font-size здесь — тот же --text-small,
     что и у самой колонки (.os-work-dot__info ниже): ch — величина
     относительно шрифта ЭЛЕМЕНТА, на котором стоит, а у <img> своего
     текста нет и кегль иначе достался бы от унаследованного дефолта
     браузера (обычно 16px, не 14px текста колонки) — бюджет и реальная
     колонка разъехались бы на десятки px (пойман в этой сессии
     Playwright'ом: колонка съёживалась вдвое из-за нехватки места,
     см. docs/CHANGELOG.md). Высота больше не резервирует место под
     подпись снизу — имя/год/описание/ссылки теперь сбоку, не под
     картинкой. */
  font-size: var(--text-small);
  max-width: calc(100vw - var(--space-5) * 2 - var(--space-6) - 34ch);
  max-height: calc(100vh - var(--space-5) * 2);
  width: auto;
  height: auto;
  object-fit: contain;
  border: none;
  opacity: 1;
  transform: none;
  flex-shrink: 0;
}
/* Открытая видео-работа — те же вписанные-в-экран пропорции, что и у
   картинки выше (object-fit: contain, реальный кадр видео, без рамки/
   фильтра — открытая работа единственное место сайта, где им можно). */
.os-work-dot.is-open-work.is-video-active video {
  font-size: var(--text-small);
  max-width: calc(100vw - var(--space-5) * 2 - var(--space-6) - 34ch);
  max-height: calc(100vh - var(--space-5) * 2);
  flex-shrink: 0;
}
/* Текстовая колонка — имя, год, описание, ссылки. Только для открытого
   просмотра: в сетке категории виден лишь .os-work-dot__label (роль
   "Проза", центрировано под иконкой, см. правило .os-work-dot__label
   выше) — .os-work-dot__year/__desc/__links остаются display:none, пока
   не стоит is-open-work. Никакой подложки (futuristic-os-design/
   SKILL.md, "Поверхностей нет") — только типографика и отступ.
   width (не max-width!) + flex-shrink:0 — колонка ВСЕГДА ровно 34ch, а не
   сжимается flex-алгоритмом, когда суммарная предпочитаемая ширина ряда
   (картинка+gap+колонка) не помещается в доступную ширину — с max-width
   flex честно ужимал именно эту колонку (единственный flex-shrink:1 в
   ряду) вдвое от заданных 34ch (реальный баг, пойман Playwright'ом в
   этой сессии — см. docs/CHANGELOG.md), а картинка (flex-shrink:0)
   оставалась на своей полной ширине. */
.os-work-dot.is-open-work .os-work-dot__info {
  /* Гаснет при приближении картинки (--work-zoom 0 → 1, пишет
     updateWorkZoom в main.js — единственный писатель). */
  opacity: calc(1 - var(--work-zoom, 0));
  align-items: flex-start;
  /* Было var(--space-4) (до этого var(--space-2)) — по прямому фидбэку
     отступы вокруг года уменьшены в полтора раза: var(--space-4) (24px)
     → var(--space-3) (16px). Оба отступа (название→год и год→описание)
     по-прежнему равны без каких-либо переопределений — это всё тот же
     единственный базовый gap колонки, компенсировать у .os-work-dot__desc
     нечего (см. комментарий там). .os-work-dot__links ниже снова
     ПЕРЕсчитана — её калькуляция вычитает именно ЭТОТ базовый gap из
     var(--space-6), чтобы видимый отступ прозы от ссылок остался
     неизменным при каждой смене базы — эта правка ссылок не касалась. */
  gap: var(--space-3);
  font-size: var(--text-small);
  width: 34ch;
  flex-shrink: 0;
  text-align: left;
  /* Контейнер панели сам не ловит клики — явный none, хотя это и так
     унаследовано от .os-work-dot (pointer-events:none там, см. комментарий
     у .os-work-dot выше). Явно дублируем здесь по той же причине, что и
     у .os-work-dot__links ("не полагаемся на наследование через один
     уровень", см. комментарий там, реальный баг уже дважды ловился на
     этом самом месте): клик по пустому месту ВНУТРИ панели — например,
     между концом прозы и строкой ссылок — должен проходить насквозь и
     закрывать работу, как и по любому другому пустому месту экрана,
     а не перехватываться панелью только потому, что курсор технически
     оказался внутри её прямоугольника. Конкретные интерактивные/текстовые
     дети (имя, год, проза, ссылки) явно возвращают pointer-events:auto
     каждый у себя — см. правила ниже. */
  pointer-events: none;
}
/* Панель — flex-колонка РОВНО высотой строки (row, не колоночная
   .is-stacked-раскладка — там "правой панели" в этом смысле нет,
   картинка и текст идут друг под другом, а не рядом), height:100%
   резолвится относительно кросс-размера родительского flex-ряда
   (.os-work-dot.is-open-work — см. явную высоту строки выше). overflow:
   hidden — сама панель никогда не скроллится, скроллится только
   .os-work-dot__desc внутри неё (ниже). ВАЖНО: именно `height`, не
   `min-height` — с `min-height:100%` реальная высота панели равнялась бы
   сумме контента (имя + год + ПОЛНОЕ описание + ссылки), которая на
   длинном описании превышала высоту строки — сама ПАНЕЛЬ начинала
   скроллиться целиком, обрезая прозу посередине, хотя места под неё
   хватало (реальный баг, найденный Playwright'ом в одной из прошлых
   итераций). `height:100% + overflow:hidden` жёстко фиксирует высоту
   снаружи независимо от контента.

   justify-content:center — по прямому фидбэку: весь блок (имя → год →
   проза → ссылки) центрируется по вертикали относительно картинки, а не
   стоит прижатым к какому-либо краю. У работы без описания или с
   коротким описанием раньше между текстом и ссылками (прижатыми к низу
   через margin-top:auto, см. .os-work-dot__links ниже) оставалась
   огромная пустота — группа ссылок висела в ней сама по себе, читалось
   как разрыв, а не единая панель. При коротком контенте justify-
   content:center делит свободную высоту поровну сверху/снизу блока; при
   длинном (контент выше строки) центрировать нечего — flex-shrink на
   прозе (см. .os-work-dot__desc ниже) уже поглотил весь излишек, и
   justify-content не оказывает видимого эффекта, раскладка ведёт себя
   как раньше (прокрутка только у прозы).

   padding-bottom убран (был var(--space-5), отступ строки ссылок до
   низа панели) — вместе с ним ушла и привязка ссылок к нижнему краю,
   больше не актуальна при центрированном блоке. */
.os-work-dot.is-open-work:not(.is-stacked) .os-work-dot__info {
  height: 100%;
  overflow: hidden;
  justify-content: center;
}
/* Шапка (имя + год) — не сжимается и не скроллится, всегда занимает
   ровно свою естественную высоту, чем бы ни было прозы ниже. */
.os-work-dot.is-open-work:not(.is-stacked) .os-work-dot__label,
.os-work-dot.is-open-work:not(.is-stacked) .os-work-dot__year {
  flex: 0 0 auto;
}
/* Единственный сжимающийся и единственный скроллящийся элемент панели —
   flex-grow:0 (не 1): проза занимает РОВНО свою естественную высоту, не
   растягивается заполнять остаток панели своим же боксом. Это важно для
   ДВУХ вещей: (1) центрирование блока (justify-content:center на
   .os-work-dot__info выше) считает свободное место от РЕАЛЬНОЙ высоты
   контента — растянутая пустым боксом проза давала бы центру ложные
   данные; (2) пустое место между концом прозы и ссылками остаётся
   НИЧЬЕЙ территорией (margin у .os-work-dot__links, не часть никакого
   элемента), и клик по нему проходит НАСКВОЗЬ, закрывая работу (реальный
   баг, пойманный в одной из прошлых итераций — с flex-grow:1 бокс
   .os-work-dot__desc растягивался вниз, и его pointer-events:auto,
   нужный для выделения текста, перехватывал клики и по пустой
   территории тоже; см. docs/CHANGELOG.md, 2026-09-22). min-height:0 +
   flex-shrink:1 остаются — без них при ДЛИННОМ описании flex-элемент по
   умолчанию не может сжаться меньше содержимого (min-height:auto у
   flex-item) и выдавливал бы родителя за его height:100% (тот самый баг
   с прокруткой всей панели, см. запись выше). overflow-y:auto — скролл
   появляется САМ, только если текст реально не влез в оставшееся после
   шапки и ссылок место. Визуальное оформление скролла (маска-затухание,
   светлый скроллбар) — отдельным классом is-clamped ниже, его включает
   JS по факту (updateOpenWorkLayout, main.js: scrollHeight > clientHeight),
   не эта раскладка сама по себе. */
.os-work-dot.is-open-work:not(.is-stacked) .os-work-dot__desc {
  flex: 0 1 auto;
  min-height: 0;
  overflow-y: auto;
}
/* Имя файла — sans 700, --text-primary (как имя узла .os-app__label, но
   крупнее и без обрезки в две строки — есть место), БЕЗ расширения (см.
   openWork/closeWork в main.js — textContent меняется на голое title,
   расширение остаётся только в подписи сетки категории). opacity: 1
   переопределяет opacity: var(--label-op) базовой подписи (в россыпи она
   видна только у подсвеченной работы, см. work-rings.md) — у открытой
   работы подпись видна всегда, наведение здесь ни при чём. */
.os-work-dot.is-open-work .os-work-dot__label {
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: var(--text-name);
  letter-spacing: normal;
  color: var(--text-primary);
  max-width: 100%;
  display: block;
  text-align: left;
  -webkit-line-clamp: unset;
  overflow: visible;
  opacity: 1;
  translate: none;
  /* Явный auto — родитель .os-work-dot__info выше держит pointer-
     events:none, имя работы должно оставаться выделяемым текстом (см.
     комментарий у .os-work-dot__info). */
  pointer-events: auto;
}
/* Год — роль "Системная метка" (uppercase/500/--tracking-label/
   --text-muted, тот же приём, что у остальных меток системы — заголовков
   разделов Profile и т.п., см. futuristic-os-design/SKILL.md). Раньше
   был ролью "Данные" (--text-secondary, --tracking-data) — по прямому
   фидбэку переведён в самую тихую и мелкую роль панели: в иерархии
   "название → описание → дата" дата читалась почти вровень с описанием
   и не давала глазу однозначного порядка важности. --text-data здесь —
   уже существующий, самый мелкий кегль панели, трогать не пришлось.
   Гарнитура — var(--font-sans), не var(--font-mono): моноширинный шрифт
   убран из проекта целиком 13.09.2026 (см. docs/CHANGELOG.md), токена
   --font-mono в системе больше нет — та же гарнитура, что и у всех
   остальных "системных меток" в живом коде сейчас (см. вопрос
   пользователю в этой сессии, CHANGELOG). Скрыт по умолчанию — данные
   показываются только вместе с открытой работой. */
.os-work-dot__year {
  display: none;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--text-data);
  text-transform: uppercase;
  letter-spacing: var(--tracking-label);
  color: var(--text-muted);
}
.os-work-dot.is-open-work .os-work-dot__year {
  display: block;
  /* Тот же явный auto, что и у .os-work-dot__label — см. комментарий у
     .os-work-dot__info. */
  pointer-events: auto;
}
/* Описание — роль "Проза" (400, --text-secondary, см. type-and-labels.md
   в futuristic-os-design), опциональное поле desc в js/data.js.
   Пустая строка — <p></p> без текстового узла, значит :empty, схлопывается
   сама (display:none), лишнего зазора в колонке не остаётся — gap
   считает только реально видимые flex-элементы .os-work-dot__info.
   Собственного margin-top нет (была калькуляция, досчитывавшая
   родительский gap до нужного значения — теперь сам родительский `gap`
   уже равен целевому отступу, см. .os-work-dot__info выше, компенсировать
   нечего) — по прямому фидбэку отступы вокруг года должны быть
   одинаковыми сверху и снизу, а не "год прижат к названию, отбит от
   описания". */
.os-work-dot__desc {
  display: none;
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: var(--text-small);
  line-height: 1.5;
  color: var(--text-secondary);
}
.os-work-dot.is-open-work .os-work-dot__desc {
  display: block;
  /* Явный auto — родитель .os-work-dot__info держит pointer-events:none
     (см. комментарий там). Проза при этом остаётся ТОЛЬКО своим
     естественным размером (flex-grow:0 у этого же элемента, см. выше) —
     не растягивается пустым боксом до строки ссылок, поэтому auto здесь
     не перехватывает клики по пустому месту НИЖЕ реального текста, только
     по самому тексту (можно выделять/кликать), см. docs/CHANGELOG.md,
     2026-09-22. */
  pointer-events: auto;
  /* .os-work-dot (родитель) — user-select:none целиком (текст подписи не
     должен выделяться при драге иконки по столу) — здесь явно возвращаем
     text, иначе проза открытой работы наследовала бы none и была бы
     некликабельной для выделения, хотя pointer-events уже auto. */
  user-select: text;
}
.os-work-dot.is-open-work .os-work-dot__desc:empty {
  display: none;
}
/* Ссылки — «Написать» (личные сообщения) и «Канал» (Telegram-канал),
   одинаковые для всех работ (значения из группы contacts в PROFILE, см.
   getWorkContactLinks в main.js — не дублируются в data.js). Восьмая и
   последняя (см. docs/CHANGELOG.md, 2026-09-19/21) итерация этого
   места — весь предыдущий аппарат (блок "Автор" с меткой/аватаром,
   стрелка "Источник", волосяные линии, залитая кнопка) снесён по прямой
   детальной спецификации задачи: две подчёркнутые ссылки в одну строку,
   без иконок/рамок/подложек — единственный громкий элемент панели
   остаётся имя работы (вес 700 у .os-work-dot__label), сами ссылки —
   вес 400, ниже по контрасту, чем имя.
   pointer-events:auto — и на контейнере, и на самой ссылке
   (.os-work-dot__link ниже): родитель .os-work-dot держит
   pointer-events:none для хит-теста по картинке, на этом уже дважды
   ловили некликабельные ссылки в этом самом месте (см. CHANGELOG,
   2026-09-17/19) — явный auto в обоих местах, не полагаемся на
   наследование через один уровень. */
.os-work-dot__links {
  display: none;
  flex-direction: row;
  flex: 0 0 auto; /* последний элемент панели — своя, не сжимаемая высота, вне скролла прозы */
  /* Расстояние между "Написать" и "Канал" — было var(--space-4) (24px),
     по прямому фидбэку убрано до var(--space-3) (16px): даже 24px между
     двумя короткими словами ещё читались как разрыв ВНУТРИ одной ссылки,
     а не расстояние между двумя отдельными. */
  gap: var(--space-3);
  /* .os-work-dot__info (родитель) уже даёт var(--space-3) через свой
     общий gap между ВСЕМИ детьми (имя/год/описание/ссылки — см.
     .os-work-dot__info выше; был var(--space-2), затем var(--space-4),
     теперь var(--space-3) — каждый раз отдельной правкой отступов вокруг
     года) — досчитываем калькуляцией ровно до var(--space-6) отступа от
     прозы сверху, а не складываем два отступа один на другой. Оба
     слагаемых — токены, новое число не введено. Калькуляция обновляется
     вместе с базовым gap каждый раз — иначе отступ прозы от ссылок
     незаметно менялся бы вместе с изменением базы, хотя сами ссылки эти
     правки не затрагивали.

     Фиксированный отступ, ОДИН И ТОТ ЖЕ в строчной и колоночной
     (.is-stacked) раскладке — раньше строчная переопределяла это на
     margin-top:auto, чтобы прижать ссылки к низу панели (см.
     docs/CHANGELOG.md, 2026-09-22, несколько итераций подряд); по
     прямому фидбэку это отменено — привязки к низу панели больше нет
     вообще, весь блок (имя/год/проза/ссылки) центрируется по вертикали
     через justify-content:center на родителе (.os-work-dot__info выше),
     а не через "прилипание" одного элемента к краю. Margin здесь снова
     просто фиксированный зазор — межэлементная пустота, не территория
     никакого элемента (клик по ней по-прежнему проходит насквозь и
     закрывает работу, см. .os-work-dot__desc ниже — это не изменилось,
     просто больше не завязано на auto-margin). */
  margin-top: calc(var(--space-6) - var(--space-3));
  pointer-events: auto;
}
.os-work-dot.is-open-work .os-work-dot__links {
  display: flex;
}
.os-work-dot__link {
  pointer-events: auto;
}
/* Признак ссылки — вес, кегль и реакция на наведение, БЕЗ подчёркивания
   (было — убрано целиком по прямому фидбэку, п.1 задачи: стрелка + вес
   700 + отклик на hover уже достаточно однозначно читаются как "это
   кнопка", отдельная линия под текстом больше не нужна). `text-decoration:
   none` — явно, а не просто "не объявлено": браузер по умолчанию
   подчёркивает `<a>` сам, без явного none снова получили бы линию.
   font-weight:700 — та же роль "Имя объекта", что и у .os-work-dot__label
   (третьего веса система не вводит, см. futuristic-os-design/SKILL.md,
   раздел «Шрифты»), просто крупнее и не под картинкой, а в панели.
   font-size — var(--text-lead), новый токен ровно на шаг крупнее
   var(--text-body) (см. :root в style.css/tokens.md) — единственный явно
   интерактивный текст панели должен быть заметно крупнее текста вокруг.
   display:inline-flex — слово (.os-work-dot__cta-text) и стрелка
   (.os-work-dot__cta-arrow) идут через `gap`, а не через обычный пробел
   в тексте: gap не даёт браузеру точки переноса строки между ними — пара
   не может разъехаться на две строки при любой ширине экрана, ничего
   дополнительного (nowrap/white-space) не требуется. align-items:baseline
   — стрелка стоит на одной линии с текстом слова, а не по центру их
   общей высоты. cursor:pointer — явно, т.к. .os-work-dot.is-open-work
   выше по дереву задаёт cursor:default, а курсор наследуется. */
.os-work-dot__cta {
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: var(--text-lead);
  line-height: 1.2;
  display: inline-flex;
  align-items: baseline;
  gap: var(--space-1);
  cursor: pointer;
  text-decoration: none;
  /* Слово цветом на hover не реагирует ("само слово не двигается" — та
     же логика, что и раньше, теперь распространяется и на цвет: слово
     уже на максимальном контрасте, --text-primary что в покое, что на
     hover, меняться некуда) — держим ОДНО правило на все состояния, а не
     дублируем одинаковое значение в :hover. */
  color: var(--text-primary);
  /* scale — отдельное свойство, не transform (CLAUDE.md, "один писатель
     на свойство"): transform здесь и не понадобился бы — сдвиг стрелки
     живёт на ДРУГОМ элементе (.os-work-dot__cta-arrow ниже), не
     пересекается с scale на самой ссылке. */
  scale: 1;
  transition: scale var(--t-hover) ease;
}
/* Короткое сжатие по нажатию через `scale` — отдельное свойство, не
   transform (тот же приём, что масштаб иконки узла при наведении, его
   пишет updateNodeScales в main.js). */
.os-work-dot__cta:active {
  scale: 0.97;
}
/* Стрелка «уходит за пределы сайта» — тот же смысл, что был у
   «Источника» в снесённых итерациях этого места (см. docs/CHANGELOG.md,
   2026-09-19/21), теперь у обеих ссылок. Тише слова в покое — кегль на
   шаг меньше (var(--text-body), тот же токен, что уже существует для
   прозы — не заведено отдельного размера специально под стрелку) и
   var(--text-muted) вместо веса/цвета слова: стрелка — тихая деталь
   рядом с громким словом, не равная ему по контрасту, пока на связку не
   навели курсор. Собственный элемент (не псевдо-::after) — сдвигается по
   диагонали на hover ОТДЕЛЬНО от слова, которое остаётся неподвижным
   ("едет только стрелка"); с ::after транслировать один только
   текст-контент без псевдоэлемента было бы нельзя. aria-hidden — глиф
   декоративный, ссылка и так озвучивается текстом слова. */
.os-work-dot__cta-arrow {
  font-size: var(--text-body);
  color: var(--text-muted);
  transition: color var(--t-hover) ease, transform var(--t-hover) ease;
}
.os-work-dot__cta:hover .os-work-dot__cta-arrow {
  color: var(--text-primary);
  transform: translate(var(--space-1), calc(var(--space-1) * -1));
}

/* Колоночная раскладка открытой работы (картинка сверху, текст снизу) —
   класс `.is-stacked`, выставляется JS-ом (updateOpenWorkLayout, main.js),
   НЕ media-запросом. Раньше здесь был `@media (max-width: 600px)` —
   заменён на JS-решение по прямому фидбэку: узкий ЭКРАН — не единственная
   причина, по которой картинка+колонка не помещаются рядом. Портретный
   плакат, вписанный по высоте в широкий экран, тоже может оказаться уже
   текстовой колонки (реальный пример — "PlakatTournament", узкий и
   тесный в строчной раскладке даже там, где по ширине экрана вроде бы
   должно было хватить) — JS считает АНАЛИТИЧЕСКИ, какой ширины вышла бы
   картинка в строке (из её реальных пропорций и высоты экрана, без
   лишнего чтения layout), и переключает в колонку, если она уже
   STACK_IMAGE_MIN_WIDTH — независимо от общей ширины вьюпорта. Узкий
   вьюпорт (≤600px) по-прежнему безусловный пол поверх этого условия —
   там колонка+картинка не помещаются рядом ни при каких пропорциях (см.
   docs/CHANGELOG.md, 2026-09-18, вывод формулы). */
.os-work-dot.is-open-work.is-stacked {
  flex-direction: column;
  align-items: center; /* картинка и колонка — стопка по центру экрана; текст внутри колонки по-прежнему слева, см. .os-work-dot__info */
  gap: var(--space-4);
}
.os-work-dot.is-open-work.is-stacked img,
.os-work-dot.is-open-work.is-stacked.is-video-active video {
  max-width: calc(100vw - var(--space-5) * 2);
  /* Высота ограничена долей экрана (не полным space-5*2), чтобы под
     картинкой гарантированно оставалось место колонке — иначе на
     квадратных/портретных работах текст пришлось бы читать со
     скролла, а скролла в системе нет нигде (CLAUDE.md). */
  max-height: 52vh;
}
/* Колонка текста стопкой — шириной с картинку над ней (07.10.2026,
   решение владельца, вариант А): раньше — во всю ширину экрана минус
   поля, и текст начинался у левого поля, а картинка стояла по центру —
   две вертикали без общей опоры. Ширину — ширину картинки этой
   раскладки, но не уже строки ссылок — считает updateOpenWorkLayout
   (--stack-info-w на узле). Группа и так центрирована (align-items:
   center), при равной ширине левый край текста совпадает с левым краем
   картинки; длинное название переносится внутри. Без посчитанной
   ширины (пока считается) — прежняя ширина экрана минус поля. */
.os-work-dot.is-open-work.is-stacked .os-work-dot__info {
  width: var(--stack-info-w, calc(100vw - var(--space-5) * 2));
}

/* Урезание длинного описания — класс `.is-clamped`, выставляется JS-ом
   (updateOpenWorkLayout, main.js) по факту отрисованной высоты, не
   заранее. max-height — инлайновый px (тоже из JS, посчитан из
   реального переполнения). Блок СРАЗУ прокручиваемый (overflow-y:auto),
   без отдельного шага/кнопки "показать полностью" — по решению задачи:
   лишний клик не нужен, если можно просто скроллить урезанный блок.
   Маска-затухание снизу — визуальная подсказка "тут ещё есть текст",
   не жёсткий обрез строки и не multi-line-clamp с "…".

   Это ОСОЗНАННОЕ узкое исключение из "скролла нет нигде" (CLAUDE.md) —
   единственное на сайте место со скроллом, и то только сам текстовый
   блок в урезанном состоянии, не страница и не работа целиком. Причина:
   на самом длинном описании натуральная высота текста БЕЗ картинки/
   подписи/ссылок уже больше высоты экрана на телефоне (посчитано,
   не на глаз — см. docs/CHANGELOG.md, 2026-09-19, и разбор в
   technical-execution.md) — показать всё без прокрутки в принципе
   нельзя, не искажая остальной макет. */
.os-work-dot__desc.is-clamped {
  overflow-y: auto;
  overscroll-behavior: contain; /* докрутка до края текста не должна утекать на страницу/фон */
  touch-action: pan-y; /* .os-work-dot (родитель) — touch-action:none для драга иконок; здесь явно возвращаем вертикальный тач-скролл именно этому блоку */
  /* pointer-events:auto здесь не нужен отдельно — уже задан на
     .os-work-dot.is-open-work .os-work-dot__desc (см. выше), is-clamped
     всегда стоит вместе с is-open-work. */
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6em), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6em), transparent 100%);
  transition: -webkit-mask-image 0.15s ease, mask-image 0.15s ease;
  /* Ползунок скролла светлее — по фидбэку "сливается с фоном, не видно,
     что можно листать ниже": дефолтный тёмный системный скроллбар на
     почти чёрном фоне (--bg-base) был практически невидим.
     --line-color — тот же светло-серый, что у линий, не новый цвет.
     Без border-radius — на сайте нигде нет скруглений, скроллбар не
     исключение. */
  scrollbar-width: thin;
  scrollbar-color: var(--line-color) transparent;
}
.os-work-dot__desc.is-clamped::-webkit-scrollbar {
  width: calc(4 * var(--px-up));
}
.os-work-dot__desc.is-clamped::-webkit-scrollbar-track {
  background: transparent;
}
.os-work-dot__desc.is-clamped::-webkit-scrollbar-thumb {
  background: var(--line-color);
  border-radius: 0;
}
/* Докрутили до конца — маска снята целиком, а не просто "почти не видна":
   реальный баг по фидбэку со скриншотом — притемнение оставалось поверх
   ПОСЛЕДНЕЙ строки текста даже когда дальше скроллить некуда, читалось
   как обрезанный текст, а не как реальный конец. .is-at-bottom
   выставляется JS-ом на scroll (main.js, делегированный слушатель на
   #os-subgraph с capture:true — 'scroll' не всплывает у элементов). */
.os-work-dot__desc.is-clamped.is-at-bottom {
  -webkit-mask-image: none;
  mask-image: none;
}

/* ══════════════════════════════════════════
   УЗЕЛ САЙТА — категория «Сайты» (renderSitesHub/buildSiteNodes в
   main.js). Спутник на спице, как категория папки: знак размера
   --site-glyph-size, подпись .os-app__label, левитация, хитбокс — иконка.
   Класс .os-work-dot — потому что клик открывает сайт тем же механизмом,
   что и работу (openWork/closeWork, правила .is-open-work выше), поэтому
   всё, что у работы в россыпи своё (размер картинки, рамка, прозрачность
   до декодирования, hover-масштаб картинки), здесь снято.

   Иконка — без подложки, рамки и круга; видимая часть вписана в квадрат
   --site-glyph-size (fitSiteGlyph):
   - маска (.os-site__mask) — силуэт файла иконки, залитый currentColor;
     нужен прозрачный фон файла;
   - картинка (.os-icon) — файл как есть, без фильтров;
   - иконки нет — первая буква подписи основной гарнитурой, нарисованная
     в canvas и поставленная той же маской (высота заглавной = сторона).
   Цвет маски и буквы — --text-secondary, наведение мышью —
   --text-primary, за то же время и той же кривой, что масштаб иконки
   (--t-hover-scale, --ease-navigate; масштаб пишет цикл).

   Снимок сайта (.os-site__shot) в покое невидим и стоит в боксе иконки
   (object-fit: contain) — FLIP открытия стартует отсюда и сюда же
   возвращается. На время перелёта (.is-work-flying) виден снимок, а не
   иконка.
═══════════════════════════════════════════ */
.os-work-dot.os-site:not(.is-open-work) {
  gap: var(--site-label-gap);
  transform: translateY(var(--levitate-y, 0px));
}
.os-work-dot.os-site.is-dragging:not(.is-open-work) { transform: none; }
.os-site .os-icon-wrap {
  width: var(--site-glyph-size);
  height: var(--site-glyph-size);
  pointer-events: auto;
  color: var(--text-secondary);
  transition: color var(--t-hover-scale) var(--ease-navigate);
}
@media (hover: hover) {
  .os-site .os-icon-wrap:hover { color: var(--text-primary); }
}
/* Подпись — от видимого низа знака, а не от низа квадрата: fitSiteGlyph
   пишет --glyph-inset-b (доля стороны квадрата под знаком). Узел ловит
   указатель знаком (clip-path по контуру — siteHitPolygon) и подписью. */
.os-site > .os-app__label {
  margin-top: calc(var(--site-glyph-size) * var(--glyph-inset-b, 0) * -1);
  pointer-events: auto;
}
/* Знак: размер и место в квадрате (в %, по видимой рамке) пишет
   fitSiteGlyph; может выходить за квадрат прозрачными полями файла. До
   замера невидим. */
.os-site__glyph {
  position: absolute;
  visibility: hidden;
}
.os-site__glyph.is-fitted { visibility: inherit; }
.os-site__mask {
  display: block;
  background-color: currentColor;
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
.os-work-dot.os-site .os-icon {
  max-width: none;
  max-height: none;
  border: none;
  opacity: 1;
  transform: none;
  transition: none;
  pointer-events: none;
}
.os-work-dot.os-site .os-site__shot {
  border: none;
  transform: none;
  transition: none;
}
.os-work-dot.os-site:not(.is-open-work) .os-site__shot {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--site-glyph-size);
  height: var(--site-glyph-size);
  max-width: none;
  max-height: none;
  margin-left: calc(var(--site-glyph-size) / -2);
  object-fit: contain;
  opacity: 1;
  visibility: hidden;
  pointer-events: none;
}
/* Лист-снимок (.os-site__sheet) — снимок сайта с карточки стопки (тот же
   файл превью): на раздаче и сборе узел летит им, у места он перетекает в
   знак и подпись за последнюю долю пути (--sheet-op пишет updateStackDeal;
   0 — знак, 1 — снимок). В покое невидим, стоит в квадрате знака. */
.os-work-dot.os-site .os-site__sheet {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--site-glyph-size);
  height: var(--site-glyph-size);
  max-width: none;
  max-height: none;
  margin-left: calc(var(--site-glyph-size) / -2);
  object-fit: contain;
  border: none;
  transform: none;
  transition: none;
  opacity: var(--sheet-op, 1);
  visibility: hidden;
  pointer-events: none;
}
.os-work-dot.os-site.is-sheet .os-site__sheet { visibility: visible; }
.os-work-dot.os-site.is-sheet .os-icon-wrap,
.os-work-dot.os-site.is-sheet > .os-app__label { opacity: calc(1 - var(--sheet-op, 1)); }
.os-work-dot.os-site.is-open-work .os-site__sheet { display: none; }
.os-work-dot.os-site.is-work-flying .os-site__shot { visibility: visible; }
.os-work-dot.os-site.is-work-flying:not(.is-open-work) .os-icon-wrap { visibility: hidden; }
.os-work-dot.os-site:not(.is-open-work) .os-work-dot__info,
.os-work-dot.os-site.is-open-work .os-icon-wrap,
.os-work-dot.os-site.is-open-work > .os-app__label { display: none; }

/* Превью сайта при наведении (updateSitePreview в main.js) — по элементу
   на сайт, дубль узла, не отдельный объект: pointer-events none,
   aria-hidden. Снимок как есть, в пропорциях файла; без рамки,
   скругления (у превью работ его тоже нет), тени, подложки и фильтра.
   Под снимком подпись — часть превью (растёт, тянется и сворачивается
   вместе с ним): название ↗ — стиль подписи узла, но --text-primary;
   описание — роль «Проза», мельче, не больше двух строк. Текст лежит
   прямо на фоне, по левому краю снимка и не шире его. transform и
   opacity пишет только цикл; в покое скрыто. Слой — над #os-subgraph
   и спицами, под подсказкой первого визита. */
.os-site-preview {
  position: fixed;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: var(--site-preview-w);
  z-index: var(--z-site-preview);
  pointer-events: none;
  user-select: none;
  transform-origin: center;
  opacity: 0;
  visibility: hidden;
}
.os-site-preview__img {
  display: block;
  width: 100%;
  height: auto;
}
.os-site-preview__title {
  max-width: 100%;
  margin-top: var(--space-2);
  font-family: var(--font-sans);
  font-size: var(--text-node);
  font-weight: 700;
  line-height: 1.25;
  color: var(--text-primary);
}
.os-site-preview__desc {
  display: -webkit-box;
  max-width: 100%;
  margin-top: var(--space-1);
  overflow: hidden;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  font-family: var(--font-sans);
  font-size: var(--text-data);
  font-weight: 400;
  line-height: 1.4;
  color: var(--text-secondary);
}

/* ══════════════════════════════════════════
   PROFILE — ЯЩИК-КАРТОТЕКА (buildDrawer/layoutDrawer в main.js).
   Задняя карточка «00» с хабом (фото — тот же узел, что прилетает со
   стола; он в #os-subgraph над ящиком), стопка карточек с корешками
   лесенкой, открытая приподнята, передние сдвинуты вниз, спереди стенка.

   Единственный объект-рисунок на сайте, нарисованный контуром —
   исключение из «Поверхностей нет» (futuristic-os-design/SKILL.md).
   Контуры карточек и ящика — только здесь; на другие уровни приём не
   переносится. Контур — --line-near, 1px; заливка — цвет фона (--bg-base,
   сплошной), поэтому передние карточки закрывают задние, а плашки не видно.
   Разделители строк — --line-img, самый тихий токен линии. Всё
   положение и размеры — инлайн из layoutDrawer (от токенов --drawer-*).
═══════════════════════════════════════════ */
/* Ящик — свой слой в .desktop (не внутри #os-subgraph): в покое — сразу
   под хабом (subgraph), над scrim — хаб лежит на задней карточке; пока
   ящик прорисовывается при входе и задвигается/стирается при уходе
   (.is-under — ставит рендер-цикл, drawerSetUnder), — под всеми слоями
   рабочего стола: спицами, лого, иконками, подписями. */
.os-drawer {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-subgraph) - 1);
  pointer-events: none;
}
.os-drawer.is-under { z-index: var(--z-drawer-under); }
/* Компактный ящик — низкий экран (layoutDrawer ставит .is-compact при
   высоте меньше --drawer-compact-h): хаб и подписи в строку, стенка тоньше. */
.os-drawer.is-compact {
  --drawer-tab-h:   var(--space-4);
  --drawer-step:    calc(var(--space-4) - var(--space-1));
  --drawer-radius:  calc(var(--drawer-step) / 2); /* 10 — свой шаг (custom property наследуется уже посчитанной) */
  --drawer-tilt:    calc(var(--drawer-step) / 4); /* 5 — угол тот же, что на обычном экране */
  --drawer-pad:     var(--space-2);
  --drawer-rim-h:   var(--space-1);
  --drawer-wall-h:  var(--space-4);
  --drawer-row-pad: var(--space-1);
}
/* Хаб — не внутри ящика, а в #os-subgraph: компактный размер ему
   задаётся отсюда (минимум токена, 60). */
.desktop:has(> .os-drawer.is-compact) .os-subgraph {
  --drawer-hub-size: calc(var(--space-7) - var(--space-1));
}
/* Подпись фото-хаба («W doyose?», та же, что на столе) на уровне Profile
   скрыта: гаснет в перелёте и проступает к перелёту обратно (--head-enter
   пишет applyDrawerGeometry). Вне потока — на том же месте под иконкой, —
   чтобы хаб был одной иконкой и не ловил клики по имени под ним; под
   хабом имя и ник рисует задняя карточка (.os-drawer__who). */
.os-subhub:has(.os-icon--photo) .os-app__label {
  position: absolute;
  top: calc(100% + var(--profile-label-gap));
  left: 50%;
  transform: translateX(-50%);
  /* Масштаб перелёта (updateLabelFlights) — от левого верхнего угла: с
     translateX(-50%) центр подписи тогда остаётся на left: 50% при любом
     scale (от 50% 0 он уезжал бы на (1 − scale) · ширина / 2). */
  transform-origin: 0 0;
  white-space: nowrap;
  pointer-events: none;
}
/* Пробник для замера токенов в px (drawerPx) — не виден и не в потоке. */
.os-drawer__probe {
  position: absolute;
  top: 0;
  left: 0;
  height: 0;
  visibility: hidden;
}
/* Листы — в обёртке во весь экран, обрезанной снизу по линии обода
   (clip-path пишет applyDrawerGeometry): ниже стенки их не видно —
   при входе они поднимаются из ящика, при уходе в него уходят. */
.os-drawer__stack {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.os-drawer__card,
.os-drawer__front {
  position: absolute;
}
/* Шапка задней карточки — имя, ник, роль — проявляется построчно при
   входе; подпись хаба гаснет и проступает: --head-enter пишет рендер-цикл
   (applyDrawerGeometry); вне уровня Profile переменной нет. */
.os-subhub .os-app__label,
.os-drawer__name,
.os-drawer__alias,
.os-drawer__role {
  opacity: var(--head-enter, 1);
  /* Строка входит снизу вверх на --drawer-row-shift и уходит вниз — тем же
     числом, что прозрачность (отдельное свойство translate: scale подписи
     хаба пишет перелёт, flipLabel). */
  translate: 0 calc((1 - var(--head-enter, 1)) * var(--drawer-row-shift));
}
.os-drawer__art {
  position: absolute;
  top: 0;
  left: 0;
  overflow: visible;
}
/* Скругления (верх карточки, углы корешка) — только у ящика, как и сам
   контур: в остальной системе радиусов нет (tokens.md). */
.os-drawer__outline {
  fill: var(--bg-base);
  stroke: var(--line-near);
  stroke-width: 1;
  stroke-linejoin: round;
}
/* Заливка обода и стенки — фон страницы без контура; контур ящика —
   отдельный путь без заливки, прорисовывается при входе (drawerFrontOutline). */
.os-drawer__fill { fill: var(--bg-base); stroke: none; }
.os-drawer__outline--bare { fill: none; }

/* Корешок — <button aria-pressed>; у задней карточки («profile») — <div>
   с .is-pressed: не нажимается (05.10.2026), вид тот же. Клик открывает карточку (drawerSwitch в
   main.js), наведение приподнимает закрытый лист на --drawer-hover-lift. В корешке
   только подпись группы, по центру (номеров нет). Осознанное отступление
   от роли «системная метка» (только корешки ящика, решение владельца
   29.09.2026): обычный регистр, без трекинга, кегль --text-data, вес 400;
   --text-muted, у задней карточки --text-secondary, у открытой
   --text-primary. Поле текста — за скосом бока. */
.os-drawer__tab {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-1) calc(var(--drawer-tab-skew) + var(--drawer-tab-pad)) 0;
  font: 400 var(--text-data)/1 var(--font-sans);
  white-space: nowrap;
  color: var(--text-muted);
  pointer-events: auto;
  transition: color var(--t-hover) ease;
}
.os-drawer__card[data-i="0"] .os-drawer__tab { color: var(--text-secondary); }
.os-drawer__tab:hover { color: var(--text-secondary); }
/* Открытая карточка — aria-pressed на корешке (цель переключения), клик по
   нему ничего не делает. */
.os-drawer .os-drawer__tab[aria-pressed="true"],
.os-drawer .os-drawer__tab.is-pressed { color: var(--text-primary); cursor: default; }

/* Имя и роль под хабом — мельче alias (подписи хаба): имя
   --text-secondary, роль --text-muted. В компактном режиме — справа от хаба. */
.os-drawer__who {
  position: absolute;
  text-align: center;
}
.os-drawer.is-compact .os-drawer__who { text-align: left; }
/* Имя — громкий элемент уровня (700, --text-name, как «Имя» у открытой
   работы); ник под ним — мельче и тише; роль (если есть в данных) — тише ещё. */
.os-drawer__name {
  font: 700 var(--text-name)/1.25 var(--font-sans);
  color: var(--text-primary);
}
.os-drawer__alias,
.os-drawer__role {
  font: 400 var(--text-small)/1.5 var(--font-sans);
  color: var(--text-secondary);
}
.os-drawer__role { color: var(--text-muted); }

/* Содержимое карточки — видно только у открытой. */
.os-drawer__body {
  position: absolute;
  pointer-events: auto;
  container-type: inline-size; /* ширину пишет layoutDrawer; от неё — колонка ключей */
}
.os-drawer__card:not(.is-open) .os-drawer__body { visibility: hidden; }
.os-drawer__row {
  --key-w: min(var(--drawer-key-w), max(var(--drawer-key-min), var(--drawer-key-share)));
  display: grid;
  grid-template-columns: var(--key-w) minmax(0, 1fr) auto;
  column-gap: var(--space-3);
  align-items: baseline;
  padding: var(--drawer-row-pad) 0;
  color: var(--text-secondary);
  text-decoration: none;
  /* Появление строки — --row-enter пишет рендер-цикл: прозрачность и
     сдвиг снизу вверх на --drawer-row-shift одним числом (уход — вниз). */
  opacity: var(--row-enter, 1);
  translate: 0 calc((1 - var(--row-enter, 1)) * var(--drawer-row-shift));
}
.os-drawer__row + .os-drawer__row { border-top: 1px solid var(--line-img); }
/* Строка с «копировать» (почта): «копировать» не отнимает места у адреса —
   не помещается в строку рядом с ним, уходит под адрес, к правому краю
   колонки значений (там же, где стрелки ↗ соседних строк). Решает место,
   а не ширина экрана: flex с переносом, значение не шире своей колонки.
   Когда помещается — те же позиции, что у сетки выше. */
.os-drawer__row--copy {
  display: flex;
  flex-wrap: wrap;
}
.os-drawer__row--copy .os-drawer__key { flex: 0 0 var(--key-w); }
.os-drawer__row--copy .os-drawer__value {
  flex: 1 1 auto;
  min-width: 0;
  max-width: calc(100% - var(--key-w) - var(--space-3));
}
.os-drawer__row--copy .os-drawer__copy { margin-left: auto; }
.os-drawer__row--wide,
.os-drawer__row--prose { display: block; }
/* Ключ (Возраст, Telegram, 2023 — н.в.) — «данные». */
.os-drawer__key {
  font: 400 var(--text-data)/1.5 var(--font-sans);
  letter-spacing: var(--tracking-data);
  color: var(--text-secondary);
}
/* Значение и проза — sans 400. Перевод строки в тексте данных
   (profile-data.js, «\n») — новая строка: переносы ставит владелец.
   Адрес почты переносится после «@» (<wbr>, buildDrawerLinkRow); разрыв
   посреди слова (anywhere) — только если и часть до «@» не помещается. */
.os-drawer__value,
.os-drawer__row--wide,
.os-drawer__row--prose {
  font: 400 var(--text-small)/1.5 var(--font-sans);
  color: var(--text-secondary);
  overflow-wrap: anywhere;
  white-space: pre-line;
}
a.os-drawer__value { text-decoration: none; }
.os-drawer__arrow {
  font: 400 var(--text-small)/1.5 var(--font-sans);
  color: var(--text-muted);
}
.os-drawer__copy {
  font: 400 var(--text-data)/1.5 var(--font-sans);
  letter-spacing: var(--tracking-data);
  color: var(--text-muted);
  white-space: nowrap;
}
.os-drawer__value,
.os-drawer__arrow,
.os-drawer__copy { transition: color var(--t-hover) ease; }
.os-drawer__row--link:hover .os-drawer__value,
.os-drawer__row--link:hover .os-drawer__arrow,
a.os-drawer__value:hover,
.os-drawer__copy:hover { color: var(--text-primary); }

/* Временное поле для копирования без Clipboard API (copyByCommand). */
.os-drawer__copy-buffer {
  position: fixed;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
}

/* ══════════════════════════════════════════
   ПОДСКАЗКА ПЕРВОГО ВИЗИТА — один раз за всё время на каждую (ключ
   os-hints-seen, main.js, блок ПОДСКАЗКИ ПЕРВОГО ВИЗИТА). Плавная кривая
   от объекта + две строки прозы, фон на это время затемнён. Не карточка и
   не наклейка: та же линия, что у черты-якоря Profile (--line-near, 1px),
   та же гарнитура, текст прямо на фоне — без подложки, рамки и тени.
   Всё движение (затемнение, точка, рост кривой, текст) пишет рендер-цикл;
   здесь только вид и слои.
═══════════════════════════════════════════ */
.os-hint {
  position: fixed;
  inset: 0;
  z-index: var(--z-hint);
  pointer-events: none;
  visibility: hidden;
}
.os-hint.is-on { visibility: visible; }
.os-hint__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.os-hint__curve {
  fill: none;
  stroke: var(--line-near);
  stroke-width: 1px;
  stroke-linecap: round;
}
.os-hint__dot { fill: var(--text-primary); }
/* Роль «Проза»: 400, одна гарнитура, без выделений. Кегль --text-hint —
   один у всех подсказок на данном экране, не меняется при нехватке места;
   цвет --hint-color (чуть ярче обычной прозы). Межстрочный 1.25 — две
   строки читаются одним блоком; стоят над линией. Строки заданы явно (две
   span-строки), поэтому nowrap. */
.os-hint__text {
  position: absolute;
  left: 0;
  top: 0;
  font-family: var(--font-sans);
  font-size: var(--text-hint);
  font-weight: 400;
  line-height: 1.25;
  color: var(--hint-color);
  white-space: nowrap;
  opacity: 0;
}
.os-hint__text.is-end { text-align: right; }
/* «ОК» — конец линии и кнопка закрытия: роль «Системная метка» (500,
   UPPERCASE, --tracking-label), но цвет --text-primary — это действие.
   Без фона, рамки и тени; зона нажатия — невидимый min-size (28 px, на
   тач-устройствах 44 px). Наведение — прозрачность кнопки, появление —
   прозрачность подписи (её пишет рендер-цикл). */
.os-hint__ok {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: var(--hint-ok-hit);
  min-height: var(--hint-ok-hit);
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  font-family: var(--font-sans);
  font-size: var(--text-label);
  font-weight: 500;
  line-height: 1;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-primary);
  cursor: pointer;
  pointer-events: auto;
  transition: opacity var(--t-hover) var(--ease-out);
}
.os-hint__ok:hover { opacity: 0.7; }
.os-hint__ok-label { display: block; opacity: 0; }
@media (pointer: coarse) {
  .os-hint__ok {
    min-width: var(--hint-ok-hit-touch);
    min-height: var(--hint-ok-hit-touch);
  }
}
.os-hint__line { display: block; }
/* safe-area снизу в px — для режима «у нижнего края» (hintBottomPlace). */
.os-hint__safe {
  position: absolute;
  width: 0;
  height: env(safe-area-inset-bottom, 0px);
  visibility: hidden;
}
/* Замер --hint-dx/--hint-dy в px (clamp с vmin) — один раз при размещении. */
.os-hint__probe {
  position: absolute;
  width: var(--hint-dx);
  height: var(--hint-dy);
  visibility: hidden;
}

/* Затемнение — слой того же фона (--bg-base) поверх линий и узлов своего
   слоя; кликом по нему подсказка только закрывается. Временный прожектор
   на один объект, а не «затемнение по глубине». Два экземпляра — в
   #os-apps (стол) и в #os-subgraph (уровни): объект поднимается над
   затемнением только внутри своего контекста наложения. */
.os-hint-dim {
  position: absolute;
  inset: 0;
  z-index: var(--z-hint-dim);
  background: var(--bg-base);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}
.os-hint-dim.is-on { visibility: visible; }
.os-hint-dim.is-catching { pointer-events: auto; }
.os-app.is-hint-target,
.os-subchild.is-hint-target,
.os-subhub.is-hint-target,
.os-work-dot.is-hint-target { z-index: var(--z-hint-target); }

@media (prefers-reduced-motion: reduce) {
  .os-links, .os-app,
  .os-sublinks, .os-stack__card { transition: none; }
  /* Левитацию при reduced-motion гасит сам JS (levitateY() в main.js
     возвращает 0, --levitate-y = 0px) — отдельного .os-node {
     transform: none } здесь больше нет: он снимал ещё и центрирование
     translate(-50%,-50%) у узлов, которые им центрируются. */
  /* Масштаб наведения хаба при reduced-motion не растёт вовсе (решает
     updateNodeScales в main.js). Сигнал того, что хаб интерактивен,
     остаётся — цвет подписи, его переход здесь мгновенный. */
  .os-subhub .os-app__label { transition: none; }
  /* Строки ящика — без сдвига (и без задержки — это решает main.js). */
  .os-drawer__row, .os-subhub .os-app__label, .os-drawer__name, .os-drawer__alias, .os-drawer__role { translate: none; }

  .os-subgraph.has-open-work .os-work-dot:not(.is-open-work):not(.is-peek-shown),
  .os-subgraph.has-open-work .os-work-dot.is-peek-shown,
  .os-subgraph.has-open-work .os-subhub,
  .desktop:has(.os-subgraph.has-open-work) .os-sublinks,
  .os-pager { transition: none; }

  .os-year-filter,
  .os-year-filter__btn,
  .os-hint__ok { transition: none; }

  /* Ссылки «Написать»/«Канал»: цвет стрелки на hover
     по-прежнему меняется (просто мгновенно, без transition, тем же
     приёмом, что и у хаба выше) — сам факт "это ссылка, на неё навели"
     остаётся читаемым (подчёркивания в системе больше нет — см. п.1
     записи 2026-09-22 ниже в CHANGELOG, стрелка теперь единственный
     подвижный признак). А вот геометрическое движение — сдвиг стрелки по
     диагонали и сжатие по нажатию — отключено полностью, не просто без
     анимации: transform/scale возвращены к состоянию покоя явно, а не
     оставлены "доехавшими мгновенно" (в точности то, что просит задача —
     "движения стрелки и сжатия нет", а не "движение есть, но без
     плавности"). */
  .os-work-dot__cta,
  .os-work-dot__cta-arrow {
    transition: none;
  }
  .os-work-dot__cta:active {
    scale: 1;
  }
  .os-work-dot__cta:hover .os-work-dot__cta-arrow {
    transform: none;
  }
}

/* ══════════════════════════════════════════
   ЗАКРЫТЫЙ УЗЕЛ СТОЛА ("locked": true, markLocked в main.js) — виден, но
   не открывается. Иконка чёрно-белая (--locked-icon-filter на <img>:
   обёртку с её scale и opacity фильтр не трогает, маски спиц читаются из
   отдельной картинки, зона клика — слой ::after обёртки), подпись
   приглушена (--locked-label-op; вместе с --label-in загрузки, один
   писатель на свойство). Фильтр на картинке — единственное исключение из
   «фильтров нет» (решение владельца 06.10.2026): grayscale — цветовая
   матрица без пересчёта альфы, драг в WebKit проверен. При наведении
   и после тапа (.is-soon пишет кадр, updateLockedLabels)
   вместо названия — «скоро» тем же стилем и на том же месте: обе строки в
   одной ячейке сетки, ширина подписи — по длинной, узел и спица не
   сдвигаются. Курсор обычный, масштаба при наведении нет (updateNodeScale).
   ══════════════════════════════════════════ */
.os-app.is-locked,
.os-app.is-locked .os-icon-wrap { cursor: default; }
.os-app.is-locked .os-icon-wrap { opacity: var(--locked-icon-op); }
.os-app.is-locked .os-icon { filter: var(--locked-icon-filter); }
.os-app.is-locked > .os-app__label {
  display: grid;
  opacity: calc(var(--label-in, 1) * var(--locked-label-op));
}
.os-app.is-locked .os-app__name,
.os-app.is-locked .os-app__soon { grid-area: 1 / 1; }
.os-app.is-locked .os-app__soon,
.os-app.is-locked.is-soon .os-app__name { visibility: hidden; }
.os-app.is-locked.is-soon .os-app__soon { visibility: visible; }

/* Приближение картинки открытой работы (07.10.2026, toggleWorkZoom в
   main.js; режимы «на весь экран» и «в точке нажатия»): системные
   курсоры zoom-in / zoom-out, своего рисунка нет. «В точке» палец тащит
   картинку — браузер не прокручивает и не масштабирует страницу под ним
   (touch-action у узла и так none). В приближении текст погашен и не
   ловит клики — клик мимо картинки проходит на пустое место и возвращает
   обычный вид. */
.os-work-dot.is-open-work.is-zoomable img { cursor: zoom-in; }
.os-work-dot.is-open-work.is-zoomed img { cursor: zoom-out; }
.os-work-dot.is-open-work.is-zoomed .os-work-dot__info,
.os-work-dot.is-open-work.is-zoomed .os-work-dot__info * { pointer-events: none; }
