/* ============================================================================
   ИЛАН · DOOH
   Светлая тема по умолчанию, тёмная переключателем, крупный режим отдельно.
   Без blur и тяжёлых теней: на улице со старого Android они стоят кадров.
   Шрифт системный — ни одного байта на загрузку начертаний.
   ========================================================================= */

:root {
  /* Нативные контролы (галочки, стрелка списка, автозаполнение) берут цвета
     отсюда, а не из наших переменных. Схема обязана совпадать с темой:
     иначе в светлой теме галочки чёрные, а подставленный браузером логин
     написан белым по белому. */
  color-scheme: light;

  --bg:            #f4f5f7;
  --surface:       #ffffff;
  --surface-2:     #fafbfc;
  --surface-hover: #f0f2f5;

  --border:        #dfe3e8;
  --border-strong: #c4cad2;

  --text:          #1a1d21;
  --text-muted:    #5c6570;
  --text-dim:      #8b95a1;

  --accent:        #1f6feb;
  --accent-hover:  #1a5fd0;
  --accent-soft:   #e8f0fe;

  --ok:            #1a7f45;
  --ok-soft:       #e3f4e9;
  --warn:          #a86400;
  --warn-soft:     #fdf3e0;
  /* Текст на жёлтой плашке. Обычный --text на светлой теме читается, а на
     тёмной жёлтая подложка тёмная, и белый текст на ней тусклый: комментарий
     отчётчику надо читать на улице, в спешке и на солнце. */
  --warn-ink:      #6b3f00;
  /* Ярлык «вызов» в ленте логов: свой цвет, иначе он сливался с фоном строки
     и тип события приходилось читать словами. */
  --tag-call-bg:   #ede9fe;
  --tag-call-ink:  #6d28d9;
  --bad:           #c0392b;
  --bad-soft:      #fbeae8;

  --sp-1: 4px;  --sp-2: 8px;  --sp-3: 12px;
  --sp-4: 16px; --sp-5: 24px; --sp-6: 32px;

  --r-sm: 4px; --r-md: 8px; --r-lg: 12px; --r-pill: 999px;

  --fs-2xs: 10px;
  --fs-xs: 12px; --fs-sm: 13px; --fs-md: 15px;
  --fs-lg: 18px; --fs-xl: 22px;

  --tap: 46px;                 /* минимальная цель для пальца */
  --header-h: 54px;
  /* Безопасные зоны экрана — отдельными переменными, а не env() по месту.
     В обычной вкладке они нулевые, а в ярлыке на рабочем столе iPhone под
     строкой статуса и полосой жеста лежит наша же страница. Имя нужно не
     ради красоты: env() нельзя подставить снаружи, и проверка вёрстки под
     ярлык не смогла бы воспроизвести ни одного из этих отступов. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  /* Сколько полосы жеста приходится на саму карту. Карта кончается на --sp-3
     выше нижнего края страницы, и этот отступ полосу уже частично закрывает:
     поднимать легенду на всю зону значит поднять её на треть выше нужного. */
  --safe-in-map: max(0px, var(--safe-bottom) - var(--sp-3));

  /* Полная высота шапки вместе с безопасной зоной сверху. В обычной вкладке
     зона нулевая, а в ярлыке на рабочем столе iPhone страница рисуется под
     строкой статуса (viewport-fit=cover плюс black-translucent), и время с
     значками сети ложились прямо на вкладки. Всё, что прибито под шапку,
     отсчитывается отсюда, а не от --header-h. */
  --hdr-h: calc(var(--header-h) + var(--safe-top));

  /* Размеры матрицы держим здесь: их читает и CSS, и виртуализация
     столбцов в matrix.js — двух источников правды тут быть не должно. */
  --mx-col-w: 132px;
  --mx-row-w: 330px;
  /* Высоты строки и шапки столбца подобраны под самый длинный адрес (137
     символов) и название (62). При другом кегле их надо пересчитывать, иначе
     строки разъезжаются по высоте и таблица выглядит кривой. */
  --mx-row-h: 98px;
  --mx-col-h: 182px;
  /* Превью ровно 2:1 — как сам макет на экране. Считается от ширины столбца за
     вычетом отступов; при другой ширине столбца пересчитывать обязательно,
     иначе картинку снова начнёт сплющивать. */
  --mx-thumb-h: 58px;
  --mx-name-lines: 4;

  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Helvetica Neue", Arial, sans-serif;

  --shadow: 0 1px 2px rgba(16, 24, 40, .06);
  --shadow-pop: 0 4px 16px rgba(16, 24, 40, .14);
}

:root[data-theme="dark"] {
  color-scheme: dark;

  --bg:            #14171c;
  --surface:       #1c2027;
  --surface-2:     #21262e;
  --surface-hover: #272d36;

  --border:        #2e3540;
  --border-strong: #414a57;

  --text:          #e8eaed;
  --text-muted:    #9aa4b0;
  --text-dim:      #6f7883;

  --accent:        #58a0ff;
  --accent-hover:  #74b0ff;
  --accent-soft:   #1b2b45;

  --ok:            #48c07a;
  --ok-soft:       #16301f;
  --warn:          #e0a336;
  --warn-soft:     #33260e;
  --warn-ink:      #f5d79a;
  --tag-call-bg:   #2a2145;
  --tag-call-ink:  #b9a2ff;
  --bad:           #ef6a5c;
  --bad-soft:      #351a17;

  --shadow: 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-pop: 0 4px 16px rgba(0, 0, 0, .5);
}

/* Кегль зависит от ширины окна, а не от переключателя.
   Переключатель «Крупный режим» убран: на телефоне крупный кегль оказывался
   слишком велик, на ПК — в самый раз, и получалось, что правильное значение
   определяется устройством, а не вкусом. Значения ниже — ровно те, что раньше
   стояли за переключателем: телефонам и тесным окнам достаётся прежний обычный
   набор (см. :root выше), просторным окнам — прежний крупный, большим
   мониторам — ещё чуть больше. Кому нужно иначе, тот меняет масштаб браузера
   или системный шрифт телефона.

   Порог именно 1500px, а не 900: это тот же порог, по которому матрица считает
   окно тесным (isNarrow в matrix.js). На ноутбуке 1366x768 окно браузера
   высотой около 620px, и крупный кегль стоит там целой строки экранов —
   замерено: шапка столбца просит 201px вместо 183, строка 120 вместо 98, и в
   таблицу помещается два экрана вместо трёх. Матрица — рабочий стол менеджера
   на весь день, и разница между двумя и тремя строками для него больше, чем
   разница в два пункта кегля.

   Кегль и размеры ячеек ходят только вместе. Мельче ячейки при прежнем кегле —
   это адрес экрана, который распирает строку со 108 до 110 пикселей, и таблица
   идёт волнами; уже проверено на мониторе 1800+.

   Высота шапки НЕ растёт вместе с кеглем: она прибита к верху, и её прибавка
   сдвигала бы вниз всю страницу разом. */
/* Телефона в этом списке нет намеренно.
 *
 *  Здесь стоял отдельный набор кеглей для телефонов, на шаг крупнее базового:
 *  список макетов читают на улице с вытянутой руки, и это казалось разумным.
 *  Владелец просил прибавку в круге 3 и, увидев её вживую на iPhone, отменил —
 *  на телефоне такой кегль оказался велик. Телефон снова получает базовый
 *  набор, тот же, что и тесное окно браузера на ПК. */

@media (min-width: 1500px) {
  :root {
    --fs-2xs: 12px;
    --fs-xs: 14px; --fs-sm: 15px; --fs-md: 17px;
    --fs-lg: 21px; --fs-xl: 26px;
    --tap: 56px;
    /* Крупному кеглю нужен и столбец пошире, иначе название макета обрезается
       раньше, чем читается, — и больше высоты, иначе адрес перестаёт влезать.
       Превью считается от ширины столбца за вычетом отступов (по 8px) и
       делится пополам — пропорция 2:1 обязана сохраниться. */
    --mx-col-w: 150px;
    --mx-row-h: 120px;
    --mx-col-h: 200px;
    --mx-thumb-h: 67px;
  }
}

/* Мониторы 1920 и 2560: человек сидит от них дальше, чем от ноутбука, а
   матрица занимает весь экран. Кегль здесь тот же, что и на обычном ПК, —
   больше отдавать нечему: строк и столбцов на экране должно остаться столько
   же. Прибавляем только заголовки и место под названием макета.

   Размеры ячеек НЕ уменьшаем. Раньше здесь стояли свои, меньшие значения — они
   имели смысл, пока крупный режим был отдельным переключателем и перебивал их
   специфичностью. Без него меньшие ячейки достались бы большому монитору
   вместе с крупным кеглем, и содержимое перестало бы в них помещаться: адрес
   экрана распирал строку со 108 до 110 пикселей, и таблица шла волнами. */
@media (min-width: 1800px) {
  :root {
    --fs-lg: 22px; --fs-xl: 27px;
    /* Место под названием выросло — отдаём его названию, а не пустоте:
       иначе длинные названия обрезаются там, где экран как раз большой. */
    --mx-name-lines: 4;
  }
}

* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

/* Двойной тап больше не масштабирует страницу: на ходу его легко сделать
   случайно, а нужен он ровно никогда. Карта живёт по своим правилам. */
body, button, a, label, input, select, textarea { touch-action: manipulation; }
.leaflet-container, .leaflet-container * { touch-action: auto; }

html { -webkit-text-size-adjust: 100%; }

body {
  min-height: 100vh;
  /* На iOS 100vh — это высота окна БЕЗ адресной строки, то есть больше того,
     что реально видно. Страница получается выше экрана, её можно «утащить»
     вниз, и человек упирается в пустоту. dvh меряет видимое; старые браузеры
     остаются на vh. */
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: var(--fs-md);
  line-height: 1.45;
  overscroll-behavior-y: none;
}

[hidden] { display: none !important; }

button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }
a { color: var(--accent); }
/* Ссылка, оформленная кнопкой, кнопкой и должна выглядеть: браузер подчёркивает
   любой <a>, и «Выгрузить в CSV» шла с подчёркиванием посреди ряда кнопок. */
a.btn { text-decoration: none; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Полосы прокрутки. Системные светло-серые на тёмном фоне выглядят как
   заплатка, а в матрице их сразу две и они всё время на виду. */
:root { scrollbar-color: var(--border-strong) transparent; }
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--border-strong); border-radius: var(--r-pill);
  border: 3px solid transparent; background-clip: content-box;
}
/* Всё наведение — только там, где есть чем наводить.
   На сенсорном экране браузер оставляет состояние наведения на последней
   нажатой кнопке, и она продолжает светиться, пока человек не тронет
   другую: в админке из-за этого подсвечивались соседние вкладки. Поэтому
   каждое правило с :hover обёрнуто в `@media (hover: hover)`. Новые
   писать так же. */
@media (hover: hover) { ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: content-box; } }
::-webkit-scrollbar-corner { background: transparent; }

svg.i { width: 1.15em; height: 1.15em; flex: none; fill: none;
        stroke: currentColor; stroke-width: 2;
        stroke-linecap: round; stroke-linejoin: round; }

/* ---------- Загрузка ---------------------------------------------------- */

#boot {
  position: fixed; inset: 0; display: grid; place-items: center;
  background: var(--bg); z-index: 100;
}
.spinner {
  width: 26px; height: 26px; border-radius: 50%;
  border: 2.5px solid var(--border); border-top-color: var(--accent);
  animation: spin .7s linear infinite;
}
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
.spinner.on-dark { border-color: rgba(255,255,255,.35); border-top-color: #fff; }
@keyframes spin { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* ---------- Вход -------------------------------------------------------- */

.login {
  position: relative;
  min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4);
}
.login-theme { position: absolute; top: var(--sp-3); right: var(--sp-3); }
.login-head { text-align: center; margin-bottom: var(--sp-5); }
.login-card {
  width: 100%; max-width: 340px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--r-lg);
  padding: var(--sp-5); box-shadow: var(--shadow);
}
.login-brand {
  font-size: var(--fs-xl); font-weight: 700; letter-spacing: -.02em;
  margin-bottom: var(--sp-1);
}
.login-sub { color: var(--text-muted); font-size: var(--fs-sm); }

/* Показ пароля: вводить вслепую с телефона на улице — верный способ
   заработать блокировку после трёх попыток. */
.input-wrap { position: relative; }
.input-wrap .input { padding-right: calc(var(--tap) - 4px); }
.peek {
  position: absolute; right: 2px; top: 50%; transform: translateY(-50%);
  width: calc(var(--tap) - 6px); height: calc(var(--tap) - 6px);
  display: grid; place-items: center; border-radius: var(--r-sm);
  color: var(--text-dim);
}
@media (hover: hover) { .peek:hover { color: var(--text); } }

.field { margin-bottom: var(--sp-3); }
/* Пояснение под полем, а не сбоку от него: строчным элементом оно обтекало
   узкое поле даты и читалось как продолжение самого поля. */
.field > .adm-note { display: block; margin: var(--sp-1) 0 0; }
/* Длинное пояснение прячется под знак вопроса рядом с подписью: три строки
   текста на каждое поле — и самих полей уже не видно за ними. Короткое
   остаётся строкой: прочитать его проще, чем открыть. */
.hint-btn {
  margin-left: var(--sp-2); width: 18px; height: 18px; padding: 0;
  border: 1px solid var(--border-strong); border-radius: 50%;
  background: transparent; color: var(--text-muted);
  font-size: 11px; font-weight: 700; line-height: 1; cursor: pointer;
  text-transform: none; letter-spacing: normal; vertical-align: middle;
  /* Знак вопроса живёт во флекс-строке рядом с текстом, а флекс по умолчанию
     сжимает всё, чему не хватило места. На телефоне круг из-за этого
     превращался в овал шириной в пару пикселей и уезжал к правому краю. */
  flex: none;
}
@media (hover: hover) { .hint-btn:hover { color: var(--accent); border-color: var(--accent); } }
/* Свёрнутое пояснение раздела: первое предложение и знак вопроса за ним. */
.adm-note.is-folded { display: flex; align-items: baseline; gap: var(--sp-1); }
.hint-pop {
  position: fixed; z-index: 1400; max-width: min(340px, calc(100vw - 16px));
  padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border);
  border-radius: var(--r-md); background: var(--surface);
  box-shadow: var(--shadow-pop); color: var(--text-muted);
  font-size: var(--fs-xs); line-height: 1.45;
}
.field label {
  display: block; font-size: var(--fs-xs); font-weight: 600;
  color: var(--text-muted); margin-bottom: var(--sp-1);
  text-transform: uppercase; letter-spacing: .04em;
}
.input {
  width: 100%; min-height: var(--tap); padding: 0 var(--sp-3);
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong); border-radius: var(--r-md);
  /* Ровно 16px — граница, ниже которой iOS сам приближает страницу при
     тапе в поле. Приближение потом не отменяется и уводит вёрстку вбок. */
  font-size: max(16px, var(--fs-md));
}
textarea.input { padding: var(--sp-2) var(--sp-3); line-height: 1.4; resize: vertical; }
/* Запертое поле должно выглядеть запертым, а не сломанным. Браузеры рисуют
   disabled по-своему — в тёмной теме iOS текст в нём почти пропадал, и логин
   человека приходилось угадывать. */
.input.is-locked, .input:disabled {
  background: var(--surface-hover); color: var(--text-muted);
  border-color: var(--border); cursor: default; opacity: 1;
  -webkit-text-fill-color: var(--text-muted);
}
.input:focus { border-color: var(--accent); outline: none;
               box-shadow: 0 0 0 3px var(--accent-soft); }
/* Подставленный браузером логин: цвет текста и фон задаём сами. Иначе Chrome
   берёт их из своей палитры, и в светлой теме получается белое по голубому. */
