/* Shared content, overlays and responsive rules.
 *
 * Sections 6–8 of the original sheet, plus the Telegram contact widget and the
 * cookie notice. Loaded by all four surfaces, after `app.css` where that exists:
 * the responsive block must stay last, exactly as it was.
 */

/* 6. Content blocks ====================================================== */

.page-heading {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px 16px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}

/* Allow the title and explanatory text to retain a readable width on
   intermediate viewports (~768px) rather than collapsing into a narrow vertical
   column beside a wide group of actions. When actions require more space than
   remains, wrapping drops the entire action group below. */
.page-heading > div:first-child,
.page-heading > :first-child:not(.heading-actions) {
  flex: 1 1 320px;
  min-width: min(100%, 280px);
}

/* The title is the only display-face line on a work surface, so the scale is
   compact: this is an operator panel read all day, not a landing page. Actions
   align with the top of the title so a 38px button does not hang off the
   subtitle's baseline. */
.page-heading h1 {
  font-family: var(--font-display);
  /* One step below the storefront's editorial size: a work surface is read all day, so the
     title is sized to scan rather than to announce. */
  font-size: clamp(var(--fs-22), 2.1vw, var(--fs-28));
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.2;
}

/* A section heading under the page title. Without this rule the element took the
   browser's `1.5em` default and rendered at 21px — a size no token declares and no
   step of the scale contains, so the same level of the outline carried two different
   values on neighbouring screens (measured 16 October 2026: 21px here, 38.4px for a
   direct `.site-section > h2`). `--fs-20` is the step the drawer head and the
   documentation body already use for a section heading. */
.page-heading h2 {
  font-size: var(--fs-20);
}

.page-heading p {
  margin-top: 4px;
  max-width: 720px;
  font-size: var(--fs-14);
  color: var(--text-muted);
}

.heading-actions {
  display: flex;
  gap: 8px;
  flex: 0 0 auto;
  max-width: 100%;
  /* Wrap at every width, not only below 700px: at 768 the providers heading and its
     action button together measured 768.8px, and a row that cannot wrap answers that
     with a 1px horizontal scroll. Where everything fits, wrapping changes nothing. */
  flex-wrap: wrap;
}
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}

/* A stat is a reading on the page, not a raised surface: the quiet border and
   the small radius keep the row reading as one strip of figures rather than a
   shelf of boxes (docs/design-system.md, section 6). */
/* The heading rule. A heading is separated from its body by weight and space, not by an
   accent stub: the reference introduces a section with type, and a 72px bar under every
   heading was the decoration that made the panel look like a template. */
.page-heading,
.panel-head {
  position: relative;
}

 /* A stat is a reading on the page, not a raised surface: the quiet border and
   the small radius keep the row reading as one strip of figures rather than a
   shelf of boxes (docs/design-system.md, section 6). */
/* Карточная модель Linear: показание живёт на первой ступени поверхности с
   hairline-рамкой и радиусом 12, а не на голой странице. */
.stat {
  padding: 16px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: inset 0 1px 0 var(--glint);
  min-width: 0;
}

.stat-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.stat-head svg {
  width: 16px;
  height: 16px;
  color: var(--text-faint);
}

.stat-value {
  display: block;
  margin: 12px 0 2px;
  font-family: var(--font-mono);
  font-size: var(--fs-22);
  font-weight: 500;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.stat-value small {
  font-size: var(--fs-14);
  font-weight: 500;
  color: var(--text-faint);
}

.stat-foot {
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.grid-2 {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.9fr);
  gap: 16px;
  align-items: start;
}

.stack {
  display: grid;
  gap: 16px;
}

/* Карточная модель Linear: панель — surface-1 с hairline-рамкой и радиусом 12,
   отстоит от следующей на 16px. Разделение больше не верхней линейкой, а зазором
   и подъёмом поверхности. */
.panel {
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  /* Верхняя внутренняя кромка: лёгкий блик поднятой панели по спеке Linear. */
  box-shadow: inset 0 1px 0 var(--glint);
  margin-block-end: 16px;
  min-width: 0;
  overflow: hidden;
}

.panel-head {
  display: flex;
  /* Wraps for the same reason `.panel-actions` does, one level up. A badge and a button
     are both data-sized — «Не задана — встроенный дефолт» is longer than «Готов» — and
     without a wrap the row could only overrun: `.panel` clips (`overflow: hidden`), and
     a `nowrap` badge has no ellipsis, so the tail of the label was simply cut off.
     Measured 16 October 2026 at 390px: `channels` ended 38px past the viewport and
     `schedule` 13px, both with no visible sign of truncation. */
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: 44px;
  padding: 12px 0;
  border-bottom: 1px solid var(--line);
}

.panel-head h2 {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.panel-head p {
  margin-top: 3px;
  font-size: var(--fs-12);
  color: var(--text-faint);
}

/* A badge and a button side by side in a panel head.
 *
 * Wraps rather than shrinks: the badge text and the button label are both data-sized —
 * «Не настроен» is longer than «Готов» — and a fixed row would clip one of them at the
 * narrow end (docs/design-system.md, section 6).
 */
.panel-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.panel-body {
  padding: 16px 16px;
}

.panel-body.flush {
  padding: 0;
}

.panel-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  border: 0;
  background: transparent;
  color: var(--text-muted);
  font-size: var(--fs-12);
  font-weight: 500;
  white-space: nowrap;
}

.panel-link svg {
  width: 14px;
  height: 14px;
}

.panel-link:hover {
  color: var(--accent);
}

.panel-link:active {
  color: var(--accent-text);
}

/* Route list (overview) */

.route-list {
  display: grid;
  gap: 8px;
}

.route-row {
  display: grid;
  grid-template-columns: 9px minmax(140px, 1fr) 96px 88px 80px 16px;
  align-items: center;
  gap: 16px;
  padding: 12px 12px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: var(--fs-12);
  color: var(--text-muted);
  text-align: left;
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease),
    transform var(--dur-instant) var(--ease);
}

.route-row:active {
  transform: scale(0.995);
}

.route-row:hover {
  border-color: var(--line-strong);
  background: var(--surface-3);
}

.route-name {
  display: grid;
  min-width: 0;
}

.route-name strong {
  font-size: var(--fs-14);
  font-weight: 600;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.route-name small {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.route-row > svg {
  width: 15px;
  height: 15px;
  color: var(--text-faint);
}

/* Meter rows (top models) */

.meter-list {
  display: grid;
  gap: 12px;
}

.meter {
  display: grid;
  gap: 8px;
  min-width: 0;
}

.meter-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-size: var(--fs-12);
}

.meter-head strong {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.meter-head span {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.meter-track {
  height: 6px;
  border-radius: var(--radius-pill);
  background: var(--surface-3);
  overflow: hidden;
}

/* The fill arrives as an inline width and used to snap on every re-render. It transitions on
   `transform` rather than `width`: a bar growing is the one place where the motion carries the
   meaning — how much of the cap is gone — and scaleX composites where an animated width would
   force layout on every frame.

   The width stays 100% and the inline style sets `--fill` instead, so the scale is what moves.
   `transform-origin: left` because a spend bar grows from where it started, not from its
   middle. */
.meter-fill {
  height: 100%;
  width: 100%;
  transform-origin: left center;
  transform: scaleX(var(--fill, 0));
  transition: transform var(--dur-base) var(--ease);
  border-radius: var(--radius-pill);
  background: var(--neutral);
}

.meter-fill.warn {
  background: var(--warn);
}

/* Toolbar */

.toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

.toolbar select,
.toolbar input:not(.search-box input) {
  width: auto;
  min-width: var(--toolbar-field-min-width);
}

.toolbar .spacer {
  flex: 1;
}

.result-count {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
}

/* Tables */

.table-panel {
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: inset 0 1px 0 var(--glint);
  overflow: hidden;
}

/* The per-vendor breakdown behind its disclosure. It is reference material inside a row that
   is read by scanning, so it stays collapsed until asked for (C4). */
.offer-more {
  margin-block-start: 4px;
}

.offer-more > summary {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-size: var(--fs-12);
  color: var(--text-muted);
  cursor: pointer;
  list-style: none;
}

.offer-more > summary::-webkit-details-marker {
  display: none;
}

.offer-more > summary::before {
  content: "+";
  margin-inline-end: 6px;
  color: var(--text-muted);
}

.offer-more[open] > summary::before {
  content: "-";
}

.offer-more[open] > .offer-line {
  margin-block-start: 4px;
}

/* The footer row of a catalogue page: the offer to read the next step of the list. It is
   inside the panel rather than under it, so the table and the button that extends it read as
   one object — the table is not finished, it is continued. */
.catalogue-more {
  display: flex;
  justify-content: center;
  padding: 12px;
  border-block-start: 1px solid var(--line);
  background: var(--surface);
}

/* Горизонтальная прокрутка таблицы была невидимой: на узком экране «Журнал решений»
   шире окна, и ничто не сообщало, что справа есть ещё колонки - пользователь читал
   обрезанную таблицу как полную. Четыре фоновых слоя: два кроющих (`local` - едут вместе
   с содержимым и прячут тень у самого края) и две тени (`scroll` - стоят на месте).
   Тень справа исчезает, когда прокрутка дошла до конца, и это единственный сигнал,
   который не занимает места и не требует JS. */
.table-scroll {
  overflow-x: auto;
  background:
    linear-gradient(to right, var(--surface) 40%, transparent) left center / 26px 100% no-repeat local,
    linear-gradient(to left, var(--surface) 40%, transparent) right center / 26px 100% no-repeat local,
    linear-gradient(to right, var(--scroll-shadow), transparent) left center / 14px 100% no-repeat scroll,
    linear-gradient(to left, var(--scroll-shadow), transparent) right center / 14px 100% no-repeat scroll;
  /* Полоса прокрутки под таблицей тонкая и в тон линии: она не элемент интерфейса, а
     подсказка о состоянии. */
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) transparent;
}

table.data {
  width: 100%;
  min-width: var(--table-data-min-width);
  border-collapse: collapse;
}

/* Nested overview tables are narrower than the page table; the 820px floor
   would force a phantom scroll inside a panel that already fits. */
table.data.wide {
  min-width: 640px;
}

table.data th {
  height: 40px;
  padding: 0 16px;
  /* No fill in the header: the rule beneath it is what separates the labels from the rows,
     and a tinted band was the second surface a table does not need. */
  background: transparent;
  border-bottom: 1px solid var(--line-strong);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
  /* Заголовок колонки — подпись: под переводом он растёт и обязан переноситься,
     а не обрезаться (docs/i18n.md §3). Рост в высоту строки заголовка. */
  text-align: start;
}

table.data td {
  padding: 12px 16px;
  border-bottom: 1px solid var(--line-soft);
  font-size: var(--fs-12);
  color: var(--text-muted);
  vertical-align: middle;
}

table.data tbody tr:last-child td {
  border-bottom: 0;
}

table.data tbody tr.interactive {
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease);
}

table.data tbody tr.interactive:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  border-radius: var(--radius-xs);
}

/* Зебра почти на пределе различимости (1.2% белого). Задача не разметить таблицу, а не
   дать глазу соскользнуть на соседнюю строку в журнале на сорок записей; заметная
   полосатость на таком количестве строк превращается в рябь. */
table.data tbody tr:nth-child(even) td {
  background: var(--zebra);
}

[data-theme="light"] table.data tbody tr:nth-child(even) td {
  background: var(--zebra);
}

[data-theme="light"] .table-scroll {
  background:
    linear-gradient(to right, var(--surface) 40%, transparent) left center / 26px 100% no-repeat local,
    linear-gradient(to left, var(--surface) 40%, transparent) right center / 26px 100% no-repeat local,
    linear-gradient(to right, var(--scroll-shadow), transparent) left center / 14px 100% no-repeat scroll,
    linear-gradient(to left, var(--scroll-shadow), transparent) right center / 14px 100% no-repeat scroll;
}

/* Строка, которая откроется карточкой, при наведении получает кромку слева: курсор
   говорит это только тому, у кого есть мышь, а кромка видна и при навигации с
   клавиатуры. `inset` вместо `border-left` - рамка сдвинула бы содержимое ячейки. */
table.data tbody tr.interactive:hover td:first-child {
  box-shadow: inset 2px 0 0 var(--accent-line);
}

table.data tbody tr:hover td {
  background: var(--surface-hover);
}

/* Запиненная в каталоге модель: переход из панели («выбрать» у ключа) скроллит к этой
   строке, и без метки цель невидима в списке из десятков. Мягкий акцентный фон +
   кромка слева — тот же приём, что у hover-кромки интерактивных строк. */
table.data tbody tr.is-pinned td {
  background: var(--accent-soft);
}

/* Тёмная зебра проигрывает пину по порядку, светлая — сильнее на атрибут темы;
   близнец выравнивает специфичность и стоит позже. */
[data-theme="light"] table.data tbody tr.is-pinned td {
  background: var(--accent-soft);
}

table.data tbody tr.is-pinned td:first-child {
  box-shadow: inset 2px 0 0 var(--accent-line);
}

/* В карточном режиме ячейки прозрачны (зебра там гасится нарочно), поэтому метка
   красит саму карточку: правило на tr не конфликтует с ячеечными совсем. */
@media (max-width: 1080px) {
  table.data tbody tr.is-pinned {
    background: var(--accent-soft);
  }
}

table.data td.empty-cell:hover {
  background: transparent;
}

.cell-primary {
  display: block;
  color: var(--text);
  font-size: var(--fs-14);
  font-weight: 600;
}

/* Кэш-ставка под ценой входа: вторичная, моноширинная, тише основной цифры. */
.cache-rate {
  display: block;
  margin-block-start: 3px;
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  line-height: 1.35;
  white-space: nowrap;
}

/* В строке поставщика это часть одной цены, но не продолжение цифр: разделитель
   даёт глазом читаемую границу даже при узком переносе. */
.offer-line .cache-rate {
  display: inline-block;
  margin-block-start: 0;
  margin-inline-start: 8px;
  vertical-align: baseline;
}

.offer-line .cache-rate::before {
  content: "· ";
  color: var(--text-faint);
}

/* Цена и кэш в карточке — две строки одного поля. */
@media (max-width: 1080px) {
  table.data.price-list td.price .cache-rate {
    display: block;
    margin-inline-start: 0;
  }
}

