/* ==========================================================================
   PostCorp Control Plane — design system
   1. Tokens
   2. Reset and base
   3. Primitives (buttons, fields, badges, chips)
   4. App shell (sidebar, topbar)
   5. Login
   6. Content blocks (headings, stats, panels, tables)
   7. Overlays (modal, drawer, toast, tooltip)
   8. Responsive
   ========================================================================== */

/* 1. Tokens ============================================================== */

:root {
  color-scheme: dark;
  --relay-acid: #b8ff47;
  --relay-acid-soft: rgba(184, 255, 71, 0.08);
  --relay-acid-line: rgba(184, 255, 71, 0.28);
  --relay-grid: rgba(255, 255, 255, 0.035);
  --radius-icon: var(--radius-sm);

  --font-sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
  --font-mono: "JetBrains Mono", "Cascadia Code", Consolas, ui-monospace, monospace;
  /* Display face for the wordmark and page titles only. It has the wide,
     geometric look of the brand banners but is too mannered for body text. */
  --font-display: "Unbounded", "Inter", system-ui, sans-serif;

  --bg: #08090b;
  --surface: #0e1013;
  --surface-2: #14161a;
  --surface-3: #1b1e23;
  --surface-hover: rgba(255, 255, 255, 0.04);
  --line: #23262c;
  --line-soft: #1a1d21;
  --line-strong: #363b43;

  --text: #f1f2f4;
  --text-muted: #979ca4;
  --text-faint: #6b7079;

  /* The accent is platinum, not a hue. Emphasis is carried by contrast and
     weight, which is what keeps the interface in the same register as the
     chrome mark; a saturated brand colour fights it. */
  --accent: #f2f4f7;
  --accent-hover: #ffffff;
  --accent-ink: #0a0b0d;
  --accent-soft: rgba(242, 244, 247, 0.08);
  --accent-line: rgba(242, 244, 247, 0.24);

  /* Neutral badge tone for categories (profile, policy, role, proxy): meaning
     is carried by the label, not by a hue. */
  --neutral: #b3b8c0;
  --neutral-soft: rgba(179, 184, 192, 0.09);
  --neutral-line: rgba(179, 184, 192, 0.22);

  /* Semantic states are the only chromatic values in the palette, and they are
     deliberately desaturated: at full saturation they are what makes a dark UI
     look cheap. They must never be used for emphasis, only for status. */
  --ok: #7fa38c;
  --ok-soft: rgba(127, 163, 140, 0.12);
  --ok-line: rgba(127, 163, 140, 0.28);

  --warn: #c3a468;
  --warn-soft: rgba(195, 164, 104, 0.12);
  --warn-line: rgba(195, 164, 104, 0.28);

  --danger: #c97b70;
  --danger-hover: #d68d82;
  --danger-soft: rgba(201, 123, 112, 0.12);
  --danger-line: rgba(201, 123, 112, 0.3);

  /* Chrome ramp for the brand mark: a bright edge, a mid tone, and a dark
     valley. Three stops are enough to read as polished metal at 32px. */
  --steel-hi: #ffffff;
  --steel-mid: #b9c4cf;
  --steel-lo: #4a5560;
  --steel-glow: rgba(255, 255, 255, 0.55);

  --radius-xs: 6px;
  --radius-sm: 8px;
  --radius: 12px;
  --radius-lg: 16px;
  --radius-pill: 999px;
  /* The brand mark sits between xs and sm: round enough to read as a tile,
     square enough that the orbit inside it is not a circle in a circle. */
  --radius-mark: 9px;
  --radius-mark-lg: 11px;

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 12px 28px -12px rgba(0, 0, 0, 0.7);
  --shadow-lg: 0 30px 70px -20px rgba(0, 0, 0, 0.8);

  --sidebar-w: 254px;
  --topbar-h: 62px;
  --content-max: 1520px;

  /* Table sizing. `table.data` has an 820px floor, and the card breakpoint at
     which the tables give up on that floor is measured against it: when the
     table's own minimum stops fitting, the layout changes, not the scrollbar.
     The breakpoint is named as a token so JS reads the same number the media
     query uses (getComputedStyle on the root; a media query cannot take var()).
     --table-data-min-width is that floor for the page tables; the 1080px media
     block below must be re-measured when it changes — see the incident note
     above that block. */
  --table-card-breakpoint: 1080px;
  --table-data-min-width: 820px;

  /* Reserved widths. The health pill holds space for its widest label so the
     controls beside it do not shuffle on every state change; toolbar fields
     keep their width across filter changes for the same reason. */
  --health-min-width: 148px;
  --toolbar-field-min-width: 150px;

  /* Motion. --ease is a critically damped curve: it settles without overshoot,
     which is the right default for UI that did not carry gesture momentum.
     --ease-spring overshoots slightly and is reserved for surfaces that arrive
     as physical objects (modal, drawer). --ease-exit is the only place an
     accelerating curve is allowed: a surface leaving should get out of the way,
     and the same curve on an entrance would start slow at the exact moment the
     operator is watching. Durations are named by role so a new component picks a
     tier instead of inventing a number; an exit is faster than its entrance,
     because arriving is information and leaving is not. */
  --ease: cubic-bezier(0.32, 0.72, 0, 1);
  --ease-spring: cubic-bezier(0.22, 1.2, 0.36, 1);
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
  --dur-instant: 0.1s;
  --dur-fast: 0.16s;
  --dur-base: 0.24s;
  --dur-exit: 0.16s;

  /* Two tokens the storefront needs and the applications do not. A marketing page
     is read once, top to bottom, and its sections arrive as the visitor scrolls to
     them: that is the "once" row of the frequency table, where the budget for
     pleasure lives. --dur-reveal is deliberately longer than --dur-base, because it
     is not an interface element responding to a click; --stagger is the delay step
     between siblings in one revealed group, small enough that six cards finish
     inside a third of a second. Both are declared here rather than in public.css,
     because a second place that declares tokens is the start of a second palette. */
  --dur-reveal: 0.44s;
  --stagger: 55ms;

  /* Cycle durations live on a different scale from interface motion: they
     describe a loop that never settles, not an entrance. Stretching --dur-*
     past 0.24s would make a new button invent 18s; these names keep that
     class of number out of rules. --dur-spin is the pending ring; the calm
     pair is what reduced-motion re-declares with !important. */
  --loop-orbit: 18s;
  --loop-breathe: 4.5s;
  --loop-gleam: 3.6s;
  --loop-gleam-cross: 4.4s;
  --loop-gleam-stagger: -1.4s;
  --loop-shimmer: 1.3s;
  --loop-orbit-calm: 26s;
  --loop-breathe-calm: 6s;
  --loop-skeleton-calm: 1.6s;
  --dur-spin: 0.6s;
  --dur-spin-calm: 0.9s;

  /* The pad under the large mark (login hero, storefront hero). Born on the
     dark theme from the chrome render's own reflections; drawn here so the
     light theme — graphite over a near-white panel, where nothing gathers
     behind the object — wears the same quiet disc. */
  --orbit-pad: rgba(139, 150, 165, 0.1);

  /* Overlay dimmers. Two meanings, not four colours: a modal sits over the
     page, a sidebar or drawer scrim sits over the shell. Reduced transparency
     overrides the pair rather than restating rgba on each selector. */
  --scrim: rgba(0, 0, 0, 0.62);
  --scrim-dim: rgba(0, 0, 0, 0.55);

  /* Width of a meter fill. Set inline on the element; the fallback keeps an
     unstyled track from inheriting a missing custom property as invalid. */
  --fill: 0;
}

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

  --bg: #f2f3f5;
  --surface: #ffffff;
  --surface-2: #f7f8fa;
  --surface-3: #eeeff2;
  --surface-hover: rgba(16, 18, 21, 0.04);
  --line: #e3e5e9;
  --line-soft: #edeef1;
  --line-strong: #c8ccd2;

  --text: #101214;
  --text-muted: #5b6169;
  --text-faint: #868b93;

  --accent: #14171b;
  --accent-hover: #000000;
  --accent-ink: #ffffff;
  --accent-soft: rgba(20, 23, 27, 0.06);
  --accent-line: rgba(20, 23, 27, 0.2);

  /* A touch denser than the dark pad: on near-white the same 10% grey reads as
     nothing, and the pad exists to give the graphite mark a stage. */
  --orbit-pad: rgba(120, 132, 143, 0.2);

  --neutral: #5b6169;
  --neutral-soft: rgba(91, 97, 105, 0.07);
  --neutral-line: rgba(91, 97, 105, 0.18);

  --ok: #4a7059;
  --ok-soft: rgba(74, 112, 89, 0.1);
  --ok-line: rgba(74, 112, 89, 0.24);

  --warn: #8a6a28;
  --warn-soft: rgba(138, 106, 40, 0.1);
  --warn-line: rgba(138, 106, 40, 0.24);

  --danger: #a04c40;
  --danger-hover: #8a3f34;
  --danger-soft: rgba(160, 76, 64, 0.1);
  --danger-line: rgba(160, 76, 64, 0.24);

  /* On a light surface the mark needs a darker ramp, otherwise the white
     highlight disappears into the background. */
  --steel-hi: #f7fafc;
  --steel-mid: #8d9aa8;
  --steel-lo: #2c3742;
  --steel-glow: rgba(44, 55, 66, 0.35);

  --shadow-sm: 0 1px 2px rgba(15, 23, 32, 0.08);
  --shadow-md: 0 14px 30px -18px rgba(15, 23, 32, 0.3);
}

