/* ==========================================================================
   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)
   6.5 Per-view rhythm (what one screen may change about its own density)
   7. Overlays (modal, drawer, toast, tooltip)
   8. Responsive
   ========================================================================== */

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

:root {
  color-scheme: dark;
  /* Relay tokens harmonized into the graphite/platinum system:
     callers get refined platinum accents rather than decorative green. */
  --relay-acid: var(--accent);
  --relay-acid-soft: var(--accent-soft);
  --relay-acid-line: var(--accent-line);
  --relay-grid: rgba(255, 255, 255, 0.025);
  --radius-icon: var(--radius-sm);

  /* Geist, the face the reference is set in (jamiemckaye.com). Checked before adopting:
     the variable cut carries the full Russian alphabet, latin-ext, «»—…№ and the arrow
     and box-drawing ranges the interface draws, at 56 KB subset — so the reference's
     typography does not cost us the Cyrillic the product is written in.
     The stack falls through to the system grotesque when the face fails to load. Inter was
     named here for that job and is gone: it was a whole family embedded and served to
     nobody, because a fallback that a variable font never reaches does not need its own
     bytes. The system face is a worse match and a free one. */
  --font-sans: "Geist", "Segoe UI", system-ui, -apple-system, sans-serif;
  --font-mono: "Geist Mono", "Cascadia Code", Consolas, ui-monospace, monospace;
  /* The display face. The reference sets headings in its text face at a large size rather
     than in a second family, and that is what makes its pages read as one document. The
     wordmark is drawn as a mark, not as type, so no third family is needed for it. */
  --font-display: "Geist", 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;
  /* Contrast, measured against `--bg` and set to a floor rather than to a colour somebody
     liked: the reference keeps its secondary text at 12.75:1, and its quietest grey is still
     legible. Ours was 4:1 on `--text-faint` — below the 4.5:1 floor for small text, on a
     surface whose whole job is to be read. `--text-muted` already passed at 7.22:1.
     The hue is kept; only the lightness moved. */
  --text-muted: #979ca4;
  --text-faint: #747981;

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

  /* Terminal register. The interface reads as a console: corners are square, rules are
     hairlines, labels are monospace. A rounded card is a template; a ruled line is a
     document, and the product is closer to a document than to a dashboard. */
  --radius-xs: 2px;
  --radius-sm: 2px;
  --radius: 2px;
  --radius-lg: 3px;
  --radius-pill: 2px;
  /* 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;

  /* Type scale and measure, taken from the reference the owner pointed at
     (jamiemckaye.com). The numbers are theirs; the register is the point — a display size
     that clamps against the viewport, an editorial size for section headings, one body
     size, and a small-caps label. Sizes are declared here so a new surface picks a step
     instead of inventing a clamp of its own.
     The measure tokens exist because a line of prose has an ideal length independent of
     the window: 68ch for a column beside a figure, 72ch for a page of documentation. */
  --text-display: clamp(2.75rem, 1.7rem + 5.2vw, 6.5rem);
  --text-display-leading: 0.98;
  /* Measured on the reference at 1440: 104.4px type carries -6.0552px of tracking, which is
     -0.058em. Ours was -0.03em — half as tight, and at display size that reads as loose and
     unresolved rather than as a headline. Tightening scales with the type, so the ratio is
     what is copied, not the pixel value. */
  --text-display-tracking: -0.058em;
  --text-editorial: clamp(1.75rem, 1.4rem + 1.2vw, 2.25rem);
  --text-editorial-leading: 1.15;
  --text-body: 1.0625rem;
  --text-body-leading: 1.65;
  --text-label: 0.71875rem;
  --text-label-tracking: 0.14em;
  --measure-narrow: 68ch;
  --measure-wide: 72ch;
  /* The vertical rhythm between chapters. The reference this page is measured against gives
     every section 96px above and below its hairline — 192px between two blocks of content,
     which is what makes one screen carry one idea instead of three. Ours was 32px, and the
     page came out at 3004px against the reference's 6049: half the space, and the difference
     between a document and a stack. The token is named so a new section picks the rhythm
     instead of writing its own padding. */
  --section-gap: clamp(48px, 6.7vw, 96px);

  /* 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);
  --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;
  /* And the step for the surface that is not read once. An operator opens a panel section dozens
     of times in a shift, so its sequence has to be over before it can be noticed *as* a sequence:
     26ms against the storefront's 55ms, capped at two steps. Both exist because one number cannot
     serve both rows of the frequency table, and a rule that reached for `--stagger` here would be
     inventing a value it did not choose. */
  --stagger-tight: 26ms;

  /* 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;
  /* Same measurement as the dark block above, and the light theme needed more of it: muted
     was 5.63:1 and faint 3.09:1 against `#f2f3f5`. Both now clear the floors — 7.09:1 and
     4.55:1 — which is what the reference's 12.75:1 looks like at our smaller body size. */
  --text-muted: #4d5259;
  --text-faint: #6b6f76;

  --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);

  /* Measured on its own pill, not on the page: `--ok` sat on `--ok-soft` at 4.44:1, just
     under the small-text floor, and the panel's "Система в норме" is 10.5px. #486d56 gives
     4.65:1. The hue is unchanged; only the lightness moved. */
  --ok: #486d56;
  --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);

  --relay-acid: var(--accent);
  --relay-acid-soft: var(--accent-soft);
  --relay-acid-line: var(--accent-line);
  --relay-grid: rgba(0, 0, 0, 0.02);
}