.cell-sub {
  display: block;
  margin-top: 3px;
  max-width: 340px;
  font-size: var(--fs-12);
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.cell-mono {
  font-family: var(--font-mono);
  font-size: var(--fs-14);
}

/* Одна строка «поставщик · его имя модели · цена» в ячейке каталога.

   Блок, а не inline: в ячейке их столько, сколько поставщиков у модели, и каждый должен
   читаться отдельной строкой без <br> в разметке. Ячейка не обрезается по ширине, как
   `.cell-sub`: обрезанное имя модели у вендора — это ровно тот факт, за которым клиент
   сюда и смотрит. */
.offer-line {
  display: block;
  font-size: var(--fs-14);
  line-height: 1.55;
  color: var(--text-muted);
}

.offer-line + .offer-line {
  margin-top: 2px;
}

.offer-line .cell-mono {
  color: var(--text);
}

.row-actions {
  display: flex;
  justify-content: flex-end;
  gap: 4px;
}

.row-actions.start {
  justify-content: flex-start;
}

.pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 16px;
  border-top: 1px solid var(--line);
  background: var(--surface-2);
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.pagination > div {
  display: flex;
  align-items: center;
  gap: 8px;
}

.page-indicator {
  min-width: 62px;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-muted);
}

/* Empty, loading, error states */

.empty {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  gap: 8px;
  padding: 56px 24px;
  text-align: center;
}

.empty.compact {
  padding: 24px 16px;
  gap: 8px;
}

.empty-icon {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  margin-bottom: 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text-faint);
}

.empty.compact .empty-icon {
  width: 36px;
  height: 36px;
  margin-bottom: 2px;
}

.empty strong {
  font-size: var(--fs-14);
  font-weight: 600;
}

/* Restrict muted styling to the direct descriptive span of the empty state so
   nested button labels (such as primary actions with an inner span) retain their
   standard contrast. */
.empty > span {
  max-width: 420px;
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.empty .btn {
  margin-top: 12px;
}

.empty.compact .btn {
  margin-top: 8px;
}

.btn-group {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  max-width: 100%;
}

.skeleton-page {
  display: grid;
  gap: 16px;
}

/* The shimmer moves a gradient across a pseudo-element with `transform`, not the block's own
   `background-position`.

   Animating background-position repaints the whole block on every frame, and these are large:
   the tallest is 320px of full-width surface, repainting at 60fps on the main thread that is
   simultaneously parsing up to nine API responses. Paint cost scales with area, so the biggest
   skeleton was the most expensive thing on screen during the load it exists to cover.

   A translated pseudo-element composites: the layer is rasterised once and moved by the
   compositor. Same appearance, no per-frame paint. */
.skeleton {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Three times the width so the band can travel a full width in each direction without an
     edge entering the box. */
  width: 300%;
  inset-inline-start: -100%;
  /* Brightest at its leading edge and fading behind it, so the highlight reads as light crossing
     the block rather than as a bar sliding inside it. A symmetric gradient — which this was until
     26 September 2026 — gives the highlight two visible ends at once, and a highlight with two
     ends is a shape, not a reflection. */
  background: linear-gradient(
    90deg,
    transparent 0%,
    var(--surface-3) 34%,
    transparent 74%
  );
  /* Two animations on one clock: the travel, and the envelope that makes the highlight appear near
     one edge and be gone before the other.

     Without the envelope the band's own edge enters and leaves the box as a hard line, which is
     the one thing a shimmer must not do; with it the loop has no seam at all, because the opacity
     is zero at both 0% and 100% and there is nothing to restart. Both properties are composited —
     `transform` and `opacity` — so neither costs a paint per frame, which is the constraint the
     comment above records. */
  animation:
    shimmer var(--loop-shimmer) linear infinite,
    shimmer-fade var(--loop-shimmer) linear infinite;
}

/* Asymmetric on purpose: brightest just past the left edge, faded by the time it reaches the
   right one — "переливалась с одного края и затухала к другому". */
@keyframes shimmer-fade {
  0%,
  100% { opacity: 0; }

  18%,
  62% { opacity: 1; }
}

/* Sized against the real blocks they stand in for: a 56px head over a 46px
   panel-head, or a 96px stat over the compacted card, would jump on arrival. */
.skeleton.head {
  height: 46px;
}

.skeleton.stats {
  height: 88px;
}

.skeleton.block {
  height: 320px;
}

@keyframes shimmer {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(33.333%);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* 6.5 Per-view rhythm ====================================================
   Every cabinet screen shared one rhythm, and the owner named it as the last place the
   product was not at the level it should be: "у эталона каждая секция имеет свой ритм и
   плотность, у нас — общий".

   It was not a matter of taste, it was measurable. Ten rules were shared by all seventeen
   screens — `.content` gave each one the same 1520px column and the same 22px padding,
   `.page-heading h1` the same size, `table.data td` the same 10px row padding — so a
   five-row list of operator accounts and a five-hundred-row decision log were laid out
   identically. Measured on 26 September 2026: `decisions` and `users` differed by nothing
   in the stylesheet, only in how many rows the API returned.

   The switch is `data-view`, which `app.js` and `portal.js` set on `#content` when they
   render a screen. An attribute rather than a class per view, because seventeen views each
   adding their own class is a convention that decays — and the one that had already decayed
   is exactly why they all looked alike.

   Three decisions about what a screen may change:

   - **Density and measure, not structure.** A screen may tighten or loosen its rows, narrow
     its column, and set how much air sits above its first heading. It may not invent a
     heading size: the type scale is the design system's, and `--text-*` has no token for
     "this screen is special".
   - **Table density applies only above 1080px.** Below that a table is a card, and the card's
     padding is decided by the responsive block for reasons of touch and wrapping. A view
     selector has higher specificity than `table.data td`, so an unscoped override would win
     there too and silently break the card — which is why every table rule below sits inside
     `@media (min-width: 1081px)`.
   - **Nothing is added to a screen that has no reason to differ.** A view absent from this
     section keeps the shared rhythm, and that is the answer for most of them.

   The dense screens are the ones an operator reads for hours: `decisions` and `actions` are
   logs, and their rows are the unit of reading. The loose ones are the administrative lists
   that hold a handful of rows and are usually glanced at. The dashboards and the forms are
   untouched. */

/* A screen that is one long log: tighter rows, and the full width, because these tables
   carry the most columns in the product. */
@media (min-width: 1081px) {
  .content[data-view="decisions"] table.data td,
  .content[data-view="actions"] table.data td,
  .content[data-view="ledger"] table.data td {
    padding-block: 8px;
  }

  .content[data-view="decisions"] table.data th,
  .content[data-view="actions"] table.data th,
  .content[data-view="ledger"] table.data th {
    height: 34px;
  }

  /* A list that holds a handful of rows reads better with air between them: the eye is
     comparing whole records, not scanning down a column. */
  .content[data-view="users"] table.data td,
  .content[data-view="tariffs"] table.data td,
  .content[data-view="leads"] table.data td,
  .content[data-view="keys"] table.data td {
    padding-block: 16px;
  }

  /* The two log screens are also the two widest: their rows carry a request id, a model, a
     provider and four figures. */
  .content[data-view="decisions"],
  .content[data-view="actions"] {
    max-width: min(1760px, 100%);
  }
}

/* A screen that is a table of figures has nothing to say in a 1520px column if it has three
   columns: the eye has to travel the width of the window to pair a label with its value. */
.content[data-view="glossary"],
.content[data-view="settings"],
.content[data-view="topup"] {
  max-width: 920px;
  /* Capped for reading, but pinned to the cabinet's left edge: a centered narrow column
     moved the page title 133–157px right of every other section's title, so switching
     sections made the heading jump sideways. */
  margin-inline-start: 0;
  margin-inline-end: auto;
}

/* The dashboards put their first heading straight under the topbar, because the figures are
   the content and a title is a caption for them. The lists keep the shared gap, because
   there the heading is what the screen is and the table is what it holds. */
.content[data-view="overview"] > .page-heading,
.content[data-view="caching"] > .page-heading,
.content[data-view="schedule"] > .page-heading {
  margin-bottom: 12px;
}

/* 7. Overlays =========================================================== */

/* The console: one search field for every surface (ui/console.mjs).
   It is a command line, so it is drawn as one — monospace, square, ruled — and it sits at
   the top of the viewport rather than centred, because a centred box reads as a dialog you
   must dismiss while a command line is something you are already inside of. */
.console {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: min(14dvh, 120px) 20px 20px;
}

.console.hidden {
  display: none;
}

.console-scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim);
  backdrop-filter: blur(2px);
  animation: fade-in var(--dur-fast) var(--ease);
}

.console-box {
  position: relative;
  width: min(680px, 100%);
  border: 1px solid var(--line-strong);
  background: var(--surface);
  box-shadow: var(--shadow-lg);
  animation: pop-in var(--dur-base) var(--ease-spring);
}

.console-field {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 0 16px;
  height: 52px;
  border-bottom: 1px solid var(--line);
}

.console-caret {
  font-family: var(--font-mono);
  font-size: var(--fs-14);
  color: var(--accent);
}

.console-input {
  height: 100%;
  border: 0;
  border-radius: 0;
  background: transparent;
  font-family: var(--font-mono);
  font-size: var(--fs-14);
  color: var(--text);
}

/* Рамку ввода гасит собственное кольцо: оно рисовалось бы внутри коробки, поверх
   подсказки и счётчика. Кольцо перенесено на саму коробку — тот же приём, что у
   `cap-widget`, и по той же причине: фокус обязан быть виден, но не внутри
   составного поля. Правило ниже держит индикатор, пока фокус где-то в коробке. */
.console-box:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

.console-input:focus {
  outline: none; /* focus-exempt: кольцо рисует .console-box:focus-within */
  box-shadow: none;
}

.console-esc {
  padding: 2px 8px;
  border: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.console-list {
  margin: 0;
  padding: 8px 0;
  max-height: min(46dvh, 420px);
  overflow-y: auto;
  list-style: none;
}

.console-item {
  display: grid;
  grid-template-columns: 92px minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 12px;
  padding: 8px 16px;
  cursor: pointer;
}

.console-item.active {
  background: var(--accent-soft);
  box-shadow: inset 2px 0 var(--accent);
}

.console-kind {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.console-title {
  font-size: var(--fs-14);
  color: var(--text);
}

/* The visible entrance to the console. It reads as a field rather than as a button — a
   magnifier, a word and the shortcut — because that is what it opens, and a plain icon
   button beside theme and refresh would look like a third toggle. The shortcut is shown
   rather than described: the point of the button is that a visitor who does not know the
   shortcut learns it by seeing it. */
.console-trigger {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text-muted);
  font: inherit;
  font-size: var(--fs-14);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}

.console-trigger:hover {
  color: var(--text);
  border-color: var(--line-strong);
}

/* Locale switch (docs/i18n.md): one segmented control per bilingual surface.
   Two-letter codes are self-named (RU/EN), so the control itself needs no
   translation; the active segment carries state in colour, not case. */
.locale-switch {
  display: inline-flex;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
  overflow: hidden;
}

.locale-switch button {
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  /* `--fs-13` was never declared — not in `:root`, not in the light block, not by any
     script — so this declaration was dropped by the parser and the control simply
     inherited whatever its parent had. Measured 16 October 2026: 14px on the storefront
     (inside `.site-head-actions`, beside 14px nav links) and 12px in both cabinets
     (inside the 12px `.field-hint` legal nav). Both contexts are real, so both are now
     stated instead of inherited by accident. */
  font-size: var(--fs-14);
  font-weight: 500;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}

/* In the cabinets the switch is one item of the legal footnote, so it takes that
   register rather than the header's. */
.field-hint .locale-switch button {
  font-size: var(--fs-12);
}

.locale-switch button + button {
  border-inline-start: 1px solid var(--line);
}

.locale-switch button:hover {
  color: var(--text);
}

.locale-switch button:active {
  transform: scale(0.975);
}

.locale-switch button[aria-pressed="true"] {
  background: var(--accent-soft);
  color: var(--accent-text);
}

.console-trigger:active {
  transform: scale(0.975);
}

.console-trigger svg {
  width: 15px;
  height: 15px;
  flex: none;
  /* Filled, like every other icon: this rule used to re-stroke it for the old set. */
  fill: currentColor;
  stroke: none;
}

.console-trigger kbd {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.04em;
  color: var(--text-faint);
  white-space: nowrap;
}

/* On a narrow screen the label and the shortcut are the first things to go: the magnifier
   still names the action, and the console is reachable by tapping it. */
@media (max-width: 800px) {
  .console-trigger-label,
  .console-trigger kbd {
    display: none;
  }
  .console-trigger {
    /* Icon-only, so the width is the icon's 17px plus padding: 32px, under the target floor.
       It is a button, and a button is 44×44 here whatever it holds (2.5.5). */
    min-width: 44px;
    justify-content: center;
    padding: 0 8px;
  }
}

.console-hint {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-faint);
  white-space: nowrap;
}

.console-empty,
.console-foot {
  padding: 12px 16px;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

.console-foot {
  margin: 0;
  border-top: 1px solid var(--line);
}

@media (max-width: 700px) {
  .console {
    padding: 12px 12px 20px;
  }

  .console-item {
    grid-template-columns: minmax(0, 1fr) auto;
  }

  .console-kind {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .console-scrim,
  .console-box {
    animation: none;
  }
}

/* Меню переполнения («ещё»). Спека Linear: третья ступень поверхности, hairline,
   радиус 8px, пункты 32px; над ящиком (75) и консолью (90), ниже только тосты.
   Рост — от угла триггера, как у диалогов (`--origin-*` ставит ui/menu.mjs). */
.menu {
  position: fixed;
  z-index: 95;
  min-width: 200px;
  max-width: min(320px, calc(100vw - 16px));
  max-height: min(320px, calc(100dvh - 16px));
  overflow-y: auto;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-3);
  box-shadow: var(--shadow-md);
  transform-origin: var(--origin-x, 85%) var(--origin-y, 0%);
  animation: menu-in var(--dur-fast) var(--ease);
}

.menu.closing {
  animation: menu-out var(--dur-exit) var(--ease-exit) forwards;
}

@keyframes menu-in {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes menu-out {
  to {
    opacity: 0;
    transform: scale(0.97);
  }
}

.menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 32px;
  padding: 4px 8px;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--fs-14);
  text-align: start;
  cursor: pointer;
}

.menu-item svg {
  width: 15px;
  height: 15px;
  flex: none;
}

.menu-item-hint {
  margin-inline-start: auto;
  font-size: var(--fs-12);
  color: var(--text-faint);
}