/* 2. Reset and base ====================================================== */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  background: var(--bg);
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  min-width: 320px;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  font-weight: 640;
  /* Tracking is size-specific: large text reads too loose at 0, so headings
     tighten. Per-size values live with each heading rule; this is the base for
     the mid sizes (h3/h4). */
  letter-spacing: -0.012em;
  line-height: 1.25;
}

h1 {
  letter-spacing: -0.022em;
  line-height: 1.12;
}

h2 {
  letter-spacing: -0.016em;
  line-height: 1.2;
}

p {
  margin: 0;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

button {
  cursor: pointer;
}

a {
  color: var(--accent);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

code,
.mono {
  font-family: var(--font-mono);
  font-size: 0.92em;
}

svg {
  width: 18px;
  height: 18px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.svg-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

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

/* Заголовок раздела и панель деталей получают фокус программно (`navigate` в `app.js`,
   `captureFocus`) — чтобы следующий Tab начинался в новом содержимом, а не в боковом
   меню. Chrome считает такой фокус «видимым», если до него было нажатие клавиши, и
   рисовал платиновую рамку вокруг слова «Обзор» после входа по Enter. Рамка на
   неинтерактивном заголовке обещает нажатие, которого нет; объявление для скринридера
   от её снятия не страдает — оно приходит от самого перемещения фокуса.

   Только `tabindex="-1"`: элемент, до которого можно дойти табом, свою рамку сохраняет. */
[tabindex="-1"]:focus-visible {
  outline: none;
}

::selection {
  background: var(--accent-soft);
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border: 3px solid transparent;
  border-radius: var(--radius-pill);
  background-clip: content-box;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--text-faint);
  background-clip: content-box;
}

/* Utilities */

.hidden {
  display: none !important;
}

/* Exit animations.

   Every overlay here arrives as a considered object and used to vanish the instant
   `.hidden` applied `display: none`. The asymmetry is what makes a dismissal read as a
   crash rather than a close, and the rule it breaks is the one about entering and leaving
   by the same path (docs/design-system.md, §4).

   Driven by a class rather than by `@starting-style` or an exit transition, because the
   element is removed from the layout at the end: JS adds `.closing`, waits for the
   animation to finish, then applies `.hidden`. `closeSurface` in app.js and portal.js is
   the one place that sequence lives.

   `--dur-exit` is shorter than `--dur-base` and `--ease-exit` accelerates: arriving is
   information the operator reads, leaving is not, and a slow exit is a surface in the way
   of whatever they are doing next. The reduced-motion block below zeroes all of it for
   free, so the calm variant is instant rather than a different animation. */

.modal-backdrop.closing,
.sidebar-scrim.closing,
.drawer-scrim.closing {
  animation: fade-out var(--dur-exit) var(--ease-exit) forwards;
}

/* The dialog leaves along the axis it arrived on, and slightly faster than its scrim, so
   the surface is gone before the dimming lifts rather than the reverse. */
.modal-backdrop.closing .modal,
.modal-backdrop.closing .confirm-dialog {
  animation: pop-out var(--dur-exit) var(--ease-exit) forwards;
}

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

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

@keyframes fade-out {
  to {
    opacity: 0;
  }
}

@keyframes pop-out {
  to {
    opacity: 0;
    transform: translateY(6px) scale(0.99);
  }
}

/* Back to the edge it is docked to, mirroring slide-in. The travel is the same distance,
   so the two directions read as one object on one axis. */
@keyframes slide-out {
  to {
    opacity: 0;
    transform: translateX(32px);
  }
}

@keyframes toast-out {
  to {
    opacity: 0;
    transform: translateY(6px);
  }
}

.muted {
  color: var(--text-muted);
}

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

.truncate {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nowrap {
  white-space: nowrap;
}

/* Overline label above a heading. It stays quiet: at accent brightness it would
   compete with the title instead of labelling it. */
.section-index {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.numeric {
  font-variant-numeric: tabular-nums;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.icon-btn,
.btn > svg,
.stat-head > svg,
.panel-link > svg,
.nav-item > svg,
.empty-icon > svg {
  stroke: currentColor;
  stroke-width: 1.65;
  stroke-linecap: square;
  stroke-linejoin: bevel;
}

.icon-btn {
  position: relative;
  border-radius: var(--radius-xs);
  background: linear-gradient(var(--surface-3), var(--surface-2));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
}

.icon-btn::after {
  content: "";
  position: absolute;
  inset: 3px;
  border: 1px solid transparent;
  pointer-events: none;
  transition: border-color var(--dur-fast) var(--ease);
}

.icon-btn:hover::after,
.icon-btn:focus-visible::after {
  border-color: var(--accent-line);
}

.section-index::before {
  content: "//";
  margin-inline-end: 6px;
  color: var(--accent);
}

/* 3. Primitives ========================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 38px;
  padding: 0 15px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  color: var(--text);
  font-size: 13px;
  font-weight: 560;
  /* Мелкий кегль просит положительный трекинг: на 13px буквы стоят слишком плотно, и
     подпись кнопки читается как одно слово (docs/design-system.md §10.6). */
  letter-spacing: 0.004em;
  white-space: nowrap;
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease),
    transform var(--dur-instant) var(--ease);
}

/* Фокус виден и без наведения: кнопка, до которой добрались с клавиатуры, обязана
   отличаться от соседней. Обводка снаружи, чтобы не сдвигать содержимое. */
.btn:focus-visible {
  outline: 2px solid var(--accent-line);
  outline-offset: 2px;
}

.btn svg {
  width: 16px;
  height: 16px;
}

/* Pressed state fires on pointer-down, not on click, so feedback is immediate.
   The scale is small because the button does not move in space, it acknowledges
   the press. */
.btn:active:not(:disabled) {
  transform: scale(0.975);
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

/* Главное действие экрана. Тень здесь не украшение: она ставит кнопку на слой выше
   поверхности, и глаз находит её первой, не читая подписи (§10.5). Внутренняя светлая
   кромка сверху - материал, поймавший свет, а не рамка. */
.btn.primary {
  background: var(--accent);
  color: var(--accent-ink);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.35) inset, 0 2px 10px rgba(0, 0, 0, 0.45);
}

.btn.primary:hover:not(:disabled) {
  background: var(--accent-hover);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.5) inset, 0 4px 16px rgba(0, 0, 0, 0.5);
}

/* Нажатие снимает подъём: кнопка уходит к поверхности, а не просто уменьшается. */
.btn.primary:active:not(:disabled) {
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.25) inset, 0 1px 3px rgba(0, 0, 0, 0.4);
}

.btn.primary:disabled {
  box-shadow: none;
}

.btn.secondary {
  background: transparent;
  border-color: var(--line);
  color: var(--text);
}

.btn.secondary:hover:not(:disabled) {
  border-color: var(--line-strong);
  background: var(--surface-hover);
}

.btn.ghost {
  background: transparent;
  color: var(--text-muted);
}

.btn.ghost:hover:not(:disabled) {
  background: var(--surface-hover);
  color: var(--text);
}

/* Опасное действие: цвет несёт смысл, поэтому фокус-ринг тоже красный - иначе
   платиновая обводка на красной кнопке читается как «всё в порядке». */
.btn.danger {
  background: var(--danger);
  color: var(--accent);
}

.btn.danger:hover:not(:disabled) {
  background: var(--danger-hover);
}

.btn.danger:focus-visible {
  outline-color: var(--danger-hover);
}

.btn.full {
  width: 100%;
}

.btn.sm {
  height: 32px;
  padding: 0 11px;
  font-size: 12px;
}

/* Same anchoring as .icon-btn, which is already positioned. A labelled button
   only carries a tooltip when it is disabled and the label cannot say why. */
.btn[data-tooltip] {
  position: relative;
}

/* In-flight submit. Only the trailing icon is swapped for a spinner: the label
   stays in place, so the button neither resizes nor stops naming the action the
   user just committed to. `disabled` alone communicated nothing. */
.btn[data-loading] {
  pointer-events: none;
}

.btn[data-loading] svg {
  display: none;
}

.btn[data-loading]::after {
  content: "";
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  opacity: 0.75;
  animation: spin var(--dur-spin) linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

.icon-btn {
  display: inline-grid;
  place-items: center;
  width: 36px;
  height: 36px;
  min-width: 36px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-muted);
  position: relative;
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease), transform var(--dur-instant) var(--ease);
}