/* 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;
  /* The column holds the screen and the footer, and the footer is a sibling of the shell rather
     than a child of it.

     That placement is the whole point. Both `#login` and `#app` are `min-height: 100dvh` and one
     of them is always hidden, so a footer inside either is invisible on the other screen —
     measured on 26 September 2026: on `/app` the footer's box was 0×0, because it lived inside
     `#app` while the login was showing. As a sibling it is present on every screen of every
     cabinet, which is what "a footer everywhere" means. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  /* 600, measured on the reference (its h1 and its 36/41.6px section headings all sit at
     600). Ours was 640 — a value between two real steps that made headings read heavier than
     the face they are set in. */
  font-weight: 600;
  /* 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;
}

/* Icons: one filled pixel set for the whole product.
 *
 * The set is `halfmage/pixelarticons` (MIT, 16-grid) plus the ten vendor marks we draw
 * ourselves, composed into `icons.svg` by `tools/build_icons.mjs`. It is **filled paths**,
 * not strokes — the opposite of what this rule said — so the base declares `fill` and no
 * stroke, and `shape-rendering` keeps the pixels square instead of antialiasing a 1-unit
 * edge into a grey smudge at 18px.
 *
 * Why a vendored set replaced our own drawings: 51 names with **15 drawn differently** across
 * the four documents, no test comparing them, and no single shape language. The owner asked
 * for an old-computer register and for ready-made parts; the set is both, and it is a
 * hundredth of the weight of the real XP artwork (501 PNGs at ~350 KB each).
 *
 * The vendor marks (`i-lab-*`) are the exception and keep their stroke drawing: the set
 * carries no brand logos. They are declared in `icons-own.svg` rather than mixed in, so the
 * two drawing systems stay separable — see `.lab-mark` below for their own rule. */
svg {
  width: 18px;
  height: 18px;
  flex: none;
  fill: currentColor;
  stroke: none;
  shape-rendering: crispEdges;
}

/* The vendor marks are stroke-drawn, so they opt back out of the fill above. A brand logo is
   a drawn outline in this set of marks, and filling it would render a solid blob. */
svg:has(use[href^="#i-lab"]) {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: square;
  stroke-linejoin: bevel;
}

/* Метка лабы у имени модели: рисунок наследует общее правило svg, здесь только размер
   по контексту. 16px в строке таблицы, 13px в плотных пилюлях оператора. */
.lab-mark {
  width: 16px;
  height: 16px;
  vertical-align: middle;
}

.price-editor-row .lab-mark,
.draggable-model-pill .lab-mark {
  width: 13px;
  height: 13px;
}

.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);
}

/* A heading focused by `navigate` is a scroll anchor, not a control. `:focus-visible`
   never matches a programmatic focus, but the outline still appeared on it because the
   heading carries `tabindex="-1"` and the browser paints focus for it; the reference
   treatment is that only things you can act on show a ring. */
h1[tabindex="-1"]:focus,
h2[tabindex="-1"]:focus,
h1[tabindex="-1"]:focus-visible,
h2[tabindex="-1"]:focus-visible {
  outline: none;
}

/* Заголовок раздела и панель деталей получают фокус программно (`navigate` в `app.js` и
   `captureFocus`), чтобы следующий Tab начинался в новом содержимом, а не в меню. */