/* Hover и клавиатурный фокус: заливка плюс внутреннее кольцо — внешний outline
   в меню был бы рамкой внутри рамки. */
.menu-item:hover,
.menu-item:focus-visible {
  background: var(--surface-hover);
}

.menu-item:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px var(--accent-line);
}

.menu-item.danger {
  color: var(--danger);
}

.menu-item:disabled {
  opacity: 0.5;
  cursor: default;
}

.menu-sep {
  height: 1px;
  margin: 4px 8px;
  background: var(--line);
}

@media (prefers-reduced-motion: reduce) {
  .menu,
  .menu.closing {
    animation: none;
  }
}

/* Справка по шорткатам (`?`). Один реестр рисует и клавиши, и эту таблицу —
   ui/shortcuts.mjs; разойтись они не могут по построению. */
.shortcut-help {
  width: min(460px, 100%);
}

.shortcut-help-body {
  display: grid;
  gap: 2px;
  max-height: 60dvh;
  overflow-y: auto;
  padding: 4px 16px 16px;
}

.shortcut-row {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 32px;
  padding: 4px 0;
  border-block-start: 1px solid var(--line-soft);
  font-size: var(--fs-14);
}

.shortcut-row:first-child {
  border-block-start: 0;
}

.shortcut-keys {
  display: inline-flex;
  flex: none;
  gap: 4px;
  min-width: 76px;
}

.shortcut-help kbd {
  min-width: 24px;
  padding: 2px 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-xs);
  background: var(--surface-2);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  text-align: center;
}

/* Modal */

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: grid;
  place-items: center;
  padding: 24px;
  background: var(--scrim);
  backdrop-filter: blur(3px);
  animation: fade-in var(--dur-fast) var(--ease);
  overflow-y: auto;
}

.modal {
  width: min(700px, 100%);
  /* A grid item's minimum is its content. Without this a table with a fixed minimum width
     (the price list) stretched the dialog to 806px on a 390px screen and pushed its own
     edges off both sides; the table scrolls in its own box instead. */
  min-width: 0;
  max-height: calc(100dvh - 48px);
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow-lg);
  /* The spring curve makes the surface read as an object arriving rather than
     a rectangle fading in. It is a blocking task, so it is paired with the
     dimming scrim above.

     Диалог растёт из точки нажатия, а не из центра экрана: `--origin-*` ставит `app.js`
     по координатам последнего pointerdown перед открытием (`docs/design-system.md` §10.4).
     До этого форма создания провайдера появлялась в середине окна, хотя кнопка была в
     правом верхнем углу, и связь между нажатием и результатом читалась только по тексту
     заголовка. Фолбэк 50% - открытие с клавиатуры, где точки нажатия нет. */
  transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
  animation: pop-in var(--dur-base) var(--ease-spring);
}

.modal.narrow {
  width: min(520px, 100%);
}

@keyframes pop-in {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.modal > header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 16px 20px;
  border-bottom: 1px solid var(--line);
}

.modal > header h2 {
  margin-top: 3px;
  font-size: var(--fs-16);
}

.modal > form,
.modal > .modal-body {
  min-width: 0;
  padding: 20px;
  overflow-y: auto;
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 16px;
}

.form-grid .full {
  grid-column: 1 / -1;
}

.form-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 8px;
  padding-top: 16px;
  border-top: 1px solid var(--line);
}

/* Model detail sheet (`ui/model_card.mjs`)
 *
 * One dialog per page, filled with the model that was pressed: the overview, the measured
 * figures, the base rates (cache read and write separately, because a vendor publishes them
 * independently) and every provider's own offer. The panel scrolls, the page behind it does
 * not: the reader returns to the same filter and scroll position.
 *
 * A missing cache rate prints a dash, never a zero price — "the vendor does not publish it"
 * and "it is free" are different answers, and only one of them is true.
 */
.model-sheet {
  width: min(720px, calc(100vw - 32px));
  max-height: calc(100dvh - 48px);
  min-width: 0;
  padding: 24px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-lg);
  overflow: auto;
  overscroll-behavior: contain;
}

.model-sheet[open] {
  animation: pop-in var(--dur-base) var(--ease-spring);
}

.model-sheet::backdrop {
  background: var(--scrim);
}

.model-detail-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 44px;
  gap: 8px 16px;
  align-items: start;
  margin-block-end: 24px;
}

.model-detail-title {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.model-detail-title .overline {
  margin: 0 0 4px;
}

.model-detail-title h2 {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-20);
  overflow-wrap: anywhere;
}

.model-detail-tags {
  grid-area: 2 / 1 / 3 / -1;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.model-detail-close {
  grid-area: 1 / 2;
  align-self: start;
}

.model-tile-mark {
  display: inline-flex;
  flex: none;
}

.model-tile-mark svg {
  width: 16px;
  height: 16px;
  fill: currentColor;
}

.model-detail-section {
  display: grid;
  gap: 8px;
  margin-block-start: 16px;
  padding-block-start: 16px;
  border-block-start: 1px solid var(--line);
}

.model-detail-section h3 {
  margin: 0;
  font-size: var(--fs-14);
  font-weight: 600;
}

.model-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  margin: 0;
}

.model-metric {
  display: grid;
  gap: 2px;
}

.model-metric small {
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.model-metric b {
  font-family: var(--font-mono);
  font-size: var(--fs-16);
  font-weight: 500;
}

.model-rates {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin: 0;
}

.model-rate {
  display: grid;
  gap: 4px;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-2);
}

.model-rate dt {
  display: grid;
  gap: 2px;
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.model-rate dt small {
  color: var(--text-faint);
}

.model-rate dd {
  margin: 0;
  font-size: var(--fs-16);
}

.model-offers {
  display: grid;
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  border-block-start: 1px solid var(--line);
}

.model-offer {
  display: grid;
  gap: 8px;
  padding-block: 12px;
  border-block-end: 1px solid var(--line);
}

.model-offer-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
}

.model-offer-name {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
}

.model-offer-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.model-offer-upstream {
  color: var(--text-muted);
  font-size: var(--fs-12);
}

.model-offer-state {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-inline-start: auto;
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.model-offer-rates {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 8px 16px;
}

.model-offer-rate {
  display: grid;
  gap: 2px;
}

.model-offer-rate small {
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.model-offer-rate b {
  font-size: var(--fs-14);
  font-weight: 500;
}

.model-endpoints {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.model-endpoints code {
  padding: 4px 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-xs);
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
}

.model-detail-cta {
  display: flex;
  justify-content: flex-end;
  margin-block-start: 16px;
}

@media (max-width: 520px) {
  .model-sheet {
    width: calc(100vw - 16px);
    padding: 16px;
  }
}

/* Model pin editor */

.pin-editor {
  display: grid;
  gap: 8px;
  margin-bottom: 8px;
}

.pin-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 44px;
  gap: 8px;
}

/* Top-up form.
 *
 * A panel that is also a form: one field and one button, so a dialog would be a modal
 * over an otherwise empty page. The button sits at the end of the flow rather than beside
 * the field, because the field's hint carries the bounds and the rate — text the customer
 * reads before deciding the figure, not after reaching for the button.
 *
 * Sized to content with logical properties, per docs/design-system.md section 6: the hint
 * is a translated sentence whose length is not ours to predict.
 */
.topup-form {
  display: grid;
  gap: 16px;
  /* Stretch, not start: the method picker and the amount field are the form's own width, and
     `justify-items: start` collapsed them to their content — a two-column picker rendered as
     one narrow column beside empty space. The button is pulled back to its own width below,
     because a full-width submit in a 420px panel reads as a banner. */
  justify-items: stretch;
  padding: 20px;
  max-width: 420px;
}

.topup-form > .btn {
  justify-self: start;
}

/* Payment method picker.
 *
 * Buttons rather than a `<select>`: there are two or three methods, each needs a line of
 * explanation ("списание в рублях"), and an option element cannot carry one. A radio group
 * in ARIA rather than in markup, because the visual is a card and a native radio inside it
 * would be a second focus target for the same choice.
 *
 * Auto-fit rather than a fixed column count: a deployment with one method must not render a
 * half-width card beside empty space, and a third method must not need a media query.
 */
.method-picker {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 8px;
  width: 100%;
}

.method {
  display: grid;
  gap: 2px;
  justify-items: start;
  text-align: start;
  padding: 12px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  cursor: pointer;
  font: inherit;
  transition: border-color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);
}

.method:hover {
  border-color: var(--line-strong);
  background: var(--surface-3);
}

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

/* The selected state is a border, background, and ring, never colour alone: a customer who
 * cannot distinguish the accent hue still has to see which method they picked
 * (docs/design-system.md, section 6). */
.method.active {
  border-color: var(--accent);
  background: var(--accent-soft);
  box-shadow: 0 0 0 1px var(--accent);
}
.method strong {
  font-size: var(--fs-14);
  font-weight: 600;
}

.method span {
  font-size: var(--fs-12);
  color: var(--text-muted);
}

/* Checkbox picker.
 *
 * A list of checkboxes rather than a native multi-select: a multi-select needs a
 * modifier key nobody discovers, and in both places this appears the choice is the
 * product — the vendors a customer's key may use, and the vendors a tariff offers.
 *
 * Shared because both surfaces render it. It lives here rather than twice so a fix to
 * the row's hit area or its focus ring applies to both.
 */
.picker {
  display: grid;
  gap: 8px;
  /* Bounded and scrolled: a deployment with forty vendors would otherwise push the
     submit button past the bottom of a dialog that is already at max height. */
  max-height: 260px;
  overflow-y: auto;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-2);
}

.picker-row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 8px 12px;
  border-radius: var(--radius-sm);
  cursor: pointer;
}

.picker-row:hover {
  background: var(--surface-hover);
}

.picker-label {
  display: grid;
  /* `justify-items: start`, потому что первая строка подписи бывает меткой-капсулой
     (`.badge` в списке тиров кабинета). В grid по умолчанию элемент растягивается на всю
     ячейку, и «1 TIER» превращался в капсулу во всю ширину диалога - метка выглядела
     полем ввода. Текст от этого не страдает: строка и так занимает свою ширину. */
  justify-items: start;
  gap: 2px;
  min-width: 0;
}

.picker-label strong {
  font-family: var(--font-mono);
  /* Идентификатор модели и цена — то, по чему клиент выбирает, поэтому обе строки стоят
     на кегле основного текста (14), а не на 12: на 12px выбор читался построчно, а не
     одним взглядом. Иерархию держат цвет и насыщенность: имя — 600 и `--text`, цена —
     обычная насыщенность, пояснение ниже — 12 и приглушённый. */
  font-size: var(--fs-14);
  font-weight: 600;
}

.picker-label small {
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.picker-price {
  font-family: var(--font-mono);
  font-size: var(--fs-14);
  color: var(--text-muted);
  white-space: nowrap;
}

/* В клиентском каталоге цена — не подпись к строке, а сама строка решения: клиент выбирает
   между каналами по ней. Там она набирается основным цветом, а приглушение остаётся
   пояснению ниже. В админке и панели `.picker-price` держит счётчик моделей, и там
   приглушённый цвет уместен. */
.catalogue-picker .picker-price {
  color: var(--text);
}

/* A vendor row with its model list underneath. The list is a `details`, so it costs no
   script and an untouched vendor stays one line tall; it opens by itself when the tariff
   already narrows that vendor, so a saved selection is never hidden behind a click. */
.picker-item {
  display: grid;
  gap: 2px;
}

.picker-models {
  margin-inline: 40px 12px;
}

.picker-models > summary {
  min-height: 24px;
  padding-block: 4px;
  font-size: var(--fs-12);
  color: var(--text-muted);
  cursor: pointer;
}

.picker-models-empty {
  margin-inline: 40px 12px;
}

.picker-model-list {
  display: grid;
  gap: 2px;
  padding-block: 4px;
}

.picker-model {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 8px;
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  overflow-wrap: anywhere;
  cursor: pointer;
}

.picker-model:hover {
  background: var(--surface-hover);
}

/* Двухшаговый выбор модели (`ui/model_picker.mjs`) на витрине: первый шаг — сетка карточек
   лабораторий, второй — поиск и список моделей. Класс `.picker` здесь не подходит: это бокс
   на 260px со скроллом для диалогов, и в нём выбор превращался в мелкие капсулы в тесной
   рамке. Классы подставляются через `classes` в вызове, сам пикер их не знает; лежат они в
   общем файле, потому что тот же пикер рисуют кабинет и панель. */
.model-picker {
  display: grid;
  gap: 16px;
}

.model-picker [data-picker-labstrip] {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
  gap: 8px;
}

/* Карточка лаборатории: марка, название, счётчик справа. Flex, а не сетка с колонкой под
   марку: у «Все лаборатории» марки нет, и текст не должен падать в узкую колонку. */
.model-lab-card {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  min-height: 56px;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-2);
  color: var(--text);
  font-size: var(--fs-14);
  text-align: start;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease), transform var(--dur-instant) var(--ease);
}

.model-lab-card:hover {
  border-color: var(--line-strong);
  background: var(--surface-3);
}

.model-lab-card.is-active {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.model-lab-card:active {
  transform: scale(0.975);
  transition-duration: var(--dur-instant);
}

.model-lab-card svg {
  flex: none;
  width: 24px;
  height: 24px;
}

.model-lab-card .mono {
  margin-inline-start: auto;
  color: var(--text-muted);
  font-size: var(--fs-12);
}

/* Две строки зарезервированы всегда: «Vendor · Family» занимает их честно, односложные
   имена центрируются — и тогда все карточки полосы одной высоты (замерено: ряд 69px
   рядом с рядом 56px читался как недоделка). Только карточный пресет: мелкая капсула
   `.chip` в диалогах этой высоты не несёт. */
.model-lab-card .picker-lab-name {
  /* Две строки по 22px: замеренная высота двухстрочного имени, а не расчётная 1.45em —
     расчёт давал 40.6px и оставлял ряды с разницей в 2px. */
  min-height: 44px;
  display: flex;
  align-items: center;
  min-width: 0;
}

/* Счётчик и бейдж «+N» закреплённых моделей стоят вместе у правого края: auto-отступ только у
   первого, иначе свободное место делится между ними и счётчик отрывается от названия. */
.model-lab-card .mono ~ .mono {
  margin-inline-start: 0;
}

.model-picker-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
}

.model-picker-bar [data-picker-current] {
  color: var(--text);
  font-size: var(--fs-14);
  font-weight: 600;
}