.icon-btn:hover:not(:disabled) {
  border-color: var(--line-strong);
  color: var(--text);
  background: var(--surface-hover);
}

.icon-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.icon-btn.bare {
  border-color: transparent;
}

.icon-btn.sm {
  width: 30px;
  height: 30px;
  min-width: 30px;
}

.icon-btn.sm svg {
  width: 15px;
  height: 15px;
}

.icon-btn.danger-hover:hover:not(:disabled) {
  color: var(--danger);
  border-color: var(--danger-line);
  background: var(--danger-soft);
}

/* Tooltip on hover for icon-only controls.

   Not rendered until hovered, and that is a layout fix rather than a style choice. The
   hidden box used to be `opacity: 0`, which still occupies space: `white-space: nowrap` on
   "Обновить каталог моделей" made a ~200px box beside a 30px button, and inside
   `.table-scroll` (overflow-x: auto) those boxes added 96px of phantom scroll width. Every
   wide section had a scrollbar for a tooltip nobody was hovering.

   The cost is the fade: an element cannot animate opacity out of `display: none`. The
   discrete-transition dance that would restore it is not worth the machinery for a hover
   label that appears instantly anyway. */
[data-tooltip]::after {
  content: attr(data-tooltip);
  display: none;
  position: absolute;
  bottom: calc(100% + 8px);
  inset-inline-start: 50%;
  transform: translateX(-50%);
  padding: 5px 9px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-xs);
  background: var(--surface-3);
  color: var(--text);
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
  pointer-events: none;
  z-index: 60;
  box-shadow: var(--shadow-md);
}

[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
  display: block;
}

/* Fields */

.field {
  display: grid;
  gap: 7px;
  min-width: 0;
  margin-bottom: 16px;
}

.field > span,
.field > label,
.field-label {
  font-size: 12px;
  font-weight: 540;
  color: var(--text-muted);
}

/* `input:not(...)` rather than a list of `input[type=...]`: an <input> with no
   type attribute is a text field, but an attribute selector would not match it,
   which silently left such fields unstyled. */
input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]),
select,
textarea {
  width: 100%;
  min-width: 0;
  height: 38px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font-size: 13px;
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease);
}

textarea {
  height: auto;
  min-height: 84px;
  padding: 10px 12px;
  line-height: 1.6;
  resize: vertical;
  font-family: var(--font-mono);
  font-size: 12px;
}

input:hover:not(:disabled),
select:hover:not(:disabled),
textarea:hover:not(:disabled) {
  border-color: var(--line-strong);
}

input:focus,
select:focus,
textarea:focus {
  outline: none;
  border-color: var(--accent-line);
  box-shadow: 0 0 0 3px var(--accent-soft);
}

/* Каретка платиновая, а не системная синяя: единственная цветная точка на экране не
   должна приходить от браузера. */
input,
textarea {
  caret-color: var(--accent);
}

/* Подпись поля светлеет вместе с фокусом: связь между заголовком и рамкой видна без
   стрелок и без чтения (§10.4 - пространственная связность). */
.field:focus-within > span,
.field:focus-within > label,
.field:focus-within > .field-label {
  color: var(--text);
}

/* `:user-invalid`, не `:invalid`: пустое обязательное поле не красное до того, как в
   него попробовали что-то ввести. Иначе форма встречает пользователя ошибками, которых
   он не совершал. */
input:user-invalid,
select:user-invalid,
textarea:user-invalid {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent);
}

/* Число - величина, а не слово: моноширинный набор и табличные цифры, чтобы разряды в
   соседних полях стояли в столбик и опечатка в цене была видна глазом. */
input[type="number"],
input[inputmode="numeric"],
input[inputmode="decimal"] {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* Системный спиннер - единственная деталь формы, которую рисует браузер: пара крошечных
   стрелок другой геометрии и другого цвета, прижатая к правому краю нашего поля. В
   «Остатке квоты, токенов» она стояла рядом с моноширинной цифрой и читалась как чужая
   вставка. Значения здесь вводят, а не накручивают по одному: квота - шестизначная,
   цена - с копейками, и щелчок по стрелке меняет её на единицу. Клавиши вверх/вниз
   продолжают работать - у поля остаётся тип `number`, вместе с проверкой ввода. */
input[type="number"] {
  appearance: textfield;
}

input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
  appearance: none;
  margin: 0;
}

input::placeholder,
textarea::placeholder {
  color: var(--text-faint);
}

input:disabled,
select:disabled,
textarea:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

select {
  appearance: none;
  padding-inline-end: 34px;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 17px) 17px, calc(100% - 12px) 17px;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

/* A control docked inside a field (reveal password). The input reserves room on
   the right so the value can never slide under the button. */
.field-affix {
  position: relative;
  display: grid;
}

.field-affix > input {
  padding-inline-end: 40px;
}

.affix-btn {
  position: absolute;
  top: 50%;
  inset-inline-end: 4px;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  transform: translateY(-50%);
  border: 0;
  border-radius: var(--radius-xs);
  background: transparent;
  color: var(--text-faint);
  transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}

.affix-btn svg {
  width: 16px;
  height: 16px;
}

.affix-btn:hover {
  color: var(--text);
  background: var(--surface-hover);
}

.affix-btn[aria-pressed="true"] {
  color: var(--accent);
}

.field-hint {
  margin: 0;
  font-size: 11px;
  line-height: 1.5;
  color: var(--text-faint);
}

/* The slot is always reserved, so an arriving message never shifts the submit
   button out from under the pointer. It fades rather than appears, because the
   text is announced by role="alert" at the same moment. */
.form-error {
  min-height: 18px;
  margin: 0 0 4px;
  font-size: 12px;
  color: var(--danger);
}

.form-error:not(:empty) {
  animation: fade-in var(--dur-fast) var(--ease);
}

/* A per-line batch report replaces the single sentence, so the slot has to grow.
   Scrolls rather than stretches: five hundred lines can be rejected, and the
   submit button must stay reachable without a page-length scroll. */
.error-list {
  margin: 8px 0 0;
  padding: 0 0 0 4px;
  max-height: 168px;
  overflow-y: auto;
  list-style: none;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.7;
}

.error-list b {
  font-weight: 600;
}

/* The field the server named. The border alone would not survive a red-green
   colour deficiency, so the ring thickens as well. */
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent);
}

.search-box {
  position: relative;
  flex: 1 1 260px;
  max-width: 360px;
}

.search-box svg {
  position: absolute;
  top: 50%;
  inset-inline-start: 12px;
  transform: translateY(-50%);
  width: 16px;
  height: 16px;
  color: var(--text-faint);
  pointer-events: none;
}

.search-box input {
  padding-inline-start: 36px;
}

/* Checkbox

   Флажок был системным везде, кроме списка выбора провайдеров, где ему задали только
   `accent-color`. Браузер рисует его своей геометрией: другой радиус, другая толщина
   рамки, другой размер, чем у поля рядом - в форме выпуска ключа шесть таких флажков
   стоят под полем ввода, и они выглядели деталью из чужого интерфейса.

   Галочка нарисована двумя границами псевдоэлемента, а не глифом: шрифтовая галочка
   зависит от того, есть ли она в установленном шрифте, и в Windows подставлялась
   заметно тяжелее нашего штриха.

   `:not(.switch)` - переключатель это тоже `input[type=checkbox]`, и он уже одет. */

input[type="checkbox"]:not(.switch) {
  appearance: none;
  display: inline-grid;
  place-content: center;
  width: 17px;
  height: 17px;
  min-width: 17px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-xs);
  background: var(--surface-2);
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease),
    transform var(--dur-instant) var(--ease);
}