/* Branded selection color works on both dark and light backgrounds. */
::selection {
  background: var(--accent-soft);
  color: var(--text);
}

::-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;
}

/* This block used to set `stroke` and `stroke-width` on the controls' icons. It was a
   workaround for the old stroke-drawn set, which needed a different weight in a control than
   in prose. The set is filled now, so there is nothing to re-stroke: leaving the rule would
   paint a 1.65px outline around every filled glyph in a button and turn each icon into a
   blob. The vendor marks carry their own rule above. */

.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-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  /* Terminal register: a control is a labelled command, so it is monospace and
     spaced. Small mono type needs positive tracking to stay legible. */
  letter-spacing: 0.06em;
  text-transform: uppercase;
  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);
  position: relative;
}

/* No lift on hover. A command that jumps under the pointer is decoration; the border and
   the fill carry the response, which is what keeps the interface in one register. */
.btn:hover:not(:disabled) {
  border-color: var(--line-strong);
}

/* Фокус виден и без наведения: кнопка, до которой добрались с клавиатуры, обязана
   отличаться от соседней. Обводка снаружи, чтобы не сдвигать содержимое. */
.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. Magnetic hover is reset on active state. */
.btn:active:not(:disabled) {
  transform: scale(0.975);
}

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

/* Главное действие экрана. Тень здесь не украшение: она ставит кнопку на слой выше
   поверхности, и глаз находит её первой, не читая подписи (§10.5). Внутренняя светлая
   кромка сверху - материал, поймавший свет, а не рамка. */
.btn.primary {
  background: var(--accent);
  color: var(--accent-ink);
  /* No drop shadow. The reference has no raised controls: a filled button is already the
     strongest thing on a page that is otherwise lines and type, and a shadow under it
     reads as a second, softer border. */
}

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

/* Press feedback is the scale from `AGENTS.md`; the shadow that used to change here was
   the second half of a lift this register does not have. */
.btn.primary:active:not(:disabled) {
  transform: scale(0.975);
  transition-duration: var(--dur-instant);
}

.btn.disabled,
.btn.primary:disabled {
  opacity: 0.5;
}

.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.xs {
  width: 22px;
  height: 22px;
  min-width: 22px;
  padding: 2px;
}

.icon-btn.xs svg {
  width: 13px;
  height: 13px;
}

.copy-btn[data-copied] {
  animation: copy-pop var(--dur-base) var(--ease-spring);
}

@keyframes copy-pop {
  50% {
    transform: scale(1.14);
  }
}
.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 {
  /* Field labels are the service layer, like table headers and keys: monospace, spaced,
     same size. A form read as a document has one voice for what names a value. */
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
}

/* `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-family: var(--font-mono);
  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;
}

select option {
  background: var(--surface-2);
  color: var(--text);
}
/* 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 8px;
  border: 1px solid var(--line);
  border-radius: 0;
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  /* 11px - самый мелкий текст, который здесь что-то утверждает («одобрен», «отказ»,
     «подтверждена»). Моноширинный слой уже разрежает буквы; дополнительный трекинг
     держит слово читаемым на таком кегле (§10.6). */
  letter-spacing: 0.04em;
  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: var(--ok);
  border-color: var(--ok-line);
  background: var(--ok-soft);
}
.badge.pro {
  color: var(--neutral);
  border-color: var(--neutral-line);
  background: var(--neutral-soft);
}
.badge.free {
  color: var(--accent);
  border-color: var(--accent-line);
  background: var(--accent-soft);
}

.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);
}

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

/* The mark simplifies itself when it is drawn small, and it has to.

   The same SVG is used at 40px in the login hero and at 32px in the four headers, and it was
   drawn for the first. At 32px the whole mark is 22 CSS pixels across, which makes its stroke
   2.8 × 22/64 = 0.96px — and the rails are *dashed* on top of that, `5 4` in user units, so the
   dash period lands under one pixel. Measured on 26 September 2026 in the storefront header: the
   two rails and the flare were sub-pixel noise, and the mark read as a smudge rather than as the
   brand.

   `container-type: size` on the host is what lets the mark ask how big it is. This is the one
   component in the product whose correct drawing depends on its own size, so it is the one
   component that needs this; everything else takes its size from the layout.

   What changes below 40px: the rails lose their dash and become solid, the glow is dropped, and
   the core stops breathing. **Nothing is removed any more.** The flare used to be hidden at this
   size, and that was the last difference between the mark in the corner of the storefront and the
   mark beside the login form — the owner looked at the two and said so. A mark that is the same
   drawing everywhere is worth more than four pixels of a star, and the geometry is unchanged:
   `TestBrandMarkIsTheSameShapeEverywhere` requires every version to carry the rails and the core. */