.model-picker-search {
  flex: 1 1 200px;
  min-width: 0;
}

/* Список ограничен по высоте: «Все лаборатории» открывает весь каталог, и без предела страница
   уходила вниз на десятки экранов раньше, чем читатель доходил до таблицы. */
.model-picker-list {
  display: grid;
  gap: 4px;
  max-height: 440px;
  overflow-y: auto;
}

.model-picker-row {
  display: grid;
  /* The name has a floor of its own. With `minmax(0, 1fr)` the vendor, price and type capsule
     took their full one-line width first and left the name 29px: "codex-auto-review" broke into
     one character per line. Under the floor the meta block wraps below the name instead. */
  grid-template-columns: auto minmax(min(100%, 16ch), 1fr) auto;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease);
}

.model-picker-row:hover {
  border-color: var(--line-strong);
  background: var(--surface-3);
}

.model-picker-row:has(input:checked) {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.model-picker-row input {
  margin: 0;
  accent-color: var(--accent);
}

/* Родной кружок радио рядом с моноширинным именем читался недоделкой (13px системного
   контрола среди токенных строк). Радио остаётся в форме — name-группа, клавиатура,
   скринридер — но не рисуется: видимый знак выбора несёт сама строка (рамка + фон
   по :checked выше), тот же приём, что в .radio-row. Фокус падает на строку через
   :focus-within-эквивалент ниже. Только радио: чекбоксы множественного выбора остаются
   видимыми — там коробочка и есть аффорданс «можно несколько». */
.model-picker-row > input[type="radio"] {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.model-picker-row:has(> input[type="radio"]:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.model-picker-name {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-14);
  font-weight: 600;
  overflow-wrap: anywhere;
}

.model-picker-name svg {
  flex: none;
}

.model-picker-empty {
  padding: 16px 12px;
}

/* Класс ставит пикер (рендер + applyFilter): все видимые строки одной модальности →
   бейджи «Текст» гаснут; смесь — возвращает. Данные в DOM остаются: это про шум,
   а не про сокрытие факта. Селектор по атрибуту: у пресета по умолчанию у списка
   нет класса, на который можно было бы положиться. */
[data-picker-list].modality-uniform .modality-badge {
  display: none;
}

/* Пагинация списка пикера: строки за пределами страницы скрыты отдельным классом от
   фильтрового `hidden` — «Выбрать найденные» обхватывает и их (весь найденный набор),
   поэтому механизмы не смешаны. */
[data-picker-list] > .picker-paged {
  display: none;
}

.picker-more-row {
  display: flex;
  justify-content: center;
  padding-block-start: 8px;
}

/* [hidden] слабее класса с display: скрытие явное. */
.picker-more-row[hidden] {
  display: none;
}

/* Строка-канал: поставщик, цена и бейдж вида стоят справа от названия, пояснение (здоровье,
   замеры) уходит под название на всю ширину. Цена набрана основным цветом: по ней выбирают. */
.model-picker-meta {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  min-width: 0;
}

.model-picker-price {
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--fs-14);
  white-space: nowrap;
}

.model-picker-row > .field-hint {
  grid-column: 2 / -1;
  min-width: 0;
}

/* Сортировка, поставщик и тариф в панели шага моделей. Ширина по содержимому, а не
   фиксированная: названия поставщиков и тарифов задаёт оператор. */
.model-picker-select {
  min-height: 44px;
  max-width: 100%;
}

/* Карточка модели: название модели сверху, под ним поставщики этой модели строками со своей
   ценой. Сравнение цен одной модели и «разрешить / заблокировать» поставщика — в одном месте. */
.model-offer-group {
  display: grid;
  gap: 4px;
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.model-offer-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  min-height: 44px;
  padding-inline: 4px;
}

.model-offer-head .model-picker-name {
  flex: 1 1 16ch;
}

/* Строка поставщика внутри карточки: без своей рамки, с отступом под название модели. */
.model-picker-row.model-offer-row {
  border-color: transparent;
  background: var(--surface);
}

.model-picker-row.model-offer-row .model-picker-name {
  font-weight: 400;
}

/* Плюс/минус у поставщика: «+» — поставщик ключу не разрешён, нажатие разрешает; «−» —
   разрешён, нажатие блокирует. Сама галочка остаётся в форме для клавиатуры и чтения, а
   видимым знаком служит этот значок. */
.model-picker-row.model-offer-row {
  grid-template-columns: auto minmax(min(100%, 12ch), 1fr) auto;
}

.offer-toggle {
  display: inline-grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--fs-16);
  line-height: 1;
}

.offer-toggle-remove {
  display: none;
}

.model-offer-row:has(input:checked) .offer-toggle {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--bg);
}

.model-offer-row:has(input:checked) .offer-toggle-add {
  display: none;
}

.model-offer-row:has(input:checked) .offer-toggle-remove {
  display: inline;
}

/* Галочка в строке поставщика визуально заменена знаком «+/−», но остаётся настоящим полем:
   её читает форма, до неё доходит клавиатура, фокус рисуется на знаке. */
.model-offer-row > input[data-picker-input] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.model-offer-row {
  position: relative;
}

.model-offer-row > input[data-picker-input]:focus-visible + .offer-toggle {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Тот же выбор малым блоком, для формы: плотнее карточки и короче список. */
.model-picker-compact [data-picker-labstrip] {
  grid-template-columns: repeat(auto-fill, minmax(144px, 1fr));
}

.model-picker-compact .model-lab-card {
  min-height: 48px;
  padding: 8px 12px;
}

.model-picker-compact .model-picker-list {
  max-height: 300px;
}

.model-picker-compact .model-picker-row {
  padding: 8px;
}

/* The model card (`ui/model_card.mjs`): a face a reader chooses by, not a row of a table.
 *
 * An art band on top — layered gradients whose angle and strength come from the model id,
 * so the same name paints the same band everywhere; the generated lab art sits in the element
 * and the gradient is its fallback. Under it: lab, name, the three rates a
 * reader compares (cache read included), then health, the provider count and the way into
 * the detail sheet. The whole face is the button; the bottom line names what pressing it
 * does, so the card is not a secret door.
 */
.model-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

.model-tile {
  position: relative;
  display: grid;
  grid-template-rows: 96px auto;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  color: var(--text);
  text-align: start;
  overflow: hidden;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}

.model-tile:hover,
.model-tile:focus-visible {
  border-color: var(--line-strong);
}

.model-tile:active {
  transform: scale(0.985);
}

.model-tile-art {
  display: block;
  width: 100%;
  height: 96px;
  object-fit: cover;
  object-position: center;
  /* The generated art is one picture per lab, monochrome by construction. Light theme
     inverts it, so a card keeps a dark-on-light band instead of a hole in the page; the
     fallback gradient beneath inverts with it. */
  background:
    radial-gradient(120% 130% at 15% 0%, color-mix(in oklab, var(--accent) var(--art-alpha, 18%), transparent) 0%, transparent 55%),
    radial-gradient(90% 100% at 100% 20%, color-mix(in oklab, var(--accent-line) 60%, transparent) 0%, transparent 62%),
    linear-gradient(calc(120deg + var(--art-angle, 0deg)), var(--surface-2), var(--surface));
  transition: transform var(--dur-base) var(--ease), opacity var(--dur-fast) var(--ease);
}

[data-theme="light"] .model-tile-art {
  filter: invert(1) contrast(0.92);
}

.model-tile:hover .model-tile-art {
  transform: scale(1.06);
  opacity: 0.92;
}

.model-tile-body {
  position: relative;
  display: grid;
  gap: 8px;
  padding: 16px;
}

.model-tile-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.model-tile-lab {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.model-tile-badges {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 4px;
}

.model-tile-name {
  font-family: var(--font-mono);
  font-size: var(--fs-16);
  overflow-wrap: anywhere;
}

.model-tile-rates {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 16px;
}

.model-tile-rate {
  display: grid;
  gap: 2px;
}

.model-tile-rate small {
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.model-tile-rate b {
  font-size: var(--fs-14);
  font-weight: 500;
}

.model-tile-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-block-start: 4px;
  padding-block-start: 12px;
  border-block-start: 1px solid var(--line);
  font-size: var(--fs-12);
  color: var(--text-muted);
}

.model-tile-health {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.model-tile-open {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-inline-start: auto;
  color: var(--accent-text);
}

.model-tile-open svg {
  width: 13px;
  height: 13px;
  fill: currentColor;
}

@media (max-width: 520px) {
  .model-tiles {
    grid-template-columns: 1fr;
  }
}


/* Конструктор (`ui/model_picker.mjs`, `groupCards`): та же карточка, что на витрине, но
   внутри — выбор. Закрытая карточка даёт решение списком: алиас, цена «от», число
   поставщиков и «плюс», разрешающий всех поставщиков модели сразу. Открытая показывает
   поставщиков с их ценами; категория и порядок цены сужают предложения внутри карточки,
   не трогая фильтры страницы. */
.model-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 16px;
}

.model-cards .model-tile {
  cursor: default;
}

.model-card-head {
  display: flex;
  align-items: flex-start;
  gap: 8px;
}

.model-card-toggle {
  display: grid;
  flex: 1 1 auto;
  gap: 8px;
  min-width: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  text-align: start;
  cursor: pointer;
}

.model-card-actions {
  display: flex;
  align-items: center;
  gap: 4px;
}

.model-card-body {
  display: none;
  gap: 8px;
}

.model-card[data-card-open="true"] .model-card-body {
  display: grid;
}

.model-card-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding-block-start: 8px;
  border-block-start: 1px solid var(--line);
}

.model-card-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.model-card-filtered {
  display: none;
}

/* The other view of the same card: one line instead of a face. The markup does not change —
   art becomes a thumbnail, the body becomes one wrapping line, the foot loses its rule. A
   third presentation is therefore impossible: a surface can only choose the container class. */
.view-toggle {
  display: inline-flex;
  gap: 4px;
}

.view-toggle .icon-btn.is-active {
  border-color: var(--line-strong);
  background: var(--surface-2);
  color: var(--text);
}

.catalogue-view-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.model-tiles.is-rows,
.model-cards.is-rows {
  display: grid;
  grid-template-columns: 1fr;
  gap: 8px;
}

.model-tiles.is-rows .model-tile,
.model-cards.is-rows .model-tile {
  grid-template-rows: none;
  grid-template-columns: 72px minmax(0, 1fr);
}

.model-tiles.is-rows .model-tile-art,
.model-cards.is-rows .model-tile-art {
  width: 72px;
  height: 100%;
  min-height: 72px;
}

.model-tiles.is-rows .model-tile-body,
.model-cards.is-rows .model-tile-body {
  gap: 8px;
  padding: 8px 16px;
}

.model-tiles.is-rows .model-tile-body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
}

.model-tiles.is-rows .model-tile-top,
.model-cards.is-rows .model-tile-top {
  flex: 0 0 auto;
}

.model-tiles.is-rows .model-tile-name,
.model-cards.is-rows .model-tile-name {
  flex: 1 1 20ch;
  font-size: var(--fs-14);
}

.model-tiles.is-rows .model-tile-rates,
.model-cards.is-rows .model-tile-rates {
  flex: 0 0 auto;
}

.model-tiles.is-rows .model-tile-foot,
.model-cards.is-rows .model-tile-foot {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

.model-tiles.is-rows .model-tile-foot {
  margin-inline-start: auto;
}

.model-cards.is-rows .model-card-head {
  align-items: center;
}

.model-cards.is-rows .model-card-toggle {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
}

/* In a row, the foot is a whole line of its own once the row is too narrow to hold it beside
   the rates: measured at 390px its 411px of text was clipped by the card's own overflow. */
@media (max-width: 700px) {
  .model-tiles.is-rows .model-tile-foot,
  .model-cards.is-rows .model-tile-foot {
    flex-basis: 100%;
    margin-inline-start: 0;
  }
}


/* Confirm dialog */

.confirm-dialog {
  width: min(430px, 100%);
  padding: 28px 24px 24px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  text-align: center;
  box-shadow: var(--shadow-lg);
  /* Тот же закон, что у формы: подтверждение растёт из кнопки «Удалить», по которой
     нажали, а не из середины экрана. Для опасного действия это ещё и указание, что́
     именно спрашивают: диалог физически связан с той строкой таблицы. */
  transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
  animation: pop-in var(--dur-base) var(--ease-spring);
}

.confirm-icon {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  margin: 0 auto 16px;
  border: 1px solid var(--danger-line);
  border-radius: 50%;
  background: var(--danger-soft);
  color: var(--danger);
}

.confirm-dialog h2 {
  font-size: var(--fs-16);
}

.confirm-dialog p,
.confirm-copy {
  margin: 8px 0 0;
  font-size: var(--fs-14);
  color: var(--text-muted);
}

.confirm-copy p + p {
  margin-top: 8px;
}

.confirm-actions {
  display: flex;
  gap: 8px;
  margin-top: 24px;
}

.confirm-actions .btn {
  flex: 1;
}

/* Secret reveal */

.secret-modal {
  width: min(560px, 100%);
}

.secret-value {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 16px 0 20px;
  padding: 12px 16px;
  border: 1px solid var(--accent-line);
  border-radius: var(--radius-sm);
  background: var(--accent-soft);
}

.secret-value code {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-12);
  color: var(--text);
  overflow-wrap: anywhere;
  word-break: normal;
}

/* Drawer */

.drawer {
  position: fixed;
  top: 0;
  inset-inline-end: 0;
  bottom: 0;
  z-index: 75;
  width: min(620px, 100%);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border-inline-start: 1px solid var(--line-strong);
  box-shadow: var(--shadow-lg);
  animation: slide-in var(--dur-base) var(--ease);
}

/* The drawer enters from the right edge it is docked to and dismisses back to it (see
   slide-out). Entering from one side and leaving by another breaks the spatial model.

   32px, not 22: a 620px surface moving 3.5% of its own width does not read as arriving from
   an edge, it reads as a cross-fade with a jitter. The reduced-motion rule is what protects
   the vestibular case, so full motion can afford to state the direction legibly. */
@keyframes slide-in {
  from {
    transform: translateX(32px);
    opacity: 0;
  }
  to {
    transform: none;
    opacity: 1;
  }
}

.drawer-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 20px 20px;
  border-bottom: 1px solid var(--line);
}

.drawer-head h2 {
  margin: 4px 0 4px;
  font-size: var(--fs-20);
  overflow-wrap: anywhere;
}