input[type="checkbox"]:not(.switch):hover:not(:disabled) {
  border-color: var(--accent-line);
}

/* Отклик до отпускания (§10.1): флажок в списке из шести строк - то место, где человек
   быстро щёлкает подряд, и подтверждение нажатия нужно раньше перерисовки. */
input[type="checkbox"]:not(.switch):active:not(:disabled) {
  transform: scale(0.92);
}

input[type="checkbox"]:not(.switch)::after {
  content: "";
  width: 9px;
  height: 5px;
  border-inline-start: 2px solid var(--accent-ink);
  border-block-end: 2px solid var(--accent-ink);
  /* Смещение на 1px вверх: оптический центр галочки ниже геометрического, потому что
     нижний угол уходит вниз, и без сдвига она сидит в квадрате низко. */
  transform: translateY(-1px) rotate(-45deg) scale(0.55);
  opacity: 0;
  transition: opacity var(--dur-instant) var(--ease), transform var(--dur-fast) var(--ease-spring);
}

input[type="checkbox"]:not(.switch):checked {
  background: var(--accent);
  border-color: var(--accent);
}

input[type="checkbox"]:not(.switch):checked::after {
  opacity: 1;
  transform: translateY(-1px) rotate(-45deg) scale(1);
}

input[type="checkbox"]:not(.switch):disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Switch */

.switch-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  margin-bottom: 16px;
  padding: 11px 13px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: 13px;
}

.switch-field small {
  display: block;
  margin-top: 3px;
  font-size: 11px;
  color: var(--text-faint);
}

.switch {
  appearance: none;
  position: relative;
  width: 38px;
  height: 22px;
  min-width: 38px;
  border-radius: var(--radius-pill);
  background: var(--surface-3);
  border: 1px solid var(--line-strong);
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}

.switch::after {
  content: "";
  position: absolute;
  top: 3px;
  inset-inline-start: 3px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--text-faint);
  /* The knob is the one control that travels a real distance on toggle, so it
     gets the spring curve: a small overshoot reads as the knob landing. */
  transition: transform var(--dur-base) var(--ease-spring), background var(--dur-fast) var(--ease);
}

.switch:checked {
  background: var(--accent-soft);
  border-color: var(--accent-line);
}

/* Дорожка отвечает на нажатие раньше, чем ручка доедет: сам переключатель - самый
   медленный элемент интерфейса (пружина на 220ms), и без этого первые кадры после
   нажатия выглядят как «не сработало». */
.switch:active:not(:disabled) {
  background: var(--surface-hover);
  border-color: var(--accent-line);
}

.switch:checked::after {
  transform: translateX(16px);
  background: var(--accent);
}

/* Badges, dots, chips */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 9px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 560;
  /* 11px - самый мелкий текст, который здесь что-то утверждает («одобрен», «отказ»,
     «подтверждена»). Без положительного трекинга буквы на таком кегле слипаются, и
     бейдж читается как пятно нужного цвета, а не как слово (§10.6). */
  letter-spacing: 0.012em;
  line-height: 1.6;
  white-space: nowrap;
}

/* A badge that explains itself on hover has to anchor the tooltip to itself:
   [data-tooltip]::after is absolutely positioned, so without this it would attach
   to whichever ancestor happens to be positioned. */
.badge[data-tooltip] {
  position: relative;
}

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

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

.badge.warn {
  color: var(--warn);
  border-color: var(--warn-line);
  background: var(--warn-soft);
}

/* .info and .violet are category badges, not states. They render in the
   neutral tone so the only colours on a page are the accent and the real
   warning and failure states. */
.badge.info,
.badge.violet {
  color: var(--neutral);
  border-color: var(--neutral-line);
  background: var(--neutral-soft);
}

.badge.mono {
  font-family: var(--font-mono);
  font-size: 10.5px;
}
.badge.ch {
  color: #9cd4ad;
  border-color: rgba(127, 163, 140, 0.45);
  background: rgba(127, 163, 140, 0.12);
  box-shadow: 0 0 10px -2px rgba(127, 163, 140, 0.28);
}
.badge.pro {
  color: #c9b4e8;
  border-color: rgba(179, 184, 192, 0.45);
  background: rgba(179, 184, 192, 0.12);
  box-shadow: 0 0 10px -2px rgba(179, 184, 192, 0.28);
}
.badge.free {
  color: #93b5d8;
  border-color: rgba(147, 181, 216, 0.45);
  background: rgba(147, 181, 216, 0.12);
}

.dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--text-faint);
  flex: none;
}

.dot.up,
.dot.ok {
  background: var(--ok);
  box-shadow: 0 0 0 3px var(--ok-soft);
}

.dot.warn {
  background: var(--warn);
  box-shadow: 0 0 0 3px var(--warn-soft);
}

.dot.down,
.dot.bad {
  background: var(--danger);
  box-shadow: 0 0 0 3px var(--danger-soft);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}

.chip {
  display: inline-flex;
  align-items: center;
  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: 11px;
  white-space: nowrap;
}

.chip.more {
  color: var(--text-faint);
  border-style: dashed;
}

.trend-good {
  color: var(--ok);
}

.trend-warn {
  color: var(--warn);
}

.trend-bad {
  color: var(--danger);
}

/* Brand lockup */

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.brand-mark {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: var(--radius-mark);
  background: var(--surface-2);
  border: 1px solid var(--line);
}

.brand-mark.lg {
  width: 40px;
  height: 40px;
  border-radius: var(--radius-mark-lg);
}

/* WebGL mark -------------------------------------------------------------- */

/* logo3d.js adds .orbit-3d only after a context exists, so the SVG above stays
   visible on failure, on reduced-motion, and before the module loads. */
.orbit-3d {
  position: relative;
}

.orbit-3d .orbit-mark {
  display: none;
}

/* Pixel pre-state --------------------------------------------------------- */

/* What the mark wears before logo3d.js lands — and forever, when WebGL never
   arrives. The owner ordered the "half-broken SVG" replaced: the large hosts show
   pixels *only* (the .pre variant carries no rings at all), so there is no window
   where the old placeholder renders above or beside the pixel grid.

   The choreography is a cyber-tech boot, not random static: the squares assemble
   around each ellipse clockwise from the top (px-arrive, keyed by --a, the angle
   the generator stamps on every square), and while the WebGL render is still
   landing a light wave sweeps the same direction once (px-sweep) — the figure
   "powers up" into the chrome mark that replaces it. The wave runs only while the
   pixel layer exists, so it is the loading state, not a loop the finished mark
   keeps running.

   Positioning comes from the x/y attributes each rect carries from the generator;
   this sheet owns size, colour and clock only — writing x/y here would zero the
   geometry the generator just placed. The 32px marks are not .pre and keep the
   quiet rings: a shimmering grid at that size reads as noise, and they never get
   a canvas anyway. */
.orbit-mark.pre .orbit {
  display: none;
}