.brand-mark,
.login-orbit {
  container-type: size;
}

@container (max-width: 39px) {
  .orbit-mark .orbit-rail {
    stroke-dasharray: none;
    animation: none;
  }

  .orbit-mark {
    filter: none;
  }

  .orbit-mark .orbit-core {
    animation: none;
  }
}

.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);
  /* 9px is below the legible floor on a phone and in a screenshot; the tagline is
     supporting text, not a legal footnote, so it stays in the caption tier. */
  font-size: 10.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-faint);
}

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

.app-shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
  /* Grows to fill the column between the top of the page and the footer. `min-height` alone was
     enough while the shell was the last element; with a footer below it, the shell has to take the
     leftover space rather than only a viewport's worth, or a short cabinet leaves the footer at the
     bottom of the viewport and the page scrolling anyway. */
  flex: 1;
  min-height: 0;
}

.app-shell,
.login-shell,
.site-main {
  background-image: none;
}

.sidebar {
  position: sticky;
  top: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  height: 100dvh;
  /* One plane, separated by a rule. A tinted sidebar was a second surface the reference
     does not have: its navigation sits on the page and the line is what divides them. */
  background: transparent;
  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: 36px;
  padding: 0 10px;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  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 and finite. Retries rotate only while their control is in
   flight, which is the one case where the motion *is* the status.
 *
 * Two infinite animations were removed from here, and the reason is the frequency table in
 * `.cursor/rules/interface-motion.mdc`, not taste: the sidebar is on every screen of the
 * panel and the overview's health icons are on the screen an operator opens most, so both
 * sit in the "hundreds of times a day, always visible" row — the row that says never. The
 * reference measures zero running animations in a resting page and 55 transitions; ours ran
 * 18 in the panel, six of them these loops. State is already carried by colour and by the
 * dot beside the label; a permanent pulse adds no reading, only movement. Measured:
 * `getComputedStyle` found `icon-route-trace` and `icon-health-pulse` running `infinite` on
 * the panel's own chrome. */
@keyframes icon-retry-turn {
  to { transform: rotate(1turn); }
}
.is-loading > svg[data-icon-state="retry"],
button[data-loading] svg {
  transform-origin: center;
  animation: icon-retry-turn var(--dur-spin) linear infinite;
}

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

.nav-item.active {
  background: var(--accent-soft);
  color: var(--text);
  font-weight: 500;
  box-shadow: inset 2px 0 var(--accent);
}

.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;
}

/* The footer of the three application cabinets: the panel, the client portal and the supply
   desk. It is the same element on all three, because what it says is about the product rather
   than about the cabinet.

   It is deliberately small and deliberately not a menu. The storefront's footer is an instrument
   strip plus three columns of navigation, because a visitor has no sidebar and no way to know
   what exists; an operator inside a cabinet already has every section listed beside the content,
   so repeating it would be chrome, and chrome is removed rather than styled (section 1). What is
   left is what the screen cannot say from inside: the protocol, how money moves, how the route is
   chosen, and the two public pages. Every value has a source — `docs/api.md`, the tariff model.

   `flex-wrap` and `gap` rather than a grid: the strip has six short items and no column alignment
   to keep, and a grid would pin a width that a translated label breaks. It is pushed to the bottom
   by the body's own flex column — the shells above it take `flex: 1` — so a cabinet with three rows
   of data does not leave the footer floating in the middle of the screen. */
.app-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 18px;
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: 14px 22px 18px;
  border-block-start: 1px solid var(--line-soft);
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.app-foot-mark {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--text-muted);
}

.app-foot-fact code {
  font-family: inherit;
  color: var(--text-muted);
}

.app-foot-link {
  margin-inline-start: auto;
  color: var(--text-muted);
  text-decoration: none;
  border-block-end: 1px solid transparent;
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}

.app-foot-link + .app-foot-link {
  margin-inline-start: 0;
}

.app-foot-link:hover,
.app-foot-link:focus-visible {
  color: var(--text);
  border-block-end-color: var(--line-strong);
}