.drawer-head p {
  font-size: var(--fs-12);
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

.drawer-body {
  flex: 1;
  padding: 20px 20px 32px;
  overflow-y: auto;
}

.detail-status {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.detail-status small {
  display: block;
  margin-top: 8px;
  font-size: var(--fs-12);
  color: var(--text-faint);
}

.detail-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 1px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--line);
  overflow: hidden;
}

.detail-cell {
  padding: 12px 16px;
  background: var(--surface);
  min-width: 0;
}

/* Direct child, not any descendant: the label is the cell's own span, and a value that
   wraps its text in a span — a monospaced address, a masked secret — would otherwise
   inherit the label's uppercase and letter-spacing. Case is a marker here, so applying it
   to a value makes the value wrong rather than merely styled. */
.detail-cell > span {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.detail-cell strong {
  display: block;
  margin-top: 4px;
  font-size: var(--fs-14);
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* A sentence under a value, inside `strong` so it is not the cell's own label span and does
 * not inherit its uppercase. It wraps: this is an instruction ("вставьте этот адрес"), and
 * `.cell-sub` would ellipsise it to half a sentence — right in a table row, wrong here.
 *
 * `font-weight: 400` because it sits inside `strong`, which would otherwise make a note read
 * as loudly as the figure it explains. */
.detail-note {
  display: block;
  margin-top: 4px;
  font-size: var(--fs-12);
  font-weight: 400;
  color: var(--text-faint);
  overflow-wrap: anywhere;
}

/* A cell whose value is one long unbreakable string — a callback URL an operator
   copies — spans the row instead of forcing the whole grid to the width of its
   longest line. `1 / -1` rather than a column count: the grid is auto-fit, so the
   number of columns depends on the viewport. */
.detail-cell.full {
  grid-column: 1 / -1;
}

.detail-section {
  margin-top: 24px;
}

.detail-section h3 {
  margin-bottom: 12px;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Structured enquiry text keeps its line breaks: a supplier's URL, models and
   volume are composed as labelled lines, and collapsing them into a paragraph
   would make the operator re-parse what the form already structured. */
.preserve-breaks {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.metric-table {
  display: grid;
  gap: 8px;
}

.metric-row {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr));
  gap: 12px;
  align-items: center;
  padding: 12px 12px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-muted);
}

/* A fifth cell would otherwise wrap onto an implicit row and sit under the name,
   which reads as a second record rather than as the same one. Declared per count
   instead of `auto-fit`, which sizes by container width and not by item count.

   The actions column is `auto`, not `1fr`: four icon buttons have a fixed width,
   and an equal share of the row is narrower than that in the drawer — the buttons
   then spilled left across the status badge, since they must not be clipped. */
.metric-row.wide {
  grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr)) auto;
}

/* A movement or a request in a trail: four short figures, a time, and the link that leads
   to the other side of the pair (the ledger entry to its trace, the request to its trace).
   Six tracks rather than reusing `.wide`: that one ends at an `auto` action cell, and a
   link in it would take whatever width the label happens to need. */
.metric-row.wide.trail {
  grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr)) minmax(0, 0.9fr) max-content max-content;
}

/* The row the opened record itself is: marked by fill and not by colour of text, so the
   operator finds «этот запрос» in a column of neighbours without reading every id. */
.metric-row.current {
  background: var(--accent-soft);
}

/* The provider key row carries one cell more than the other wide rows: the vendor's verdict
   on the credential — «отвечает», «не работает», «не проверен».

   That cell and the status cell are `max-content`, not `auto` or `1fr`: both hold a
   badge, a badge is sized by its own text and cannot be ellipsized, and an `auto`
   track still shrinks under pressure — measured, the status badge overflowed its cell
   by 10px into the actions column and «Активен» was cut mid-word once a second badge
   appeared beside it. The masked key and the egress keep flexible tracks, because an
   address is the value that legitimately clips (section 6).

   The health cell is always present, unlike the override marker it replaced: a key with no
   verdict is a fact an operator needs during an outage, whereas a key that overrides nothing
   was worth no width at all. */
.metric-row.wide.keys {
  grid-template-columns: minmax(0, 1.4fr) repeat(2, minmax(0, 1fr)) max-content max-content auto;
}

/* The price row carries a sixth cell: who set the figure and when. It sits next to the
   source, because both answer "how much do I trust this number".

   The source cell is `max-content` for the reason the key row's badges are — it can hold
   the «устарела» badge, which is sized by its own text and cannot be ellipsized. The
   author track is flexible: a display name and a timestamp are data, so their length is
   set by the deployment and this is the cell that legitimately clips (section 6). */
.metric-row.wide.prices {
  grid-template-columns: minmax(0, 1.3fr) repeat(2, minmax(0, 1fr)) max-content minmax(0, 1.2fr) auto;
}

/* Панель деталей шириной 620px — своя единица измерения, не окно.

   Эти строки рассчитаны на ширину страницы, а живут внутри выезжающей панели, и
   медиа-запрос на 700px их там не догоняет: окно 1440px, панель 620px, правило молчит.
   Измерено на прайс-строке в панели: цена «$3.00 → $15.00 / 1M» получала 80px из нужных
   120, наценка — 80 из 117, автор — 96 из 228. Обрезалось главное: за что списывают
   деньги. Раздел 6 разрешает клипать адрес и имя автора, но не сумму.

   Контейнерный запрос вместо медиа: условие — ширина панели, и оно верно на любом окне.
   Две колонки, а не одна: столбик из шести ячеек на строку ключа даёт панель в три
   экрана прокрутки, а 275px на ячейку хватает и цене, и подписи автора. */
.drawer {
  container: drawer / inline-size;
}