.orbit-mark .px {
  width: 3.4;
  height: 3.4;
  rx: 0.6;
  fill: var(--steel-mid, #8b96a5);
  opacity: 0;
  animation:
    px-arrive 0.5s var(--ease) calc(var(--a, 0) / 360 * 0.9s - 0.15s) both,
    px-sweep 1.15s var(--ease) calc(var(--a, 0) / 360 * 0.9s) both,
    px-breathe 2.8s ease-in-out calc(var(--a, 0) / 360 * -2.8s) infinite;
}

/* The sweep brightens each square as the wave passes its angle, then hands back to
   the steady shimmer the mark keeps for as long as it stays pixel. */
@keyframes px-arrive {
  from {
    opacity: 0;
  }
  to {
    opacity: 0.42;
  }
}

@keyframes px-sweep {
  0%,
  100% {
    fill: var(--steel-mid, #8b96a5);
  }
  50% {
    fill: var(--steel-hi, #dfe6ec);
  }
}

/* Once the wave has passed, the squares settle into the slow shimmer of a figure
   that is alive rather than one that is loading forever: a 2.8s breathe, phased by
   angle so the light appears to circle the rings. */
@keyframes px-breathe {
  0%,
  100% {
    opacity: 0.24;
  }
  50% {
    opacity: 0.6;
  }
}

/* The core disc breathes out of step with the rings, so the mark has a centre
   rather than one uniform twinkle. */
.orbit-mark .px.core {
  fill: var(--steel-hi, #dfe6ec);
  animation:
    px-arrive 0.5s var(--ease) -0.15s both,
    px-breathe 2.2s ease-in-out calc(var(--i, 0) * -0.21s) infinite;
}

/* Reduced motion keeps the figure but stills the choreography: frozen at
   opacity 0 the pixels would be absent, not calm — the same rule the reveal layer
   follows. The assembly reads as a complete, still figure. */
@media (prefers-reduced-motion: reduce) {
  .orbit-mark .px {
    animation: none;
    opacity: 0.45;
  }
}

.orbit-canvas {
  display: block;
  width: 100%;
  height: 100%;
  /* The renderer draws a square buffer; centring keeps it aligned inside a
     host whose box may not be exactly square during a resize. */
  margin: auto;
}

/* Animated chrome mark ---------------------------------------------------- */

/* The symbol paints its own strokes and fills, so the global svg rules for
   icons (currentColor stroke, 18px box) must not apply here. */
.orbit-mark {
  width: 100%;
  height: 100%;
  overflow: visible;
  stroke: none;
  fill: none;
  filter: drop-shadow(0 0 4px var(--steel-glow));
}

.brand-mark .orbit-mark {
  width: 74%;
  height: 74%;
}

/* Gradient stops cannot read a custom property from a presentation
   attribute, so the colours are applied here instead. */
.s-hi {
  stop-color: var(--steel-hi);
}

.s-mid {
  stop-color: var(--steel-mid);
}

.s-lo {
  stop-color: var(--steel-lo);
}

.s-core-in {
  stop-color: var(--steel-hi);
  stop-opacity: 0.9;
}

.s-core-mid {
  stop-color: var(--steel-hi);
  stop-opacity: 0.22;
}

.s-core-out {
  stop-color: var(--steel-hi);
  stop-opacity: 0;
}

.orbit {
  transform-origin: 32px 32px;
  /* ease-in-out, not --ease: the keyframes reverse (0° → 6° → 0°), and --ease is a critically
     damped ease-out — correct for an entrance, wrong for a loop. Each half decelerated into
     the extreme and then left it at full speed, so the mark lurched twice a cycle instead of
     swinging. Its sibling orbit-breathe already uses the symmetric curve for the same shape. */
  animation: orbit-tilt var(--loop-orbit) ease-in-out infinite;
}

.orbit-core {
  fill: url(#g-steel-core);
  stroke: none;
  animation: orbit-breathe var(--loop-breathe) ease-in-out infinite;
}

.orbit-body {
  fill: none;
  stroke: url(#g-steel);
  stroke-width: 4.2;
}

.orbit-body.cross {
  stroke: url(#g-steel-cross);
}

/* The gleam is a short dash chased around the ellipse. Its length is a
   fraction of the ~125 unit perimeter of both ellipses, which are congruent
   because the crossed ring only swaps the radii. */
.orbit-gleam {
  fill: none;
  stroke: var(--steel-hi);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 16 109;
  animation: orbit-gleam var(--loop-gleam) linear infinite;
}

.orbit-ring-b .orbit-gleam {
  animation-duration: var(--loop-gleam-cross);
  animation-delay: var(--loop-gleam-stagger);
}

.orbit-flare {
  fill: var(--steel-hi);
  stroke: none;
  animation: orbit-breathe var(--loop-breathe) ease-in-out infinite;
}

@keyframes orbit-gleam {
  from {
    stroke-dashoffset: 125;
  }
  to {
    stroke-dashoffset: 0;
  }
}

/* A slow tilt reads as a rotating solid instead of a spinning flat icon. */
@keyframes orbit-tilt {
  0%,
  100% {
    transform: rotate(0deg) scale(1);
  }
  50% {
    transform: rotate(6deg) scale(1.015);
  }
}

.orbit-rail {
  fill: none;
  stroke: url(#g-steel);
  stroke-width: 2.8;
  stroke-linecap: square;
  stroke-dasharray: 5 4;
  animation: rail-trace var(--loop-gleam) linear infinite;
}

.rail-b {
  animation-delay: var(--loop-gleam-stagger);
}

@keyframes rail-trace {
  from { stroke-dashoffset: 18; }
  to { stroke-dashoffset: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .orbit-rail { animation: none; }
}

@keyframes orbit-breathe {
  0%,
  100% {
    opacity: 0.75;
  }
  50% {
    opacity: 1;
  }
}

.brand-text {
  display: grid;
  min-width: 0;
}

.brand-text strong {
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.brand-text small {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* 4. App shell =========================================================== */

.app-shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  min-height: 100dvh;
}

.sidebar {
  position: sticky;
  top: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  height: 100dvh;
  background: var(--surface);
  border-inline-end: 1px solid var(--line);
  padding-block-start: env(safe-area-inset-top, 0px);
  padding-block-end: env(safe-area-inset-bottom, 0px);
}

.sidebar-head {
  display: flex;
  align-items: center;
  gap: 10px;
  height: var(--topbar-h);
  padding: 0 16px;
  border-bottom: 1px solid var(--line);
}

.sidebar nav {
  flex: 1;
  padding: 14px 10px;
  overflow-y: auto;
}

.nav-group {
  margin: 18px 8px 6px;
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.nav-group:first-child {
  margin-top: 4px;
}

.nav-item {
  display: grid;
  grid-template-columns: 20px 1fr auto;
  align-items: center;
  gap: 11px;
  width: 100%;
  height: 38px;
  padding: 0 11px;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 500;
  text-align: left;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}

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

.nav-item[data-view="channels"] svg,
.nav-item[data-view="policies"] svg,
.nav-item[data-view="models"] svg { color: var(--accent); }

/* Icon-pack motion is semantic: routes trace, health breathes, and retries rotate
   only while their control is in flight. Static icons remain quiet for scanning. */
@keyframes icon-route-trace {
  from { stroke-dashoffset: 16; }
  to { stroke-dashoffset: 0; }
}
@keyframes icon-health-pulse {
  0%, 100% { opacity: .7; transform: scale(.96); }
  50% { opacity: 1; transform: scale(1); }
}
@keyframes icon-retry-turn {
  to { transform: rotate(1turn); }
}
.nav-item[data-view="channels"] svg path,
.nav-item[data-view="policies"] svg path { stroke-dasharray: 4 3; animation: icon-route-trace var(--loop-gleam) linear infinite; }
.stat-head svg[data-icon-state="health"],
.health-pill svg { transform-origin: center; animation: icon-health-pulse var(--loop-breathe) ease-in-out infinite; }
.is-loading > svg[data-icon-state="retry"],
button[data-loading] svg { animation: icon-retry-turn var(--dur-spin) linear infinite; }

@media (prefers-reduced-motion: reduce) {
  .nav-item[data-view="channels"] svg path,
  .nav-item[data-view="policies"] svg path,
  .stat-head svg[data-icon-state="health"],
  .health-pill svg,
  .is-loading > svg[data-icon-state="retry"],
  button[data-loading] svg { animation: none; }
}

.nav-item:hover {
  background: var(--surface-hover);
  color: var(--text);
}

.nav-item.active {
  background: var(--accent-soft);
  color: var(--text);
  font-weight: 580;
}

.nav-item.active svg {
  color: var(--accent);
}

.nav-count {
  padding: 1px 7px;
  border-radius: var(--radius-pill);
  background: var(--surface-3);
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
}

.nav-item.active .nav-count {
  background: var(--accent-soft);
  color: var(--accent);
}

.sidebar-foot {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 12px;
  border-top: 1px solid var(--line);
}

.operator {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  min-width: 0;
}

.avatar {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  flex: none;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface-2);
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 650;
}

.operator-text {
  display: grid;
  min-width: 0;
}

.operator-text strong {
  font-size: 12.5px;
  font-weight: 580;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.operator-text small {
  font-size: 11px;
  color: var(--text-faint);
}

main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 30;
  display: flex;
  align-items: center;
  gap: 12px;
  height: var(--topbar-h);
  padding: 0 22px;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--line);
}

.breadcrumbs {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  font-size: 13px;
  color: var(--text-faint);
}

.breadcrumbs strong {
  color: var(--text);
  font-weight: 580;
}

.top-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-inline-start: auto;
}

/* The label changes — «Checking» → «System nominal» → «Database down» — and the pill is in a
   flex row with the theme and refresh buttons, so every width change pushed two controls the
   operator aims at all day sideways. `min-width` holds the widest label's worth of space, and
   `justify-content` keeps the dot and text at the left rather than centred in the reserved
   box, so the text does not shuffle within it either. */
.health {
  display: inline-flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  min-width: var(--health-min-width);
  padding: 5px 11px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.health.ok {
  color: var(--ok);
  border-color: var(--ok-line);
  background: var(--ok-soft);
}

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

/* Reserved for the same reason: this goes from empty to «SYNC 12:04:31» on the first load,
   and the buttons beside it must not move when it does. */
.last-sync {
  min-width: 88px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  color: var(--text-faint);
  white-space: nowrap;
}

.content {
  flex: 1;
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 22px 22px 40px;
}

.sidebar-scrim,
.drawer-scrim {
  position: fixed;
  inset: 0;
  background: var(--scrim-dim);
  animation: fade-in var(--dur-fast) var(--ease);
}

.sidebar-scrim {
  z-index: 35;
}

.drawer-scrim {
  z-index: 70;
}

/* 5. Login =============================================================== */

.login-shell {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr);
  /* dvh, not vh: on mobile the browser chrome makes 100vh taller than the
     visible area, which pushed the form under the address bar. */
  min-height: 100dvh;
}

.login-brand {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 40px;
  padding: 36px 52px;
  background: var(--surface);
  border-inline-end: 1px solid var(--line);
  overflow: hidden;
}

.login-brand > * {
  position: relative;
  z-index: 1;
}

/* Bounded so the copy never runs underneath the mark on the right, and
   vertically centred in whatever space the lockup leaves. */
.login-copy {
  max-width: 620px;
  margin-block: auto;
}

.login-copy h1 {
  margin: 0 0 20px;
  font-family: var(--font-display);
  font-size: clamp(30px, 3.1vw, 42px);
  font-weight: 500;
  line-height: 1.12;
  letter-spacing: -0.02em;
}

.login-copy > p {
  max-width: 520px;
  font-size: 16px;
  line-height: 1.65;
  color: var(--text-muted);
}

.login-features {
  display: grid;
  gap: 12px;
  margin-top: 30px;
  padding: 0;
  list-style: none;
}

.login-features li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13.5px;
  color: var(--text-muted);
}

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

/* Hero mark. It sits behind the copy as decoration, so it must never take
   pointer events or push the layout around. */
.login-orbit {
  position: absolute;
  inset-inline-end: -80px;
  bottom: 50%;
  z-index: 0;
  width: clamp(320px, 34vw, 520px);
  aspect-ratio: 1;
  transform: translateY(50%);
  opacity: 0.5;
  pointer-events: none;
}

/* The pad under the large mark. On the dark theme the chrome object's own
   reflections make a soft grey disc, and the owner asked the light theme for
   the same; the light render is graphite over a near-white panel, where nothing
   gathers behind it. So the pad is drawn rather than left to the render: a
   quiet radial on both themes, keyed to each palette so the dark keeps what it
   had and the light gains it. It sits under the pixel figure too, so the
   loading state and the finished mark sit on the same stage. */
.login-orbit::before {
  content: "";
  position: absolute;
  inset: 8%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    var(--orbit-pad, rgba(139, 150, 165, 0.16)) 0%,
    transparent 70%
  );
  pointer-events: none;
}

.login-orbit .orbit-mark {
  filter: drop-shadow(0 0 26px var(--steel-glow));
}

/* The 3D render carries its own highlights and reads best at full strength.

   It is also a solid object rather than a background flourish, and that changes where it
   belongs: the flat SVG could sit under the copy at half opacity, but an opaque chrome mark
   over a sentence hides the sentence. So it is pushed to the panel's right edge and the copy
   is bounded away from it (`.login-copy` max-width below), leaving the two side by side
   instead of stacked. Clipping is also avoided: a photoreal object cut by the panel edge
   looks like a mistake, while the flat mark read as decoration. */
.login-orbit.orbit-3d {
  inset-inline-end: -20px;
  width: clamp(300px, 26vw, 420px);
  opacity: 1;
  /* Once per session, when WebGL actually mounts. Opacity only: this node already
     uses transform to sit beside the copy, and an arrival that rewrote transform
     would un-center it. */
  animation: fade-in var(--dur-base) var(--ease);
}

/* Only when the 3D mark is actually mounted: with the flat SVG the copy may run wide,
   because a half-opacity flourish behind text is what that version is for. */
.login-brand:has(.login-orbit.orbit-3d) .login-copy {
  max-width: min(520px, 58%);
}

/* Tablet keeps two columns: the large orbit collides with the manifesto.
   Below 960px the shell is one column and the orbit is the identity — hiding
   it there left a phone with a form and no mark (docs/commercial-audit.md §5). */
@media (max-width: 1180px) and (min-width: 961px) {
  .login-orbit {
    display: none;
  }
}

.login-panel {
  display: grid;
  place-items: center;
  padding: 46px 40px;
  background: var(--bg);
}

.login-form {
  width: min(400px, 100%);
}

.login-form h2 {
  margin: 0 0 6px;
  font-family: var(--font-display);
  font-size: 24px;
  font-weight: 500;
}

.login-form > .muted {
  margin-bottom: 26px;
  font-size: 13.5px;
}

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

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

/* 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);
  font-size: 21px;
  font-weight: 500;
  letter-spacing: -0.01em;
}

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

.heading-actions {
  display: flex;
  gap: 8px;
  flex: none;
}

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 10px;
  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). */
.page-heading,
.panel-head {
  position: relative;
}

.page-heading::after,
.panel-head::after {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  inset-block-end: -8px;
  width: 72px;
  height: 1px;
  background: var(--accent);
  opacity: 0.55;
}

.page-heading h1::before {
  content: "SYS / ";
  margin-inline-end: 7px;
  color: var(--text-faint);
  font: 500 9px/1 var(--font-mono);
  letter-spacing: 0.08em;
  vertical-align: middle;
}

 /* 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). */
.stat {
  padding: 13px 15px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface);
  min-width: 0;
}

.stat-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 12px;
  font-weight: 540;
  color: var(--text-muted);
}

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

.stat-value {
  display: block;
  margin: 9px 0 2px;
  font-size: 24px;
  font-weight: 640;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

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

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

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

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

.panel {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
  min-width: 0;
  overflow: hidden;
}

.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  min-height: 46px;
  padding: 9px 16px;
  border-bottom: 1px solid var(--line);
}

.panel-head h2 {
  font-size: 13.5px;
  font-weight: 600;
}

.panel-head p {
  margin-top: 2px;
  font-size: 12px;
  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: 14px 16px;
}

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

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

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

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

/* 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: 14px;
  padding: 11px 13px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-size: 12.5px;
  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: 13px;
  font-weight: 580;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.route-name small {
  font-family: var(--font-mono);
  font-size: 11px;
  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: 13px;
}

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

.meter-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  font-size: 12.5px;
}

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

.meter-head span {
  flex: none;
  font-family: var(--font-mono);
  font-size: 11px;
  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: 11px;
  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-sm);
  background: var(--surface);
  overflow: hidden;
}

/* Горизонтальная прокрутка таблицы была невидимой: на узком экране «Журнал решений»
   шире окна, и ничто не сообщало, что справа есть ещё колонки - пользователь читал
   обрезанную таблицу как полную. Четыре фоновых слоя: два кроющих (`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, rgba(0, 0, 0, 0.5), transparent) left center / 14px 100% no-repeat scroll,
    linear-gradient(to left, rgba(0, 0, 0, 0.5), 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: 36px;
  padding: 0 14px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
  text-align: left;
  white-space: nowrap;
}

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

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

table.data tbody tr.interactive {
  cursor: pointer;
}

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

/* Строка, которая откроется карточкой, при наведении получает кромку слева: курсор
   говорит это только тому, у кого есть мышь, а кромка видна и при навигации с
   клавиатуры. `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);
}

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

.cell-primary {
  display: block;
  color: var(--text);
  font-size: 13px;
  font-weight: 580;
}

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

.cell-mono {
  font-family: var(--font-mono);
  font-size: 11.5px;
}

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

   Блок, а не inline: в ячейке их столько, сколько поставщиков у модели, и каждый должен
   читаться отдельной строкой без <br> в разметке. Ячейка не обрезается по ширине, как
   `.cell-sub`: обрезанное имя модели у вендора — это ровно тот факт, за которым клиент
   сюда и смотрит. */
.offer-line {
  display: block;
  font-size: 11.5px;
  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: 5px;
}

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

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

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

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

/* Empty, loading, error states */

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

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

.empty strong {
  font-size: 14px;
  font-weight: 580;
}

.empty span {
  max-width: 420px;
  font-size: 12.5px;
  color: var(--text-muted);
}

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

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

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

/* 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%;
  background: linear-gradient(
    90deg,
    transparent 25%,
    var(--surface-3) 50%,
    transparent 75%
  );
  animation: shimmer var(--loop-shimmer) linear infinite;
}

/* 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;
  }
}

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

/* 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%);
  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);
}

@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: 17px 20px;
  border-bottom: 1px solid var(--line);
}

.modal > header h2 {
  margin-top: 3px;
  font-size: 17px;
}

.modal > form,
.modal > .modal-body {
  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: 9px;
  margin-top: 6px;
  padding-top: 17px;
  border-top: 1px solid var(--line);
}

/* Model pin editor */

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

.pin-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 36px;
  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: 14px;
  /* 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: 18px;
  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: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  cursor: pointer;
  font: inherit;
}

.method:hover:not(.active) {
  border-color: var(--line-strong);
}

/* The selected state is a border and a background, 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);
}

.method strong {
  font-size: 13px;
  font-weight: 600;
}

.method span {
  font-size: 11.5px;
  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-sm);
  background: var(--surface-2);
}

.picker-row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 10px;
  align-items: center;
  padding: 8px 10px;
  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);
  font-size: 12.5px;
  font-weight: 600;
}

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

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

/* Confirm dialog */

.confirm-dialog {
  width: min(430px, 100%);
  padding: 26px 24px 22px;
  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 15px;
  border: 1px solid var(--danger-line);
  border-radius: 50%;
  background: var(--danger-soft);
  color: var(--danger);
}

.confirm-dialog h2 {
  font-size: 17px;
}

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

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

.confirm-actions {
  display: flex;
  gap: 9px;
  margin-top: 22px;
}

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

/* Secret reveal */

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

.secret-value {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 15px 0 18px;
  padding: 13px 14px;
  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: 12.5px;
  color: var(--text);
  word-break: break-all;
}

/* 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: 18px 20px;
  border-bottom: 1px solid var(--line);
}

.drawer-head h2 {
  margin: 5px 0 4px;
  font-size: 18px;
  overflow-wrap: anywhere;
}

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

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

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

.detail-status small {
  display: block;
  margin-top: 6px;
  font-size: 12px;
  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 14px;
  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: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.detail-cell strong {
  display: block;
  margin-top: 5px;
  font-size: 13px;
  font-weight: 580;
  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: 11.5px;
  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: 22px;
}

.detail-section h3 {
  margin-bottom: 10px;
  font-family: var(--font-mono);
  font-size: 10px;
  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: 6px;
}

.metric-row {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr));
  gap: 10px;
  align-items: center;
  padding: 10px 13px;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: 11.5px;
  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;
}

/* 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 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

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

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

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

.price-editor-head,
.price-editor-row {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) repeat(5, minmax(84px, 0.5fr));
  gap: 8px;
  align-items: center;
}

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

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

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

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

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

.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: 6px;
  /* 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 6px;
  margin-inline-start: 18px;
}

/* 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: 12.5px;
  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: 13px 15px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  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: 10px;
}

.import-group-head b {
  display: block;
  font-size: 13px;
  font-weight: 560;
  overflow-wrap: anywhere;
}

.import-group-head small {
  display: block;
  margin-top: 3px;
  font-size: 11px;
  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: 5px;
  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: 14px;
}

.trace-summary > div {
  padding: 12px 14px;
  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: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.trace-summary strong {
  display: block;
  margin-top: 6px;
  font-size: 16px;
  font-weight: 640;
  overflow-wrap: anywhere;
}

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

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

/* 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: 13px;
  font-weight: 560;
  user-select: none;
}

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

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

.candidate-card {
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  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: 10px;
  margin-bottom: 10px;
}

.candidate-head strong {
  font-size: 13px;
  font-weight: 580;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

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

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

.candidate-metrics b {
  font-family: var(--font-sans);
  font-size: 13px;
  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: 22px;
}

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

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

textarea.sans {
  font-family: var(--font-sans);
  font-size: 13px;
}

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

.probe-result strong {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
}

.probe-result p {
  margin-top: 6px;
  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(18px, env(safe-area-inset-bottom, 0px));
  z-index: 90;
  display: grid;
  gap: 9px;
  width: min(360px, calc(100dvw - 36px));
}

.toast {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 12px 14px;
  border: 1px solid var(--line-strong);
  border-inline-start: 3px solid var(--ok);
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  font-size: 13px;
  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 {
  border-inline-start-color: var(--danger);
}

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

.toast.warn {
  border-inline-start-color: var(--warn);
}

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

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

/* 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: mark, wordmark, compact orbit. The manifesto goes — it is
     what made this column too tall, not the brand itself. */
  .login-brand {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    padding: 28px 22px 12px;
    border-inline-end: none;
    border-block-end: 1px solid var(--line);
    overflow: visible;
  }

  .login-copy {
    display: none;
  }

  .login-orbit,
  .login-orbit.orbit-3d {
    position: relative;
    inset: auto;
    width: min(168px, 42vw);
    transform: none;
    opacity: 1;
    order: -1;
  }

  .login-brand > .brand .brand-mark {
    display: none;
  }

  .login-panel {
    padding: 32px 22px;
  }

  .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: 14px;
  }

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

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

  .topbar {
    padding: 0 16px;
  }

  .last-sync {
    display: none;
  }
}

@media (max-width: 700px) {
  /* On phones the orbit is identity, not a second hero. Keeping its desktop-sized
     flex item made the brand consume 320–347px before the form even started. */
  .login-brand {
    gap: 10px;
    padding: 18px 18px 12px;
  }

  .login-orbit,
  .login-orbit.orbit-3d {
    width: min(112px, 30vw);
  }

  .login-panel {
    padding: 24px 18px 32px;
  }

  .login-form h2 {
    font-size: 22px;
  }

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

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

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

  .page-heading {
    flex-direction: column;
    align-items: stretch;
  }

  /* `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. A 42px pill would be a circle among squares. */
    width: 42px;
    height: 42px;
    /* 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: 6px -20px -20px;
    padding: 14px 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: 16px;
  }

  /* 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. 32px is half a finger. */
  .btn.sm {
    height: 40px;
    padding: 0 14px;
  }

  .icon-btn {
    width: 42px;
    height: 42px;
    min-width: 42px;
  }

  .icon-btn.sm {
    width: 40px;
    height: 40px;
    min-width: 40px;
  }

  /* 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 {
    display: none;
  }

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

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

/* 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, which JS reads via
   getComputedStyle. The two must move together: change the token, change this query. */
@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: 10px;
    padding: 12px 14px;
    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: 6px 0;
    border-bottom: 0;
  }

  table.data td::before {
    content: attr(data-label);
    display: block;
    margin-bottom: 3px;
    font-family: var(--font-mono);
    font-size: 10px;
    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: 6px;
    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;
  }

  /* The rule above freezes the chase mid-ring and would leave a stray dash on
     the mark, so the highlight is dropped instead of frozen. */
  .orbit-gleam {
    display: none;
  }

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

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

/* 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;
  }
}

@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. */
  :root {
    --scrim: rgba(0, 0, 0, 0.86);
    --scrim-dim: rgba(0, 0, 0, 0.8);
  }

  /* Верхняя полоса и выехавшее меню - тоже материал, и они держат текст: адрес раздела и
     четырнадцать названий. Размытие снимается, фон становится кроющим - иначе под
     названием раздела просвечивает таблица, ровно то, от чего человек включил настройку. */
  .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: 14px;
  font-size: 14px;
  line-height: 1.6;
}

.rules-copy h3 {
  margin: 8px 0 0;
  font-size: 15px;
}

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

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

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

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

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

.model-signals {
  display: inline-flex;
  align-items: flex-end;
  gap: 7px;
  flex: none;
}

.signal {
  position: relative;
  display: inline-flex;
  align-items: flex-end;
  gap: 2px;
  height: 14px;
}

/* Общий тултип центрируется над хозяином. Эти метки — 23px в начале первой ячейки: короб
   вылезает за панель, а `border-collapse` на таблице срезает всё, что вышло из ячейки —
   «Скорость ещё не измерена» приезжала нарезанной полоской. Сажаем к концу метки, внутрь
   строки. */
.signal[data-tooltip]::after {
  bottom: auto;
  top: 50%;
  inset-inline-start: 100%;
  margin-inline-start: 8px;
  transform: translateY(-50%);
}

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

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

/* Карточки снимают отступ первой ячейки, и вертикально центрированный короб больше не
   влезает в строку заголовка. Сажаем под метку: у остальной карточки место есть. */
@media (max-width: 1080px) {
  .signal[data-tooltip]::after {
    top: calc(100% + 6px);
    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. */
  :root {
    --text-muted: var(--text);
    --text-faint: var(--text);
    --line: var(--line-strong);
    --line-soft: var(--line-strong);
  }

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

/* ==========================================================================
   Constructor Workspace: Drag-and-Drop Channels & Groups
   ========================================================================== */

.constructor-workspace {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
  gap: 18px;
  align-items: start;
  margin-top: 18px;
}

.constructor-board-column {
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 18px;
  gap: 14px;
  transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}

.constructor-board-column.drag-hover {
  border-color: var(--ok);
  box-shadow: 0 0 16px -2px rgba(127, 163, 140, 0.35);
}

.board-column-head {
  display: flex;
  flex-direction: column;
  gap: 8px;
  border-bottom: 1px solid var(--line);
  padding-bottom: 12px;
}

.bch-title-row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.bch-title-row h3 {
  margin: 0;
  font-size: 16px;
}

.bch-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 13px;
  color: var(--text-muted);
}

.board-column-dropzone {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 180px;
}

.dropzone-header {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: var(--text-muted);
}

.dropzone-cards {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.dropzone-empty {
  padding: 24px;
  text-align: center;
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius-sm);
  color: var(--text-faint);
  font-size: 13px;
}

.constructor-provider-card {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 14px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  cursor: grab;
  user-select: none;
  transition: transform var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), opacity var(--dur-fast) var(--ease);
}

.constructor-provider-card:hover {
  border-color: var(--line-strong);
  transform: translateY(-2px);
}

.constructor-provider-card.dragging {
  opacity: 0.4;
  cursor: grabbing;
}

.cpc-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}