/* On a phone the strip wraps to two lines and the links come first: a tap target at the end of
   a long scroll is the one thing here that has to stay reachable. */
@media (max-width: 700px) {
  .app-foot {
    gap: 6px 14px;
    padding: 12px 18px 16px;
  }

  .app-foot-link {
    margin-inline-start: 0;
  }
}

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

/* Shared page transitions: the arrival, for all four surfaces.
 *
 * The history matters here, because this rule has been reversed once and the next reader needs to
 * know which side won.
 *
 * It began as 0.24s out and 0.24s in — 0.48s of movement per section switch, on the action
 * `interface-motion.mdc` puts in its most frequent row. On 25 September 2026 the movement was
 * removed and the time halved: opacity only, `--dur-fast`, no `translateY`. That decision was
 * recorded in this comment and in the rule.
 *
 * On 26 September 2026 the owner reversed it — "добавь такие же плавные переключения как на
 * лендинге везде в app как микро анимации". The reversal is deliberate and it is bounded, because
 * the objection the rule raises is real: an operator opens a section dozens of times a shift, and a
 * sequence they notice is a sequence that has become a delay. So:
 *
 * - **The rise is 6px, not the storefront's 12px**, and it is on the blocks *inside* the view
 *   rather than on the view, so the page never moves as one sheet.
 * - **The step is `--stagger-tight` (26ms), not `--stagger` (55ms)**, capped at the third child:
 *   the whole sequence is `--dur-fast` plus 52ms and is over in 0.21s.
 * - **The distance shrinks with depth**, as on the storefront — a block rises 6px, a card inside
 *   it 4px — because a card is already inside something that is moving.
 * - **Nothing here is on the keyboard path.** A filter re-renders its own section and does not
 *   re-enter this transition: `navigate` is the only caller.
 *
 * If it reads as a delay in daily use, the next step is what it was in September — delete it, not
 * tune it. It is one pair of rules and one class.
 *
 * What the rule still forbids and this still does not do: no large surface travels across the
 * viewport; nothing animates `width`, `height`, `top`, `left` or `background-position`; nothing
 * starts from `scale(0)`; and `prefers-reduced-motion` removes all of it — `ui/transition.mjs`
 * returns a no-op before the class is ever added, so a calm reader gets the static swap and never
 * sees a pre-state at all. */
.route-changing {
  opacity: .85;
  transition: opacity var(--dur-fast) var(--ease-exit);
}

.route-arrive {
  animation: route-arrive var(--dur-fast) var(--ease);
}

@keyframes route-arrive {
  from { opacity: .85; }
  to { opacity: 1; }
}

/* The view's own blocks arrive in sequence, and that is what makes a section switch read as a
   change of place rather than as a swap of pixels.

   Direct children only. A log with three hundred rows is *one* child, so a table never staggers
   its rows, and the cap at the third step means a long screen does not arrive in slow motion. */
.route-arrive > * {
  animation: block-arrive var(--dur-fast) var(--ease) both;
}

.route-arrive > :nth-child(2) {
  animation-delay: var(--stagger-tight);
}

.route-arrive > :nth-child(n + 3) {
  animation-delay: calc(var(--stagger-tight) * 2);
}

@keyframes block-arrive {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}

/* A card inside an arriving block rises a shorter distance, for the reason the storefront gives:
   it is already inside something that is moving, and two equal distances read as two objects
   rather than as one group.

   Only the stat grid — it is the one place where several sibling cards appear at once in both
   applications, and the four-step cap keeps the tail inside the lifetime `ui/transition.mjs` holds
   the class for. */
.route-arrive .stats > * {
  animation: card-arrive var(--dur-fast) var(--ease) both;
}

.route-arrive .stats > :nth-child(2) { animation-delay: var(--stagger-tight); }
.route-arrive .stats > :nth-child(3) { animation-delay: calc(var(--stagger-tight) * 2); }
.route-arrive .stats > :nth-child(n + 4) { animation-delay: calc(var(--stagger-tight) * 3); }

@keyframes card-arrive {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

.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. `flex: 1` because the footer is a sibling now and the
     sign-in screen is what fills the space above it. */
  flex: 1;
  min-height: 0;
}

.login-brand {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 40px;
  padding: 36px 52px;
  background: transparent;
  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);
  /* The editorial step, not the display one: the login headline sits beside a form and a
     figure, so it introduces the stage rather than owning the viewport. */
  font-size: var(--text-editorial);
  font-weight: 500;
  line-height: var(--text-editorial-leading);
  letter-spacing: -0.02em;
}