.input:-webkit-autofill,
.input:-webkit-autofill:hover,
.input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--text);
  -webkit-box-shadow: 0 0 0 1000px var(--accent-soft) inset;
  caret-color: var(--text);
}

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--sp-2); min-height: var(--tap); padding: 0 var(--sp-4);
  border-radius: var(--r-md); font-size: var(--fs-md); font-weight: 600;
  border: 1px solid transparent; transition: background .12s;
}
.btn-primary { background: var(--accent); color: #fff; }
@media (hover: hover) { .btn-primary:hover:not(:disabled) { background: var(--accent-hover); } }
.btn-ghost { border-color: var(--border-strong); color: var(--text); background: var(--surface); }
@media (hover: hover) { .btn-ghost:hover:not(:disabled) { background: var(--surface-hover); } }
.btn-block { width: 100%; }
.btn:disabled { opacity: .55; cursor: default; }

.form-error {
  margin-top: var(--sp-3); padding: var(--sp-2) var(--sp-3);
  background: var(--bad-soft); color: var(--bad);
  border-radius: var(--r-md); font-size: var(--fs-sm);
}

/* ---------- Каркас ------------------------------------------------------ */

.app { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }

.hdr {
  position: sticky; top: 0; z-index: 1100;
  height: var(--hdr-h); flex: none;
  display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--safe-top) var(--sp-3) 0;
  background: var(--surface); border-bottom: 1px solid var(--border);
}
/* Марка — подпись, а не орган управления. Отделяем её чертой, иначе рядом
   с вкладками она читается как ещё одна кнопка, особенно при увеличении. */
.hdr-brand {
  /* Ужимается первой: марка — наименее важное в шапке. min-width: 0 обязателен,
     иначе flex-элемент не сожмётся ниже своего содержимого и налезет на вкладки
     при увеличении или на узком экране. */
  flex: 0 1 auto; min-width: 0; overflow: hidden;
  font-weight: 700; font-size: var(--fs-md);
  letter-spacing: -.01em; white-space: nowrap; user-select: none;
  padding-right: var(--sp-3); border-right: 1px solid var(--border);
}
.hdr-brand span { color: var(--text-dim); font-weight: 500; }
/* У отчётчика вкладок нет, и черта после марки повисала в пустоте — она
   отделяет марку от вкладок, а отделять нечего. */
.hdr:has(#tabs[hidden]) .hdr-brand { padding-right: 0; border-right: 0; }
/* Приставка «· DOOH» уходит задолго до того, как места станет мало: с тремя
   вкладками и двумя кнопками её некуда девать. */
@media (max-width: 900px) { .hdr-brand span { display: none; } }

/* На телефоне у менеджера и админа вкладки в шапку не помещаются и «Админка»
   обрезается на полуслове. Марка уступает место: вкладки — рабочий орган,
   марка — украшение. У отчётчика вкладок нет, и марка остаётся. */
@media (max-width: 480px) {
  .hdr:has(#tabs:not([hidden])) .hdr-brand { display: none; }
}
/* На 360px даже без марки три вкладки не помещались — ужимаем отступы. */
@media (max-width: 400px) {
  .tabs { padding: 2px; }
  .tabs button { padding: 0 var(--sp-2); }
  .hdr { gap: var(--sp-2); padding: var(--safe-top) var(--sp-2) 0; }
  .hdr-right .icon-btn { width: 40px; height: 40px; }
}

/* При сильном увеличении вкладки уезжают в горизонтальную прокрутку, а не
   выдавливают марку и кнопки за край. */
.tabs { display: flex; gap: 2px; background: var(--bg); min-width: 0;
        padding: 3px; border-radius: var(--r-md);
        overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tabs button { flex: none; }
.tabs button {
  padding: 0 var(--sp-3); height: calc(var(--tap) - 12px);
  border-radius: var(--r-sm); font-size: var(--fs-sm); font-weight: 600;
  color: var(--text-muted);
}
.tabs button[aria-selected="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }

.hdr-right { margin-left: auto; flex: none; display: flex; align-items: center; gap: var(--sp-1); }

/* Кегль растёт вместе с шириной окна, а высота шапки — нет: она прибита к
   верху, и кнопки в 56px в неё просто не поместились бы. Порог тот же, что и у
   кегля. */
@media (min-width: 1500px) {
  .hdr .icon-btn { width: 46px; height: 46px; }
  .hdr .tabs button { height: 38px; font-size: 15px; }
  .hdr-brand { font-size: 16px; }
}

.icon-btn {
  width: var(--tap); height: var(--tap); display: grid; place-items: center;
  border-radius: var(--r-md); color: var(--text-muted);
}
@media (hover: hover) { .icon-btn:hover { background: var(--surface-hover); color: var(--text); } }
.icon-btn.busy svg { animation: spin .8s linear infinite; }

.main { flex: 1; padding: var(--sp-3); max-width: 1600px; width: 100%; margin: 0 auto; }

/* ---------- Меню пользователя ------------------------------------------ */

.menu-wrap { position: relative; }
/* Кегль и высоты в меню фиксированные: иначе переключатель «Крупный режим»
   уезжает из-под курсора ровно в тот момент, когда по нему жмут, и сравнить
   два состояния глазами невозможно. */
.menu {
  position: absolute; right: 0; top: calc(var(--header-h) + 4px); z-index: 1150;
  font-size: 15px;
  min-width: 220px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--r-md);
  box-shadow: var(--shadow-pop); padding: var(--sp-2);
}
.menu-head { padding: var(--sp-2); border-bottom: 1px solid var(--border); margin-bottom: var(--sp-2); }
.menu-name { font-weight: 600; }
.menu-role { font-size: var(--fs-xs); color: var(--text-dim); }
.menu button {
  display: flex; align-items: center; gap: var(--sp-2); width: 100%;
  padding: 0 var(--sp-2); min-height: 42px;
  border-radius: var(--r-sm); font-size: 14px; text-align: left;
  color: var(--text);
}
.menu-role { font-size: 12px; }
@media (hover: hover) { .menu button:hover { background: var(--surface-hover); } }
.menu-sep { height: 1px; background: var(--border); margin: var(--sp-2) 0; }

.switch { margin-left: auto; width: 34px; height: 20px; border-radius: var(--r-pill);
          background: var(--border-strong); position: relative; flex: none; transition: background .15s; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px;
                 width: 16px; height: 16px; border-radius: 50%; background: #fff;
                 transition: transform .15s; }
.switch[data-on="1"] { background: var(--accent); }
.switch[data-on="1"]::after { transform: translateX(14px); }

/* ---------- Нерабочее время --------------------------------------------- */

.closed {
  max-width: 480px; margin: 12vh auto; text-align: center;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); padding: var(--sp-6) var(--sp-5);
}
.closed svg { width: 34px; height: 34px; color: var(--text-dim); margin-bottom: var(--sp-3); }
.closed p { color: var(--text-muted); line-height: 1.6; }

/* ---------- Уведомления ------------------------------------------------- */

/* Панель массовых действий на телефоне прибита к низу, и уведомление с
   «Отменить» садилось прямо на неё: кнопки становились недоступны на все три
   секунды. Высоту панели проставляет matrix.js. */
.toasts {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--sp-4) + var(--safe-bottom) + var(--bulk-h, 0px)
                + var(--mx-sb-h, 0px) + var(--offline-h, 0px));
  transition: bottom .12s;
  /* Выше контролов Leaflet (у них до 1000), иначе карта съедает уведомления. */
  z-index: 1300; display: flex; flex-direction: column; gap: var(--sp-2);
  width: calc(100% - var(--sp-5)); max-width: 420px; pointer-events: none;
}
/* Высоту панели массовых действий проставляет matrix.js в --bulk-h: на ПК она
   в одну строку, на телефоне — в три, и фиксированным числом это не описать.
   Уведомление встаёт над ней, а не поверх: иначе «Отменить» накрывает те самые
   кнопки, которыми человек сейчас пользуется. */
.toast {
  display: flex; align-items: center; gap: var(--sp-2);
  padding: var(--sp-3); border-radius: var(--r-md);
  background: var(--text); color: var(--bg);
  font-size: var(--fs-sm); font-weight: 500; box-shadow: var(--shadow-pop);
  animation: toast-in .16s ease-out;
}
.toast.ok  { background: var(--ok);  color: #fff; }
.toast.bad { background: var(--bad); color: #fff; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }

/* ---------- Общее ------------------------------------------------------- */

.empty {
  padding: var(--sp-6) var(--sp-4); text-align: center;
  color: var(--text-dim); font-size: var(--fs-sm);
}
/* Пустое состояние внутри сетки — это её элемент, и без явного растягивания на
   все колонки оно вставало в первую: на телефоне выглядело по центру, а на ПК
   уезжало влево и читалось как обломок вёрстки. */
.cards > .empty, .mx-tiles > .empty { grid-column: 1 / -1; }
/* Загрузка: крутилка с подписью. Просто пустая страница читается как «ничего
   нет», а это самый неудачный вывод из всех возможных. */
.empty.loading {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
  padding-top: 15vh;
}
.loading-slow { color: var(--warn); font-size: var(--fs-xs); }

.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }

/* ---------- Панель и переключатели -------------------------------------- */

.bar {
  display: flex; align-items: center; gap: var(--sp-3);
  margin-bottom: var(--sp-3); flex-wrap: wrap;
}
.bar-stat { font-size: var(--fs-sm); color: var(--text-muted); margin-left: auto; }
.bar-stat b { color: var(--text); font-weight: 700; }

.seg { display: inline-flex; gap: 2px; padding: 3px;
       background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-md); }
.seg button {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  min-height: calc(var(--tap) - 12px); padding: 0 var(--sp-3);
  border-radius: var(--r-sm); font-size: var(--fs-sm); font-weight: 600;
  color: var(--text-muted); white-space: nowrap;
}
.seg button[aria-selected="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.seg-sm button { min-height: calc(var(--tap) - 16px); font-size: var(--fs-xs); padding: 0 var(--sp-2); }
.seg-icons button { padding: 0 var(--sp-2); }
.seg-filter { flex: 1; min-width: 0; max-width: 420px; }
.seg-filter button { flex: 1; }
.count {
  margin-left: var(--sp-1); padding: 0 5px; border-radius: var(--r-pill);
  background: var(--border); color: var(--text-muted);
  font-size: var(--fs-xs); font-weight: 700; min-width: 18px;
}
.seg button[aria-selected="true"] .count { background: var(--accent); color: #fff; }

.tools { display: flex; align-items: center; gap: var(--sp-3);
         margin-bottom: var(--sp-3); flex-wrap: wrap; }
.tools-right { margin-left: auto; display: flex; gap: var(--sp-2); }

.search { position: relative; flex: 1; min-width: 200px; }
.search .input { padding-left: 38px; }
.search-i { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
            color: var(--text-dim); pointer-events: none; }

.check { display: inline-flex; align-items: center; gap: var(--sp-2);
         font-size: var(--fs-sm); color: var(--text-muted); cursor: pointer;
         min-height: var(--tap); user-select: none; }
.check input { width: 18px; height: 18px; accent-color: var(--accent); }

/* ---------- Список экранов ---------------------------------------------- */

.screens { display: flex; flex-direction: column; gap: var(--sp-2); }

.row {
  display: flex; align-items: center; gap: var(--sp-3);
  width: 100%; text-align: left; padding: var(--sp-3);
  min-height: var(--tap);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); color: var(--text);
}
@media (hover: hover) { .row:hover { background: var(--surface-hover); } }
.row:active { transform: scale(.995); }
.row-body { flex: 1; min-width: 0; }
.row-name { display: block; font-weight: 600; font-size: var(--fs-md); line-height: 1.3; }
.row-meta { display: flex; align-items: center; gap: var(--sp-2); margin-top: 3px; flex-wrap: wrap; }
.row-dim { font-size: var(--fs-xs); color: var(--text-dim); }

.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.row-todo .dot { background: var(--warn); }
.row-done .dot { background: var(--ok); }
.row-off  .dot { background: var(--bad); }
/* Легенда обязана совпадать с маркерами на карте, а те рисуются постоянным
   цветом независимо от темы. */
.dot-todo { background: #d98c00; }
.dot-done { background: #1a9a52; }
.dot-off  { background: #d24437; }
.row-off { opacity: .72; }

.tag { font-size: var(--fs-xs); font-weight: 700; padding: 2px 7px; border-radius: var(--r-pill); }
.tag-todo { background: var(--warn-soft); color: var(--warn); }
.tag-done { background: var(--ok-soft);   color: var(--ok); }
.tag-off  { background: var(--bad-soft);  color: var(--bad); }
/* Видеоотчёт: акцентный, а не жёлтый. Жёлтый в этом списке значит
   «есть задания», и второй жёлтый рядом с ним не различался бы. */
.tag-video { background: var(--accent-soft); color: var(--accent); }

/* ---------- Карта -------------------------------------------------------- */

.map-wrap { position: relative; border: 1px solid var(--border);
            border-radius: var(--r-md); overflow: hidden; }
/* Высота карты — по видимой части окна. На vh нижний край карты вместе с
   легендой уезжал под адресную строку Safari, и легенду было не достать. */
/* Начало карты приходит из JS (syncMapHeight): вычитать фиксированные 120px
   «на панель» можно ровно на одном размере экрана, а на остальных под картой
   оставалась пустая полоса. Запас на 100vh нужен старым браузерам без dvh. */
/* Безопасную зону снизу карта из своей высоты НЕ вычитает. Пробовали — и
   владелец сразу это поймал: «ты не легенду опустил ниже, а карту поднял от
   нижнего края». Карта тем и полезна, что её много; полосе жеста достаётся
   кусок самой карты, а не пустая полоса под ней. Наверх над полосой поднимают
   себя те, кого она закрыла бы, — легенда и атрибуция. */
#map { height: calc(100vh - var(--map-top, 200px) - var(--sp-3));
       height: calc(100dvh - var(--map-top, 200px) - var(--sp-3));
       min-height: 380px; background: var(--surface-2); }
/* Leaflet объявляет флагу display:inline !important, и его CSS грузится позже
   нашего — побеждает поздний. Перебиваем специфичностью. Сама атрибуция
   остаётся: это условие бесплатных тайлов OSM. */
.leaflet-control-attribution svg.leaflet-attribution-flag { display: none !important; }
.leaflet-container { font: inherit; background: var(--surface-2); }

/* Маркер конструкции: кружок из долей по числу сторон, рисуется в app.js.
   У Leaflet свой значок с белым фоном и рамкой — убираем оба, иначе вокруг
   каждой точки был бы белый квадрат. */
.map-pin { background: none; border: 0; }
.map-pin svg { display: block; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35)); }

/* ---------- Полосы между квадратами карты --------------------------------
   Многолетняя беда Leaflet в браузерах на Chromium: на стыках квадратов
   проступают серые полоски в один пиксель. Причина не в наших картинках и не в
   кэше, а в округлении координат при композиции слоёв — заведено в Leaflet как
   #6101, #6541, #3575 и #9731 и не закрыто до сих пор. В Safari и Firefox не
   воспроизводится, поэтому на iPhone полос и нет.

   Появляются они не сразу: по мере перемещения по карте смещение слоя растёт,
   и округление начинает промахиваться чаще — отсюда «через пару часов работы».

   Лечим composited-слоем на контейнере квадратов: браузер перестаёт округлять
   положение каждого квадрата по отдельности и рисует слой целиком. Средство
   самое безобидное из известных — картинку не размывает и на скорость не
   влияет. */
.leaflet-tile-container {
  transform: translateZ(0);
  will-change: transform;
}
/* Перекрывать квадраты на полпикселя — общепринятая правка от этих швов, и
   она здесь НЕ применяется: в прошлом круге её проверили A/B-снимками
   (logs/ui/ab-tiles-A-256_5.png против ab-tiles-B-256.png), и оказалось, что
   растянутый квадрат браузер пересчитывает, край размывается — швы она и
   рисовала. Повторять не надо.

   Остаётся ещё один резерв, если полосы не уйдут: округление положения слоя до
   целых пикселей при перемещении. Оно трогает внутренности Leaflet, поэтому
   берётся последним и только по факту. */

/* Leaflet рисует всплывашку своими цветами, поэтому в тёмной теме текст
   на ней пропадал. Переводим на наши токены. */
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
  background: var(--surface); color: var(--text);
  box-shadow: var(--shadow-pop); border: 1px solid var(--border);
}
.leaflet-popup-content-wrapper { border-radius: var(--r-md); }
.leaflet-popup-content { margin: 6px; min-width: 200px; }
.leaflet-container a.leaflet-popup-close-button { color: var(--text-dim); }
@media (hover: hover) { .leaflet-container a.leaflet-popup-close-button:hover { color: var(--text); } }
.leaflet-control-zoom a {
  background: var(--surface); color: var(--text); border-color: var(--border);
}
@media (hover: hover) { .leaflet-control-zoom a:hover { background: var(--surface-hover); } }
/* .leaflet-container { font: inherit } задирает и атрибуцию до размера текста
   страницы: она разрасталась на пол-карты и накрывала легенду. */