.cpc-title {
  font-size: 14px;
  display: block;
}

.cpc-models {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.draggable-model-pill {
  display: inline-block;
  padding: 2px 7px;
  font-size: 11px;
  font-family: var(--font-mono);
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  cursor: grab;
  transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}

.draggable-model-pill:hover {
  border-color: var(--accent-line);
  background: var(--surface-hover);
}

.draggable-model-pill.dragging {
  opacity: 0.3;
}

.cpc-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  margin-top: 4px;
}

/* Быстрый перенос вендора в другую надгруппу. Своя высота, потому что стоит в ряд с
   кнопкой `.btn.sm`, но `padding` обязан оставить место под стрелку: короткая запись
   `padding: 3px 6px` перебивала `padding-inline-end: 34px` из общего правила `select`,
   и подпись «Перенести в…» уезжала под стрелку — текст выглядел обрезанным на середине
   слова. Позиции стрелки заданы заново: значения общего правила рассчитаны на 38px и на
   32px опускали её ниже центра. */
.cpc-quick-move {
  height: 32px;
  font-size: 12px;
  padding: 0 30px 0 10px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line);
  background-color: var(--surface-3);
  background-position: calc(100% - 16px) 14px, calc(100% - 11px) 14px;
  color: var(--text-muted);
  max-width: 190px;
}