.login-copy > p {
  max-width: var(--measure-narrow);
  font-size: var(--text-body);
  line-height: var(--text-body-leading);
  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:has(.mascot) {
  opacity: .9;
  /* No mask, and no `invert`.

     The mask existed because the photograph it replaced had hard canvas edges and a mid-tone
     background: it had to be faded into the page or it ended on a visible rectangle. A sprite
     has its own silhouette, and masking it cut the figure's left side off — measured on
     25 September 2026, the torso's left edge was faded to nothing while the right was solid,
     which reads as a rendering fault rather than as a drawing.

     `invert` existed to flip the drawing's polarity per theme. The sprite carries its own
     greys, so the same drawing is legible on both. */
}
.login-orbit:has(.mascot)::before { display: none; }
.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 figure is an opaque object, and that is what decides where it sits.

   The flat SVG it replaced could sit under the copy at half opacity, because a line drawing
   under a sentence leaves the sentence readable. A figure with a filled case over a sentence
   hides the sentence — and the figure is in the flow of `.login-brand` rather than behind it,
   so `z-index` is not the fix: the two have to be kept apart. So the copy is bounded and the
   figure takes the rest. Clipping is avoided for the same reason it was before: an object cut
   by the panel edge looks like a mistake.

   The bound is unconditional, and it used to wait for a class. `.orbit-3d` was added by the
   WebGL mark once it had a context, so the rule applied only to that version and the flat SVG
   let the copy run wide on purpose. There is one drawing now, it is always opaque, and a rule
   that waited for a class nothing sets would leave the copy under the figure. */
.login-orbit:has(.mascot) {
  inset-inline-end: -20px;
  width: clamp(300px, 30vw, 460px);
}

/* One `:has()` and not two: nesting it inside another is invalid, so the whole rule is
   dropped by the parser — measured on 25 September 2026, the copy ran 257px under the figure
   at 1440px because of exactly that. `.mascot` is a descendant of `.login-brand`, so a single
   level reaches it. */
.login-brand:has(.mascot) .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;
}

/* The "remember login" row of the three auth pages. The box itself is dressed
   by the common checkbox rule above; the composite only aligns box and text
   and keeps the row clear of the error line below it. The label wraps the
   input, so the wording is as much a target as the box. */
.check-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 12px;
  font-size: 13.5px;
  color: var(--text-muted);
  cursor: pointer;
}

.check-row:hover {
  color: var(--text);
}

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

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

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

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

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

.heading-actions {
  display: flex;
  gap: 8px;
  flex: 0 0 auto;
  max-width: 100%;
  /* Wrap at every width, not only below 700px: at 768 the providers heading and its
     action button together measured 768.8px, and a row that cannot wrap answers that
     with a 1px horizontal scroll. Where everything fits, wrapping changes nothing. */
  flex-wrap: wrap;
}
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 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). */
/* The heading rule. A heading is separated from its body by weight and space, not by an
   accent stub: the reference introduces a section with type, and a 72px bar under every
   heading was the decoration that made the panel look like a template. */
.page-heading,
.panel-head {
  position: relative;
}

 /* A stat is a reading on the page, not a raised surface: the quiet border and
   the small radius keep the row reading as one strip of figures rather than a
   shelf of boxes (docs/design-system.md, section 6). */
.stat {
  padding: 14px 16px;
  border: 0;
  border-inline-start: 1px solid var(--line);
  border-radius: 0;
  background: transparent;
  min-width: 0;
}

.stat:first-child {
  border-inline-start: 0;
}

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

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

.stat-value {
  display: block;
  margin: 10px 0 2px;
  font-family: var(--font-mono);
  font-size: 26px;
  font-weight: 500;
  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: 0;
  border-block-start: 1px solid var(--line);
  border-radius: 0;
  background: transparent;
  min-width: 0;
  overflow: hidden;
}

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

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

.panel-head p {
  margin-top: 3px;
  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: 40px;
  padding: 0 14px;
  /* No fill in the header: the rule beneath it is what separates the labels from the rows,
     and a tinted band was the second surface a table does not need. */
  background: transparent;
  border-bottom: 1px solid var(--line-strong);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.12em;
  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);
}