.leaflet-control-attribution {
  background: var(--surface); color: var(--text-dim);
  font-size: 9px; line-height: 1.4; padding: 0 4px; opacity: .55;
  /* В ярлыке нижние пиксели карты закрыты полосой жеста, а атрибуция OSM —
     условие бесплатных тайлов, и наполовину скрытой она быть не должна. */
  margin-bottom: var(--safe-in-map);
}
.leaflet-control-attribution a { color: var(--text-muted); }

.map-legend {
  position: absolute; left: var(--sp-2); bottom: var(--sp-2); z-index: 500;
  display: flex; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); font-size: var(--fs-xs); color: var(--text-muted);
}
/* На телефоне легенда и обязательная атрибуция OSM в одну строку по низу карты
   не помещаются — легенда встаёт над атрибуцией. */
@media (max-width: 620px) {
  /* Легенда и обязательная атрибуция в одну строку не помещаются: легенда
     занимает почти всю ширину телефона. Значит легенда стоит НАД атрибуцией,
     вплотную — её высота (13px) плюс два пикселя зазора, — а над полосой
     жеста их поднимает безопасная зона. Больший запас читается как «легенда
     уехала вверх», и это ровно то, что владелец увидел на своём телефоне. */
  .map-legend { bottom: calc(15px + var(--safe-in-map));
                left: var(--sp-1); padding: 4px var(--sp-2); gap: var(--sp-2); }
}
.legend { display: inline-flex; align-items: center; gap: 5px; }

.pop {
  display: flex; flex-direction: column; align-items: flex-start; gap: 5px;
  width: 100%; padding: var(--sp-2); text-align: left;
  border-radius: var(--r-sm); color: var(--text); font-size: var(--fs-sm);
}
@media (hover: hover) { .pop:hover { background: var(--surface-hover); } }
.pop + .pop { border-top: 1px solid var(--border); margin-top: 2px; padding-top: var(--sp-2); }
.pop b { font-weight: 600; line-height: 1.3; }
/* Значков может быть два — состояние и видео. Переносятся, а не сжимают
   всплывашку: она и так узкая, а адрес конструкции важнее. */
.pop-tags { display: flex; flex-wrap: wrap; gap: 5px; }

/* ---------- Шапка экрана ------------------------------------------------- */

.screen-head { display: flex; align-items: flex-start; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.screen-head .icon-btn { flex: none; margin-left: -8px; }
.screen-title { min-width: 0; padding-top: 9px; }
.screen-title h1 { font-size: var(--fs-lg); font-weight: 700; line-height: 1.25; letter-spacing: -.01em; }
.screen-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; }

.notice {
  display: flex; gap: var(--sp-2); align-items: flex-start;
  padding: var(--sp-3); margin-bottom: var(--sp-3);
  background: var(--bad-soft); color: var(--bad);
  border-radius: var(--r-md); font-size: var(--fs-sm); line-height: 1.4;
}
.notice svg { flex: none; margin-top: 2px; }
/* Экран занят коллегой — это предупреждение, а не поломка: жёлтый, не красный.
   Красным помечен экран не в сети, и путать их нельзя. */
.notice-busy { background: var(--warn-soft); color: var(--warn-ink, var(--text)); }
.notice-busy svg { color: var(--warn); }

.trigger-mode { display: flex; align-items: center; gap: var(--sp-2);
                margin-bottom: var(--sp-3); flex-wrap: wrap; }
.tm-layout { margin-left: auto; flex: none; }
/* Кнопка «?» не ужимается: когда ей не хватало места, она сплющивалась в
   нечитаемый огрызок в 20px вместо того, чтобы перенести соседа на строку
   ниже. Перенос честнее сплющивания. */
.trigger-mode .icon-btn { flex: none; }
.seg-filter { flex: 1 1 auto; max-width: 100%; }
.tm-label { font-size: var(--fs-sm); color: var(--text-muted); }
.icon-sm { width: calc(var(--tap) - 12px); height: calc(var(--tap) - 12px); }

/* Строки управления на странице экрана живут по ширине телефона, а не по
   кеглю: крупнее становятся карточки макетов, ради которых крупный режим и
   включают. Без этого на 360px в крупном режиме счётчик «Все» уезжал за край,
   а «?» сжимался вдвое — до 20px вместо 44. */
@media (max-width: 400px) {
  .tools, .trigger-mode { --tap: 46px; }
  .seg-filter button { font-size: 13px; padding: 0 var(--sp-2); }
  .trigger-mode .seg-sm button { font-size: 12px; }
  .tm-label { font-size: 13px; }
  .count { font-size: 12px; }
}

/* ---------- Автоматический режим ------------------------------------------ */
/*  Экран прогона человек смотрит с вытянутой руки, второй рукой держа камеру:
    всё крупно, кнопка одна и во всю ширину. */

.auto { display: flex; flex-direction: column; gap: var(--sp-3); }
.auto-title { font-size: var(--fs-lg); font-weight: 700; line-height: 1.25; }
.auto-hint { color: var(--text-muted); font-size: var(--fs-sm); }
.auto-note { padding: var(--sp-2) var(--sp-3); border-radius: var(--r-md);
             background: var(--bg); font-size: var(--fs-sm); }
.auto-note.is-warn { background: var(--warn-soft); color: var(--warn); font-weight: 600; }
.auto-note.is-done { background: var(--ok-soft); color: var(--ok); font-weight: 600; }
.auto-notes h3 { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase;
                 letter-spacing: .05em; color: var(--text-dim); margin-bottom: var(--sp-2); }
.auto-note-row { display: flex; flex-direction: column; gap: 2px;
                 padding: var(--sp-2) 0; border-top: 1px solid var(--border);
                 font-size: var(--fs-sm); overflow-wrap: anywhere; }
.auto-note-row span { color: var(--text-muted); }
/* Кнопка во всю ширину и высотой в полтора пальца: по ней жмут не глядя. */
.auto-go { width: 100%; min-height: calc(var(--tap) + 8px); font-size: var(--fs-md); }

.auto-run { align-items: stretch; text-align: center; }
.auto-shot { width: 100%; max-height: 42vh; object-fit: contain;
             border-radius: var(--r-md); background: var(--surface-2); }
.auto-now { font-size: var(--fs-xl); font-weight: 700; line-height: 1.2;
            overflow-wrap: anywhere; }
.auto-bar { height: 10px; border-radius: 999px; background: var(--surface-2);
            overflow: hidden; }
.auto-bar-fill { height: 100%; width: 0; background: var(--accent);
                 transition: width .2s linear; }
.auto-meta { display: flex; justify-content: space-between; gap: var(--sp-2);
             font-size: var(--fs-sm); color: var(--text-muted); }
.auto-left { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text); }

/* ---------- Карточки макетов --------------------------------------------- */

/* Привязка прокрутки выключена по той же причине, что и у таблицы матрицы:
   браузер принимает дорисовку карточек за сдвиг содержимого и добавляет к
   прокрутке ещё, кадр за кадром. */
.cards { display: grid; gap: var(--sp-3); overflow-anchor: none; }
.cards-list  { grid-template-columns: 1fr; }
.cards-tiles { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 700px)  { .cards-list  { grid-template-columns: repeat(2, 1fr); }
                             .cards-tiles { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1100px) { .cards-list  { grid-template-columns: repeat(3, 1fr); }
                             .cards-tiles { grid-template-columns: repeat(5, 1fr); } }

/* Подпись менеджера: имя по центру, линии по обе стороны. Раньше имя было
   светло-серым и прижималось к левому краю — на тёмной теме его было почти не
   видно, и блоки сливались в один поток. Отступ между блоками больше подписи:
   граница между менеджерами должна читаться при быстрой прокрутке.
   Прилипает к верху — листая сотню макетов, всегда видно, чей это блок. */
.mgr {
  grid-column: 1 / -1; display: flex; align-items: center; gap: var(--sp-3);
  margin-top: var(--sp-5); font-size: var(--fs-xs); font-weight: 700;
  color: var(--text); text-transform: uppercase; letter-spacing: .05em;
  /* Прилипает под шапкой, а не под верхним краем окна: шапка тоже липкая и
     накрыла бы подпись собой.

     Подпись следующего блока выталкивает предыдущую, а не встаёт рядом с ней:
     раньше, когда карточки прошлого менеджера были уже пролистаны, наверху
     висели два имени подряд, и было непонятно, чей блок сейчас на экране.
     Добивается это тем, что подпись занимает всю высоту своей полосы и
     непрозрачна — следующая наезжает на предыдущую и закрывает её. */
  position: sticky; top: var(--hdr-h); z-index: 3;
  padding: var(--sp-2) 0; background: var(--bg);
  min-height: calc(var(--sp-2) * 2 + 1.2em);
}
/* Липкие элементы одного уровня перекрываются в порядке следования: чем ниже
   в разметке, тем выше по слою. Так следующая подпись накрывает предыдущую, а
   не наоборот. */
.cards > .mgr ~ .mgr { z-index: 4; }
.mgr::before, .mgr::after {
  content: ""; flex: 1; height: 1px; background: var(--border);
}
.mgr-name { white-space: nowrap; }
.cards > .mgr:first-child { margin-top: 0; }

.card {
  display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); overflow: hidden;
}
.card.is-done { border-color: var(--ok); }
/* Карточку с комментарием видно целиком, а не только по плашке внизу: при
   быстрой прокрутке взгляд цепляется за рамку, а не за мелкий текст.
   Толщина 2px выбрана владельцем по снимкам (scripts/note_variants.py,
   вариант C из четырёх): 1px на солнце с телефона в руке не читался, а
   насыщенный цвет при 1px спорил с зелёной рамкой снятого макета. */
.card.has-note { border-color: var(--warn); border-width: 2px; }
.card.is-skip { opacity: .6; }
/* Запланированный макет — не «не снимать»: снимать его будем, просто позже.
   Гасим слабее, чем вычеркнутый, и дату пишем цветом задания, иначе на бегу
   эти две карточки не различить. */
.card.is-plan { opacity: .78; }
.card.is-new  { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }

.card-title {
  padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm); font-weight: 600;
  line-height: 1.3; text-align: center; overflow-wrap: anywhere;
}
/* В плитке заголовок не длиннее двух строк, а высоту ему выравнивает по ряду
   alignTitles: у коротких названий вторая строка стояла пустой и отъедала у
   превью полтора сантиметра экрана. В списке равнять нечего — колонка одна. */
.cards-tiles .card-title {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Превью — это и есть кнопка вызова. Служебная зона карточки не кликается,
   поэтому промахнуться пальцем на ходу почти невозможно.

   Перетаскивание и долгое нажатие отключены: браузер иначе принимает нажатие
   на кнопку за попытку утащить картинку или предлагает «Сохранить
   изображение», а отчётчик в этот момент вызывает ролик у конструкции. */
.shot {
  position: relative; display: block; width: 100%; padding: 0;
  aspect-ratio: 2 / 1; background: #0d0f12; overflow: hidden; cursor: pointer;
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
}
/* Макет вписываем в кадр целиком, искажая пропорции. Обрезка по краям
   выглядит аккуратнее, но у DOOH-макетов главное на краях: логотип, цена,
   дата. Отчётчику нужно узнать макет, а не любоваться им. */
.shot-img { width: 100%; height: 100%; object-fit: fill; display: block; transition: transform .1s;
             -webkit-user-drag: none; user-select: none; pointer-events: none; }
.shot-empty {
  width: 100%; height: 100%; display: grid; place-items: center;
  color: var(--text-dim); font-size: var(--fs-xs); background: var(--surface-2);
}
.shot-empty .i { width: 28px; height: 28px; }
.shot:active .shot-img { transform: scale(.985); }

/* Плашка «Снято» — единственный ответ на вопрос «этот я уже снял?», и читают
   её на ходу, с вытянутой руки, на солнце. Крупнее и плотнее прежнего: кегль
   на шаг выше, поля больше, фон гуще, тень отделяет от светлого кадра. */
.shot-badge {
  position: absolute; left: 6px; bottom: 6px;
  padding: 3px 9px; border-radius: var(--r-pill);
  background: rgba(0, 0, 0, .84); color: #fff;
  font-size: var(--fs-sm); font-weight: 700; letter-spacing: .01em;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}
.card.is-done .shot-badge { background: var(--ok); }
.card.is-plan .shot-badge { background: var(--accent); }

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

   Появляется только час после съёмки — столько же, сколько её принимает
   сервер. Дальше кнопки нет вовсе: обещать действие, на которое придёт отказ,
   хуже, чем не обещать. */
.shot-wrap { position: relative; }
.undo-shot {
  position: absolute; right: 6px; bottom: 6px; z-index: 2;
  display: inline-flex; align-items: center; gap: 5px;
  appearance: none; cursor: pointer; font: inherit;
  padding: 3px 10px; min-height: 28px; border-radius: var(--r-pill);
  border: 1px solid rgba(255, 255, 255, .5);
  background: rgba(0, 0, 0, .74); color: #fff;
  font-size: var(--fs-xs); font-weight: 600;
}
.undo-shot .i { width: 15px; height: 15px; }
@media (hover: hover) { .undo-shot:hover { background: rgba(0, 0, 0, .9); } }

/* В плитке карточка вдвое уже, и подпись «Отменить» ложилась прямо на плашку
   «Снято»: два самых важных элемента кадра перекрывали друг друга. Остаётся
   значок, и уходит он в верхний угол — по диагонали от плашки, чтобы палец,
   метящий в отмену, не попадал по превью рядом с ней. */
.cards-tiles .undo-shot {
  top: 6px; bottom: auto; right: 6px;
  width: 32px; height: 32px; min-height: 0; padding: 0;
  justify-content: center; border-radius: 50%;
}
.cards-tiles .undo-shot .undo-text { display: none; }
.cards-tiles .undo-shot .i { width: 17px; height: 17px; }

.shot-over {
  position: absolute; inset: 0; display: none;
  flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2);
  background: rgba(0, 0, 0, .62); color: #fff; font-size: var(--fs-sm); font-weight: 600;
}
.card.is-busy .shot-over { display: flex; }

/* Вспышка успеха — ровно секунда, как договаривались. */
.card.flash .shot::after {
  content: ""; position: absolute; inset: 0;
  box-shadow: inset 0 0 0 4px var(--ok); animation: flash 1s ease-out forwards;
}
@keyframes flash { from { background: rgba(26,127,69,.45); } to { background: transparent; opacity: 0; } }