@container drawer (max-width: 760px) {
  .metric-row,
  .metric-row.wide,
  .metric-row.wide.keys,
  .metric-row.wide.prices,
  .metric-row.wide.trail {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* Прайс-лист: таблица «модель × четыре цены × наценка» с редактируемыми ячейками.

   Отдельный композит, а не `.metric-row`: та строка — читаемая, её ячейки обрезаются
   многоточием, и поле ввода в ней потеряло бы последние цифры суммы. Здесь ячейка — это
   input, обрезать её нельзя вовсе.

   Имя модели держит гибкий трек, цены — фиксированные: сумма набирается шестью символами,
   и растягивать её за счёт имени значит обрезать имя, по которому оператор строку и
   находит. */
.price-editor {
  display: grid;
  gap: 4px;
  margin-block: 16px;
  max-height: min(52dvh, 480px);
  overflow: auto;
  padding-inline-end: 4px;
}

/* The name keeps a floor of its own. Without it the fixed price tracks took the whole row on a
   phone and the model name collapsed to 0–6px, so the one column an operator finds a row by
   was the first to vanish. Below the floor the table scrolls sideways, header included,
   because both share one grid. */
.price-editor-head,
.price-editor-row {
  display: grid;
  grid-template-columns: minmax(180px, 1.6fr) minmax(130px, 0.9fr) minmax(96px, 0.7fr) repeat(6, minmax(84px, 0.5fr));
  gap: 8px;
  align-items: center;
  min-width: 980px;
}

/* Заголовок липкий: в таблице на шестьдесят строк колонка «Выход» перестаёт быть
   различима от «Кэш» ровно там, где шапка ушла за край прокрутки. */
.price-editor-head {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: 8px 0;
  background: var(--surface);
  font-size: var(--fs-12);
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.price-editor-row > label {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  font-size: var(--fs-12);
}

.price-editor-row > label > .mono {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.price-editor-row input {
  padding: 8px 8px;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  text-align: right;
}

/* Строка без цены помечена слева, а не цветом текста: цвет здесь уже занят состоянием
   валидации, и два смысла на одном канале не различаются. */
.price-editor-row.unpriced > label {
  box-shadow: inset 2px 0 0 var(--warn);
  padding-inline-start: 8px;
}

.price-editor-tools {
  margin-top: 4px;
}

/* Широкий диалог — только для прайс-листа: шесть колонок в 700px нечитаемы. */
.modal.wide {
  width: min(1080px, 100%);
}

/* An audit finding is a badge, a title, and the evidence — and the evidence is the whole
   point of the row. In the four-track grid above it landed in a 1fr cell and was ellipsized
   to «HTTP 401 за 641…», which is exactly the figure an operator came for.

   So two tracks, and the detail is placed on the second row of the second one: it reads as a
   continuation of its title rather than as a fourth column, and it opts out of the row's
   `nowrap` because a sentence about a certificate or a vendor's refusal has to be readable
   whole (docs/design-system.md, section 6: text length is set by data). */
.metric-row.audit {
  grid-template-columns: max-content minmax(0, 1fr);
  align-items: start;
}

.metric-row.audit .audit-detail {
  grid-column: 2;
  overflow: visible;
  white-space: normal;
  line-height: 1.5;
  color: var(--text-faint);
}

/* The audit's own head: verdict, the address dialled, the path, the catalogue size. The
   address is the value that legitimately clips here — it is a URL, and the rest are short —
   so it keeps the flexible track and the three figures beside it are sized by their text. */
.metric-row.audit-head {
  grid-template-columns: max-content minmax(0, 1fr) max-content max-content;
}

/* One indented well under a row, for a series read against the row above it: a key's
   measured metrics and a price's revisions. Two selectors on one rule rather than two
   rules, because they are the same composite and a second copy would drift. */
.key-metrics,
.price-history {
  display: grid;
  gap: 8px;
  /* Logical, not `margin-left`: the indent is "after the row's start edge", which is the
     right side under an RTL locale (docs/design-system.md, section 6). */
  margin-block: 0 8px;
  margin-inline-start: 20px;
}

/* Every cell clips rather than only the name. A grid column is min-content wide by
   default, so a long value — a masked proxy address is the one that occurs — pushes
   the column past its track and lands under the next cell's content. Observed with
   a provider key whose egress is a proxy: the address ran through the status badge
   (docs/design-system.md, section 6: text length is set by data). */
.metric-row > * {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The actions cell is the exception: it is sized by its buttons, not by text, and
   clipping it would cut a control in half. */
.metric-row > *:has(> .row-actions),
.metric-row .row-actions {
  overflow: visible;
}

/* The reason an attempt failed spans the whole row rather than taking a fifth cell:
   a vendor's message is a sentence, not a figure, and in a track it would be
   ellipsized to nothing. It also opts out of the row's nowrap for the same reason —
   this is the one cell whose whole value has to be readable. */
.attempt-note {
  grid-column: 1 / -1;
  white-space: normal;
  overflow: visible;
  overflow-wrap: anywhere;
  color: var(--text-muted);
}

.metric-row strong {
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  color: var(--text);
}

/* Channel import plan */

/* The plan can hold thirty groups, so it scrolls on its own rather than pushing
   the submit button off the modal. Same reasoning as .error-list. */
.import-plan {
  display: grid;
  gap: 12px;
  max-height: 46dvh;
  overflow-y: auto;
  margin-bottom: 16px;
}

.import-group {
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-2);
}

/* A group needing an operator decision is dimmed and its switch disabled. Opacity
   alone would read as "loading", so the border carries the warning colour too. */
.import-group.blocked {
  border-color: color-mix(in srgb, var(--warn) 45%, var(--line));
  background: color-mix(in srgb, var(--warn) 6%, var(--surface-2));
}

.import-group-head {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-bottom: 12px;
}

.import-group-head b {
  display: block;
  font-size: var(--fs-14);
  font-weight: 600;
  overflow-wrap: anywhere;
}

.import-group-head small {
  display: block;
  margin-top: 3px;
  font-size: var(--fs-12);
  color: var(--text-faint);
}

/* The switch is the first thing in the row and must not shrink when a long base
   URL wraps beside it. */
.import-group-head > .switch {
  flex: none;
  margin-top: 2px;
}

/* Channel names come from data and vary in length, so they wrap instead of being
   sized to a fixed width (docs/design-system.md, section 6). */
.import-channels {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin: 8px 0;
}

/* The last field inside a group has the card's own padding under it already. */
.import-group .field:last-of-type {
  margin-bottom: 0;
}

/* Request trace */

.trace-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}

.trace-summary > div {
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}

.trace-summary span {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.trace-summary strong {
  display: block;
  margin-top: 8px;
  font-size: var(--fs-16);
  font-weight: 600;
  overflow-wrap: anywhere;
}

.trace-reason {
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: var(--fs-12);
  line-height: 1.6;
  color: var(--text-muted);
}

.candidate-list {
  display: grid;
  gap: 8px;
}

/* The "everyone else" fold under the chosen candidates in a decision trace.
   A `details`, so it costs no script; the summary is styled as a control row
   because it is one — the old inline styles this replaces carried a cursor,
   a border and a radius that belonged to the system, not the element. */
.candidate-accordion {
  margin-top: 12px;
}

.candidate-accordion summary {
  cursor: pointer;
  padding: 8px 12px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: var(--fs-14);
  font-weight: 600;
  user-select: none;
}

.candidate-accordion summary:hover {
  background: var(--surface-hover);
}

.candidate-list.follow {
  margin-top: 12px;
}

.candidate-card {
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-2);
}

.candidate-card.chosen {
  border-color: var(--accent-line);
  background: var(--accent-soft);
}

.candidate-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.candidate-head strong {
  font-size: var(--fs-14);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.candidate-metrics {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
  gap: 8px;
}

.candidate-metrics span {
  display: grid;
  gap: 3px;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.candidate-metrics b {
  font-family: var(--font-sans);
  font-size: var(--fs-14);
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text);
}

.lead-block {
  margin-bottom: 16px;
}

.follow-block {
  margin-top: 16px;
}

.follow-block-tight {
  margin-top: 12px;
}

.table-scroll.follow {
  margin-top: 24px;
}

.field-hint.follow {
  margin-top: 16px;
}

table.data.wide {
  min-width: 640px;
}

textarea.sans {
  font-family: var(--font-sans);
  font-size: var(--fs-14);
}

.probe-result {
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-2);
  font-size: var(--fs-12);
}

.probe-result strong {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: var(--fs-14);
}

.probe-result p {
  margin-top: 8px;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.probe-result.good {
  border-color: var(--ok-line);
  background: var(--ok-soft);
}

.probe-result.good strong {
  color: var(--ok);
}

.probe-result.bad {
  border-color: var(--danger-line);
  background: var(--danger-soft);
}

.probe-result.bad strong {
  color: var(--danger);
}

/* A partial charge is neither of the two: the request was served and then broke, so
   green would say it completed and red would say nothing was delivered. */
.probe-result.warn {
  border-color: var(--warn-line);
  background: var(--warn-soft);
}

.probe-result.warn strong {
  color: var(--warn);
}

/* Toasts */

.toast-stack {
  position: fixed;
  inset-inline-end: max(18px, env(safe-area-inset-right, 0px));
  bottom: max(20px, env(safe-area-inset-bottom, 0px));
  z-index: 90;
  display: grid;
  gap: 8px;
  width: min(360px, calc(100dvw - 36px));
}

.toast {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 16px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  background: var(--surface-3);
  font-size: var(--fs-14);
  box-shadow: var(--shadow-md);
  animation: toast-in var(--dur-base) var(--ease-spring);
}

.toast svg {
  width: 16px;
  height: 16px;
  margin-top: 1px;
  color: var(--ok);
}

.toast.error svg {
  color: var(--danger);
}

.toast.warn svg {
  color: var(--warn);
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* 7.5 Emitted but unstyled ==============================================
   These classes are produced by the frontend and had no rule at all, so the elements
   fell back to browser defaults — an unstyled `<h3>`, a bare `<pre>`, a row of radios
   with no row. A class inventory (`tools/class_inventory.mjs`) lists them; each rule
   below is written against the markup that emits it. */

/* A heading that introduces a panel rather than a page. Sits between `.page-heading`
   (page) and `.panel-head` (inside a panel). */
.panel-heading {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px 16px;
  flex-wrap: wrap;
  margin-block: 28px 16px;
  padding-block-start: 16px;
  border-block-start: 1px solid var(--line);
}

.panel-heading h3 {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text);
}

.panel-heading p {
  margin-block-start: 8px;
  max-width: var(--measure-narrow);
  font-size: var(--fs-14);
  line-height: 1.6;
  color: var(--text-muted);
}

/* A sub-heading inside a panel: same register as `.panel-head`, one step quieter. */
.panel-subhead {
  margin-block: 24px 12px;
}

.panel-subhead h3 {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Stat rows in the cabinets reuse the shared `.stat`; only the grid differs. */
.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 0;
  margin-block: 16px;
  border-block: 1px solid var(--line);
}

/* A scrolling frame around a table. The table keeps its own minimum width, so the
   frame is what scrolls instead of the page. */
.table-wrap {
  overflow-x: auto;
  max-width: 100%;
}

/* A row of radio choices as one control row rather than a vertical list. */
.radio-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.radio-row label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border: 1px solid var(--line);
  border-radius: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}

.radio-row label:hover {
  border-color: var(--line-strong);
  color: var(--text);
}

.radio-row input:checked + span,
.radio-row label:has(input:checked) {
  border-color: var(--accent-line);
  color: var(--text);
  background: var(--accent-soft);
}

/* The overview's own arrangement: readings first, then the panels they summarise. */
.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  /* По карточному языку системы (16px), не отдельным 28px: единственный
     выбивающийся зазор читался как ошибка сетки. */
  gap: 0 16px;
  align-items: start;
}

/* Provider import: a group of rows with its own head. */
.import-groups {
  display: grid;
  gap: 0;
}

.group-card {
  display: grid;
  gap: 12px;
}

.group-select {
  display: grid;
  gap: 8px;
}

.group-select > span,
.group-select > label {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* The chips that name the channels a provider feeds. */
.channel-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* A rejected import attempt, kept apart from the form that produced it. */
.reject-block {
  padding: 12px 16px;
  border: 1px solid var(--danger-line);
  border-radius: var(--radius);
  background: var(--danger-soft);
  font-size: var(--fs-14);
  line-height: 1.6;
  color: var(--text);
}

/* A raw payload. Monospace, scrollable, and never wider than its panel. */
.code-block {
  margin: 0;
  padding: 12px 16px;
  border: 1px solid var(--line);
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  line-height: 1.6;
  color: var(--text-muted);
  overflow-x: auto;
  white-space: pre;
}

/* The label half of a stat pair, when the pair is not the shared `.stat`. */
.stat-label {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* Actions waiting on the operator, listed under the control that started them. */
.pending-actions {
  display: grid;
  gap: 8px;
  margin-block-start: 12px;
}

/* A filter row that is a grid rather than a flex row: the cabinet's ledger. */
.ledger-filter-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 12px;
  align-items: end;
}

/* Numbers beside a meter, aligned in a column. */
.meter-numbers {
  display: flex;
  gap: 12px;
  justify-content: space-between;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  font-variant-numeric: tabular-nums;
  color: var(--text-faint);
}

/* A model's price row in the client cabinet: what we charge, what it costs us. */
.price-in,
.price-out {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.price-out {
  color: var(--text-faint);
}

/* The candidate rows inside a decision trace. */
.trace-candidates {
  display: grid;
  gap: 0;
}

.trace-candidate {
  display: grid;
  gap: 8px;
  padding: 12px 0;
  border-block-end: 1px solid var(--line);
}

.trace-candidate-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.trace-candidate-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.candidate-rank {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-faint);
}

/* A row in the key-tier picker. */
.key-tier-row {
  display: flex;
  align-items: center;
  gap: 12px;
}

.key-tiers-picker {
  display: grid;
  gap: 8px;
}

/* A picker that resolves one entity from a list. */
.token-picker {
  display: grid;
  gap: 8px;
}

/* An inline error box under a field, distinct from `.form-error`'s reserved line. */
.field-error-box {
  padding: 12px 12px;
  border: 1px solid var(--danger-line);
  border-radius: var(--radius);
  background: var(--danger-soft);
  font-size: var(--fs-14);
  line-height: 1.5;
  color: var(--text);
}

/* The foot of a picker card: the action row under the options. */
.option-foot {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  margin-block-start: 12px;
}

/* The model table inside a client panel. */
.model-data-table {
  width: 100%;
}

/* A row the operator can act on. */
.selectable {
  cursor: pointer;
}

/* Dense is a modifier, not a second table. */
.tight {
  padding-block: 8px;
}

/* A larger body line, used for one sentence under a heading. */
.text-lg {
  font-size: var(--text-body);
  line-height: var(--text-body-leading);
}

/* Storefront sections that carry their own heading arrangement. */
.trust-section,
.catalogue-proof {
  padding-block-start: clamp(20px, 2.4vw, 32px);
}

/* The brand wordmark block in the shell header. */

/* A name line inside a decision card. */
.cpc-name {
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  color: var(--text-muted);
}

/* A per-channel note in the panel: quieter than a panel body. */
.bch-info {
  font-size: var(--fs-12);
  line-height: 1.55;
  color: var(--text-faint);
}

/* No pixel pre-state for the brand mark survives here: it existed to hold the WebGL render's
   place while it booted, and both the render and the in-page SVG were removed — the mark is the
   tab icon now (`ui/brand.mjs` is gone; see the brand lockup comment above). */

/* 8. Responsive ========================================================= */

.mobile-only {
  display: none;
}

@media (max-width: 1180px) {
  .grid-2 {
    grid-template-columns: minmax(0, 1fr);
  }

  .route-row {
    grid-template-columns: 9px minmax(120px, 1fr) 90px 78px 16px;
  }

  .route-row > :nth-child(5) {
    display: none;
  }
}

@media (max-width: 960px) {
  .login-shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
  }

  /* Identity stays: wordmark and the compact figure. The manifesto goes — it is
     what made this column too tall, not the brand itself.
     The block is sized to its contents and the panel takes the rest: a fixed art box
     plus a centred form left a screen-high gap between the two. */
  .login-brand {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    padding: 24px 24px 12px;
    border-inline-end: none;
    border-block-end: 1px solid var(--line);
    overflow: visible;
  }

  /* The manifesto goes — it is what made this column too tall — but the page's `h1`
     lives inside it, and `display: none` takes a heading out of the accessibility tree
     as well as off the screen. Measured 16 October 2026: below this breakpoint every
     login document had no `h1` at all (`#app` is hidden and `#login` began at `h2`),
     which is a missing page title rather than a quieter one (1.3.1, 2.4.6).
     The block is therefore clipped instead of removed: nothing of it is painted, and
     the outline the document declares stays true. The pattern is the one `.sr-only`
     already uses, and no interactive element lives here, so nothing focusable is left
     invisible. */
  .login-copy {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    border: 0;
  }

  /* `:has(.mascot)` повторяет специфичность десктопного clamp-преска (0,2,0) и стоит
     позже: иначе фигура держала 300px минимума и на 320px уезжала за край (замерено). */
  .login-orbit,
  .login-orbit:has(.mascot) {
    position: relative;
    inset: auto;
    /* A full-width band rather than a small square: the artwork is a portrait, and a
       210px box showed a fragment of it above a screen of empty space. */
    width: 100%;
    aspect-ratio: 16 / 10;
    transform: none;
    opacity: 1;
    order: -1;
  }

  .login-orbit:has(.mascot) {
    mask-image: linear-gradient(180deg, black 62%, transparent 100%);
  }

  .login-panel {
    padding: 24px 24px;
    align-content: start;
  }

  .app-shell {
    grid-template-columns: minmax(0, 1fr);
  }

  .mobile-only {
    display: inline-grid;
  }

  .sidebar {
    position: fixed;
    top: 0;
    inset-inline-start: 0;
    width: var(--sidebar-w);
    transform: translateX(-100%);
    transition: transform var(--dur-base) var(--ease);
  }

  /* Выехавшее меню - слой над страницей, а не новая страница: под ним видно, что таблица
     осталась на месте. Материал вместо кроющей плашки говорит это без слов и делает
     возврат ожидаемым (`docs/design-system.md` §10.5). Тень остаётся: размытие сообщает
     «сквозь», тень - «выше». */
  .sidebar.open {
    transform: none;
    background: color-mix(in srgb, var(--surface) 92%, transparent);
    backdrop-filter: blur(20px);
    box-shadow: var(--shadow-lg);
  }

  /* The nav items are the primary targets of the whole panel on a phone — every section is
     reached through one — and 38px is below the 44px platform floor. Widened rather than
     re-spaced: the gap between them stays, so a mis-tap lands on a neighbour rather than on
     nothing. */
  .nav-item {
    height: 46px;
    font-size: var(--fs-14);
  }

  .nav-item svg {
    width: 19px;
    height: 19px;
  }

  .content {
    padding: 20px 16px 40px;
  }

  .topbar {
    padding-inline: var(--shell-gutter);
  }

  .last-sync {
    display: none;
  }
}

@media (max-width: 700px) {
  /* The crumb shares the bar with four controls and shrank to «O…» at 390px, while the
     page title directly under the bar says the same thing. Hidden rather than ellipsized:
     a one-letter address is not an address. */
  .topbar > .breadcrumbs { display: none; }

  /* Readings pair up on a phone. One per row made the overview seven screens of figures
     before the first panel, which is the opposite of scannable; two per row keeps the
     numbers comparable and the section short. */
  .stats,
  .stats-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .stat {
    border-inline-start: 1px solid var(--line);
  }

  .stat:nth-child(odd) {
    border-inline-start: 0;
  }

  /* A summary reading is not a third peer when the row holds two: it takes the full
     width instead of leaving an empty half-cell. Overview's own wide card is marked
     separately and keeps its place. */
  .stats > .stat-summary {
    grid-column: 1 / -1;
  }

  .stat-value {
    font-size: var(--fs-22);
  }

  /* The pinned count and its reset button are one instruction, so they own a full
     row with the action at its end instead of wrapping into the bulk-button cloud. */
  .model-picker-bar [data-picker-pinned] {
    display: flex;
    flex: 1 1 100%;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }

  .overview-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 0;
  }

  /* On phones the figure is identity, not a second hero, but it is still a portrait:
     a 112px square showed a fragment of the drawing and left the rest of the screen
     empty, so it is a full-width band that fades into the form below it. */
  .login-brand {
    gap: 12px;
    padding: 16px 20px 12px;
  }

  .login-orbit {
    width: 100%;
    aspect-ratio: 16 / 9;
  }

  .login-orbit:has(.mascot) {
    mask-image: linear-gradient(180deg, black 58%, transparent 100%);
  }

  .login-panel {
    padding: 24px 20px 32px;
  }
  .topbar {
    display: grid;
    /* 44px, not 38px: the menu button is a 44px target and a 38px column made it bleed
       6px into the gap, so the burger sat closer to the address than the gutter it was
       meant to start from. The three slots are the same on all three cabinets — menu,
       address, actions — and the address is the only one whose content varies per screen,
       so `minmax(0, 1fr)` lets it shrink while the others stay at their own size. */
    grid-template-columns: 44px minmax(0, 1fr) auto;
    align-items: center;
    height: var(--topbar-h);
    padding-inline: var(--shell-gutter);
    gap: 8px;
  }

  /* Placed by name rather than by document order: the client cabinet inserts the
     notification chip and panel into its actions group at runtime. */
  .topbar > .breadcrumbs { grid-column: 2; min-width: 0; overflow: hidden; }
  .topbar > .top-actions { grid-column: 3; justify-self: end; min-width: 0; gap: 8px; }

  .breadcrumbs {
    min-width: 0;
    flex-wrap: nowrap;
    overflow: hidden;
    white-space: nowrap;
  }

  /* The trail is one line in a 95px bar at 390px. Every crumb may shrink, and every one of
     them says so with an ellipsis: the previous rule pinned them all at `flex: 0 0 auto`, so
     the trail ran past the bar and the nav's `overflow: hidden` cut it mid-glyph, with no
     sign that anything was missing. The separator stays fixed — it is one character and
     there is no shorter form of it. */
  .breadcrumbs > * {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* The brand crumb and its separator leave at this width, where the sidebar is already
     off-canvas and the trail is 104px. Measured 16 October 2026 at 390px: the address —
     the only thing a breadcrumb exists to say — was the smallest crumb, «Расписание
     проверок» getting 49px of the 153px it needs, while «POSTCORPORATE» held 37px and
     the «/» was squeezed to a 2px sliver that cannot render a glyph at all. The brand is
     not lost: the sidebar head carries the full lockup, reachable through the menu
     button, and the cabinet footer repeats it (docs/design-system.md §1: chrome that
     repeats what is already on screen is removed, not styled). */
  .breadcrumbs > span {
    display: none;
  }

  .top-actions { min-width: 0; flex-wrap: nowrap; gap: 8px; }
  /* Narrow topbar controls keep the shared 44×44 touch target. The previous 40px rule
     bought no horizontal space — width was already 44px elsewhere — and made theme and
     refresh controls shorter than the menu, locale switch and console trigger. */
  .top-actions .icon-btn { width: 44px; height: 44px; }
  /* The reveal button keeps its 44×44 at every width. The icons inside keep their own size. */
  .health { min-width: 0; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  .content { padding: 16px 12px 28px; }

  .login-form h2 {
    font-size: var(--fs-22);
  }

  .login-form > .muted {
    margin-bottom: 20px;
  }

  .metric-row,
  .metric-row.wide,
  .metric-row.wide.keys,
  .metric-row.wide.prices,
  .metric-row.wide.trail {
    grid-template-columns: minmax(0, 1fr);
  }

  .key-metrics,
  .price-history {
    margin-inline-start: 8px;
  }

  .page-heading {
    flex-direction: column;
    align-items: stretch;
  }
  .page-heading > div:first-child,
  .page-heading > :first-child:not(.heading-actions) {
    flex: 0 1 auto;
    min-width: 0;
  }

  /* `flex: 1` alone divided the row between the buttons and truncated the longer label
     ("Добавить провайдера" lost its end). Wrapping first, then filling, gives each button
     the width its own text needs. */
  .heading-actions {
    flex-wrap: wrap;
  }

  .heading-actions .btn {
    flex: 1 1 auto;
    justify-content: center;
  }

  /* The filter row is a column: a select with a 150px floor beside a search field cannot
     share 390px, and the result count belongs under them rather than squeezed between. */
  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  /* `flex: 1 1 260px` sizes the main axis, and the main axis is now vertical — the search
     box grew to 260px tall. Both the basis and the max-width have to be released here, or
     the field is a tall empty panel with an icon floating in the middle of it. */
  .search-box {
    flex: 0 0 auto;
    max-width: none;
  }

  .toolbar select,
  .toolbar input:not(.search-box input) {
    width: 100%;
    min-width: 0;
  }

  .toolbar .spacer {
    display: none;
  }

  .result-count {
    align-self: flex-end;
  }

  .form-grid {
    grid-template-columns: minmax(0, 1fr);
  }

  .pagination {
    flex-wrap: wrap;
  }

  /* The label goes, the dot stays. `.health span` hid both, and the pill collapsed to an
     18×12px empty capsule in the top bar: the one element whose entire job is to say
     whether the relay is up became a decoration that says nothing. `:not(.dot)` is what
     makes this a truncation rather than a deletion. */
  .health span:not(.dot) {
    display: none;
  }

  .health {
    gap: 0;
    padding: 0;
    /* Sized and shaped like the icon buttons beside it, not like its vanished text: three
       controls in a row read as a row. It carries the same 44px as they do, because it is the
       same kind of thing to a finger. */
    width: 44px;
    height: 44px;
    /* The reserved label width goes with the label: 148px of empty capsule would push the
       two buttons beside it off a phone. */
    min-width: 0;
    border-radius: var(--radius-sm);
    justify-content: center;
  }

  /* `.last-sync` is already `display: none` at the wider breakpoint, so it reserves nothing
     here and needs no override. */

  /* Dialogs, drawers and forms on a phone.

     Two things change and both are about the hand rather than the width: controls grow to
     a touch target, and the submit row stops scrolling away. */

  .modal-backdrop {
    padding: max(12px, env(safe-area-inset-top, 0px)) max(12px, env(safe-area-inset-right, 0px))
      max(12px, env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
  }

  .modal {
    /* Matches the backdrop's own padding. The desktop value subtracts 48px, which left a
       gap this layout does not have and cost two lines of a crowded form. */
    max-height: calc(100dvh - 24px);
  }

  /* The actions row is pinned to the foot of the scrolling form. A token form is ~800px
     tall in a 849px window: with the row in the flow, "Сохранить" sits below the fold and
     the dialog looks like it has no way to confirm. */
  .modal > form .form-actions {
    position: sticky;
    bottom: -20px;
    /* Cancels the form's own bottom padding so the bar reaches the dialog's edge instead
       of floating above a strip of background. */
    margin: 8px -20px -20px;
    padding: 16px 20px;
    background: var(--surface);
  }

  /* Full width and stacked. Two 38px buttons side by side put the destructive and the
     confirming action within a thumb's width of each other. */
  .form-actions {
    flex-direction: column-reverse;
    align-items: stretch;
  }

  .drawer {
    width: 100%;
  }

  /* Touch targets. 38px controls and 30px icon buttons are comfortable with a cursor and
     not with a finger; 44px is the platform floor on both iOS and Android. Set here rather
     than globally: on a desktop the taller control would just be loose. */
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]),
  select,
  .btn {
    height: 44px;
  }

  /* iOS zooms a focused field whose computed size is under 16px. The rest of the
     type scale stays; only the control the keyboard opens is lifted. */
  input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="file"]),
  select,
  textarea,
  textarea.sans {
    font-size: var(--fs-16);
  }

  /* The `.sm` variants matter more than the base ones, not less: they are what the drawer
     uses for "+ Ключ", "Каталог моделей" and every row action, so they are the buttons a
     thumb actually hunts for. 40px is half a finger; the base rule's 44px holds here too. */

  /* Two rules used to live here, shrinking `.icon-btn` to 42px and `.icon-btn.sm` to 40px on a
     phone. That is backwards — the smaller the screen, the more the finger needs — so both are
     gone and the base 44px applies everywhere. */

  /* Tooltips are hover-only, so on a touch screen they never appear — and worse, a tap
     leaves :hover stuck on the button, printing a label over whatever the tap opened. The
     base rule now hides with `display: none`, so the reveal is what has to be cancelled. */
  [data-tooltip]:hover::after,
  [data-tooltip]:focus-visible::after,
  [data-tooltip]:not(.icon-btn):hover::before,
  [data-tooltip]:not(.icon-btn):focus-visible::before {
    display: none;
  }

  .pin-row {
    grid-template-columns: minmax(0, 1fr) 44px;
  }

  .pin-row input:first-child {
    grid-column: 1 / -1;
  }
}