[data-theme="light"] table.data tbody tr:nth-child(even) td {
  background: rgba(0, 0, 0, 0.015);
}

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

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

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

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.compact {
  padding: 24px 16px;
  gap: 6px;
}

.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.compact .empty-icon {
  width: 36px;
  height: 36px;
  margin-bottom: 2px;
}

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

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

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

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

.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%;
  /* Brightest at its leading edge and fading behind it, so the highlight reads as light crossing
     the block rather than as a bar sliding inside it. A symmetric gradient — which this was until
     26 September 2026 — gives the highlight two visible ends at once, and a highlight with two
     ends is a shape, not a reflection. */
  background: linear-gradient(
    90deg,
    transparent 0%,
    var(--surface-3) 34%,
    transparent 74%
  );
  /* Two animations on one clock: the travel, and the envelope that makes the highlight appear near
     one edge and be gone before the other.

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

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

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

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

.skeleton.stats {
  height: 88px;
}

.skeleton.block {
  height: 320px;
}

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

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

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

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

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

   Three decisions about what a screen may change:

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

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

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

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

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

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

/* A screen that is a table of figures has nothing to say in a 1520px column if it has three
   columns: the eye has to travel the width of the window to pair a label with its value. */
.content[data-view="glossary"],
.content[data-view="settings"],
.content[data-view="topup"] {
  max-width: 920px;
}

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

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

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

.console.hidden {
  display: none;
}

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

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

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

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

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

.console-input:focus {
  outline: none;
  box-shadow: none;
}

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

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

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

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

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

.console-title {
  font-size: 13.5px;
  color: var(--text);
}

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

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

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

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

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

/* On a narrow screen the label and the shortcut are the first things to go: the magnifier
   still names the action, and the console is reachable by tapping it. */
@media (max-width: 800px) {
  .console-trigger-label,
  .console-trigger kbd {
    display: none;
  }
  .console-trigger {
    padding: 0 8px;
  }
}

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

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

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

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

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

  .console-kind {
    display: none;
  }
}

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

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

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

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

.modal > header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 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;
  transition: border-color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease),
              box-shadow var(--dur-fast) var(--ease);
}

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

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

/* The selected state is a border, background, and ring, never colour alone: a customer who
 * cannot distinguish the accent hue still has to see which method they picked
 * (docs/design-system.md, section 6). */
.method.active {
  border-color: var(--accent);
  background: var(--accent-soft);
  box-shadow: 0 0 0 1px var(--accent);
}
.method strong {
  font-size: 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);
  overflow-wrap: anywhere;
  word-break: normal;
}

/* Drawer */

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

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

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

.drawer-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 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;
  }
}

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

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

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

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

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

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

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

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

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

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

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

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

/* The overview's own arrangement: readings first, then the panels they summarise. */
.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 0 28px;
  align-items: start;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* The mark's own size rule lives with the rest of it, above. There is no pixel pre-state any more:
   it existed to hold the mark's place while the WebGL render booted, and the SVG is the mark now. */