.card-notes { padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border); }
/* Комментарий пропустить нельзя: это подсказка от менеджера человеку, который
   уже стоит у конструкции, и цена пропуска — повторный выезд. Поэтому плашка
   заметная на обеих темах, а не только полоска слева. Оба вида комментария
   выглядят одинаково: отчётчику важен смысл, а не то, кто кому его писал. */
.note {
  font-size: var(--fs-sm); line-height: 1.4; font-weight: 500;
  color: var(--warn-ink, var(--text));
  background: var(--warn-soft); border: 1px solid var(--warn);
  padding: var(--sp-2); border-radius: var(--r-sm); overflow-wrap: anywhere;
}
.note + .note { margin-top: var(--sp-2); }
.note-body { display: block; }
/* В плитке комментарий сворачивается до двух строк: иначе одна подробная
   инструкция растягивает весь ряд. В списке — основном виде — целиком. */
/* Три строки, а не две: первую съедает подпись «Комментарий:», и от самого
   текста оставалась одна строчка — по ней ничего не понять. */
.note-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
              overflow: hidden; }
.note-more { display: none; margin-top: 2px; font-size: var(--fs-xs); font-weight: 700;
             color: var(--accent); }
.note-cut .note-more { display: block; }
.note b { color: var(--warn); }

/* Служебная зона тянется до низа карточки, а записи в ней прижаты к верху.
   Раньше её прижимало книзу целиком, и у карточки с одним вызовом между
   превью и строчкой лога зияла дыра высотой в две строки — ряд карточек
   выглядел дырявым. */
.card-foot {
  padding: var(--sp-2) var(--sp-3); flex: 1;
  display: flex; flex-direction: column; gap: 1px;
  border-top: 1px solid var(--border); background: var(--surface-2);
}
.hist {
  display: flex; align-items: baseline; gap: 6px;
  font-size: var(--fs-xs); color: var(--text-dim); line-height: 1.5;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hist time { color: var(--text-muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.hist-none { color: var(--text-dim); font-style: italic; }
/* Кто сделал правку — сбоку и тише самой правки: в строке важнее, что
   произошло, а не кто это был. */
.hist-who { margin-left: auto; flex: none; padding-left: var(--sp-2);
            color: var(--text-dim); }

/* ---------- Модальные окна ----------------------------------------------- */

.modal {
  position: fixed; inset: 0; z-index: 1200; display: grid; place-items: center;
  padding: var(--sp-4); background: rgba(16, 24, 40, .45);
  /* Палец на затемнении не должен листать страницу под окном: она остаётся на
     месте, пока окно открыто. */
  touch-action: none;
}
/* Листается только содержимое: заголовок и кнопки стоят на месте. Раньше
   прокручивалась карточка целиком, и в карточке ячейки на телефоне до кнопок
   «Запланировать» и «Отметить снятым» приходилось долистывать — они уезжали
   за нижний край вместе с историей изменений. */
.modal-card {
  width: 100%; max-width: 420px; max-height: 86vh;
  display: flex; flex-direction: column; overflow: hidden;
  /* Длинное слово внутри окна переносится, а не растягивает окно вбок. */
  overflow-wrap: anywhere;
  /* На телефоне vh считается по окну без адресной строки, и низ карточки
     уезжал под панель браузера: кнопки видно, а последние строки списка — нет.
     dvh меряет то, что реально видно; старые браузеры остаются на vh. */
  max-height: 86dvh;
  background: var(--surface); border-radius: var(--r-lg); box-shadow: var(--shadow-pop);
}
/* Карточка ячейки матрицы шире: в ней адрес экрана и список вызовов. Ширина
   выбрана по кнопкам: их бывает четыре, и в 560px они не помещались — на ПК
   «Сохранить» уезжала на второй ряд одна. */
.modal-card:has(.mx-hist) { max-width: 620px; }
/* z-index обязателен. Шапка прибита к верху, но у неё нет своего слоя, и любой
   позиционированный элемент в теле окна — образец ячейки в легенде, уголок
   комментария — при прокрутке проезжал прямо поверх заголовка. */
.modal-head {
  display: flex; align-items: center; gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 2;
  background: var(--surface);
}
/* Название макета бывает одним словом на сорок символов: «Аллея_Спагетти+
   Сосиски+Батончики+Кофе_13.08». Без переноса оно распирало шапку окна шире
   самого окна, и крестик «закрыть» уезжал за край экрана — окно оставалось не
   закрыть ничем, кроме Escape. min-width: 0 обязателен: flex-элемент по
   умолчанию не сжимается уже своего содержимого. */
.modal-head h2 { font-size: var(--fs-md); font-weight: 700; flex: 1;
                 min-width: 0; overflow-wrap: anywhere; }
/* Список в окне бывает длинным — сводка это сорок пять строк. Дойдя до его
   конца, свайп раньше подхватывал страницу под окном: окно оставалось на
   месте, а листалось что-то другое, и обратный свайп уже не возвращал список.
   Прокрутка не выходит за пределы окна. */
.modal-body { padding: var(--sp-4); overflow: auto; min-height: 0; flex: 0 1 auto;
              overscroll-behavior: contain; touch-action: pan-x pan-y; }
.modal-body p { margin-bottom: var(--sp-3); font-size: var(--fs-sm); line-height: 1.5; color: var(--text-muted); }
.modal-body p b { color: var(--text); }
/* Черта над кнопками: содержимое листается под ними и обрывается на полуслове,
   и без черты обрыв читается как поломка вёрстки, а не как «дальше есть ещё». */
.modal-foot { padding: var(--sp-3) var(--sp-4) var(--sp-4); display: flex; justify-content: flex-end;
              gap: var(--sp-2); flex-wrap: wrap; border-top: 1px solid var(--border); }
.modal-foot .btn { white-space: nowrap; }
/* На телефоне кнопки в одну строку не помещаются: «Запланировать на 12.09»
   рядом с «Отмена» просит 320px при 264 доступных, и «Отмена» ломалась посреди
   слова. Раскладываем по две в ряд, главное действие — отдельной строкой
   сверху. Столбцом было хуже: у карточки ячейки кнопок пять, и они занимали
   264 пикселя из 932 — «Запланировать» и «Отметить снятым» оказывались за
   нижним краем окна, до них надо было листать. */
@media (max-width: 620px) {
  .modal-foot { justify-content: stretch; }
  .modal-foot .btn { flex: 1 1 calc(50% - var(--sp-2)); }
  .modal-foot .btn-primary { flex-basis: 100%; order: -1; }
}

/* ---------- Узкий экран -------------------------------------------------- */

@media (max-width: 560px) {
  .main { padding: var(--sp-2); }
  /* Верхний отступ — это безопасная зона, а не украшение: обнулив его вместе
     с боковыми, мы вернули бы вкладки под строку статуса в ярлыке. */
  .hdr { padding: var(--safe-top) var(--sp-2) 0; gap: var(--sp-2); }
  .hdr-brand { padding-right: var(--sp-2); }
  .bar-stat { width: 100%; margin-left: 0; order: 3; }
  .tools-right { margin-left: 0; }
  .screen-title h1 { font-size: var(--fs-md); }
}

/* ---------- Обрыв связи --------------------------------------------------- */

.offline-bar {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--sp-4) + var(--safe-bottom));
  z-index: 1250; display: flex; align-items: center; gap: var(--sp-2);
  flex-wrap: wrap; justify-content: center; text-align: center;
  padding: var(--sp-2) var(--sp-4); border-radius: var(--r-md);
  max-width: calc(100% - var(--sp-5));
  background: var(--bad); color: #fff;
  font-size: var(--fs-sm); font-weight: 600; box-shadow: var(--shadow-pop);
}
/* Обратный отсчёт до следующей попытки: видно, что сервис не сдался, а сам
   пробует достучаться — и что ждать осталось секунды, а не неизвестно сколько. */
.offline-left { font-weight: 400; opacity: .85; white-space: nowrap; }
/* Пока связи нет, вызывать бессмысленно — гасим превью-кнопки, чтобы человек
   не жал впустую и не думал, что экран не отвечает. Всё остальное остаётся
   читаемым: человек у конструкции должен видеть, какой макет ему снимать. */
body.is-offline .shot { opacity: .45; pointer-events: none; }

/* ============================================================================
   Матрица: строки — 45 экранов, столбцы — макеты.
   Закреплены первая колонка и шапка. Все строки одной высоты, все столбцы
   одной ширины — кроме закреплённой колонки, где адрес виден целиком.
   ========================================================================= */

.mx-tools { display: flex; align-items: center; gap: var(--sp-3);
            margin-bottom: var(--sp-3); flex-wrap: wrap; }
.mx-tools-right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-2); }
/* На широком мониторе поиск иначе растягивается на пол-экрана и перевешивает
   всю панель — а вводят в него от силы десяток символов. min-width обязателен:
   поле ввода само по себе не сжимается уже двух десятков символов, и на
   ноутбуке 1366 панель из-за этого уходила на второй ряд — а это минус целая
   строка экранов в таблице. Пусть лучше ужмётся поиск: в нём набирают
   несколько букв названия. */
.mx-tools .search { flex: 1 1 160px; min-width: 150px; max-width: 420px; }

/* Ноутбук 1366×768 и всё, что уже: панель обязана остаться в один ряд. Второй
   ряд стоит целой строки экранов, а их на таком окне видно всего три. Списки
   здесь ужимаются по ширине, подписи галочек короче (см. isNarrow в
   matrix.js), а поиск отдаёт место первым. */
@media (min-width: 721px) and (max-width: 1500px) {
  .mx-tools { gap: var(--sp-2); }
  .mx-tools .search { flex: 1 1 130px; min-width: 120px; }
  .mx-mgr { max-width: 160px; }
  .mx-sort { max-width: 150px; }
  .mx-tools-right .btn { padding: 0 var(--sp-2); }
}
.mx-mgr { width: auto; min-width: 170px; }
/* Порядок макетов. Уже менеджера: там три коротких пункта, и лишняя
   ширина съедала бы место у галочек фильтров. */
.mx-sort { width: auto; min-width: 150px; }
.mx-count { font-size: var(--fs-sm); color: var(--text-muted); white-space: nowrap; }
.mx-hint { font-size: var(--fs-xs); color: var(--text-dim); }
@media (max-width: 1250px) { .mx-hint { display: none; } }
.mx-count-box { display: flex; align-items: center; gap: var(--sp-1); }
/* Знак вопроса не растёт вместе с кеглем: рядом с ним кнопки «Выбрать» и
   «Сводка» в 38px, и цель для пальца в 56px ломала бы им строку. */
.mx-help { width: 38px; height: 38px; flex: none; }

/* ---------- Как читать матрицу ------------------------------------------- */

.mx-legend { display: grid; gap: var(--sp-2); }
.mx-legend-row { display: flex; align-items: center; gap: var(--sp-3); }
/* Образец ячейки. Фон, крестик и галочку рисуют те же классы, что и в самой
   матрице, — своих цветов у легенды нет, разойтись ей не с чем. */
.mx-lg-cell {
  width: 52px; height: 34px; flex: none; position: relative;
  display: grid; place-items: center;
  border: 1px solid var(--border); border-radius: var(--r-sm);
  font-size: var(--fs-sm); font-weight: 700;
}
.mx-legend-text { display: flex; flex-direction: column; min-width: 0; }
.mx-legend-text b { font-size: var(--fs-sm); }
.mx-legend-text span { font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.35; }
.mx-legend-sep { height: 1px; background: var(--border); margin: var(--sp-4) 0 var(--sp-3); }
.mx-legend-tips p { font-size: var(--fs-sm); color: var(--text-muted);
                    line-height: 1.45; margin-bottom: var(--sp-2); }
.mx-legend-tips p:last-child { margin-bottom: 0; }
/* В легенде дата стоит рядом с галочкой, а не под ней: образец низкий, и
   вторая строка в нём не помещается. */
.mx-lg-cell .mx-plan-date { display: inline; margin: 0 0 0 3px; }

/* ---------- Календарь ----------------------------------------------------- */

/* Своё небольшое окно вместо <input type="date">: нативный календарь на
   Android выглядит по-разному в каждой прошивке, не знает про наши правила
   (прошедшие дни недоступны) и открывается поверх окна собственным слоем. */
.cal { user-select: none; }
.cal-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.cal-month { flex: 1; text-align: center; font-weight: 700; font-size: var(--fs-md); }
.cal-nav { width: 42px; height: 42px; flex: none; }
/* Стрелка «вперёд» — та же иконка, отражённая: второй символ в спрайте ради
   этого держать незачем. */
.cal-nav.is-next svg { transform: scaleX(-1); }

.cal-week, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-week { margin-bottom: 4px; }
.cal-week span { text-align: center; font-size: var(--fs-xs); font-weight: 600;
                 color: var(--text-dim); }
/* Суббота и воскресенье красным, как в любом календаре: выбрать их можно —
   задание всё равно висит до съёмки, — но менеджеру сразу видно, что в этот
   день снимать никто не выйдет. Класс ставится при отрисовке: в сетке есть
   пустые клетки в начале месяца, и считать выходные по nth-child нельзя —
   смещение каждый месяц своё. */