/* ==========================================================================
   Физика интерфейса: отклик на нажатии
   ==========================================================================
   Закреплено `docs/design-system.md` §10.1 и `SYSTEM_RULES.md` D-8. До этого в файле
   было два `:active` на четыре тысячи строк: кнопка отвечала, а боковое меню, вкладки,
   чипы и строки таблиц с действием — нет, и нажатие по ним читалось как «не сработало».
   Сдвиг на один пиксель плюс более тёмная поверхность: мгновенно, без перелёта, и не
   меняет раскладку. */

.icon-btn:active:not(:disabled),
.nav-item:active,
.tab:active,
.chip:active {
  background: var(--surface-3);
  transform: translateY(1px);
  transition-duration: var(--dur-instant);
}

/* Строка, по которой открывается карточка: в разметке она помечена `.interactive`
   (`app.js`, семь таблиц — провайдеры, каналы, прокси, аккаунты, заявки, журнал решений,
   журнал действий). Поверхность без сдвига: строка стоит в таблице, и сдвиг одной строки
   ломает выравнивание колонок.

   Селекторы здесь — только те, что есть в разметке. Первая редакция этого блока несла
   `.pill`, `.section-link`, `.table-action` и `.list-row`, которых нет ни в одном файле:
   правило, которое ничего не выбирает, — тот же мёртвый рычаг, что запрещает ARCH-06. */