/* At 320px a cabinet topbar cannot seat its controls on the 16px gutter. Measured on
   `/supply`, whose actions group is the widest of the three cabinets: menu button 44 + the
   address + the language switch 91 + the theme toggle 44 + refresh 44 + the console trigger
   44 = 307px of content inside 288px, so the row pushed 3px past the viewport and collapsed
   the right gutter from 16px to 0 — a horizontal scrollbar on the narrowest phone.
   What gives is the two gaps around the actions group, not a control and not the row's own
   rhythm: the topbar's `column-gap` and the group's own `gap`, both 8px to 4px. That is still
   on the 4px spacing grid, and together they buy 16px — enough to bring the right gutter back
   to its full 16px, which either one alone did not (9px and 5px respectively).
   The controls themselves stay 44px wide: the canon does not soften a tap target on a narrow
   screen (docs/design-system.md §8), and the client cabinet solves its own 320px case by
   moving the theme toggle to Settings, which the supplier cabinet has nowhere to move it to.
   Measured after: 0px of overflow and a 16px gutter at 320, 324, 330 and 339px, with the
   former 8px restored from 341px up. */
@media (max-width: 340px) {
  /* Two gaps, measured. The topbar is a grid of three columns — the menu button, the address
     (0px at this width, the breadcrumb is hidden) and the actions group — so its own
     `column-gap` is what sits between the menu button and the actions. The actions group
     holds four controls, so its `gap` is what sits between the switch, the toggle, refresh
     and the console trigger.
     Reducing only the actions' gap cleared the overflow but left the right gutter at 9px,
     because the row's two 8px column gaps still ate 16px; reducing only the topbar's gap left
     it at 5px. Both at 4px gives 16 + 44 + 4 + 4 + 235 = 303 of 320, which restores the
     full 16px gutter and leaves the row 17px of slack.
     `>` and both class names on the second rule, to outweigh `.topbar > .top-actions`
     (line 5964), which declares 8px at a higher specificity than a bare `.top-actions`. */
  .topbar {
    column-gap: 4px;
  }

  .topbar > .top-actions {
    gap: 4px;
  }
}

/* Tables become cards.

   The threshold is the tables' own width, not a device: `table.data` sets `min-width: 820px`
   and the widest section (tokens) measures 1046px, so a viewport that cannot seat ~1060px of
   content shows a horizontal scrollbar over the primary content of the page. At 768px that
   hid 316px — three of the eight columns — and nothing is read by scrolling sideways to find
   a column and back to find the row.

   This lived inside the 700px block, which is how the tablet width kept the scrollbar: the
   layout it needed was written but gated on a narrower screen than the problem.

   `display: block` on the table parts is what allows it. The row becomes the card, the cell
   becomes a label/value pair, and the header row is dropped because each cell now carries its
   own label in `data-label` (`labelTableCells` in app.js and portal.js). Without those labels
   this layout is a column of unexplained values, so the two halves are a pair — do not remove
   one.

   1080 stays a literal because a media query cannot read var(); the source of truth
   for the number is --table-card-breakpoint in :root, and the design-system check
   asserts this literal and the token agree. The two must move together. */
@media (max-width: 1080px) {
  /* Карточный режим отменяет прокрутку, а вместе с ней и всё, что о ней сообщало:
     кроющие полосы и тени по краям висели бы вертикальными лентами вдоль столбца
     карточек. Зебра и кромка первой ячейки гаснут по той же причине - они разделяли
     строки таблицы, а здесь строки уже разделены рамкой карточки, и полоса внутри
     карточки читалась бы как её часть. */
  .table-scroll {
    overflow-x: visible;
    background: none;
  }

  /* `:not(:hover)` обязателен: правило стоит в файле ниже общего `tr:hover td`, и без
     исключения чётная карточка перестала бы отвечать на курсор - при той же
     специфичности побеждает последнее объявление. */
  table.data tbody tr:nth-child(even):not(:hover) td {
    background: transparent;
  }

  /* Только тень: `background: transparent` здесь погасил бы и подсветку наведения на
     первой ячейке, и карточка отвечала бы на курсор всеми полями, кроме первого. */
  table.data tbody tr.interactive:hover td:first-child {
    box-shadow: none;
  }

  table.data,
  table.data tbody,
  table.data tr,
  table.data td {
    display: block;
    min-width: 0;
    width: auto;
  }

  table.data thead {
    display: none;
  }

  /* Each row is a bordered card rather than a strip separated by a rule. Rules alone left
     the values of one record and the title of the next reading as one block, which is the
     opposite of what a card layout is for: the border says where a record ends. */
  table.data tbody tr {
    display: block;
    margin-bottom: 12px;
    padding: 12px 16px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--surface);
  }

  table.data tbody tr:last-child {
    margin-bottom: 0;
  }

  /* The panel itself stops drawing a frame: it would be a border around a column of
     borders. The pager keeps its own top rule, so it still reads as attached. */
  .table-panel {
    border: 0;
    background: transparent;
  }

  /* Label above value, not beside it.

     A two-column cell was tried and it is wrong here: an 8.5rem label column takes a third
     of a 390px screen, and every value — a badge, a meter, a `p1 p2 p3 p4` list — was left
     wrapping inside the remainder. The vertical form gives the value the whole width, which
     is what a phone has to spend. */
  table.data td {
    display: block;
    padding: 8px 0;
    border-bottom: 0;
  }

  table.data td::before {
    content: attr(data-label);
    display: block;
    margin-bottom: 3px;
    font-family: var(--font-mono);
    font-size: var(--fs-12);
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-faint);
  }

  /* The first cell is the record's identity, so it reads as the card's title rather than
     as one more labelled field. */
  table.data tbody tr > td:first-child {
    display: block;
    margin-bottom: 8px;
    padding-top: 0;
  }

  table.data tbody tr > td:first-child::before {
    content: none;
  }

  /* The actions cell spans the card's foot, where a thumb reaches it. Selected by the
     absence of `data-label` rather than by `:last-child`: the panel's last column is a row
     of buttons, but the portal's is "Остаток после" — real data whose label
     `:last-child::before { content: none }` silently deleted. `labelTableCells` only stamps
     cells whose header has text, so no label means no header, which means not a field. */
  table.data tbody tr > td:not([data-label]) {
    display: block;
    margin-top: 8px;
    padding-bottom: 0;
  }

  table.data tbody tr > td:not([data-label])::before {
    content: none;
  }

  .row-actions {
    justify-content: flex-start;
  }

  /* An empty state spans the card and keeps its centred layout: it is one message, not a
     record with fields. */
  table.data td.empty-cell {
    display: block;
    padding: 0;
  }

  table.data td.empty-cell::before {
    content: none;
  }

  /* Hover on a card is meaningless on a touch screen, and the row is the card here, so
     the tint would flood it. */
  table.data tbody tr:hover td {
    background: transparent;
  }

  /* The subtitle truncated to one line inside a column; in a card it has the width to
     wrap, and an address or model id is worth reading in full. */
  .cell-sub {
    max-width: none;
    overflow: visible;
    white-space: normal;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation: none !important;
    transition-duration: 0.01ms !important;
  }

  /* Reduced motion removes decorative movement while preserving static state cues. */
  .btn[data-loading]::after,
  .skeleton {
    animation: none !important;
  }

  .skeleton::after {
    display: none;
  }
}

/* The brand figure: a sprite drawn by `ui/mascot.mjs` into a canvas, with its screen laid
   over it as markup. Two elements because they are two things — the case is a drawing, the
   screen is text — and the text has to be real text, set in the interface's own mono face.

   The host carries no size of its own. It takes the box its parent gives it — the storefront
   hero's right column, the login's `.login-orbit` — and the canvas fills that box. The one
   thing that must not happen is the canvas feeding its own size back into the host, which is
   why the canvas is `position: absolute`: as a static block it would stretch its parent, the
   parent would give it a bigger box, and the figure would grow on every frame. Measured at
   that state: a login canvas 300px wide reported its host at 33 million pixels tall.

   The glass is positioned as a percentage of the frame, not of the host. The frame is exactly
   the figure's box in whole sprite pixels, so the host is usually a few pixels larger, and a
   percentage of the host would put the text off its screen by that much. */