.cal-week span:nth-child(n+6), .cal-day.is-weekend { color: var(--bad); }
.cal-day.is-weekend.is-picked { color: #fff; }
.cal-day {
  height: 42px; border-radius: var(--r-sm); padding: 0;
  display: grid; place-items: center; line-height: 1;
  font-size: var(--fs-sm); font-weight: 600; color: var(--text);
}
@media (hover: hover) { .cal-day:hover:not(:disabled) { background: var(--surface-hover); } }
/* Прошедшие дни и сегодня недоступны: «запланировать на сегодня» — это просто
   «снимать», и сервер такую дату не принимает. */
.cal-day.is-past { color: var(--text-dim); opacity: .4; cursor: default; }
.cal-day.is-today { box-shadow: inset 0 0 0 1px var(--border-strong); }
.cal-day.is-picked, .cal-day.is-picked:hover {
  background: var(--accent); color: #fff; box-shadow: none;
}
/* Невысокий телефон: шесть рядов чисел плюс пояснение и две кнопки в 660px не
   помещались, и «Отмена» оказывалась наполовину за нижним краем окна. Клетка
   в 36px пальцем берётся всё ещё уверенно. */
@media (max-height: 780px) {
  .cal-day { height: 36px; }
  .cal-head { margin-bottom: 4px; }
}

/* Матрица занимает ровно окно: прокручивается сама таблица, а панель массовых
   действий и плашки остаются на виду. Подбирать высоту таблицы вычитанием
   пикселей нельзя — над ней то появляется, то исчезает плашка, и панель
   массовых действий уезжала под нижний край. */
/* Высоту окна задаём именно .app: .main — flex-элемент с flex-basis: 0, и
   собственная height у него в расчёте не участвует. Без определённой высоты у
   родителя таблица растягивается на все 4500px и уносит панель за край. */
.app:has(.mx-wrap) { height: 100vh; height: 100dvh; }
.main:has(.mx-wrap) {
  display: flex; flex-direction: column; min-height: 0;
  /* Ограничение ширины в 1600px разумно для списка экранов, но матрице оно
     стоит шестнадцати столбцов на мониторе 2560: ширина здесь — это макеты,
     которые видно без прокрутки. */
  max-width: none;
}
.main:has(.mx-wrap) > *:not(.mx-wrap) { flex: none; }

.mx-wrap {
  overflow: auto; flex: 1; min-height: 0;
  border: 1px solid var(--border); border-radius: var(--r-md);
  background: var(--surface);
  overscroll-behavior: contain;
  /* Браузер сам подкручивает прокрутку, когда содержимое выше видимой части
     меняется, — и с виртуализацией строк это шло вразнос: прыжок на середину
     дорисовывал строки, браузер считал это сдвигом содержимого и добавлял к
     прокрутке ещё, кадр за кадром до самого конца списка. Строки мы двигаем
     сами и высоту держим распорками, помощь тут только мешает. */
  overflow-anchor: none;
}

.mx { border-collapse: separate; border-spacing: 0; table-layout: fixed; }

/* Ширина колонки макета одна на всех; закреплённая колонка экранов шире,
   чтобы длинный адрес читался целиком.
   Значения подобраны под данные: адрес доходит до 137 символов, название
   макета — до 62. При 330 и 132 пикселях оба укладываются в четыре строки. */
.mx-col, .mx-cell { width: var(--mx-col-w); min-width: var(--mx-col-w); max-width: var(--mx-col-w); }
.mx-corner, .mx-row { width: var(--mx-row-w); min-width: var(--mx-row-w); max-width: var(--mx-row-w); }

/* Распорки виртуального окна: держат ширину за невидимые столбцы и высоту за
   невидимые строки, чтобы полосы прокрутки соответствовали всем макетам и всем
   экранам, а не отрисованным. Своя высота строки распорке не положена — её
   задаёт ячейка, и в начале списка распорка обязана быть нулевой. */
.mx-pad { padding: 0; border: 0; background: transparent; }
.mx tbody tr.mx-vpad { height: 0; }
.mx-vpad td { padding: 0; border: 0; background: transparent; }

.mx-corner, .mx-row, .mx-col {
  position: sticky; background: var(--surface-2); z-index: 2;
  border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
  padding: 0; text-align: left; font-weight: 600;
}
.mx-corner { left: 0; top: 0; z-index: 4; }
.mx-row    { left: 0; z-index: 3; }
.mx-col    { top: 0; }

.mx-corner-in { padding: var(--sp-2) var(--sp-3); display: flex;
                flex-direction: column; justify-content: flex-end; height: 100%; }
.mx-corner-sub { font-size: var(--fs-xs); color: var(--text-dim); font-weight: 500; }

/* Высота шапки одна на все столбцы и рассчитана на самое длинное название.

   Воздух внутри шапки ужат нарочно: строка охвата («На 12 экранах») стоит
   примерно строки названия, а растить шапку нельзя — на ноутбуке 1366 каждые
   лишние 14px стоят целой строки экранов в таблице. Отступ сверху и снизу 6px
   вместо 8 и промежутки 2px вместо 4 отдают эти пиксели названию: замерено на
   живых данных, обрезанным осталось одно машинное название из 249.

   Отступ по бокам остаётся 8px: от него считается ширина превью, а пропорция
   2:1 обязана сохраниться. */
.mx-col-in { padding: 6px var(--sp-2); display: flex; flex-direction: column;
             gap: 2px; height: var(--mx-col-h); }
/* Галочка и меню лежат поверх превью: отдельная строка управления стоила бы
   ещё одной строки экранов на ноутбуке, где их и так видно четыре. */
.mx-col-top { position: relative; flex: none; }
.mx-col-check {
  position: absolute; left: 3px; top: 3px; width: 18px; height: 18px;
  border-radius: var(--r-sm); border: 2px solid rgba(255, 255, 255, .85);
  background: rgba(0, 0, 0, .5); padding: 0;
}
@media (hover: hover) { .mx-col-check:hover { border-color: var(--accent); } }
/* Галочка в шапке нарочно бесцветная. Цвета в матрице уже заняты: жёлтый —
   ждёт съёмки, зелёный — снято. Если красить ими же «снимаем на всех
   экранах», зелёный начнёт означать две разные вещи. */
.mx-col-check.is-on, .mx-col-check.is-part { background: rgba(0, 0, 0, .6); }
/* Галочка — рамкой, черта частичного состояния — прямоугольником: и то и
   другое дешевле любого символа и не зависит от шрифта. */
.mx-col-check.is-on::after {
  content: ""; position: absolute; left: 4px; top: 0;
  width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
.mx-col-check.is-part::after {
  content: ""; position: absolute; left: 3px; top: 6px;
  width: 7px; height: 2px; background: #fff; border-radius: 1px;
}
.mx-col-menu {
  position: absolute; right: 2px; top: 2px; width: 21px; height: 21px;
  display: grid; place-items: center; border-radius: var(--r-sm);
  color: #fff; background: rgba(0, 0, 0, .6);
  /* Светлая рамка обязательна: превью бывают чёрные, и без неё кнопка на них
     просто исчезает. */
  border: 1px solid rgba(255, 255, 255, .45);
}
@media (hover: hover) { .mx-col-menu:hover { background: rgba(0, 0, 0, .85); border-color: #fff; } }
.mx-col-menu .i { width: 16px; height: 16px; }

/* Ширину меню задавал только `min-width`, а название стояло в одну строку —
   поэтому у макета с длинным названием меню разъезжалось на пол-экрана.
   Верхняя граница есть, название переносится на две строки. */
.col-menu {
  position: fixed; z-index: 1200; min-width: 220px; padding: 4px;
  max-width: min(320px, calc(100vw - 16px));
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--shadow-pop);
}
.col-menu-head {
  padding: var(--sp-2) var(--sp-3) var(--sp-1); font-size: var(--fs-xs);
  color: var(--text-dim); font-weight: 600; line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere;
}
.col-menu button {
  display: block; width: 100%; text-align: left;
  padding: var(--sp-2) var(--sp-3); border-radius: var(--r-sm);
  font-size: var(--fs-sm); color: var(--text);
}
@media (hover: hover) { .col-menu button:hover { background: var(--surface-hover); } }
/* Фильтры за кнопкой «Фильтры» на тесном окне. Строка во всю ширину меню:
   на телефоне попадать пальцем надо в строку, а не в квадратик галочки. */
.filter-menu .check { display: flex; width: 100%; padding: var(--sp-1) var(--sp-3);
                      border-radius: var(--r-sm); color: var(--text); }
@media (hover: hover) { .filter-menu .check:hover { background: var(--surface-hover); } }
.mx-filters { white-space: nowrap; }

/* Разворачивает свёрнутый список в сводке. Выглядит ссылкой, а не кнопкой:
   это не действие над данными, а «показать остальное». */
.mx-more {
  display: block; width: 100%; text-align: left; padding: var(--sp-2) 0;
  color: var(--accent); font-size: var(--fs-sm); font-weight: 600;
}
@media (hover: hover) { .mx-more:hover { text-decoration: underline; } }

.mx-thumb { width: 100%; height: var(--mx-thumb-h); object-fit: fill;
            border-radius: var(--r-sm); background: #0d0f12; display: block; }
/* Пока превью собирается — крутилка, а не чёрный прямоугольник: видно, что
   картинка будет. Когда попытки кончились — перечёркнутый значок и
   всплывашка с причиной: обещать картинку, которой не будет, нельзя. */
.mx-thumb-empty { background: var(--surface-2); display: grid; place-items: center; }
.mx-thumb-empty .i { width: 20px; height: 20px; color: var(--text-dim); }
.mx-thumb-empty .spinner { width: 16px; height: 16px; border-width: 2px; }
.shot-empty.is-failed .i, .mx-thumb-empty.is-failed .i { color: var(--text-muted); }
.mx-col-name {
  flex: 1; font-size: var(--fs-xs); font-weight: 600; line-height: 1.25;
  display: -webkit-box; -webkit-line-clamp: var(--mx-name-lines); -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere;
}
/* Состояние макета прямо в шапке столбца: сколько экранов ещё ждут съёмки.
   Отдельного уведомления о закрытии нет — менеджер видит это над макетом. */
.mx-col-stat { font-size: var(--fs-2xs); font-weight: 700; color: var(--warn); margin-top: 2px; }
.mx-col-stat.is-done { color: var(--ok); }
.mx-col-stat.is-skip { color: var(--text-dim); font-weight: 500; }
.mx-col-stat.is-plan { color: var(--accent); }
/* Отложенная съёмка — второй строкой под состоянием: первая строка обязана
   остаться про сегодняшнюю работу. */
.mx-col-plan { display: block; color: var(--accent); font-weight: 600; }
/* Охват — самой нижней строкой и приглушённо: он отвечает на вопрос клиента,
   а не ставит задачу, и перебивать им состояние нельзя. */
.mx-cover { font-size: var(--fs-2xs); color: var(--text-dim); font-weight: 500;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.col-menu button { display: flex; align-items: center; gap: var(--sp-3); }
/* Текущая дата плана справа в пункте меню: понять, что план уже стоит, надо
   до нажатия, а не после того, как откроется календарь. */
.col-menu-tail { margin-left: auto; color: var(--accent); font-weight: 700; flex: none; }

.mx-col-mgr { font-size: var(--fs-2xs); color: var(--text-dim); font-weight: 500;
              white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* «Без менеджера» — приглушённо-красным: это не ошибка, из-за которой всё
   встало, а пропуск, который надо закрыть. Кричащий красный в столбце, где он
   стоит рядом с настоящими предупреждениями, обесценил бы и те и другие. */
.mx-col-mgr.is-none { color: var(--bad); opacity: .85; }
/* Видеоотчёт в строке экрана: акцентный, чтобы не слиться со счётчиком
   заданий, который жёлтый. */
/* Готовые фразы над полем комментария: ряд кнопок, переносится по ширине.
   Выбранная залита — иначе после нажатия видно только, что текст в поле
   изменился, а какая именно заготовка стоит, приходится сверять глазами. */
/* Полоса «похоже на подбор пароля»: первое, что администратор видит в
   «Состоянии». Красная, а не жёлтая: жёлтым в админке помечено «стоит
   посмотреть», а это «посмотрите сейчас». */
.adm-alarm {
  display: flex; gap: var(--sp-3); align-items: flex-start;
  margin-bottom: var(--sp-4); padding: var(--sp-3);
  border: 1px solid var(--bad); border-radius: var(--r-md);
  background: var(--bad-soft); color: var(--text);
  font-size: var(--fs-sm); line-height: 1.45;
}
.adm-alarm .i { width: 20px; height: 20px; flex: none; color: var(--bad); }
.adm-alarm b { display: block; margin-bottom: 2px; }

/* Список заготовок в админке: поле и крестик в строку. */
.adm-presets { display: flex; flex-direction: column; gap: var(--sp-2);
               max-width: 760px; margin-bottom: var(--sp-2); }
.adm-preset { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.adm-preset .input { flex: 1; min-width: 0; }
/* Пометка «это видеоотчёт» и длительность. Держатся вместе и не растягиваются:
   поле заготовки должно забирать всю оставшуюся ширину, а не делиться ею. */
.adm-preset-video { display: flex; align-items: center; gap: var(--sp-1);
                    white-space: nowrap; font-size: var(--fs-sm); }
.adm-preset-video .input-num { width: 64px; flex: none; }
.adm-preset-video input[type="checkbox"] { margin: 0; }

.mx-presets { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-bottom: var(--sp-2); }
.mx-preset { font-size: var(--fs-2xs); padding: 4px var(--sp-2); }
.mx-preset.is-on { background: var(--accent-soft); color: var(--accent);
                   border-color: var(--accent); }

/* Подпись про видео не должна делать строку выше остальных: высота строки в
   матрице фиксированная, и лишние два пикселя от вертикального поля пускали
   таблицу волнами — 98 пикселей у одних строк и 99 у других. */
.mx-row-video { font-size: var(--fs-2xs); font-weight: 700; color: var(--accent);
                background: var(--accent-soft); border-radius: var(--r-pill);
                padding: 0 6px; white-space: nowrap; }
.mx-col-note { font-size: var(--fs-2xs); color: var(--warn); font-weight: 600;
               white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.mx-row-in { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column;
             gap: 3px; height: 100%; justify-content: center; overflow: hidden; }
.mx-row-name {
  font-size: var(--fs-xs); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.mx-row-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
               font-size: var(--fs-2xs); color: var(--text-dim); font-weight: 500; }
.mx-row-meta b { color: var(--warn); font-weight: 700; }
.mx-row-plan { color: var(--accent); font-weight: 600; }
/* Возраст данных занимает свою строку целиком: рядом со счётчиком он всё
   равно не помещается, а перенос по месту оставлял разделитель висеть. */
.mx-stale { color: var(--text-dim); flex-basis: 100%; }

/* Одна высота у всех строк — требование пользователя. height у строки таблицы
   работает как минимум, поэтому значение подобрано по самой высокой: адрес в
   137 символов занимает четыре строки и вместе со счётчиком просит 97px.
   Проверено замером: все 45 строк одинаковы. */
.mx tbody tr { height: var(--mx-row-h); }
@media (hover: hover) { .mx tbody tr:hover .mx-row { background: var(--surface-hover); } }
.mx tbody tr.is-sel .mx-row, .mx-col.is-sel { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Ячейка: цвет фона — состояние, галочка — флаг менеджера, уголок — комментарий. */
.mx-cell {
  border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
  text-align: center; vertical-align: middle; position: relative;
  cursor: pointer; color: var(--text-dim); font-size: var(--fs-sm);
}
/* Матрица разрежена: макет живёт в среднем на 15 экранах из 45, поэтому
   пустых ячеек большинство. Гасим их сильнее, чтобы взгляд цеплялся за
   заполненные, а не за море прочерков. */
.mx-none { background: var(--bg); cursor: default; color: var(--text-muted);
           opacity: .78; }
:root[data-theme="dark"] .mx-none { color: var(--text-dim); opacity: .55; }
.mx-skip { background: var(--surface); }
.mx-todo { background: var(--warn-soft); }
.mx-done { background: var(--ok-soft); }
/* Макета на экране сейчас нет, но след остался: снимали в прошлом размещении.
   Приглушаем, чтобы такая ячейка не читалась как сегодняшняя задача. */
.mx-gone { opacity: .5; color: var(--text-dim); }
/* Макет ушёл с экрана, а снять не успели. Это не задача — сделать уже ничего
   нельзя, — но и прятать нельзя: это провал в работе, его надо видеть. */
.mx-missed { opacity: .85; background: var(--bad-soft); color: var(--bad); font-weight: 700; }
/* Макет ушёл, а задачи на него не было: снимать и не собирались. Такой же
   крестик, но спокойный — это история размещения, а не провал в работе.
   Отличаться от пустой ячейки он обязан: «тут макета никогда не было» и «висел
   и ушёл неснятым» — разные вещи, и путать их нельзя. */
.mx-left {
  opacity: 1; background: var(--surface-hover);
  color: var(--text-muted); font-weight: 700;
}
/* Съёмка запланирована на будущее число. Синий, а не жёлтый: жёлтый — работа
   на сегодня, и запланированная на две недели вперёд ячейка не должна
   попадаться на глаза как сегодняшняя задача. */
.mx-plan { background: var(--accent-soft); }
.mx-status.is-missed { background: var(--bad-soft); color: var(--bad); font-weight: 600; }
.mx-status.is-plan { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
@media (hover: hover) { .mx-cell:not(.mx-none):hover { filter: brightness(1.06); } }

.mx-box {
  display: inline-block; width: 19px; height: 19px; border-radius: var(--r-sm);
  border: 2px solid var(--border-strong); background: var(--surface);
  position: relative; vertical-align: middle;
}
@media (hover: hover) { .mx-box:hover { border-color: var(--accent); } }
.mx-todo .mx-box, .mx-done .mx-box { border-color: transparent; }
.mx-todo .mx-box { background: var(--warn); }
.mx-done .mx-box { background: var(--ok); }
/* Пунктир — «задание есть, но ещё не началось». Сплошная галочка означала бы
   работу на сегодня, пустая рамка — «не снимаем»; ни то, ни другое не правда. */
.mx-plan .mx-box {
  border-style: dashed; border-color: var(--accent); background: transparent;
}
/* Дата под галочкой. Ячейка узкая, поэтому число мелкое и без года. */
.mx-plan-date {
  display: block; margin-top: 1px; font-size: var(--fs-2xs);
  font-weight: 700; color: var(--accent); line-height: 1;
}
.mx-done .mx-plan-date { color: var(--ok); }
.mx-skip .mx-plan-date { color: var(--text-dim); }
/* Галочка рисуется рамкой — это дешевле любого символа и не зависит от шрифта. */
.mx-todo .mx-box::after, .mx-done .mx-box::after {
  content: ""; position: absolute; left: 5px; top: 1px;
  width: 5px; height: 10px; border: solid #fff;
  border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.has-note::before {
  content: ""; position: absolute; right: 0; top: 0;
  border: 6px solid transparent; border-top-color: var(--accent);
  border-right-color: var(--accent);
}

/* ---------- Карточка ячейки ---------------------------------------------- */

.mx-note-left { margin-top: var(--sp-1); font-size: var(--fs-xs); color: var(--text-dim);
                text-align: right; min-height: 1em; }
.mx-note-left.is-low { color: var(--warn); }

.mx-dl { display: flex; gap: var(--sp-2); font-size: var(--fs-sm);
         margin-bottom: var(--sp-3); }
.mx-dl b { color: var(--text-muted); flex: none; }
.mx-status { padding: var(--sp-2) var(--sp-3); border-radius: var(--r-md);
             background: var(--bg); font-size: var(--fs-sm); margin-bottom: var(--sp-3);
             display: flex; align-items: center; gap: var(--sp-2); }
.mx-status.is-done { background: var(--ok-soft); color: var(--ok); font-weight: 600; }
/* Несделанное видео — тем же цветом, что и задание на карте и в списке
   экранов: одно состояние называется и красится одинаково везде. */
.mx-status.is-video { background: var(--warn-soft); color: var(--warn); font-weight: 600; }
/* Действие внутри плашки: «Вернуть в работу» относится к самой отметке, и
   отдельной кнопкой в сетке действий заводило в карточке лишний ряд. Высоту
   плашки оно не увеличивает — отрицательные поля съедают свой отступ. */
.mx-status-act { margin: -6px -4px -6px auto; padding: 0 var(--sp-2); min-height: 32px;
                 flex: none; border: 0; background: none; cursor: pointer;
                 color: inherit; font: inherit; font-size: var(--fs-sm);
                 text-decoration: underline; text-underline-offset: 3px; }
.mx-status-act:hover { opacity: .75; }
.mx-status-act:disabled { opacity: .5; cursor: default; }
/* Плашка с действием на узком телефоне переносит его на свою строку: обрезать
   имя того, кто снял видео, ради кнопки нельзя. */
.mx-status { flex-wrap: wrap; }
.mx-note { min-height: 76px; padding: var(--sp-2) var(--sp-3); line-height: 1.4; resize: vertical; }

.mx-hist { margin-top: var(--sp-4); border-top: 1px solid var(--border); padding-top: var(--sp-3); }
.mx-hist-title { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase;
                 letter-spacing: .05em; color: var(--text-dim); margin-bottom: var(--sp-2); }
.mx-hist .hist.is-bad time { color: var(--bad); }
/* text-overflow не действует на сам flex-контейнер: обрезать многоточием надо
   внутренний элемент, и ему обязателен min-width: 0. */
.mx-hist .hist span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.mx-hist .hist time { flex: none; }
.mx-hist .mx-hist-title + * { margin-top: 0; }
.mx-hist .mx-hist-title:not(:first-child) { margin-top: var(--sp-3); }


.mx-sum { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.mx-sum-item { flex: 1; min-width: 96px; padding: var(--sp-3); border-radius: var(--r-md);
               background: var(--bg); font-size: var(--fs-xs); color: var(--text-muted);
               display: flex; flex-direction: column; gap: 2px;
               text-align: center; align-items: center; }
.mx-sum-item b { font-size: var(--fs-xl); color: var(--text); }
.mx-sub { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase;
          letter-spacing: .05em; color: var(--text-dim); margin: var(--sp-3) 0 var(--sp-2); }
/* Число не переносится и не сжимается: у адресов вроде «ул. Маерчака, 6, на
   противоположной стороне дороги» на счётчик оставалось меньше его ширины, и
   «111» разваливалось на «11» и «1» под ним. Стоит оно по центру адреса: у
   адреса в две-три строки число, прижатое к первой, читалось как относящееся
   только к ней. */
.mx-line { display: flex; justify-content: space-between; gap: var(--sp-3);
           align-items: center;
           padding: 5px 0; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); }
.mx-line span { min-width: 0; }
.mx-line b { flex: none; white-space: nowrap; }

/* ---------- Матрица на телефоне: плитки макетов ---------------------------- */

/* Таблицу 45x250 на телефон не уместить никакими силами, поэтому там другой
   вид: плитки макетов, а экраны — чек-листом внутри макета. */
.mx-tiles {
  display: grid; gap: var(--sp-2);
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.mx-tile {
  display: flex; flex-direction: column; text-align: left; padding: 0;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); overflow: hidden;
}
.mx-tile.is-sel { outline: 2px solid var(--accent); outline-offset: -2px; }
.mx-tile-img { width: 100%; aspect-ratio: 2 / 1; object-fit: fill;
               background: #0d0f12; display: block; }
/* Тело плитки занимает всю оставшуюся высоту, а всё, кроме названия, прижато
   к низу. Плитки в ряду одной высоты сами (грид растягивает), но содержимое
   раньше шло от верха: у соседних плиток охват и состояние оказывались на
   разных уровнях, и строку приходилось искать глазами в каждой плитке заново.
   Теперь названия сверху, а нижние строки на одной линии — как в шапке
   столбца на ПК. */
.mx-tile-body { padding: var(--sp-2); display: flex; flex-direction: column; gap: 2px;
                flex: 1; }
.mx-tile-body > .mx-tile-name + * { margin-top: auto; }
.mx-tile-name {
  font-size: var(--fs-xs); font-weight: 600; line-height: 1.25;
  overflow-wrap: anywhere;
}

/* Карточка макета разворачивается почти на весь экран: в ней список из
   сорока пяти экранов, и запирать его в маленькое окно бессмысленно. */
.modal-card.is-sheet { max-width: 560px; max-height: 92vh; max-height: 92dvh; }
.mx-media-head { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-3); }
.mx-media-img { width: 132px; aspect-ratio: 2 / 1; object-fit: fill;
                border-radius: var(--r-sm); background: #0d0f12; flex: none; }
.mx-media-meta { display: flex; flex-direction: column; gap: 4px; justify-content: center; }
.mx-media-acts {
  display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-2);
  margin-bottom: var(--sp-3);
}
/* Действия в карточке ячейки: сколько влезло в ряд, столько и стоит. На ПК это
   один ряд из трёх кнопок, на телефоне — два по две. */
.mx-cell-acts {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: var(--sp-2); margin: var(--sp-3) 0;
}
/* На телефоне ровно две в ряд, и нечётная последняя занимает ряд целиком:
   короткая кнопка, прижатая к левому краю под двумя длинными, выглядит
   недоделкой. На ПК все три помещаются в один ряд, и растягивать нечего. */
@media (max-width: 620px) {
  .mx-cell-acts { grid-template-columns: repeat(2, 1fr); }
  .mx-cell-acts .btn:last-child:nth-child(odd) { grid-column: 1 / -1; }
}
.mx-media-acts .btn, .mx-cell-acts .btn {
  min-height: calc(var(--tap) - 6px); padding: 0 var(--sp-2);
  font-size: var(--fs-sm); line-height: 1.2; text-align: center;
}
/* Нечётная последняя кнопка занимает обе колонки: одинокая кнопка в пол-строки
   выглядит недоделкой. */
.mx-media-acts .btn:last-child:nth-child(odd) { grid-column: 1 / -1; }

.mx-screens { margin-top: var(--sp-3); border-top: 1px solid var(--border);
              padding-top: var(--sp-2); }
.mx-screen {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); cursor: pointer;
}
@media (hover: hover) { .mx-screen:hover { background: var(--surface-hover); } }
.mx-screen.is-gone { opacity: .55; }
.mx-screen-box {
  width: 26px; height: 26px; flex: none; border-radius: var(--r-sm);
  border: 2px solid var(--border-strong); background: var(--surface); position: relative;
}
.mx-screen-box.mx-todo { background: var(--warn); border-color: transparent; }
.mx-screen-box.mx-done { background: var(--ok); border-color: transparent; }
.mx-screen-box.mx-todo::after, .mx-screen-box.mx-done::after {
  content: ""; position: absolute; left: 7px; top: 2px;
  width: 6px; height: 12px; border: solid #fff;
  border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.mx-screen-box.is-empty { border-style: dashed; background: transparent; }
/* Запланировано: пунктир цветом задания, но без галочки — снимать будем, ещё
   не сейчас. Пустая рамка рядом означает «макета здесь нет», и спутать их
   нельзя, поэтому цвет разный. */
.mx-screen-box.mx-plan { border-style: dashed; border-color: var(--accent);
                         background: transparent; }
.mx-screen-body { min-width: 0; flex: 1; }
.mx-screen-name { font-size: var(--fs-sm); line-height: 1.3; overflow-wrap: anywhere; }
.mx-screen-note { font-size: var(--fs-xs); color: var(--text-dim); }
.mx-screen-note-flag { flex: none; font-size: var(--fs-xs); }

.col-menu-note { padding: var(--sp-2) var(--sp-3); font-size: var(--fs-xs);
                 color: var(--text-dim); line-height: 1.4; }

/* На телефоне список макетов длинный, и панель действий в конце страницы
   означала бы прокрутку через три сотни плиток к каждой кнопке. */
@media (max-width: 720px) {
  /* Панель матрицы на телефоне занимает три ряда: поиск с фильтрами, два
     списка и кнопки. Каждый ряд в полный рост съедал бы полплитки макета, а
     их на экране высотой 660px и так видно четыре, поэтому ряды здесь ниже
     крупного кегля: он про читаемость макетов, а не про панель над ними. */
  .mx-tools { gap: 6px; --tap: 44px; }
  /* Поиск, «Фильтры» и «Сбросить» делят первый ряд. Своим рядом каждая такая
     кнопка стоила целой плитки макета. Поиск отдаёт им место первым: в него
     набирают несколько букв названия, а нажать надо точно. */
  .mx-tools .search { flex: 1 1 40%; min-width: 100px; max-width: none; order: -1; }
  .mx-reset, .mx-filters { order: -1; flex: 0 0 auto; min-height: 36px;
                           padding: 0 var(--sp-2); }
  /* Два списка занимают ряд ровно пополам. С гибкой основой рядом с ними
     влезала половина галочки «К съёмке», а остальные две уезжали ниже — ряд
     получался рваным, и подпись в списке обрезалась на полуслове. */
  .mx-mgr, .mx-sort { flex: 1 1 calc(50% - 3px); min-width: 0; }
  .mx-tools .check { min-height: 34px; gap: var(--sp-1); font-size: var(--fs-xs); }
  /* Три ряда с понятными группами: поиск, фильтры, действия. Вперемешку они
     цепляются за края и обрезаются уже на 360px. */
  .mx-tools-right { margin-left: 0; flex: 1 1 100%; justify-content: space-between;
                    flex-wrap: wrap; gap: 4px; }
  /* Поля кнопок ужаты: втроём с счётчиком они не помещались в 360px и вылезали
     за край экрана вместе со всей страницей. */
  .mx-tools-right .btn { min-height: 36px; padding: 0 var(--sp-2); flex: 1 1 auto; }
  /* Счётчик занимает свою строку целиком, а кнопки делят следующую поровну.
     Без этого «Архив» не влезал к соседям и оставался один на строке — ряд
     выглядел обрывком, а не рядом. */
  .mx-count-box { flex: 1 1 100%; justify-content: flex-start; }
  .mx-count { font-size: var(--fs-xs); }

  /* Минимум 165px — это ровно две плитки на телефоне: третий столбец начинается
     только с полосы шире 520px, то есть на планшете и в альбомной ориентации.
     На 440px три плитки влезали впритык, и названия ломались по три буквы. */
  .mx-tiles { gap: 6px; grid-template-columns: repeat(auto-fill, minmax(165px, 1fr)); }
  .mx-tile-body { padding: 6px; }
  .mx-tile-name { font-size: var(--fs-2xs); }
  .mx-col-stat { font-size: var(--fs-2xs); }
  .mx-screen-row { padding: 6px var(--sp-2); }
  .mx-screen-name { font-size: var(--fs-xs); }

  /* Панель действий на телефоне съедала треть экрана: шесть кнопок в полный
     рост плюс подпись. Кнопки тут нажимают редко и осознанно, поэтому им
     хватает 38px — а видно за ними на две плитки больше. */
  #mx-bulk .bulk {
    position: fixed; left: var(--sp-2); right: var(--sp-2); margin: 0;
    bottom: calc(var(--sp-2) + var(--safe-bottom)); z-index: 1150;
    padding: var(--sp-2); gap: var(--sp-2);
  }
  body:has(#mx-bulk .bulk) .mx-tiles { padding-bottom: 120px; }
  .bulk-acts { width: 100%; }
  .bulk-acts .btn { flex: 1 1 auto; }
}

/* ---------- Массовые действия -------------------------------------------- */

.bulk {
  position: sticky; bottom: var(--sp-3); z-index: 1150;
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  margin-top: var(--sp-3); padding: var(--sp-2) var(--sp-3);
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--shadow-pop);
}
.bulk-what { font-size: var(--fs-sm); font-weight: 600; }
/* Реальный охват действия рядом с выделением: «выбран 1 экран» и «затронет
   103 ячейки» — это очень разные вещи, и вторую человек должен видеть. */
.bulk-scope { color: var(--text-muted); font-weight: 500; }
/* Кнопки в один ряд без переноса: сколько влезло, столько и показано, лишние
   прячет fitBulkActs() под «Ещё». С переносом ряд молча становился двухэтажным
   и съедал строку экранов на ноутбуке. */
.bulk-acts { margin-left: auto; display: flex; gap: var(--sp-2);
             flex: 1 1 auto; min-width: 0; justify-content: flex-end;
             flex-wrap: nowrap; overflow: hidden; }
/* Кнопки не ужимаются: подпись у них не переносится, и при нехватке места
   они не сужались, а обрезали текст. Пусть лучше лишние уйдут под «Ещё» —
   это решает fitBulkActs() по фактической ширине. */
.bulk-acts .btn { flex: none; min-height: calc(var(--tap) - 8px);
                  padding: 0 var(--sp-3); font-size: var(--fs-sm); }

/* Контейнер уведомлений сквозной для мыши, иначе он перехватывал бы клики по
   странице. Кнопке «Отменить» события нужны — возвращаем их точечно. */
.toast-undo { background: var(--surface); color: var(--text);
              border: 1px solid var(--border-strong); pointer-events: auto; }
.toast-btn { margin-left: auto; padding: 0 var(--sp-2); min-height: 30px;
             border-radius: var(--r-sm); color: var(--accent); font-weight: 700;
             font-size: var(--fs-sm); }
@media (hover: hover) { .toast-btn:hover { background: var(--accent-soft); } }

/* ---------- Телефон: плотность матрицы ----------------------------------- */
/* Блок стоит последним намеренно: правила выше объявлены позже по файлу и с
   той же специфичностью, поэтому в середине они бы просто не действовали.
   Матрица на телефоне — вспомогательный вид, элементов в ней много, и каждый
   лишний пиксель отступа стоит доли плитки на экране высотой 660px. */
@media (max-width: 720px) {
  .mx-media-acts { gap: 6px; }
  .mx-media-acts .btn { min-height: 40px; font-size: var(--fs-xs); }

  .bulk-what { font-size: var(--fs-xs); }
  /* Шесть действий тремя колонками вместо трёх рядов: панель перестаёт
     занимать треть экрана, а промахнуться по кнопке в 38px нельзя. */
  .bulk-acts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
  .bulk-acts .btn { min-height: 38px; font-size: var(--fs-xs); padding: 0 4px; }
}

/* Экран уже 380px: две плитки по 165px туда не встают, а по одной в ряд листать
   двести сорок макетов невозможно. Ширину задаём долями, а не минимумом. */
@media (max-width: 380px) {
  .mx-tiles { grid-template-columns: repeat(2, 1fr); }
}

/* ---------- Админка ------------------------------------------------------- */

/* Разделы слева, содержимое справа. Список разделов прилипает к верху: в
   «Логах» и «Экранах» страница длинная, и уходить к началу ради перехода в
   соседний раздел человеку незачем. */
.adm-wrap { display: flex; gap: var(--sp-4); align-items: flex-start; }
.adm-nav { flex: 0 0 176px; display: flex; flex-direction: column; gap: 2px;
           position: sticky; top: var(--sp-3); }
.adm-nav button {
  appearance: none; border: 0; background: none; cursor: pointer;
  font: inherit; color: var(--text-muted); text-align: left;
  padding: var(--sp-2) var(--sp-3); border-radius: var(--r-md);
  min-height: calc(var(--tap) - 8px);
}
/* pointer: fine обязателен вместе с hover. На телефоне и в режиме эмуляции
   браузер отвечает «hover есть», состояние наведения залипает после нажатия, и
   соседние разделы оставались подсвеченными серым — выглядело так, будто
   выбрано сразу три. */
@media (hover: hover) and (pointer: fine) {
  .adm-nav button:hover { background: var(--surface-hover); color: var(--text); }
}
.adm-nav button[aria-selected="true"] {
  background: var(--accent-soft); color: var(--accent); font-weight: 600;
}
.adm-main { flex: 1; min-width: 0; }
/* Раздел рисуется из прошлой картины и остаётся на месте, пока едут свежие
   данные: в админке за триста миллисекунд ничего не меняется, а моргание при
   каждом переключении раздела воспринималось как «тормозит». Полосы загрузки
   поверх раздела нет — см. admBusy в app.js. */
.adm-main { position: relative; }
/* 80ch, а не 60: при 60 подпись под заголовком «Экраны» рвалась на три
   строки посреди пустого места справа — читалась она от этого не лучше, а
   выглядела недоделкой. Ограничение всё равно нужно: строка во всю ширину
   монитора теряется глазом при переходе на следующую. */
.adm-note { font-size: var(--fs-xs); color: var(--text-muted);
            margin-bottom: var(--sp-2); max-width: 80ch; }
/* Номер строки крупнее и контрастнее остального текста в ней: по нему ищут
   место в списке, а не читают его как подпись. */
.adm-num { flex: 0 0 26px; text-align: center; font-size: var(--fs-sm);
           font-weight: 700; color: var(--text-muted);
           font-variant-numeric: tabular-nums; }

/* ---------- Перетаскивание строк ----------------------------------------- */
/* Ручка из шести точек вместо стрелок «выше/ниже»: переставить экран на
   двадцать позиций стрелками — двадцать нажатий, а на телефоне они ещё и
   съедали всю ширину строки. Тянуть можно только за ручку — палец по самой
   строке остаётся прокруткой страницы. */
/* Выделение снимаем со строки насовсем, а не в момент жеста. На iPhone долгое
   нажатие начинает выделять текст и предлагать «Копировать» раньше, чем палец
   сдвинулся, — а правило, повешенное на body уже после начала жеста, начатое
   выделение не отменяет. Строку в списке порядка всё равно не читают
   выделением: её тянут. */
.adm-row-drag {
  position: relative;
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
}
.drag-grip {
  flex: 0 0 auto; display: grid; grid-template-columns: repeat(2, 4px);
  gap: 3px; padding: var(--sp-2) 6px; margin-left: -4px;
  cursor: grab; touch-action: none; color: var(--text-dim);
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
  -webkit-user-drag: none;
}
.drag-grip i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; }
@media (hover: hover) { .drag-grip:hover { color: var(--text); } }
.adm-row.is-dragging {
  position: fixed; z-index: 1300; cursor: grabbing;
  background: var(--surface); border: 1px solid var(--accent);
  border-radius: var(--r-md); box-shadow: var(--shadow-pop);
  pointer-events: none; opacity: .96;
}
.adm-row.is-placeholder {
  background: var(--accent-soft); border: 1px dashed var(--accent);
  border-radius: var(--r-sm);
}
/* Пока строку тянут, выделение мешает и на остальной странице: палец уезжает
   со строки, и выделяется уже соседний текст. Со строк выделение снято
   постоянно (см. .adm-row-drag) — здесь только всё вокруг. */
body.is-sorting { -webkit-user-select: none; user-select: none; cursor: grabbing; }

/* Действия строки под одной кнопкой: на телефоне две-три кнопки отъедали всю
   ширину, и от адреса конструкции оставалось четыре символа. */
.row-menu-btn { flex: none; }
.row-menu { min-width: 160px; }

/* Точка состояния перед названием экрана: раньше она стояла отдельным
   столбцом между текстом и кнопками и на узком экране терялась.

   inline-block обязателен. Внутри <b> строчный <span> остаётся строчным, а
   строчному элементу ширина и высота не назначаются вовсе — круг не рисовался,
   и от него оставался только необъяснимый отступ перед названием. */
.adm-cell b .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }

/* На узком экране разделы становятся лентой поверху: колонка в 176 пикселей
   отняла бы половину ширины телефона у самого содержимого. */
@media (max-width: 860px) {
  .adm-wrap { flex-direction: column; gap: var(--sp-3); }
  /* flex-basis из настольного правила задан для колонки, а в столбце он
     означает высоту: список разделов вырастал до 176 пикселей и съедал
     треть экрана телефона пустотой. */
  /* Разделы идут одной полосой с прокруткой вбок. Вторая строка стоила целой
     строки содержимого, а разделов семь и появятся ещё.

     Прежняя беда прокрутки — обрезанные слова («остояние», «Курнал») — была не
     в самой прокрутке, а в том, что подводил ленту браузер: scrollIntoView
     ставит вкладку по центру, обрезая соседей пополам. Теперь лента
     доводится сама, до ближайшего края и с полем (см. renderAdminTab), так что
     выбранное слово целиком видно всегда.

     Тень у края показывает, что справа есть ещё: без неё полоса выглядит
     законченной, и до «Обслуживания» никто не долистывает. Рисуется четырьмя
     фонами — два цвета фона едут вместе с содержимым и закрывают тень, когда
     докрутили до края. */
  .adm-nav { position: static; flex: 0 0 auto; flex-direction: row; width: 100%;
             align-items: center; flex-wrap: nowrap; gap: var(--sp-1);
             overflow-x: auto; overflow-y: hidden; padding-bottom: 2px;
             scrollbar-width: none; -webkit-overflow-scrolling: touch;
             background:
               linear-gradient(to right, var(--bg) 40%, transparent) left center / 22px 100% no-repeat local,
               linear-gradient(to left, var(--bg) 40%, transparent) right center / 22px 100% no-repeat local,
               radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, .16), transparent) left center / 11px 100% no-repeat scroll,
               radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, .16), transparent) right center / 11px 100% no-repeat scroll; }
  .adm-nav::-webkit-scrollbar { display: none; }
  .adm-nav button { flex: 0 0 auto; white-space: nowrap;
                    -webkit-tap-highlight-color: transparent; }
  .adm-main { width: 100%; }
}

.adm-block { margin-bottom: var(--sp-6); }
/* Заголовок подраздела отбивает его от предыдущего: без этого на телефоне всё
   шло встык и читалось как одна длинная простыня. Первый заголовок в разделе
   отступа сверху не получает — он и так первый. */
.adm-head {
  display: flex; align-items: baseline; gap: var(--sp-3);
  margin: var(--sp-6) 0 var(--sp-3);
  padding-top: var(--sp-4); border-top: 1px solid var(--border);
}
.adm-main > .adm-head:first-child,
.adm-block > .adm-head:first-child,
#rep-body > .adm-head:first-child {
  margin-top: 0; padding-top: 0; border-top: 0;
}
.adm-head h2 { font-size: var(--fs-md); font-weight: 700; flex: 1; }
/* Счётчик — часть самого заголовка, а не сосед в блоке кнопок. Выравнивание по
   базовой линии считается по внешнему элементу, а не по тексту внутри него:
   лежащий в .adm-acts счётчик вставал по базовой линии этого блока и сидел
   заметно выше слова «Экраны». Внутри h2 линия одна на обоих. */
.adm-head-sub { margin-left: var(--sp-2); font-size: var(--fs-xs);
                font-weight: 400; color: var(--text-muted); white-space: nowrap; }
.adm-head .adm-sub { flex: none; }
.adm-list { border: 1px solid var(--border); border-radius: var(--r-md);
            background: var(--surface); overflow: hidden; }
.adm-row { display: flex; align-items: center; gap: var(--sp-3);
           padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); }
.adm-row:last-child { border-bottom: 0; }
.adm-row.is-off { opacity: .55; }
.adm-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.adm-cell b { font-size: var(--fs-sm); overflow-wrap: anywhere; }
.adm-sub { font-size: var(--fs-xs); color: var(--text-muted); overflow-wrap: anywhere; }
/* Ошибки вызовов в строке экрана: единственное, ради чего в эту строку вообще
   заглядывают, — значит и видно должно быть без чтения. */
.adm-bad { color: var(--bad); font-weight: 700; }
.adm-acts { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }

/* Сеансы: человек сверху, его устройства под ним со сдвигом. Раньше все входы
   шли одной слипшейся строкой через палочку, и было не понять, где кончается
   одно устройство и начинается другое. */
.adm-row-head { border-bottom: 0; padding-bottom: 0; }
/* Свёрнутый человек: строка целиком нажимается, шеврон показывает состояние.
   Устройства нужны редко — обычно ответ на вопрос «кто сейчас в системе», а
   не «с какого он браузера», — а разворачивали они раздел на два экрана. */
.adm-row-head.is-fold { cursor: pointer; padding-bottom: var(--sp-2); }
@media (hover: hover) { .adm-row-head.is-fold:hover { background: var(--surface-hover); } }
.fold-mark { flex: none; display: grid; place-items: center; width: 18px; height: 18px;
             color: var(--text-dim); transform: rotate(-90deg); transition: transform .12s; }
.adm-row-head.is-open .fold-mark { transform: rotate(90deg); }
.fold-mark .i { width: 14px; height: 14px; }
.adm-row-sub { padding-left: var(--sp-5); border-bottom: 0; }
.adm-row-sub + .adm-row-head { margin-top: var(--sp-3);
                               border-top: 1px solid var(--border); padding-top: var(--sp-3); }
.adm-device { display: flex; align-items: center; gap: var(--sp-2);
              font-size: var(--fs-sm); font-weight: 600; }
.adm-tag { padding: 1px 6px; border-radius: var(--r-pill); font-size: var(--fs-2xs);
           font-weight: 700; background: var(--accent-soft); color: var(--accent); }
.btn-sm { min-height: calc(var(--tap) - 14px); padding: 0 var(--sp-3);
          font-size: var(--fs-xs); }
.adm-bar { height: 8px; border-radius: var(--r-pill); background: var(--surface-2);
           overflow: hidden; margin-bottom: var(--sp-2); }
.adm-bar span { display: block; height: 100%; background: var(--ok); }
.adm-bar.is-warn span { background: var(--warn); }

/* Плитки сводки: крупное число и подпись под ним. Ширину задаём сеткой с
   автоподбором — на 1366 их встаёт три в ряд, на телефоне две, и ни одна не
   растягивается в полосу через весь экран. */
.stats { display: grid; gap: var(--sp-2); margin-bottom: var(--sp-4);
         grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
/* justify-content обязателен: плитки в ряду одной высоты, а подписи разной
   длины («Свободно на диске» в одну строку, «Свободно оперативной памяти» в
   две). Без него содержимое прижималось к верху, и в высокой плитке число
   стояло заметно выше, чем в соседней. */
.stat { background: var(--surface); border: 1px solid var(--border);
        border-radius: var(--r-md); padding: var(--sp-4) var(--sp-3);
        display: flex; flex-direction: column; align-items: center;
        justify-content: center; gap: var(--sp-1); text-align: center; }
.stat b { font-size: var(--fs-xl); line-height: 1.1; }
.stat span { font-size: var(--fs-xs); color: var(--text-muted); }
.stat.is-ok b { color: var(--ok); }
.stat.is-warn b { color: var(--warn); }
.stat.is-bad b { color: var(--bad); }

/* Полоса места: одна линия на весь лимит, внутри цветные доли, ниже
   расшифровка числами. Проценты не пишем — «3%» от двадцати гигабайт человеку
   не говорят ничего, а «650 МБ из 20 ГБ» говорят. */
.st-wrap { margin-bottom: var(--sp-4); }
.st-bar { display: flex; height: 12px; border-radius: var(--r-pill);
          background: var(--surface-2); overflow: hidden;
          border: 1px solid var(--border); }
.st-part { display: block; height: 100%; min-width: 2px; }
/* Легенда прижата к полосе: она её расшифровка, а не отдельный блок. */
.st-legend { display: flex; flex-wrap: wrap; gap: 4px var(--sp-3);
             margin: 6px 0 var(--sp-3); font-size: var(--fs-xs);
             color: var(--text-muted); }
.st-item { display: inline-flex; align-items: center; gap: 6px; }
.st-dot { width: 9px; height: 9px; border-radius: 3px; display: inline-block; }
.st-program    { background: #6b7280; }
.st-thumbnails { background: var(--accent); }
.st-database   { background: var(--ok); }
.st-logs       { background: var(--warn); }
.st-backups    { background: #8b5cf6; }
.st-other      { background: var(--border-strong); }
.adm-warn { font-size: var(--fs-sm); color: var(--warn-ink);
            background: var(--warn-soft); border-radius: var(--r-md);
            padding: var(--sp-2) var(--sp-3); margin-top: var(--sp-2); }
.adm-acts-left { justify-content: flex-start; margin-top: var(--sp-2); }
/* «Сохранить» — по центру и с воздухом: это итог всей формы, а не действие
   над соседней строкой. */
.adm-acts-save { justify-content: center; margin-top: var(--sp-5);
                 padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.adm-acts-save .btn { min-width: 200px; }

/* Полосы над содержимым: объявление админа и «вызов сейчас закрыт». Не
   всплывашка — эти сообщения должны висеть, пока состояние не изменится, и
   переживать переход между вкладками. */
.notices { display: flex; flex-direction: column; gap: var(--sp-2);
           padding: var(--sp-2) var(--sp-4) 0; }
.notice-say { background: var(--accent-soft); color: var(--text); }
.notice-say svg { color: var(--accent); }
/* Кнопка вызова вне рабочих часов: видно, что она есть, но нажать нельзя.
   Прятать её нельзя — исчезнувшая кнопка выглядит поломкой. */
body.is-triggers-closed .card:not(.is-shot) { opacity: .85; }

@media (max-width: 700px) {
  .notices { padding: var(--sp-2) var(--sp-3) 0; }
}

/* Форма настроек: два поля в ряд на широком экране, одно на телефоне.
   Ячейки одной строки тянутся по самой высокой — иначе поле под длинной
   подписью, переехавшей на вторую строку, уезжает вниз относительно соседа, и
   ряд выглядит рваным. */
.fields2 { display: grid; gap: 0 var(--sp-3); align-items: end;
           grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
/* Настройки читают глазами сверху вниз, а не поперёк. Строка ввода во всю
   ширину монитора 2560 — это поле, конец которого приходится искать: форму
   держим в разумной колонке, как во всех деловых сервисах. */
.adm-main .field, .adm-main .adm-note, .adm-main .fields2 { max-width: 760px; }
/* Поля времени узкие: разносить их по разным рядам — зря терять высоту.
   Ровно две колонки, а не auto-fit: браузер считает число повторов по
   ВЕРХНЕЙ границе minmax, и на телефоне колонка оставалась одна — «Начало» и
   «Конец» вставали друг под друга там, где места хватало обоим. */
.fields-time { grid-template-columns: repeat(2, minmax(0, 200px)); }
/* Ряд коротких числовых полей: «Лимит, ГБ», «Вызовы, мес.». На телефоне они
   вставали по одному на строку и занимали шесть экранов высоты при подписи в
   два слова. Ровно две колонки, а не auto-fit: браузер считает повторы по
   верхней границе minmax и на узком экране всё равно оставил бы одну. */
@media (max-width: 720px) {
  .fields2.fields-narrow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.input-num { max-width: 200px; }
/* На телефоне узкие поля не нужны: ширина и так одна, а разнобой краёв делает
   форму рваной — «Начало» до половины, «Разово открыть» до двух третей,
   объявление во всю ширину. */
@media (max-width: 720px) {
  .input-num, .dt-field, .dt-pair { max-width: 100%; }
  /* Дата и время в один ряд и на телефоне: по строке на каждое — это половина
     экрана под одну настройку. */
  .tm-wrap { width: 132px; }

  /* Строка списка на телефоне: место отдаётся тексту, а не полям вокруг него.
     Замерено на iPhone 17 (окно 402): из 384 пикселей строки адресу
     конструкции доставалось 245 — 64%, остальное съедали отступы, ручка
     перетаскивания, номер и зазоры между ними. Здесь всё это ужато до
     предела, при котором ручку ещё можно взять пальцем. */
  .adm-row { gap: var(--sp-2); padding: var(--sp-2) var(--sp-2); }
  .drag-grip { padding: var(--sp-2) 3px; margin-left: -3px; }
  .adm-num { flex: 0 0 20px; }
  /* Строка сеанса со сдвигом: на телефоне сдвиг в 20 пикселей читается так же,
     как в 32, а те двенадцать нужны тексту. */
  .adm-row-sub { padding-left: var(--sp-4); }

  /* Заголовок раздела с кнопками. Одна кнопка стоит справа от заголовка — там
     же, где на ПК: «Добавить» под словом «Пользователи» выглядела оторванной
     от него, будто относится к списку ниже.

     Две кнопки в одну строку с заголовком на телефоне не встают: «Экраны» и
     «По алфавиту» с «Добавить» рядом ужимаются до нечитаемого. Только этот
     случай и переносится — заголовок занимает свою строку, кнопки свою. */
  .adm-head .btn { white-space: nowrap; }
  .adm-head:has(.adm-acts .btn + .btn) { flex-wrap: wrap; }
  .adm-head:has(.adm-acts .btn + .btn) h2 { flex: 1 0 100%; }
  .adm-head:has(.adm-acts .btn + .btn) .adm-acts { flex: 1 0 100%;
                                                   justify-content: flex-start; }
  .adm-head:has(.adm-acts .btn + .btn) .adm-acts .btn { flex: 1; }
}
/* Свои поля даты и времени вместо родных.

   Родные рисует операционная система: на живом iPhone они выглядят иначе, чем
   в любой эмуляции на настольном браузере, и распирают страницу вбок — поле
   требует своей ширины и сжаться не может. Свои ведут себя одинаково везде,
   и то, что видно в прогоне, совпадает с тем, что видит человек. */
.dt-field {
  display: flex; align-items: center; cursor: pointer; text-align: left;
  max-width: 320px;
}
.dt-field.is-empty { color: var(--text-muted); }
@media (hover: hover) and (pointer: fine) {
  .dt-field:hover { border-color: var(--accent); }
}
.dt-pair { display: flex; gap: var(--sp-2); align-items: center; max-width: 400px; }
.dt-pair .dt-field { flex: 1; min-width: 0; }

/* Время: поле с маской и получасовые значения списком. Рабочие часы почти
   всегда круглые, и набирать «09:00» руками ради этого незачем. */
.tm-wrap { position: relative; display: flex; align-items: center;
           width: 150px; flex: none; }
.tm-input { width: 100%; padding-right: 38px; }
/* Часы стоят внутри поля, а не рядом: снаружи они читались как отдельная
   кнопка неизвестно к чему, особенно когда полей два в ряд. */
.tm-open { position: absolute; right: 2px; top: 50%; transform: translateY(-50%);
           width: 34px; height: 34px; flex: none; color: var(--text-muted); }
.tm-list {
  position: absolute; z-index: 40; top: calc(100% + 4px); left: 0;
  width: 110px; max-height: 240px; overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--shadow-pop); padding: 4px;
}
.tm-opt {
  display: block; width: 100%; text-align: left; cursor: pointer;
  padding: 6px var(--sp-2); border: 0; background: none; color: var(--text);
  font: inherit; font-size: var(--fs-sm); border-radius: var(--r-sm);
  min-height: 34px;
}
@media (hover: hover) and (pointer: fine) {
  .tm-opt:hover { background: var(--surface-hover); }
}
/* Дни недели — чипами, как в CityScreen: выбранные залиты, невыбранные
   обведены пунктиром. Галочка внутри чипа была лишней — состояние и так видно,
   а из семи квадратиков подряд получалась рябь. Сам input остаётся: он хранит
   состояние и отвечает за клавиатуру, просто не показывается. */
.check-days { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
/* Селектор двойной намеренно. Чип — это <label>, и он лежит внутри .field, а
   там уже есть правило `.field label` для подписи над полем: блочное
   отображение, отступ снизу и ЗАГЛАВНЫЕ буквы. Оно старше одиночного
   `.check-day` по весу и перебивало его целиком — отсюда «ПН» вместо «Пн» и
   буква, прижатая к верху чипа: центрировать было нечего, чип переставал быть
   флекс-контейнером. */
.check-days .check-day {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 46px; padding: 6px var(--sp-3); margin: 0;
  border: 1px dashed var(--border-strong); border-radius: var(--r-md);
  background: transparent; color: var(--text-dim);
  font-size: var(--fs-sm); font-weight: 600; cursor: pointer; line-height: 1;
  text-transform: none; letter-spacing: normal;
  min-height: calc(var(--tap) - 12px); user-select: none;
}
/* Сам input не показывается, но остаётся: он хранит состояние и отвечает за
   клавиатуру. position: relative у чипа обязателен — иначе абсолютный input
   считается от страницы, а не от чипа. */
.check-days .check-day { position: relative; }
.check-days .check-day input { position: absolute; opacity: 0; width: 0; height: 0; }
.check-days .check-day.is-on {
  border-style: solid; border-color: var(--accent);
  background: var(--accent-soft); color: var(--accent);
}
.check-days .check-day:has(input:focus-visible) {
  outline: 2px solid var(--accent); outline-offset: 2px;
}

/* Выход из пустой таблицы — прямо в ней. Кнопка стоит под объяснением, а не
   сбоку: человек сначала читает, почему пусто, и только потом ищет, что
   нажать. */
.empty .mx-show-all { display: block; margin: var(--sp-3) auto 0; }

/* ---------- Логи ---------------------------------------------------------- */
/* Одна лента по времени: входы, правки матрицы, вызовы роликов, люди и ошибки.
   Ярлык слева окрашен по типу — тип видно, не читая текст, а глазами по ленте
   идут именно за типом («где тут ошибки»). */

.log-kinds { flex-wrap: wrap; margin-bottom: var(--sp-2); }
/* Место под кнопкой «Показать ещё» зарезервировано: без этого блок то
   появлялся, то исчезал, и страница дёргалась под пальцем. */
.log-more { min-height: calc(var(--tap) + var(--sp-3)); display: flex;
            align-items: center; justify-content: center; }
.log-more-wait { display: grid; place-items: center; height: var(--tap); }
.log-tools { display: flex; gap: var(--sp-2); align-items: center;
             flex-wrap: wrap; margin-bottom: var(--sp-2); }
.log-search { flex: 1 1 220px; min-width: 160px; max-width: 420px; }
.log-dates { display: flex; gap: var(--sp-2); align-items: center;
             color: var(--text-muted); font-size: var(--fs-xs); }
.log-date { width: 150px; }
.log-row { display: flex; align-items: flex-start; gap: var(--sp-3);
           padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
           flex-wrap: wrap; }
.log-row:last-child { border-bottom: 0; }
.log-tag { flex: 0 0 auto; min-width: 74px; text-align: center;
           font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .04em;
           padding: 3px 6px; border-radius: var(--r-sm); font-weight: 700;
           background: var(--surface-2); color: var(--text-muted); margin-top: 2px; }
.tag-auth     { background: var(--accent-soft); color: var(--accent); }
.tag-matrix   { background: var(--ok-soft); color: var(--ok); }
.tag-triggers { background: var(--tag-call-bg); color: var(--tag-call-ink); }
.tag-users    { background: var(--warn-soft); color: var(--warn-ink); }
.tag-errors   { background: var(--bad-soft); color: var(--bad); }
.log-row.is-bad b { color: var(--bad); }
/* Свёрнутая пачка раскрывается по нажатию — значит, должна выглядеть нажимаемой. */
.log-row.is-bulk { cursor: pointer; }
@media (hover: hover) { .log-row.is-bulk:hover { background: var(--surface-hover); } }
.log-count { color: var(--text-dim); font-weight: 400; }
.log-details { flex: 1 0 100%; margin-top: var(--sp-2); padding-left: 86px;
               display: flex; flex-direction: column; gap: 2px;
               max-height: 260px; overflow-y: auto; }
.log-detail { font-size: var(--fs-xs); color: var(--text-muted); }
.log-more { display: flex; justify-content: center; padding: var(--sp-3) 0; }

/* На телефоне ярлык уже, чем на ПК: 74 пикселя слева отняли бы у фразы треть
   ширины, и она рвалась бы на четыре строки.

   Но ширина всё-таки фиксированная. Пока её не было вовсе, «ВХОД» и «ВЫЗОВЫ»
   занимали разное место, и текст каждой строки начинался со своего отступа —
   лента выглядела рваной, хотя все строки устроены одинаково. Владелец выбрал
   этот вариант из трёх по снимкам (`scripts/log_variants.py`). */
@media (max-width: 560px) {
  .log-row { gap: var(--sp-2); }
  .log-tag { min-width: 62px; width: 62px; flex: 0 0 62px; padding: 3px 4px;
             overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .log-details { padding-left: 70px; }
  .log-date { width: 100%; }
  .log-dates { flex: 1 1 100%; }
}

/* ---------- Полоса «Сервис обновился» ------------------------------------- */
/* Появляется, когда код на сервере обновили, а вкладка открыта со старой
   сборкой. Не выдёргивает человека: он дожимает своё дело и обновляется сам.
   Снизу, а не сверху: сверху у отчётчика шапка экрана и фильтры, и полоса
   сдвигала бы то, во что он сейчас целится пальцем. */
/* Сервис обновился, пока вкладка была открыта.
   Раньше это была скруглённая до таблетки плашка обычным весом текста — в
   интерфейсе, где нигде больше нет таких скруглений, она читалась как чужая, и
   при этом на важное сообщение не тянула. Теперь это заметная полоса с
   фирменным скруглением, значком и полужирным текстом: пропустить её нельзя,
   но она и не перекрывает работу — стоит внизу и уходит по нажатию. */
.update-bar {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--sp-4) + var(--safe-bottom) + var(--offline-h, 0px));
  z-index: 1400; display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
  border-radius: var(--r-md); border: 1px solid var(--accent);
  background: var(--accent-soft); color: var(--text);
  font-size: var(--fs-sm); font-weight: 600; box-shadow: var(--shadow-pop);
  max-width: calc(100% - var(--sp-5));
  animation: update-in .18s ease-out;
}
.update-bar .i { width: 18px; height: 18px; color: var(--accent); flex: none; }
.update-bar span { min-width: 0; }
@keyframes update-in {
  from { opacity: 0; transform: translate(-50%, 8px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}
/* Тем, кто просил не анимировать интерфейс, показываем сразу. */
@media (prefers-reduced-motion: reduce) { .update-bar { animation: none; } }

/* ---------- Финишная строка списка макетов -------------------------------- */
/* Отчётчик листает сотню карточек подряд, и главный его вопрос — «я закончил?».
   Строка стоит в самом конце списка и отвечает на него прямо. Число живое:
   сняли кадр — уменьшилось, приехал новый макет — выросло. */
/* Запас под списком: уведомление о вызове висит у нижнего края окна и садилось
   ровно на эту строку — главный ответ «осталось снять» закрывался как раз в тот
   момент, когда он меняется. Двигать уведомление нельзя, оно общее для всего
   сервиса; проще не дать строке доехать до самого низа. */
#media-list { padding-bottom: 72px; }
.finish {
  grid-column: 1 / -1;
  margin-top: var(--sp-3);
  padding: var(--sp-3);
  border-radius: var(--r-md);
  text-align: center;
  font-size: var(--fs-sm); font-weight: 700;
  border: 1px solid var(--border);
}
.finish.is-done { background: var(--ok-soft); color: var(--ok); border-color: transparent; }
.finish.is-left { background: var(--warn-soft); color: var(--warn); border-color: transparent; }

/* Строка над списком: на экране есть комментарии. Отчётчик листает сотню
   карточек, и знать заранее, что читать вообще есть что, дешевле, чем
   обнаружить это на середине. */
.note-hint {
  display: flex; align-items: center; gap: var(--sp-2);
  margin-bottom: var(--sp-3); padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-md); border: 1px solid var(--warn);
  background: var(--warn-soft); color: var(--warn-ink, var(--text));
  font-size: var(--fs-sm); font-weight: 600;
}
.note-hint .i { width: 18px; height: 18px; flex: none; color: var(--warn); }
/* Та же строка в конце списка. Плитками список идёт сеткой, поэтому строка
   обязана занимать её целиком, иначе встанет отдельной колонкой. */
.note-hint-foot { grid-column: 1 / -1; margin-top: var(--sp-3); }
.note-hint-foot .note-hint, .note-hint-foot .video-hint { margin-bottom: 0; }

/* Задание на видеоотчёт. Заметнее комментариев нарочно: комментарий можно
   прочитать по ходу, а видео либо снято, либо человек уехал без него, и
   вернуться за ним стоит нового выезда через весь город. Поэтому синяя
   акцентная плашка с кнопкой, а не жёлтая подсказка. */
.video-hint {
  display: flex; align-items: center; gap: var(--sp-2);
  margin-bottom: var(--sp-3); padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-md); border: 1px solid var(--accent);
  background: var(--accent-soft); color: var(--text);
  font-size: var(--fs-sm); font-weight: 700;
}
.video-hint .i { width: 20px; height: 20px; flex: none; color: var(--accent); }
/* Кнопка прижимается к правому краю: текст слева, действие справа — так же,
   как в любой строке со списком. */
.video-hint .btn { margin-left: auto; flex: none; }

/* ---------- Карта: швы между квадратами ----------------------------------- */
/* Здесь было перекрытие тайлов на пол-пикселя (width: 256.5px) — обычный совет
   против швов. Проверено снимками: оно эти швы и рисовало. Растянутый на
   полпикселя тайл браузер пересчитывает, край размывается, и на стыке
   появляется светлая линия — ровно та, на которую жаловались. При честных
   256px стыков нет.
   Настоящая причина редких полосок другая: битый или недокачанный квадрат,
   осевший в кэше браузера (у владельца они и пропали после чистки кэша).
   Лечится в service worker: в кэш кладём только успешно скачанные квадраты. */

/* Кнопка сброса фильтров появляется только когда фильтр включён, поэтому в
   обычном виде панель не шире прежней. Подпись короткая: на ноутбуке 1366
   панель обязана оставаться в один ряд — вторая строка стоит целой строки
   экранов в таблице, а их там видно всего три. */
.mx-reset { flex: none; white-space: nowrap; }