tr.interactive:active {
  background: var(--surface-2);
  transition-duration: var(--dur-instant);
}

@media (prefers-reduced-motion: reduce) {
  .icon-btn:active:not(:disabled),
  .nav-item:active,
  .tab:active,
  .chip:active {
    transform: none;
  }
}

/* Shared first-paint loader: a small steel wordmark and a finite tile field. It owns
   only its overlay subtree and never changes hero or panel motion. */
.pc-loader {
  position: fixed;
  inset: 0;
  z-index: 10000;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 10px;
  background: var(--bg);
  color: var(--text);
  transition: opacity 420ms ease, visibility 420ms ease;
  pointer-events: none;
}
.pc-loader.is-ready { opacity: 0; visibility: hidden; }
.pc-loader-wordmark {
  position: relative;
  color: var(--text);
  font-family: var(--font-display);
  font-size: clamp(34px, 7vw, 82px);
  font-weight: 800;
  letter-spacing: .02em;
  line-height: .9;
  text-transform: uppercase;
  text-shadow: 1px 1px 0 var(--surface-3), 2px 2px 0 var(--surface-2), 0 0 22px var(--accent-soft);
}
.pc-loader-wordmark::after {
  position: absolute;
  inset: 0;
  color: transparent;
  content: attr(data-loader-wordmark);
  -webkit-text-stroke: 1px var(--line-strong);
  transform: translate(3px, 3px);
  z-index: -1;
}
.pc-loader-sub {
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: .22em;
  text-transform: uppercase;
}
.pc-loader-grid {
  display: grid;
  grid-template-columns: repeat(8, 11px);
  gap: 4px;
  margin-top: 18px;
  transform: perspective(180px) rotateX(24deg) rotateZ(-4deg);
}
.pc-loader-grid i {
  width: 11px;
  height: 11px;
  border: 1px solid var(--line);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--surface-1);
  animation: pc-loader-tile 1.8s cubic-bezier(.2,.7,.2,1) infinite;
  animation-delay: calc(var(--tile, 0) * 42ms);
}
.pc-loader-grid i:nth-child(4n + 1) { --tile: 1; }
.pc-loader-grid i:nth-child(4n + 2) { --tile: 2; }
.pc-loader-grid i:nth-child(4n + 3) { --tile: 3; }
.pc-loader-grid i:nth-child(4n) { --tile: 4; }
@keyframes pc-loader-tile {
  0%, 100% { background: var(--surface-2); border-color: var(--line); transform: translateY(0); }
  38% { background: var(--accent); border-color: var(--accent); transform: translateY(-3px); }
  62% { background: var(--surface-3); border-color: var(--line-strong); transform: translateY(0); }
}
@media (max-width: 520px) {
  .pc-loader-grid { grid-template-columns: repeat(8, 9px); gap: 3px; }
  .pc-loader-grid i { width: 9px; height: 9px; }
}
@media (prefers-reduced-motion: reduce) {
  .pc-loader, .pc-loader-grid i { animation: none; transition-duration: 1ms; }
}
/* Relay icon system integration overrides: keep the grid beneath the existing shell surfaces. */
.app-shell,
.login-shell,
.site-main {
  background-image: linear-gradient(var(--relay-grid) 1px, transparent 1px), linear-gradient(90deg, var(--relay-grid) 1px, transparent 1px) !important;
  background-size: 32px 32px;
  background-position: -1px -1px;
}
.panel, .stat, .info-card, .option-card, .supply-card { border-radius: var(--radius-icon); }
.nav-item.active { box-shadow: inset 2px 0 var(--relay-acid); }
.nav-item.active svg { color: var(--relay-acid); filter: drop-shadow(0 0 7px rgba(184, 255, 71, 0.28)); }
.panel-head::after, .page-heading::after { background: var(--relay-acid); }
@media (prefers-reduced-motion: reduce) { .app-shell, .login-shell, .site-main { background-image: none !important; } }