.mascot {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.mascot-frame {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  transform: translate(-50%, -50%);
}

.mascot > canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.mascot-screen {
  position: absolute;
  inset-inline-start: var(--mascot-screen-x, 20.8333%);
  inset-block-start: var(--mascot-screen-y, 12.5%);
  width: var(--mascot-screen-w, 58.3333%);
  height: var(--mascot-screen-h, 33.3333%);
  display: flex;
  flex-direction: column;
  /* The lines are spread over the glass rather than stacked at its top. A terminal fills from
     the top and that is what this did first; measured on 25 September 2026, four lines in a
     24×14 glass left the bottom two-fifths empty and the screen read as half-blank rather than
     as a display with something on it. The inset keeps the first and last characters off the
     bezel. */
  justify-content: space-evenly;
  padding: 0.4em 0.6em;
  overflow: hidden;
  /* No background: the sprite already fills the glass, and a colour repeated here would be a
     second place the palette is decided. The type is light in both themes, because this is a
     screen and not a surface — one that inverted with the page would read as a hole in the
     layout rather than as something switched on. */
  font-family: var(--font-mono);
  font-size: var(--mascot-char, 8px);
  line-height: 1.35;
  /* The screen's ink is the sprite's own lit-row value, handed over by the renderer. It is
     light in both themes on purpose, and it must not be `--accent`: that token is ink on
     paper, so in the light theme it would set dark type on a dark glass. */
  color: var(--mascot-ink, #eef1f5);
  white-space: nowrap;
}

/* The reveal. Each line carries `--typed`, its visible character count, and the clip is one
   character wide — which is what makes this work in any language and at any size, where a
   `steps()` reveal would need the character count written into the stylesheet.

/* The text is in the markup from the start and the clip is what hides it, so a reader with
   scripting blocked, a crawler, and a screen reader all get the whole exchange. */
.mascot-row { display: block; }

.mascot-screen [data-mascot-line] {
  clip-path: inset(0 calc(100% - var(--typed, 100) * 1ch) 0 0);
}

/* The caret is a drawn block rather than a typed character: it must not be selectable, and
   it must not be read out. It appears only once the exchange is complete — before that the
   line's own clip is the only cursor the eye needs — and then it blinks, which is a state
   indicator and the only reason a blink is allowed here at all. Under reduced motion the
   blanket rule above freezes it lit, which is the calm variant: a prompt, not a pulse. */
.mascot-screen .caret {
  display: none;
  width: 1ch;
  height: 1em;
  vertical-align: text-bottom;
  background: currentColor;
}

.mascot-screen.is-complete .caret {
  display: inline-block;
  animation: mascot-caret 1.1s steps(1) infinite;
}

@keyframes mascot-caret {
  0%, 50% { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* The particle field. A procedural grain, drawn into a 2D canvas by
   `ui/particle-field.mjs`, used behind an empty state and never behind data: under a table
   it would compete with the figures an operator is reading, and those figures are the
   reason the panel exists. `currentColor` is what the marks are painted with, so the field
   takes the surrounding text colour in both themes without a token of its own. */
.particle-field {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  color: var(--text-faint);
  opacity: .8;
  pointer-events: none;
  z-index: 0;
}

/* The empty state becomes the field's frame. It is positioned so the canvas has a box to
   fill, and its own content is lifted above the grain. */
.empty.with-field {
  position: relative;
  overflow: hidden;
}

.empty.with-field > * {
  position: relative;
  z-index: 1;
}

/* A log entry that arrived since the previous render. The table is replaced whole on every
   refresh, so a new row lands in the same weight and colour as the row above it and an
   operator watching a busy relay cannot tell which line is new. 120ms and four pixels is
   the "barely noticeable" the frequency table allows for a list that grows on every
   request; the rule marks the row and the slide is what makes it noticed. Under reduced
   motion the blanket collapse stops the slide and the rule stays. */
@keyframes row-arrived {
  from { transform: translateY(-4px); opacity: 0; }
  to { transform: none; opacity: 1; }
}

.row-arrived {
  animation: row-arrived 120ms var(--ease) both;
  box-shadow: inset 2px 0 var(--accent);
}

@media (prefers-reduced-motion: reduce) {
  .row-arrived {
    animation: none;
    box-shadow: inset 2px 0 var(--accent);
  }
}

/* A value that changed since the previous render. The rule is the state and the fade is what
   makes it noticed: a figure that went from 412 to 419 sits in the same type in the same
   place, and an operator who looked away reads the old one. Nothing moves and no colour of
   the text changes — a left rule appears and fades over 900ms, which is the "barely
   noticeable" the frequency table allows for a section switch. Under reduced motion the
   blanket collapse at the end of this stylesheet stops the fade and the rule stays, so the
   signal survives as a still mark. */
@keyframes value-changed {
  0% { box-shadow: inset 2px 0 var(--accent); }
  100% { box-shadow: inset 2px 0 transparent; }
}

.value-changed {
  animation: value-changed 900ms var(--ease) both;
}

@media (prefers-reduced-motion: reduce) {
  .value-changed {
    animation: none;
    box-shadow: inset 2px 0 var(--accent);
  }
}

/* The in-flight spinner keeps turning under reduced motion, and this block has to sit AFTER
   the blanket `animation: none !important` above: `!important` beats a later declaration of
   the same specificity only when the later one is also `!important`, so a rule placed before
   it is simply overridden. `AGENTS.md` is explicit about the intent — reduced motion means a
   gentler equivalent, not lost feedback, and a frozen ring is an inert ring that says
   nothing about whether the request is still running. The speed is the gentler part: the
   calm token is 0.9s against 0.6s. */
@media (prefers-reduced-motion: reduce) {
  .btn[data-loading]::after,
  button[data-loading] svg,
  .is-loading > svg[data-icon-state="retry"] {
    animation: spin var(--dur-spin-calm) linear infinite !important;
  }
}

/* An opacity pulse instead of a travelling highlight: it reports the same
   pending state without motion across the viewport. */
@keyframes skeleton-pulse {
  50% {
    opacity: 0.55;
  }
}

/* And here is where that pulse is actually applied, which it was not.

   The keyframes and the calm loop token were written on 25 September 2026 and left unwired.
   `AGENTS.md` states that under reduced motion "the skeleton drops its travelling band and pulses
   its own opacity instead (`skeleton-pulse`, with `.skeleton::after` hidden rather than frozen)".
   The stylesheet did the first half only: the band was hidden and the block sat perfectly still.
   Measured on 26 September 2026 with `prefers-reduced-motion: reduce` forced — a loading screen was
   a set of motionless grey rectangles, which is the exact failure `docs/design-system.md` names:
   "a frozen skeleton is an inert grey block, and it says nothing".

   The declaration has to sit after the blanket `animation: none !important`, for the reason the
   spinner above explains: `!important` on a later rule of equal specificity wins, and a rule placed
   before the blanket one is simply overridden. */
@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: skeleton-pulse var(--loop-skeleton-calm) ease-in-out infinite !important;
  }
}

@media (prefers-reduced-transparency: reduce) {
  /* Translucency is decoration here; legibility is not. The pair of scrim
     tokens goes solid and the blur is dropped rather than thinned. */

  /* Верхняя полоса и выехавшее меню - тоже материал, и они держат текст: адрес раздела и
     четырнадцать названий. Размытие снимается, фон становится кроющим - иначе под
     названием раздела просвечивает таблица, ровно то, от чего человек включил настройку. */
  .modal-backdrop,
  .topbar,
  .sidebar {
    backdrop-filter: none;
  }

  .topbar {
    background: var(--bg);
  }

  .sidebar {
    background: var(--surface);
  }
}

/* Объяснительная страница: проза в одну колонку, ширина по чтению, а не по вьюпорту —
   строка в 180 символов это строка, которую никто не читает.

   Живёт в общем файле, потому что таких страниц теперь две и они про одно: кабинет
   поставщика объясняет, что делает модерация, панель — чем ключ отличается от прокси и
   провайдера. Вторая копия правил набора в supply.css была бы местом, где два объяснения
   начинают выглядеть по-разному. */
.rules-copy {
  max-width: 68ch;
  display: flex;
  flex-direction: column;
  gap: 16px;
  font-size: var(--fs-14);
  line-height: 1.6;
}

.rules-copy h3 {
  margin: 8px 0 0;
  font-size: var(--fs-16);
}

.rules-copy ul {
  margin: 0;
  padding-inline-start: 20px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.rules-copy code {
  font-family: var(--font-mono);
  font-size: var(--fs-14);
}

/* Две пятитактные метки у имени модели. Композит, которого нет в панели: там полоса —
   заливка расхода, здесь дискретный сигнал. Высоты растут по порядку соседа, поэтому
   отсутствующее измерение не сплющивает непрочитанные такты в плоскую линию.

   В общем файле, потому что таких каталогов теперь два: витрина и кабинет. Вторая копия
   правил — то место, где один экран начинает показывать другую скорость той же модели, а
   `public.css` кабинету не отдаётся вовсе (`ownedBy` в web/embed.go). */
.model-cell {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  min-width: 0;
  max-width: 100%;
}

.model-cell .cell-mono {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* Имя модели — прямой потомок `.model-cell`, тогда как цены и апстрим-имена лежат внутри
   ячеек и строк предложений. Поэтому иерархия строки держится на одном селекторе: имя
   набирается основным цветом и 600, цена остаётся обычной насыщенности. До этого всё в
   строке было одной насыщенности, и глаз не находил, что здесь выбирают. */
.model-cell > .cell-mono {
  color: var(--text);
  font-weight: 600;
}

/* The two bar meters beside a model name are one focusable target, not two. It carries the
   combined label for both meters, and it is 44px tall and at least 44px wide (2.5.5) — the
   bars inside stay 23×14, with the padding supplying the rest of the box. `box-sizing:
   content-box` keeps the padding out of the bars' own height, and the negative margin stops
   the target from inflating the row it sits in. */
.model-signals {
  display: inline-flex;
  align-items: flex-end;
  gap: 8px;
  flex: none;
  position: relative;
  height: 14px;
  padding: 15px 12px; /* grid-exempt: 14 + 15×2 = 44px target (2.5.5) */
  margin: -15px -12px; /* grid-exempt: cancels the padding above */
  box-sizing: content-box;
}

/* The bars inside the target are decoration: the wrapper carries their meaning. They keep
   their own tooltip rule out of the way by not having one. */
.signal {
  position: relative;
  display: inline-flex;
  align-items: flex-end;
  gap: 2px;
  height: 14px;
}

/* The generic tooltip centres itself above its host. This host is a 47px box at the start of
   the first cell: a centred box hangs out of the panel, and `border-collapse` on the table
   clips anything that leaves the cell — «Скорость ещё не измерена» arrived as a sliced strip.
   It is anchored past the end of the marks, inside the row, and dropped below them on narrow
   screens where the cards take the first cell's padding away. */
.model-signals[data-tooltip]::after {
  bottom: auto;
  top: 50%;
  inset-inline-start: 100%;
  margin-inline-start: 8px;
  transform: translateY(-50%);
}

.model-signals:hover,
.model-signals:focus-visible,
.model-signals[data-open] {
  z-index: 2;
}

.model-signals[data-open]::after {
  display: block;
}

@media (max-width: 1080px) {
  .model-signals[data-tooltip]::after {
    top: calc(100% + 8px);
    transform: none;
  }
}

.signal-tick {
  display: block;
  width: 3px;
  border-radius: var(--radius-pill);
  background: var(--text);
  opacity: 0.22;
}

.signal-tick:nth-child(1) { height: 6px; }
.signal-tick:nth-child(2) { height: 8px; }
.signal-tick:nth-child(3) { height: 10px; }
.signal-tick:nth-child(4) { height: 12px; }
.signal-tick:nth-child(5) { height: 14px; }

.signal-tick[data-on] {
  opacity: 1;
  background: var(--text-muted);
}

.signal[data-kind="speed"] .signal-tick[data-on] {
  background: var(--text);
}

@media (prefers-contrast: more) {
  /* Faint text and hairline borders are the first things to fail here, so the
     muted tiers collapse toward the primary text colour and every edge becomes
     a defined line. */

  .btn,
  .icon-btn,
  input,
  select,
  textarea {
    border-color: var(--line-strong);
  }
}

/* Windows High Contrast (и любой режим принудительных цветов) затирает наши фоны и
   границы, оставляя системную палитру. Тогда всё, что различалось заливкой, сливается:
   ступени, полосы, точки состояния. Здесь мы не спорим с системой, а возвращаем
   различие через границы системными ключевыми словами — так элемент остаётся читаемым
   и в чёрно-белом режиме. Кольцо фокуса тоже отдаётся системе: `Highlight` — это её
   собственный цвет выделения, и он гарантированно виден на её фоне. */
@media (forced-colors: active) {
  .btn,
  .icon-btn,
  .field input,
  .field select,
  .field textarea,
  .console-box,
  .catalogue-bar,
  .lab-chip {
    border: 1px solid CanvasText;
  }

  :focus-visible,
  cap-widget:focus-within,
  .console-box:focus-within {
    outline: 2px solid Highlight;
    outline-offset: 2px;
  }

  /* Полоса, точка и ступень несут смысл заливкой — под принудительными цветами
     заливка исчезает, поэтому смысл переезжает на границу. */
  .signal-tick,
  .status-dot,
  .meter > *,
  .bar > * {
    border: 1px solid CanvasText;
  }
}

/* ==========================================================================
   Telegram support
   ==========================================================================
   The cabinet has no support section: support is a conversation with a person, and
   the entrance lives in the corner of every screen instead. The handle is read from
   `/api/public/legal`; a deployment without one renders nothing. Sits below toasts
   (90) so a notification is never covered, and lifts above the cookie notice while
   that notice is open. */

.telegram-widget {
  position: fixed;
  inset-block-end: max(20px, env(safe-area-inset-bottom, 0px));
  inset-inline-end: max(16px, env(safe-area-inset-right, 0px));
  z-index: 80;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  background: var(--surface-3);
  color: var(--text);
  font-size: var(--fs-14);
  font-weight: 600;
  text-decoration: none;
  box-shadow: var(--shadow-md);
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}

.telegram-widget:hover {
  border-color: var(--accent);
  background: var(--surface-2);
}

.telegram-widget:active {
  transform: scale(0.975);
  transition-duration: var(--dur-instant);
}

.telegram-widget svg {
  width: 18px;
  height: 18px;
  color: var(--accent-text);
}

/* The cookie notice is a dialog over the bottom of the page. The contact button waits
   until it is answered instead of competing with it for the same corner. */
body.has-cookie-notice .telegram-widget {
  display: none;
}

@media (max-width: 520px) {
  .telegram-widget {
    width: 48px;
    height: 48px;
    justify-content: center;
    padding: 0;
  }

  .telegram-widget span {
    display: none;
  }
}