/* 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.
     The block is sized to its contents and the panel takes the rest: a fixed art box
     plus a centred form left a screen-high gap between the two. */
  .login-brand {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    padding: 22px 22px 10px;
    border-inline-end: none;
    border-block-end: 1px solid var(--line);
    overflow: visible;
  }

  .login-copy {
    display: none;
  }

  .login-orbit {
    position: relative;
    inset: auto;
    /* A full-width band rather than a small square: the artwork is a portrait, and a
       210px box showed a fragment of it above a screen of empty space. */
    width: 100%;
    aspect-ratio: 16 / 10;
    transform: none;
    opacity: 1;
    order: -1;
  }

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

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

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

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

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

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

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

  /* The nav items are the primary targets of the whole panel on a phone — every section is
     reached through one — and 38px is below the 44px platform floor. Widened rather than
     re-spaced: the gap between them stays, so a mis-tap lands on a neighbour rather than on
     nothing. */
  .nav-item {
    height: 46px;
    font-size: 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) {
  /* Readings pair up on a phone. One per row made the overview seven screens of figures
     before the first panel, which is the opposite of scannable; two per row keeps the
     numbers comparable and the section short. */
  .stats,
  .stats-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

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

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

  .stat-value {
    font-size: 22px;
  }

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

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

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

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

  .login-panel {
    padding: 24px 18px 32px;
  }
  .topbar {
    display: grid;
    grid-template-columns: 38px minmax(0, 1fr) auto;
    align-items: center;
    height: var(--topbar-h);
    padding: 0 12px;
    gap: 8px;
  }

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

  .breadcrumbs > * { flex: 0 0 auto; }
  .breadcrumbs strong { overflow: hidden; text-overflow: ellipsis; }
  .top-actions { min-width: 0; flex-wrap: nowrap; gap: 6px; }
  .top-actions .icon-btn { width: 40px; height: 40px; }
  .health { min-width: 0; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

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

  .login-form h2 {
    font-size: 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;
  }
  .page-heading > div:first-child,
  .page-heading > :first-child:not(.heading-actions) {
    flex: 0 1 auto;
    min-width: 0;
  }

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

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

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

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

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

  .toolbar .spacer {
    display: none;
  }

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

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

  .pagination {
    flex-wrap: wrap;
  }

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

  .health {
    gap: 0;
    padding: 0;
    /* Sized and shaped like the icon buttons beside it, not like its vanished text: three
       controls in a row read as a row. 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;
  }
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@media (prefers-reduced-transparency: reduce) {
  /* Translucency is decoration here; legibility is not. The pair of scrim
     tokens goes solid and the blur is dropped rather than thinned. */
  :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(5, 12px);
  grid-template-rows: repeat(5, 12px);
  gap: 4px;
  width: max-content;
  aspect-ratio: 1 / 1;
  margin-top: 20px;
  transform: none;
  justify-content: center;
  align-content: center;
}
.pc-loader-grid i {
  width: 12px;
  height: 12px;
  border: 1px solid var(--line);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--surface);
  animation: pc-loader-tile var(--loop-shimmer) var(--ease) infinite;
  animation-delay: calc(var(--tile, 0) * var(--stagger));
}
.pc-loader-grid i:nth-child(n + 26) {
  display: none;
}
.pc-loader-grid i:nth-child(5n + 1) { --tile: 1; }
.pc-loader-grid i:nth-child(5n + 2) { --tile: 2; }
.pc-loader-grid i:nth-child(5n + 3) { --tile: 3; }
.pc-loader-grid i:nth-child(5n + 4) { --tile: 4; }
.pc-loader-grid i:nth-child(5n)     { --tile: 5; }
@keyframes pc-loader-tile {
  0%, 100% { opacity: 0.45; }
  38% { opacity: 1; }
  62% { opacity: 0.7; }
}
@media (max-width: 520px) {
  .pc-loader-grid { grid-template-columns: repeat(5, 10px); grid-template-rows: repeat(5, 10px); gap: 3px; }
  .pc-loader-grid i { width: 10px; height: 10px; }
}
@media (prefers-reduced-motion: reduce) {
  .pc-loader { animation: none; transition-duration: var(--dur-instant); }
  .pc-loader-grid i { animation-duration: var(--loop-skeleton-calm); }
}

/* Admin Token Provider & Model Picker ===================================== */

.token-models-group {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  padding: 8px 10px;
  margin-bottom: 8px;
}

.token-provider-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

.token-provider-main {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  flex: 1;
  min-width: 0;
}

.token-reveal-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-xs);
  color: var(--text-muted);
  font-size: 11.5px;
  padding: 3px 8px;
  cursor: pointer;
}

.token-reveal-btn:hover {
  background: var(--surface-hover);
  color: var(--text);
  border-color: var(--line-strong);
}

.token-reveal-arrow {
  display: inline-block;
  font-size: 10px;
  transition: transform 0.15s ease;
}

.token-reveal-arrow.open {
  transform: rotate(90deg);
}

.token-models-sublist {
  margin-top: 8px;
  padding: 8px 10px 8px 24px;
  border-top: 1px solid var(--line-soft);
  display: flex;
  flex-wrap: wrap;
  gap: 8px 14px;
  background: rgba(0, 0, 0, 0.12);
  border-radius: 0 0 var(--radius-xs) var(--radius-xs);
}

.token-model-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  font-size: 12px;
  font-family: var(--font-mono);
  color: var(--text);
  user-select: none;
}

.token-model-item input[type="checkbox"] {
  margin: 0;
  cursor: pointer;
}

.token-picker-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
  font-size: 12px;
  color: var(--text-muted);
}

.token-picker-quicklinks {
  display: flex;
  gap: 8px;
}

.token-picker-link {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent);
  font-size: 11.5px;
  cursor: pointer;
  text-decoration: underline;
}

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

