/*
  Точка входу стилів і єдине місце, де оголошено, що за чим іде.

  Кроку збірки в деплої немає й тут: цей файл читає сам застосунок на старті
  й підставляє замість кожного @import вміст названого файлу, загорнутий у
  його шар (shared/assets.py). Для каскаду це те саме — @import ... layer(X)
  і @layer X { … } рівносильні за специфікацією. Для браузера різниця велика:
  доти він дізнавався про ці тридцять файлів лише розібравши цей, тобто перше
  малювання сторінки чекало два оберти по мережі замість одного.

  Правити тут і в сусідніх файлах — як і раніше; ASSETS_CACHE=false у .env
  перескладає все на кожен запит, тож локально нічого не змінилось.

  Порядок шарів нижче ВАЖЛИВІШИЙ за специфічність: правило з пізнішого
  шару виграє в правила з ранішого, хай яким «сильним» той селектор був.
  Завдяки цьому в проєкті майже немає !important і немає гонки селекторів.

  Хто де: rhythm — вертикальні відступи МІЖ блоками сторінки (мусить стояти
  після components, бо гасить власні margin компонентів), service —
  інлайнові стилі конкретного сервіса, utilities — точкові класи-виправлення.
*/
@layer reset, tokens, base, layout, components, rhythm, service, utilities;

@layer reset {
/* 00-reset.css */
/*
  Мінімальний ресет — лише те, без чого дефолти браузера справді заважають.
  Свідомо не «обнуляємо все»: типографіку й відступи задає 20-base.css.
*/

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

/* Атрибут hidden має вимикати елемент незалежно від того, який display
   йому задав компонентний клас. Без цього правила будь-який .hint чи .badge
   із власним display перебиває стиль браузера й лишається на видноті —
   саме через це підказка про Caps Lock висіла на сторінці входу завжди.

   !important тут не «сильніший селектор», а перемикач порядку шарів: для
   важливих оголошень шари рахуються у зворотному порядку, тож reset —
   найраніший у списку — б'є і components, і utilities. Єдине місце в
   проєкті, де це доречно: правило має діяти скрізь і без винятків. */
[hidden] {
  display: none !important;
}

/* display:block прибирає «зайвий» відступ під картинкою від рядкового
   потоку, max-width не дає їй вилізти за контейнер. */
img,
svg,
video,
canvas {
  display: block;
  max-width: 100%;
}

/* Поля форм не успадковують шрифт від сторінки самі — доводиться просити. */
input,
button,
textarea,
select {
  font: inherit;
  color: inherit;
}

table {
  border-collapse: collapse;
}

}
@layer tokens {
/* 10-tokens.css */
/*
  Токени дизайн-системи — єдине місце, де задаються кольори, відступи
  й розміри. Далі всі компоненти беруть готові змінні й не рахують нічого самі.

  Дві ідеї, на яких усе тримається:

  1. Кольори виводяться з ВІДТІНКУ (hue), а не задаються поштучно.
     Одне число --h-accent → і вся акцентна гама сторінки перерахувалась.
     Саме тому сервіс отримує свій колір одним рядком у registry.py.

  2. Світла й темна теми живуть в одному оголошенні через light-dark().
     Ніяких дублів на кожен колір і жодного окремого «темного файлу».
     Перемикає їх color-scheme нижче.
*/
:root {
  /* Обидві теми доступні; фактичну обирає data-theme або система. */
  color-scheme: light dark;

  --h-brand: 265;

  /* Відтінки станів. Вибрані так, щоб читались і на світлому, і на темному. */
  --h-ok: 150;
  --h-warn: 75;
  --h-fail: 25;
  --h-info: 230;

  /* Акцент сторінки. Дефолт — брендовий, але base.html перебиває його
     інлайном на <body>, підставляючи hue поточного сервісу. */
  --h-accent: var(--h-brand);

  /* Поверхні: bg — тло сторінки, далі за зростанням «підняття».
     Легка домішка brand-відтінку не дає сірому виглядати брудним.

     Світла тема: тло НЕ біле (.977), а картки — білі. Різниця в 2-3% світлоти
     й дає те «підняття», заради якого раніше довелося б малювати рамку.
     Коли тло було .985, картка від нього майже не відрізнялась, і межу
     тримала сама рамка — звідси й відчуття креслення, а не інтерфейсу.

     Темна тема: тло опущене до .152 — глибше, але це не чорний. Чистий
     чорний на OLED дає різкий обрив на межі з карткою, а домішка brand-
     відтінку лишає темряву кольоровою (slate), а не сірою. Крок між
     сусідніми поверхнями всюди ~.036 — рівний, тож три рівні підняття
     читаються однаково і в світлій, і в темній. */
  --bg:        light-dark(oklch(.977 .004 var(--h-brand)), oklch(.152 .016 var(--h-brand)));
  --surface:   light-dark(oklch(1    0    0),              oklch(.196 .019 var(--h-brand)));
  --surface-2: light-dark(oklch(.972 .005 var(--h-brand)), oklch(.232 .021 var(--h-brand)));
  --surface-3: light-dark(oklch(.949 .007 var(--h-brand)), oklch(.272 .023 var(--h-brand)));

  /* Текст: ink — основний, muted — другорядний, subtle — ледь помітний.
     У темній темі порядок muted/subtle за світлістю навмисно
     перевернутий: там «тихіший» означає темніший, а не світліший. */
  --ink:        light-dark(oklch(.250 .020 var(--h-brand)), oklch(.940 .008 var(--h-brand)));
  --ink-muted:  light-dark(oklch(.550 .020 var(--h-brand)), oklch(.680 .020 var(--h-brand)));
  --ink-subtle: light-dark(oklch(.680 .015 var(--h-brand)), oklch(.550 .018 var(--h-brand)));

  /* Рамки навмисно послаблені: межу коробки тепер тримає різниця поверхонь
     і тінь, а рамка лише окреслює її на випадок, коли тіні не видно (друк,
     forced-colors, поверхня на поверхні того ж рівня). Раніше .910/.300
     робили з неї головну лінію інтерфейсу — звідси й «різкість», з якою
     сітка карток читалась як таблиця. */
  --border:        light-dark(oklch(.922 .006 var(--h-brand)), oklch(.282 .019 var(--h-brand)));
  --border-strong: light-dark(oklch(.862 .010 var(--h-brand)), oklch(.358 .023 var(--h-brand)));

  --brand:     light-dark(oklch(.550 .20 var(--h-brand)), oklch(.700 .16 var(--h-brand)));
  --brand-ink: light-dark(oklch(1 0 0),                   oklch(.170 .02 var(--h-brand)));

  /*
    Похідні від --brand через color-mix: підмішування до поверхні.

    Пари hover/active тут більше немає. Вона існувала під заливку головної
    кнопки, а кнопка тепер фарбується акцентом сервісу, і ті два токени
    лишились оголошеними без жодного використання. Брендові лишаються рівно
    ті, що справді потрібні: soft — підкладка (фокус поля, ::selection),
    line — рамка.

    ЗМІШУЄМО В oklab, А НЕ В oklch — і це не стильова дрібниця, а умова
    того, щоб домішка взагалі зберігала свій колір.

    oklch полярний: у ньому колір це світлота, насиченість і КУТ відтінку.
    Змішуючи, браузер інтерполює всі три — зокрема кут. А біла поверхня
    (oklch(1 0 0)) має кут 0, бо в безбарвного кольору відтінку немає й
    формально він нульовий. Тому «12% зеленого (150°) на білому» давало
    0.12 × 150 = 18° — рожевий. Так само шапка таблиці з 7% синього
    акценту (265°) виходила рожевою на 18°.

    oklab прямокутний: ті самі кольори, але в осях «зелений↔червоний» і
    «синій↔жовтий», де в білого просто нулі. Домішка зменшує насиченість,
    не обертаючи відтінок, — тобто робить рівно те, чого від неї й чекають.

    Правило просте: підмішуємо до поверхні, рамки чи прозорого — oklab.
    oklch лишається там, де колір ОГОЛОШУЄТЬСЯ (усі рядки з oklch(...)
    вище й нижче): задавати колір відтінком зручно, обертати відтінок при
    змішуванні — ні.
  */
  --brand-soft: color-mix(in oklab, var(--brand) 12%, var(--surface));
  --brand-line: color-mix(in oklab, var(--brand) 35%, var(--border));

  --focus-offset: 2px;

  /* Стани — одна трійка на кожен: ink (текст/іконка), bg (тло), line (рамка).
     Пропорції домішування скрізь однакові, тож алерти, бейджі й рядки
     таблиць виглядають як одна сім'я. */
  --ok-ink:   light-dark(oklch(.450 .13 var(--h-ok)),   oklch(.800 .14 var(--h-ok)));
  --ok-bg:    color-mix(in oklab, var(--ok-ink) 12%, var(--surface));
  --ok-line:  color-mix(in oklab, var(--ok-ink) 32%, var(--border));

  --warn-ink:  light-dark(oklch(.500 .12 var(--h-warn)), oklch(.820 .14 var(--h-warn)));
  --warn-bg:   color-mix(in oklab, var(--warn-ink) 12%, var(--surface));
  --warn-line: color-mix(in oklab, var(--warn-ink) 32%, var(--border));

  --fail-ink:  light-dark(oklch(.500 .18 var(--h-fail)), oklch(.780 .15 var(--h-fail)));
  --fail-bg:   color-mix(in oklab, var(--fail-ink) 12%, var(--surface));
  --fail-line: color-mix(in oklab, var(--fail-ink) 32%, var(--border));

  --info-ink:  light-dark(oklch(.500 .15 var(--h-info)), oklch(.800 .13 var(--h-info)));
  --info-bg:   color-mix(in oklab, var(--info-ink) 12%, var(--surface));
  --info-line: color-mix(in oklab, var(--info-ink) 32%, var(--border));
}

/*
  АКЦЕНТ ОГОЛОШЕНИЙ НА <body>, А НЕ НА :root — І ЦЕ ОБОВ'ЯЗКОВО.

  Обчислене значення власної властивості — це її текст із УЖЕ підставленими
  var(). Підстановка відбувається на тому елементі, де властивість
  ОГОЛОШЕНО, і далі вниз іде вже готове число.

  Тому --accent, що стояв тут-таки в :root, назавжди забирав собі
  --h-accent із :root, тобто брендові 265. Інлайновий hue сервісу, який
  base.html пише на <body>, не змінював його ніколи: до моменту, коли
  браузер дійде до <body>, у --accent уже вписано 265.

  Наслідок було видно неозброєним оком: смужка в шапці кожного сервісу
  мала однаковий фіолетовий колір, хоч у реєстрі в кожного свій. Працювали
  лише --tile-accent і --side-accent — саме тому, що оголошені на самій
  плитці й на самому посиланні, поруч зі своїм інлайновим hue.

  Оголошення на body ставить підстановку туди, де hue вже відомий. На
  сторінках входу й 403 інлайнового hue немає — там body успадкує
  --h-accent із :root, тобто брендовий, як і має бути.

  Набір навмисно повний — дзеркало брендового вище. Доти акцент умів рівно
  три речі (смужка в шапці, смужка плитки, рамка врізки); тепер у ньому
  головна кнопка, фокус, шапка таблиці й підсвітка рядка.
*/
body {
  --accent:        light-dark(oklch(.550 .20 var(--h-accent)), oklch(.700 .16 var(--h-accent)));
  --accent-ink:    light-dark(oklch(1 0 0),                    oklch(.170 .02 var(--h-accent)));

  /*
    Акцент під СУЦІЛЬНУ ЗАЛИВКУ з текстом на ній — головна кнопка. У світлій
    темі він на пів кроку темніший за звичайний (.50 проти .55), і це не
    смак, а порахована межа.

    Річ у тім, що акцент у кожного сервісу свій, а контраст білого тексту
    залежить від відтінку: при .55 фіолетовий (265°) дає 5.1, а бірюзовий
    (185°) — 3.83, тобто нижче за AA. Доки кнопка була брендовою, цього не
    було видно: 265° проходив, і питання не стояло. Щойно кнопка взяла
    колір сервісу, найгірший відтінок став реальним — і саме він задає число.

    .50 — найсвітліше, при якому AA (4.5) тримається на ВСІХ 360 відтінках,
    а не лише на тих восьми, що є в реєстрі сьогодні. Найгірший випадок
    там 4.68.

    У темній лишається .70: текст на кнопці там темний (--accent-ink), і
    затемнювати тло означало б зменшувати контраст, а не збільшувати.
    Звідси й вузьке вікно затемнення в самій кнопці — 88-100%: нижче за
    88% темна тема провалює AA рівно з тієї ж причини.
  */
  --accent-solid: light-dark(oklch(.500 .20 var(--h-accent)), oklch(.700 .16 var(--h-accent)));

  /*
    Дві сходинки затемнення заливки — під стани кнопки. Числа живуть тут, а
    не в button.css, бо вони не про кнопку, а про ту саму межу контрасту:
    88% — найтемніше, при якому темна тема ще тримає AA (там текст на кнопці
    темний, і затемнення тла зближує їх, а не розводить).

    Домішуємо чорний, а не --ink: --ink у темній темі майже білий, і та сама
    формула освітлювала б кнопку замість затемнювати. Стан «натиснуто» має
    виглядати однаково в обох темах, тож і точка, до якої йдемо, мусить бути
    однакова.
  */
  --accent-hover:  color-mix(in oklab, var(--accent-solid) 92%, black);
  --accent-active: color-mix(in oklab, var(--accent-solid) 88%, black);

  --accent-soft:   color-mix(in oklab, var(--accent) 12%, var(--surface));
  --accent-line:   color-mix(in oklab, var(--accent) 40%, var(--border));

  /* Ледь помітна плівка кольору сервісу — під шапку сторінки, шапку
     таблиці, рядок під курсором. 7% домішки до поверхні: достатньо, щоб
     око побачило кольорову область, і замало, щоб текст на ній втратив
     контраст. Саме тому одне значення працює і в світлій, і в темній: у
     color-mix підмішується та поверхня, яка зараз чинна. */
  --accent-wash:   color-mix(in oklab, var(--accent) 7%, var(--surface));
  --accent-wash-2: color-mix(in oklab, var(--accent) 14%, var(--surface));

  /* Фокус переїхав із бренду на акцент — щоб обведення збігалося з рештою
     кольору сторінки. Живе тут, а не в :root, з тієї ж причини, що й решта
     блока: він посилається на --accent, і в :root той ще не існує.
     Ширина та сама: фокус це доступність, а не оздоба, і худнути йому
     нема з чого. */
  --focus-ring: 2px solid var(--accent);
}

/* Решта токенів акценту не потребує, тож лишається на :root — там, де їй
   і місце: розміри, шрифти й тіні однакові для всієї сторінки, включно з
   тим, що лежить поза <body>. */
:root {
  /* Один множник на всю сітку відступів: змінивши --density, можна
     згустити чи розрідити інтерфейс цілком, не чіпаючи компоненти. */
  --density: 1;

  /* Півкроку. Потрібен рідко — там, де рядок списку має «дихати», але вже
     не поміщається в повний крок: стрічка кампаній, фішка в клітинці
     календаря. Окремим токеном, а не 2px у стилі сервісу, — інакше він
     перестає стискатись разом із рештою при зміні --density. */
  --s-0: calc(2px  * var(--density));

  --s-1: calc(4px  * var(--density));
  --s-2: calc(8px  * var(--density));
  --s-3: calc(12px * var(--density));
  --s-4: calc(16px * var(--density));
  --s-5: calc(24px * var(--density));
  --s-6: calc(32px * var(--density));
  --s-7: calc(48px * var(--density));
  --s-8: calc(64px * var(--density));

  /* Розміри тексту в rem — щоб масштаб шрифту в налаштуваннях браузера
     працював як має. */
  --t-xs:  0.6875rem;
  --t-sm:  0.75rem;
  --t-cmp: 0.8125rem;
  --t-md:  0.9375rem;
  --t-lg:  1.0625rem;
  --t-xl:  1.3125rem;
  --t-2xl: 1.6875rem;

  /* Системні шрифти: жодного веб-шрифту, тобто нуль зовнішніх запитів
     і нуль стрибків тексту при завантаженні. */
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, "Cascadia Code", "Cascadia Mono", Menlo, Consolas, monospace;

  --r-sm:   6px;
  --r-md:   8px;
  --r-lg:   12px;
  --r-full: 999px;

  /* У темній темі тіні помітно щільніші: на темному тлі слабка тінь
     просто не читається.

     У світлій вони більше не чорні, а підфарбовані brand-відтінком. Чорна
     тінь на кольоровому тлі дає сірий ореол — те саме «брудно», від якого
     поверхні вище рятує домішка hue; тінь того ж відтінку лягає на тло як
     його ж затемнення. У темній лишається чистий чорний: підфарбовувати
     темряву нема чим, вона й так кольорова від поверхні під нею. */
  --shadow-1: light-dark(oklch(.45 .04 var(--h-brand) / .05), oklch(0 0 0 / .36));
  --shadow-2: light-dark(oklch(.42 .05 var(--h-brand) / .07), oklch(0 0 0 / .46));
  --shadow-3: light-dark(oklch(.38 .06 var(--h-brand) / .10), oklch(0 0 0 / .58));

  /* Тінь на два шари: щільний контактний і широкий розсіяний з від'ємним
     розтягом. Один шар «0 4px 12px» дає рівну сіру пляму — саме за нею
     тінь і впізнається як намальована. Два шари повторюють те, як світло
     поводиться насправді: різка межа біля опори, м'який ореол далі. */
  --e-1: 0 1px 2px var(--shadow-1);
  --e-2: 0 2px 4px var(--shadow-1), 0 6px 16px -4px var(--shadow-2);
  --e-3: 0 4px 8px var(--shadow-1), 0 16px 40px -8px var(--shadow-3);

  /*
    Рух. Дві тривалості, і третьої не буває:

      --dur-1  реакція контрола на курсор — колір, рамка. Має бути на межі
               непомітності, інакше кнопка «відстає» від миші;
      --dur-2  зсув коробки (підйом картки, виїзд шухляди) і згасання фону.
               Відстань більша за колірний перехід, тож і часу треба більше.

    Крива одна на все — стандартний ease-out матеріалу: швидкий старт,
    м'яке гальмування. Рух, що починається повільно (ease-in), читається
    як затримка інтерфейсу, а не як анімація.
  */
  --dur-1: 0.12s;
  --dur-2: 0.2s;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  /*
    Скло. Напівпрозора поверхня плюс розмиття того, що під нею — але лише
    там, де під нею СПРАВДІ щось є і воно рухається (див. .topbar на
    вузькому екрані, .menu, накладка модалки). Скло над нерухомим тлом
    відрізняється від звичайної поверхні нічим, крім ціни на перемальовку.

    saturate перед blur — не косметика: розмиття усереднює пікселі й тим
    з'їдає насиченість, тож без нього кольорова смужка сервісу під склом
    вицвітає до сірої.
  */
  --glass:      color-mix(in oklab, var(--surface) 72%, transparent);
  --glass-blur: saturate(180%) blur(12px);
}

/*
  Увесь перемикач теми — оці два правила. data-theme на <html> звужує
  color-scheme до однієї схеми, і всі light-dark() вище перемикаються
  разом. Без атрибута лишається "light dark", тобто рішення за системою.
  Ставить атрибут shared/static/js/theme.js.
*/
:root[data-theme="light"] {
  color-scheme: light;
}

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

}
@layer base {
/* 20-base.css */
/*
  Базова типографіка й вигляд «голих» тегів. Компоненти далі спираються
  на це й перевизначають лише те, що відрізняється.
*/

/*
  Сама сторінка не прокручується — прокрутка живе всередині .main
  (див. 30-layout.css). Резервувати тут жолоб під смугу не можна: він
  лежить ЗЗОВНІ <body>, тож топбар і бічна панель не діставали б до краю
  екрана, а на короткій сторінці там лишалася б порожня смужка фону.
*/

body {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--t-md);
  line-height: 1.55;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
  margin: 0;
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.011em;
  /* balance — щоб заголовок не лишав одне слово-сироту на другому рядку. */
  text-wrap: balance;
}

h1 { font-size: var(--t-xl); }
h2 { font-size: var(--t-lg); margin: var(--s-6) 0 var(--s-3); }
h3 { font-size: var(--t-md); margin: var(--s-5) 0 var(--s-2); }
h4 { font-size: var(--t-md); margin: var(--s-4) 0 var(--s-2); }

p {
  margin: 0 0 var(--s-3);
  text-wrap: pretty;
}

a {
  color: var(--brand);
  /* Підкреслення напівпрозоре в спокої й суцільне на ховері: лінк
     лишається впізнаваним, але не рябить у щільному тексті. */
  text-decoration-color: color-mix(in oklab, currentColor 35%, transparent);
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
}

a:hover {
  text-decoration-color: currentColor;
}

/* Один фокус-ринг на весь застосунок. :focus-visible, а не :focus —
   рамка з'являється при навігації клавіатурою, але не після кліку мишею.
   Прибирати її не можна: без неї з клавіатури працювати неможливо. */
:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

label {
  display: block;
  margin: var(--s-4) 0 var(--s-1);
  font-weight: 600;
  font-size: var(--t-md);
}

::placeholder {
  color: var(--ink-subtle);
  opacity: 1;
}

code, kbd, samp {
  font-family: var(--font-mono);
  font-size: 0.9em;
}

code {
  padding: 0.1em 0.35em;
  background: var(--surface-3);
  border-radius: var(--r-sm);
}

pre {
  margin: 0;
  padding: var(--s-4);
  overflow-x: auto;
  font-family: var(--font-mono);
  font-size: var(--t-cmp);
  line-height: 1.5;
  background: var(--surface-2);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}

pre code {
  padding: 0;
  background: none;
  font-size: inherit;
}

hr {
  height: 1px;
  margin: var(--s-5) 0;
  border: 0;
  background: var(--border);
}

::selection {
  background: var(--brand-soft);
  color: var(--ink);
}

/*
  Повага до системного «менше руху». Не вимикаємо анімації зовсім, а
  стискаємо до 0.01ms: так вони лишаються миттєвими, але події
  transitionend/animationend усе одно спрацьовують, і код, який на них
  розраховує, не зависає.
*/
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

}
@layer layout {
/* 30-layout.css */
/*
  Каркас сторінки: бічна панель + топбар + контент.

  Стан панелі приходить із data-атрибутів на <html> (їх ставить shell.js):
    data-sidebar="collapsed" — вузька смуга з іконок (широкий екран);
    data-drawer="open"       — панель поверх контенту (вузький екран).
  Самі атрибути нічого не роблять — уся поведінка описана тут.
*/

:root {
  --sidebar-w: 248px;
  --sidebar-rail: 60px;  /* ширина у згорнутому стані — лише під іконки */
  --topbar-h: 52px;

  /* Три ширини контенту: форма (щоб рядок не був завдовжки),
     звичайна сторінка і на всю ширину — для таблиць. */
  --w-form: 680px;
  --w-app: 1200px;
  --w-wide: 100%;
}

.app-shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  grid-template-rows: var(--topbar-h) 1fr;
  /* Панель займає обидва рядки — тому топбар починається праворуч від неї. */
  grid-template-areas:
    "sidebar topbar"
    "sidebar main";

  /* Рівно на висоту екрана, не min-height: прокручується лише .main, а
     панель із топбаром лишаються на місці. Так смуга прокрутки опиняється
     всередині контенту й не забирає собі праву межу каркаса. */
  height: 100dvh;
}

/* Згортання — це просто інша ширина колонки. Решта підлаштовується сама. */
:root[data-sidebar="collapsed"] .app-shell {
  --sidebar-w: var(--sidebar-rail);
}

/* Вузький ноутбук: панель віддає контенту 32px, лишаючись повноцінною
   панеллю з підписами. Нижня межа 901px — щоб не перетнутись із діапазоном,
   де панель уже стала шухлядою й ширина колонки не значить нічого.

   Правило нижчої специфічності за «згорнуту» вище, тож ручне згортання
   тут головніше — як і має бути: людина вирішила, а не вікно. */
@media (min-width: 901px) and (max-width: 1180px) {
  .app-shell {
    --sidebar-w: 216px;
  }
}

.sidebar {
  grid-area: sidebar;

  view-transition-name: sidebar;
  display: flex;
  flex-direction: column;

  /* Висоту дає сітка каркаса; min-height: 0 — щоб вміст не розпирав комірку.

     Панель принципово не прокручується: власна смуга прокрутки поруч зі
     смугою контенту читалася як друга сторінка, а навігація має бути одним
     нерухомим полем, де пункт завжди на тому самому місці. Тому список
     компактний за розмірами (див. .side-link і @media height нижче) і
     вміщається цілком; зайве обрізається, а не гортається. */
  min-height: 0;
  overflow: hidden;
  background: var(--surface);
  border-right: 1px solid var(--border);
}

/* Шапка панелі: бренд і кнопка згортання. Заввишки рівно з топбар — лінія
   під нею продовжує нижню межу топбару в один рівний рядок. */
.sidebar-head {
  display: flex;
  flex: none;
  align-items: center;
  gap: var(--s-1);
  height: var(--topbar-h);
  padding: 0 var(--s-2) 0 var(--s-4);
  border-bottom: 1px solid var(--border);
}

.brand {
  display: flex;
  /* Займає весь вільний рядок, кнопка лишається притиснутою до правого краю;
     min-width: 0 — щоб довга назва обрізалась, а не виштовхувала кнопку. */
  flex: 1;
  min-width: 0;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--t-md);
  font-weight: 700;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
}

.brand-name {
  overflow: hidden;
  text-overflow: ellipsis;
}

.collapse-toggle {
  flex: none;
}

/*
  Знак бренду — власний файл, а не іконка зі спрайта: спрайт за контрактом
  однотонний (stroke + currentColor), а знак кольоровий. Розмір в em, як у
  .ico, — щоб масштабувався разом із текстом поруч і не з'їжджав, коли
  сайдбар згортається.
*/
.brand-mark {
  flex: none;
  width: 1.45em;
  height: 1.45em;
  vertical-align: -0.3em;
}

.side-nav {
  flex: 1;
  min-height: 0;
  padding: var(--s-2);
}

/* Підгрупа сервісів: заголовок і пункти під ним (див. shared/base.html).
   Власних відступів не має — їх задає .side-group. Обгортка потрібна заради
   згорнутої панелі: там заголовки ховаються, і без неї групи не було б чим
   розділити (правило нижче, поруч із рештою «згорнутого»). */
.side-group {
  margin: var(--s-3) var(--s-2) 2px;
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-subtle);
}

.side-link {
  /* --h-side ставить base.html інлайном на кожне посилання сервісу.
     Немає його (Дашборд, розділи адмінки) — беремо брендовий відтінок. */
  --side-accent: light-dark(
    oklch(0.55 0.2 var(--h-side, var(--h-brand))),
    oklch(0.7 0.16 var(--h-side, var(--h-brand)))
  );

  display: flex;
  align-items: center;
  gap: var(--s-2);

  min-height: 32px;
  padding: 0 var(--s-2);
  font-size: var(--t-cmp);
  font-weight: 600;
  color: var(--ink-muted);
  text-decoration: none;
  white-space: nowrap;
  border-radius: var(--r-md);

  border-left: 3px solid transparent;

  /* border-left-color у переліку — щоб кольорова позначка активного пункту
     проявлялась разом із тлом, а не вискакувала готовою на кадр раніше. */
  transition:
    background-color var(--dur-2) var(--ease),
    border-left-color var(--dur-2) var(--ease),
    color var(--dur-1) var(--ease);
}

.side-link .ico {
  color: var(--ink-subtle);
}

/* Запобіжник під назви сервісів: замість того щоб розпирати панель, задовгий
   підпис обрізається трьома крапками. min-width: 0 обов'язковий — без нього
   флекс-елемент не стискається менше за свій вміст і обрізання не спрацює.
   Повну назву видно при наведенні: base.html кладе її в title посилання. */
.side-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Підсвічування бере відтінок самого сервісу, а не сірий --surface-3.
   Іконка під курсором і так фарбувалась у --side-accent (правило нижче) —
   тепер разом із нею фарбується й підкладка, тобто ховер став одним рухом
   кольору, а не «сіра плашка плюс кольорова іконка».

   Домішка мала (8%): це підказка «курсор тут», і сплутати її з активним
   пунктом не можна — у того і домішка вдвічі більша, і смужка ліворуч. */
.side-link:hover {
  color: var(--ink);
  background: color-mix(in oklab, var(--side-accent) 8%, var(--surface-2));
}

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

.side-link.active {
  color: var(--ink);
  background: color-mix(in oklab, var(--side-accent) 16%, var(--surface-2));
  border-left-color: var(--side-accent);
}

.side-link.active .ico {
  color: var(--side-accent);
}

/* Дуже низьке вікно (ноутбук з панеллю задач, відкрита консоль): панель не
   прокручується, тож замість смуги прокрутки список просто стає щільнішим. */
@media (max-height: 760px) {
  .side-link {
    min-height: 28px;
  }

  .side-group {
    margin-top: var(--s-2);
  }
}

/* Бренд у згорнутій панелі ховається цілком: на смузі в 60px він і кнопка
   згортання поруч не стоять, а сама кнопка потрібніша — без неї панель
   не розгорнути. На головну з вузької смуги веде пункт «Дашборд». */
:root[data-sidebar="collapsed"] .brand,
:root[data-sidebar="collapsed"] .side-label,
:root[data-sidebar="collapsed"] .side-group {
  display: none;
}

:root[data-sidebar="collapsed"] .sidebar-head {
  justify-content: center;
  padding-inline: 0;
}

/* Заголовків груп у вузькій панелі немає — лишаються самі іконки, і без
   розділювача вони злилися б в один суцільний стовпчик. Лінія каже те саме,
   що й напис: далі інша група. */
:root[data-sidebar="collapsed"] .side-section + .side-section {
  margin-top: var(--s-2);
  padding-top: var(--s-2);
  border-top: 1px solid var(--border);
}

:root[data-sidebar="collapsed"] .side-link {
  justify-content: center;
  padding-inline: 0;
}

/* У згорнутій панелі позначка активного пункту переїжджає ліворуч→праворуч:
   зліва вона зливалася б із краєм екрана, справа — межує з контентом. */
:root[data-sidebar="collapsed"] .side-link {
  border-left-width: 0;
  border-right: 3px solid transparent;
}

:root[data-sidebar="collapsed"] .side-link.active {
  border-right-color: var(--side-accent);
}

.topbar {
  grid-area: topbar;
  view-transition-name: topbar;
  /* На широкому екрані топбар і так нерухомий — це рядок сітки. sticky
     потрібен вузькому екрану, де прокручується сторінка цілком. */
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: 0 var(--s-4);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

.topbar-spacer {
  flex: 1;
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  justify-content: center;

  min-width: 34px;
  min-height: 34px;
  padding: 0 var(--s-2);
  font-size: var(--t-cmp);
  font-weight: 600;
  color: var(--ink-muted);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  cursor: pointer;
  transition:
    background-color var(--dur-2) var(--ease),
    color var(--dur-1) var(--ease),
    border-color var(--dur-2) var(--ease);
}

/* Тло проявляється м'яко, рамка більше не з'являється взагалі: рамка на
   ховері — це друга лінія поруч із тими, що вже малює топбар, і саме від
   неї кнопки в ряду смикались. Досить самої підкладки. */
.icon-btn:hover {
  color: var(--ink);
  background: var(--surface-2);
}

.icon-btn:active {
  background: var(--surface-3);
  transition-duration: var(--dur-1);
}

/* Довгий email обрізаємо трьома крапками — інакше він розпихає топбар. */
.user-name {
  max-width: 20ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.main {
  grid-area: main;

  /* min-width: 0 обов'язковий для grid-елемента: без нього широка
     таблиця всередині розпирає всю сітку замість того, щоб скролитись.
     min-height: 0 — те саме по вертикалі, щоб працював overflow нижче. */
  min-width: 0;
  min-height: 0;

  /* position: relative — не для зсуву, а щоб .main став containing block для
     своїх абсолютних нащадків. Без цього ними опікується <html>, і overflow
     нижче їх НЕ обрізає: вони лишаються в області прокрутки документа й
     тягнуть за собою другу смугу — поруч зі смугою .main.
     На кожній сторінці таких нащадків десятки: кожен <label class=
     "visually-hidden"> (position: absolute) сидить на своєму місці в потоці,
     тобто інколи за кілька екранів униз. Саме вони й розтягували <html>. */
  position: relative;

  /* Єдина область, що прокручується. Місце під смугу резервуємо завжди —
     інакше сторінки з довгим і коротким вмістом «стрибають» одна відносно
     одної; тут цей жолоб лежить усередині контенту й нікого не обрізає. */
  overflow-y: auto;
  scrollbar-gutter: stable;

  /* Бічний відступ пливе, а не стрибає на межі. clamp дає той самий --s-5
     на просторі й плавно віддає його контенту, щойно вікна стає менше, —
     без окремого медіа-запиту на кожен розмір. Верх і низ лишаються
     фіксованими: по вертикалі місця не бракує, а стрибок висоти між
     сторінками помітніший за стрибок ширини. */
  padding: var(--s-5) clamp(var(--s-3), 3vw, var(--s-5)) var(--s-8);
}

.container {
  max-width: var(--w-app);
  margin: 0 auto;
}

.container.form {
  max-width: var(--w-form);
}

.container.wide {
  max-width: var(--w-wide);
}

.drawer-toggle {
  display: none;
}

.drawer-overlay {
  display: none;
}

/*
  Вузький екран. Панель перестає бути колонкою й стає шухлядою поверх
  контенту. Межа 900px мусить збігатися з тією, що в shell.js.
*/
@media (max-width: 900px) {
  .app-shell {
    /* Колонки панелі більше немає — вона тепер position: fixed. */
    grid-template-columns: 1fr;
    grid-template-areas:
      "topbar"
      "main";

    /* Прокрутка повертається сторінці: на телефоні смуга накладна, жолоба
       не займає, зате рідний скрол ховає адресний рядок і не ламається
       від динамічної висоти вікна. */
    height: auto;
    min-height: 100dvh;
  }

  .sidebar {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 40;
    width: 248px;
    /* Панель більше не комірка сітки — висоту треба задати самій. */
    height: 100dvh;
    transform: translateX(-100%);
    box-shadow: var(--e-3);
    transition: transform var(--dur-2) var(--ease);

    /* Тут прокрутка панелі повертається — і це не суперечить рішенню вище.
       Нерухомим полем панель має бути там, де вона стоїть поруч із контентом
       постійно; шухляда ж відкривається на весь екран телефона, де висоти
       менше за список, і обрізаний кінець списку просто був би недоступним. */
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .drawer-toggle {
    display: inline-flex;
  }

  .collapse-toggle {
    display: none;
  }

  /* Скасовуємо ефекти «згорнутості»: тут панель або схована повністю,
     або показана як шухляда — вузької смуги з іконок не буває.
     А сам стан лишається збереженим і повернеться на широкому екрані. */
  :root[data-sidebar="collapsed"] .brand,
  :root[data-sidebar="collapsed"] .side-label,
  :root[data-sidebar="collapsed"] .side-group {
    display: revert;
  }

  /* Кнопки згортання тут немає (правило вище), тож у шапці лишається
     сам бренд — вирівнюємо його по лівому краю, як у розгорнутій панелі. */
  :root[data-sidebar="collapsed"] .sidebar-head {
    justify-content: flex-start;
    padding-inline: var(--s-4) var(--s-2);
  }

  :root[data-sidebar="collapsed"] .side-link {
    justify-content: flex-start;
    padding-inline: var(--s-2);
  }

  :root[data-sidebar="collapsed"] .side-link {
    border-left-width: 3px;
    border-right-width: 0;
  }

  /* Заголовки груп тут видно (правило вище), тож розділювальна лінія між
     секціями стає другим підписом того самого — прибираємо. */
  :root[data-sidebar="collapsed"] .side-section + .side-section {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
  }

  /* Затемнення під шухлядою. Ховаємо через opacity + visibility, а не
     display: none — інакше не буде плавного згасання. visibility при
     цьому прибирає його з фокусу й кліків. */
  .drawer-overlay {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 30;
    background: oklch(0 0 0 / 0.45);
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--dur-2) var(--ease), visibility var(--dur-2);
  }

  :root[data-drawer="open"] .sidebar {
    transform: none;
  }

  :root[data-drawer="open"] .drawer-overlay {
    opacity: 1;
    visibility: visible;
  }

  .main {
    /* Прокручується сторінка, а не ця область — власний жолоб тут зайвий. */
    overflow: visible;
    scrollbar-gutter: auto;
    /* Та сама ідея, що й на широкому екрані, лише коридор нижчий: на
       360px екрані бічні 16px — це 9% ширини, віддані порожнечі. */
    padding: var(--s-4) clamp(var(--s-2), 3.5vw, var(--s-4)) var(--s-7);
  }

  .user-name {
    display: none;
  }

  /*
    Скло вузького екрана — обидва місця під однією умовою.

    Топбар: на широкому екрані він рядок сітки, під ним нічого не проїжджає,
    і розмиття розмивало б суцільний фон — тобто платило б за перемальовку
    нічим. Тут він position: sticky над сторінкою, що прокручується, — саме
    той випадок, заради якого backdrop-filter існує.

    Накладка шухляди: з розмиттям затемнення можна зробити слабшим, бо
    сторінка йде на задній план не тому, що її погасили, а тому, що вона
    розфокусована.

    @supports обов'язковий і спільний: --glass напівпрозорий, і в браузері
    без розмиття контент просвічував би крізь топбар як є. Немає підтримки —
    лишаються суцільні --surface і чорний 0.45 із правил вище.
  */
  @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
    .topbar {
      background: var(--glass);
      -webkit-backdrop-filter: var(--glass-blur);
      backdrop-filter: var(--glass-blur);
    }

    .drawer-overlay {
      background: oklch(0 0 0 / 0.32);
      -webkit-backdrop-filter: blur(3px);
      backdrop-filter: blur(3px);
    }
  }
}

/*
  Друк. Каркас висотою в екран із прокруткою всередині надрукував би лише
  перший екран контенту — на папері повертаємо звичайний потік. Панель і
  топбар до аркуша не мають стосунку.
*/
@media print {
  .app-shell {
    display: block;
    height: auto;
  }

  .sidebar,
  .topbar {
    display: none;
  }

  .main {
    overflow: visible;
    padding: 0;
  }
}

/*
  Сторінки входу і 403. Власний каркас: ні панелі, ні топбару там немає.
  На вузькому екрані лишається сама картка, брендова половина ховається.
*/

.auth-body {
  display: grid;
  min-height: 100vh;
}

@media (min-width: 860px) {
  .auth-body {
    grid-template-columns: 1fr 1fr;
  }
}

.auth-brand {
  display: none;
  flex-direction: column;
  justify-content: space-between;
  padding: var(--s-7) var(--s-6);
  background: linear-gradient(
    160deg,
    color-mix(in oklab, var(--brand) 22%, var(--surface)),
    color-mix(in oklab, var(--brand) 6%, var(--bg))
  );
  border-right: 1px solid var(--border);
}

@media (min-width: 860px) {
  .auth-brand {
    display: flex;
  }
}

.auth-mark {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--t-xl);
  font-weight: 700;
  color: var(--ink);
}

.auth-mark .brand-mark {
  /* Тут напис більший (--t-xl), тож знак у em виходить сам собою більшим —
     окремого розміру не треба. */
  width: 1.3em;
  height: 1.3em;
}

.auth-tagline {
  max-width: 34ch;
  margin-top: var(--s-3);
  color: var(--ink-muted);
}

.auth-foot {
  font-size: var(--t-sm);
  color: var(--ink-subtle);
}

.auth-panel {
  display: grid;
  place-items: center;
  padding: var(--s-5) var(--s-4);
}

.auth-card {
  width: 100%;
  max-width: 380px;
}

.auth-card .auth-mark {
  margin-bottom: var(--s-5);
}

/* Логотип у картці потрібен, лише поки брендова половина схована —
   інакше він дублювався б на екрані двічі. */
@media (min-width: 860px) {
  .auth-card .auth-mark {
    display: none;
  }
}

}

@layer components {
/* 40-components/actions.css */
/*
  Ряди дій — єдина форма для всіх кнопкових груп інтерфейсу.

  Досі кожен сервіс описував свій варіант того самого: .cost-actions,
  .pc-actions, .cost-source-actions — усі три означали «кнопки в рядок із
  проміжком». Тепер це один клас, і кнопки під таблицею в «Костах»
  відбиваються так само, як під карткою в «Доменах».

    .actions         — кнопки в рядок (під таблицею, під карткою);
    .actions.stacked — те саме колонкою, коли в комірці таблиці немає ширини;
    .page-actions    — підвал сторінки: переходи в сусідні розділи.
*/
.actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
}

/*
  Колонкою — коли в комірці таблиці немає ширини на рядок. Розтягуються
  саме форми: усередині них буває поле («новий пароль»), і воно має зайняти
  комірку. Гола кнопка лишається завширшки зі свій підпис — розтягнута на
  всю комірку, вона читалась би як заголовок рядка, а не як дія.
*/
.actions.stacked {
  flex-direction: column;
  align-items: stretch;
}

.actions.stacked > .btn {
  align-self: flex-start;
}

/*
  Дія, що змінює стан, мусить бути POST, тобто жити всередині <form>. Щоб
  ця форма не ламала рядок, вона сама стає флекс-контейнером зі своєю
  однією кнопкою. display: contents тут не годиться: у частині браузерів
  він викидає <form> із дерева доступності.
*/
.actions > form {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
}

/* Кнопка в ряду не «завершує форму», тож автоматичний відступ згори
   (button.css) їй не потрібен. */
.actions > .btn,
.actions > form > .btn {
  margin-top: 0;
}

/*
  Підвал сторінки: переходи, які стосуються розділу цілком, — архів,
  довідники, повернення до списку.

  Раніше на їх місці стояло текстове посилання в <p class="hint">. Воно
  губилось під карткою й нічим не показувало, що це дія, а не примітка до
  форми вище; клікабельна зона в нього була завширшки з самі літери.

  Лінія згори завершує сторінку: під нею вже нічого немає.
*/
.page-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  padding-top: var(--s-4);
  border-top: 1px solid var(--border);
}

.page-actions .btn {
  margin-top: 0;
}

}
@layer components {
/* 40-components/alert.css */
/* Банер повідомлення. Без модифікатора — нейтральний;
   .ok / .warn / .error / .info беруть готові трійки токенів стану. */
/*
  Кант ліворуч — спільна форма для всього, що повідомляє про стан: банер,
  шапка сторінки, картка місяця. Він же й робить стан помітним здалеку:
  бліда заливка на 12% домішки читається лише зблизька, а суцільна смуга
  насиченого кольору впізнається з іншого кінця екрана.

  Колір канта береться з currentColor — тобто з того самого --ok-ink /
  --warn-ink / --fail-ink, який модифікатор нижче вже поставив тексту.
  Завдяки цьому жоден із них не має власного правила на кант, і новий стан
  (якщо колись з'явиться) отримає його безкоштовно.
*/
.alert {
  display: block;
  margin: var(--s-4) 0;
  padding: var(--s-3) var(--s-4);
  font-size: var(--t-md);
  border: 1px solid var(--border);
  border-left: 3px solid currentColor;
  border-radius: var(--r-md);
  box-shadow: var(--e-1);

  color: var(--ink);
  background: var(--surface-2);
}

/* Нейтральний банер канта не має: він нічого не сигналить, і смуга кольору
   тексту на ньому читалася б як стан, якого немає. */
.alert:not(.ok, .warn, .error, .info) {
  border-left-color: var(--border);
}

.alert.ok {
  color: var(--ok-ink);
  background: var(--ok-bg);
  border-color: var(--ok-line);
}

.alert.warn {
  color: var(--warn-ink);
  background: var(--warn-bg);
  border-color: var(--warn-line);
}

.alert.error {
  color: var(--fail-ink);
  background: var(--fail-bg);
  border-color: var(--fail-line);
}

.alert.info {
  color: var(--info-ink);
  background: var(--info-bg);
  border-color: var(--info-line);
}

/*
  Повертаємо канту насичений колір. Модифікатори вище задають border-color
  скорочено, тобто разом із лівим боком, — і кант виходив би блідим
  --*-line, як решта рамки. Саме та різниця й потрібна: рамка окреслює
  коробку, кант сигналить стан, і фарбувати їх однаково означає не мати
  канта взагалі.
*/
.alert.ok,
.alert.warn,
.alert.error,
.alert.info {
  border-left-color: currentColor;
}

/* Код усередині банера підмішується до його ж кольору (currentColor),
   а не бере власний фон — інакше він випадав би з кольорової плями. */
.alert code {
  background: color-mix(in oklab, currentColor 12%, transparent);
  color: inherit;
}

.alert .ico {
  margin-right: var(--s-1);
}

/*
  Банер, який починається з іконки. Доти він лишався блоком, і будь-який
  БЛОКОВИЙ вміст після іконки (абзац, список, обгортка <div>) починався з
  нового рядка — іконка висіла сама над текстом. Саме так виглядали
  зауваження прогону в «Розподілі костів» і застереження про читання файлу.

  Дві колонки замість flex: у flex-контейнері сусідні блокові діти стали б
  окремими колонками, а тут другий рядок сітки просто йде під текст. Банер
  без іконки правило не зачіпає — :has() його не вибере, і банери з
  <details> усередині лишаються звичайним блоком.
*/
.alert:has(> .ico:first-child) {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: var(--s-2);
}

/* Проміжок дає gap; власний відступ іконки в сітці був би подвійним. */
.alert:has(> .ico:first-child) > .ico {
  margin-right: 0;
  /* Оптичне вирівнювання по першому рядку тексту, а не по його коробці. */
  margin-top: 0.15em;
}

}
@layer components {
/* 40-components/alerts.css */
/*
  Блок «Потребує уваги» на дашборді: стани, які просять дії просто зараз.

  Файл раніше називався toast.css і містив удвічі більше — компонент тоста,
  який сам вигулькував у правому нижньому куті на КОЖНІЙ сторінці CRM. Тоста
  більше немає, і причина не в тому, що він заважав.

  З появою дзвіночка в топбарі він став другим каналом для тієї самої
  інформації, а два канали з однаковим вмістом гірші за один: людина вчиться
  не помічати обидва, бо кожен щоразу виявляється тим, який вона вже бачила.
  Разом із тостом пішли й усі його механіки — липкість, дедуплікація по ключу,
  «прибрати на кілька годин» у localStorage: вони існували рівно для того, щоб
  канал, який приходить сам, не перетворився на шум. Каналу, по який приходять
  самі, вони не потрібні.

  Що лишилось — панель на дашборді. Вона нічого не вимагає й нікуди не
  вигулькує: людина відкриває головну й бачить, що зараз варте уваги.
*/
.alerts-panel {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
  gap: var(--s-3);
}

.alerts-item {
  padding: var(--s-3) var(--s-4);
  background: var(--surface);
  border: 1px solid var(--border);
  /* Стан несе ліва грань, а не заливка: п'ять залитих карток поспіль читаються
     як одна пляма, у якій нічого не виділено. Кольори — наявні токени станів,
     власних компонент не заводить. */
  border-left: 3px solid var(--border-strong);
  border-radius: var(--r-md);
}

.alerts-item.warn  { border-left-color: var(--warn-ink); }
.alerts-item.error { border-left-color: var(--fail-ink); }
.alerts-item.ok    { border-left-color: var(--ok-ink); }
.alerts-item.info  { border-left-color: var(--info-ink); }

.alerts-item .name { font-weight: 600; }
.alerts-item .name a { color: inherit; text-decoration: none; }
.alerts-item .name a:hover { text-decoration: underline; }

.alerts-item .advice {
  margin: var(--s-1) 0 0;
  font-size: var(--t-cmp);
  color: var(--ink-muted);
}

/* Пасивний слід: «Дмитро відкривав картку 2 хв тому». Кнопок тут немає
   навмисно — підказка має коштувати нуль уваги. */
.alerts-item .trace {
  margin: var(--s-1) 0 0;
  font-size: var(--t-sm);
  color: var(--ink-subtle);
}

}
@layer components {
/* 40-components/badge.css */
/* Компактна мітка статусу. Ті самі модифікатори, що й у .alert,
   плюс .running — з живою крапкою, яка пульсує. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  padding: 0.15em var(--s-2);
  font-size: var(--t-xs);
  font-weight: 600;
  line-height: 1.6;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--ink-muted);
  background: var(--surface-3);
  border: 1px solid transparent;
  border-radius: var(--r-full);
}

.badge.ok {
  color: var(--ok-ink);
  background: var(--ok-bg);
  border-color: var(--ok-line);
}

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

.badge.error {
  color: var(--fail-ink);
  background: var(--fail-bg);
  border-color: var(--fail-line);
}

.badge.info {
  color: var(--info-ink);
  background: var(--info-bg);
  border-color: var(--info-line);
}

.badge.running {
  color: var(--info-ink);
  background: var(--info-bg);
  border-color: var(--info-line);
}

/* Крапка малюється псевдоелементом — не потребує зайвого тега в розмітці.
   Розмір в em, тож масштабується разом із текстом бейджа. */
.badge.running::before {
  content: "";
  width: 0.5em;
  height: 0.5em;
  border-radius: 50%;
  background: currentColor;
  animation: badge-pulse 1.4s ease-in-out infinite;
}

@keyframes badge-pulse {
  50% {
    opacity: 0.25;
  }
}

}
@layer components {
/* 40-components/breadcrumb.css */
/* Хлібні крихти на внутрішніх сторінках сервісів. */
.breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-1);
  margin-bottom: var(--s-3);
  font-size: var(--t-sm);
  color: var(--ink-muted);
}

.breadcrumb a {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  color: var(--ink-muted);
  text-decoration: none;
  border-radius: var(--r-sm);
}

.breadcrumb a:hover {
  color: var(--brand);
  text-decoration: underline;
}

/* Роздільник — псевдоелементом, і лише між пунктами: у розмітці його
   немає, тож він не потрапляє ні в копіювання, ні в скрінрідер. */
.breadcrumb li + li::before {
  content: "/";
  margin-right: var(--s-1);
  color: var(--ink-subtle);
}

/* display: contents прибирає сам <ol> із розкладки, лишаючи його
   семантику: пункти стають прямими дітьми флекс-контейнера. */
.breadcrumb ol {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}

.breadcrumb li {
  display: inline-flex;
  align-items: center;
}

.breadcrumb [aria-current="page"] {
  color: var(--ink);
  font-weight: 600;
}

}
@layer components {
/* 40-components/button.css */
/*
  Кнопка. Один клас .btn — основна дія; варіанти: .secondary, .ghost,
  .ok, .danger, розмір — .small. Клас .loading показує спінер (див. низ файлу).
  Однаково працює і на <button>, і на <a> — звідси text-decoration: none.
*/
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);

  min-height: 38px;
  padding: var(--s-2) var(--s-5);
  font-size: var(--t-md);
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;

  /* Акцент, а не бренд: головна дія фарбується кольором СВОГО сервісу —
     тим самим, що й плитка на дашборді, пункт у навбарі й смужка в шапці.
     Досі кнопка була фіолетовою на всіх сторінках, тобто єдиний елемент,
     який людина натискає, був єдиним, що не знав, де стоїть.

     Градієнт злегка темніє ДОНИЗУ — не «блиск», а те, що робить заливку
     заливкою: рівна пляма кольору читається як намальований прямокутник,
     а найлегший вертикальний перепад — як поверхня, на яку падає світло.

     Світлішати вгору він не може, хоч так і було б звичніше: найсвітліша
     точка кнопки — це та, де контраст із текстом найгірший, і саме вона
     впирається в AA (див. --accent-solid). Тому найсвітліша точка тут —
     сам --accent-solid, для якого межу й пораховано, а перепад іде вниз. */
  color: var(--accent-ink);
  background: linear-gradient(180deg, var(--accent-solid), var(--accent-hover));
  border: 1px solid transparent;
  border-radius: var(--r-md);
  cursor: pointer;
  text-decoration: none;
  box-shadow: var(--e-1);
  transition:
    background-color var(--dur-1) var(--ease),
    border-color var(--dur-1) var(--ease),
    color var(--dur-1) var(--ease),
    box-shadow var(--dur-2) var(--ease),
    transform var(--dur-1) var(--ease);
}

/*
  Кнопка, що завершує форму й стоїть просто в ній, без обгортки, — типовий
  випадок у простих формах сервісів. Відступ від останнього поля той самий,
  що між блоками сторінки (50-rhythm.css), тож форма не випадає з ритму.

  Раніше цей margin-top стояв на самій .btn, і його доводилось гасити
  всюди, де кнопка стоїть у ряд з іншим: у .pager, .form-actions,
  .modal-footer, у комірках таблиць, у формах-рядках кожного сервіса. Саме
  звідти й бралася більшість різнобою у відступах. Тепер відступ
  з'являється рівно там, де він потрібен.

  Групи кнопок (.actions, .form-actions, .form-row) і компактний розмір
  (.btn.small) гасять його одним класом — жодного правила «на місці».
*/
form > .btn {
  margin-top: var(--s-5);
}

/* Тінь під головною кнопкою — її ж кольору, а не сіра. Кольорова тінь
   читається як світіння самої кнопки; сіра під насиченою заливкою виглядає
   брудною плямою, бо в ній немає жодного спільного з нею відтінку. */
/*
  Стани кнопки — це рух по одній сходинці вниз тією самою трійкою кольорів
  (solid → hover → active, оголошені в токенах):

    спокій     solid  → hover
    ховер      hover  → active
    натиснуто  active (рівно, без градієнта — кнопка «притиснута»)

  Найгірший контраст на всіх трьох станах — 4.68 у світлій темі й 4.79 у
  темній; межу тримають самі токени, тут її вже нема чим порушити.

  Тінь під ховером — кольору кнопки, а не сіра: кольорова читається як
  світіння самої кнопки, а сіра під насиченою заливкою виглядає брудною
  плямою, бо не має з нею жодного спільного відтінку.
*/
.btn:hover {
  background: linear-gradient(180deg, var(--accent-hover), var(--accent-active));
  box-shadow:
    var(--e-1),
    0 4px 14px -4px color-mix(in oklab, var(--accent) 55%, transparent);
}

/* Натискання — на піксель униз і без тіні. Найдешевша тактильна відповідь,
   яка буває: кнопка піддається під курсором, і клік перестає бути подією,
   про яку дізнаєшся лише з того, що сторінка перезавантажилась. */
.btn:active {
  background: var(--accent-active);
  transform: translateY(1px);
  box-shadow: none;
}

.btn[disabled],
.btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
}

.btn.loading[disabled] {
  cursor: progress;
}

.btn.secondary {
  color: var(--ink);
  background: var(--surface);
  border-color: var(--border-strong);
}

.btn.secondary:hover {
  background: var(--surface-2);
  border-color: var(--ink-subtle);
}

/* Привид — єдиний варіант без тіні: він і задуманий як кнопка, якої не
   видно, доки на неї не навели. Тінь зробила б із неї звичайну кнопку, а
   тоді .ghost перестав би відрізнятись від .secondary. */
.btn.ghost {
  color: var(--ink-muted);
  background: none;
  border-color: transparent;
  box-shadow: none;
}

.btn.ghost:hover {
  color: var(--ink);
  background: var(--surface-2);
}

/*
  Дія з протилежним знаком до .danger: «видати», «підтвердити», «увімкнути».
  Форма навмисно та сама (тінт, а не суцільна заливка): ці дві кнопки майже
  завжди стоять парою, і заливка поруч із тінтом читалась би як різна вага
  дій, хоча вага в них однакова — обидві незворотні, просто в різні боки.

  Не заміна основній .btn: та лишається кольором акценту сервісу для дії, у
  якої пари немає.
*/
.btn.ok {
  color: var(--ok-ink);
  background: var(--ok-bg);
  border-color: var(--ok-line);
}

.btn.ok:hover {
  background: color-mix(in oklab, var(--ok-ink) 20%, var(--surface));
}

.btn.danger {
  color: var(--fail-ink);
  background: var(--fail-bg);
  border-color: var(--fail-line);
}

.btn.danger:hover {
  background: color-mix(in oklab, var(--fail-ink) 20%, var(--surface));
}

/* Компактний розмір — це кнопка в комірці таблиці або в рядку картки.
   Там вона ніколи не «завершує форму», тож власних відступів не має:
   проміжки задає ряд, у якому вона стоїть (.actions, .form-row). */
.btn.small {
  min-height: 28px;
  padding: var(--s-1) var(--s-3);
  font-size: var(--t-sm);
  margin: 0;
}

/* Спінер лежить у розмітці завжди, але прихований. Показує його клас
   .loading на кнопці — так ширина не стрибає в момент появи.
   currentColor + em: підлаштовується під будь-який варіант кнопки. */
.spinner {
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  /* Прозорий бік і дає ефект «дуги», що крутиться. */
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
  display: none;
}

.btn.loading .spinner {
  display: inline-block;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

}
@layer components {
/* 40-components/card.css */
/* Картка — основна «коробка» контенту сторінки.

   Тепер її межу тримають три речі разом, а не сама рамка: біла (чи світліша)
   поверхня на трохи темнішому тлі, волосина рамки й контактна тінь. Тому
   рамку й послаблено в токенах — коробка й без неї не розсипається, а
   сторінка з десятком карток перестала виглядати кресленням. */
.card {
  padding: var(--s-6) var(--s-6);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--e-2);
}

/* Телефон і вузький планшет. 32px падінгу на 360px екрані — це 18% ширини,
   віддані полям; текст у такій картці стискається до колонки в 40 символів
   і починає рватись. Крок відступу зменшуємо, радіус лишаємо: він про
   форму коробки, а не про її розмір. */
@media (max-width: 560px) {
  .card {
    padding: var(--s-4) var(--s-4);
  }

  .card.compact {
    padding: var(--s-3) var(--s-4);
  }
}

/*
  Компактна картка — та, що стоїть не однією на секцію, а рядком чи сіткою:
  плитки підсумків місяця, рядки дошки оплат, записи дня, підказка про
  непорахований місяць. Падінг картки-секції в такому ряду виглядає
  порожнім, тож він на крок менший.

  Чотири сервіси дійшли до цих самих значень незалежно й тримали їх у
  власних стилях — .cost-suggest, .cost-sum-card, .fin-card, .nt-row. Це
  той випадок, коли варіант мав бути в компоненті, а не в кожному з них.
*/
.card.compact {
  padding: var(--s-4) var(--s-5);
}

/*
  Краї картки. Заголовок чи абзац, що стоїть у ній першим або останнім, не
  додає власного відступу — його вже дає падінг самої картки.

  Без цього картка, яка починається з <h2>, з'їжджала вниз на подвійний
  відступ (32px падінгу плюс 32px власного margin заголовка), а картка, що
  починається з тексту, — ні. Дві сусідні картки з однаковим падінгом
  виглядали по-різному, і причина не була видна ні в розмітці, ні в стилях.
*/
.card > :first-child {
  margin-top: 0;
}

.card > :last-child {
  margin-bottom: 0;
}

/*
  Шапка сторінки. Смужка ліворуч — той самий акцент, що й у навбарі, тобто
  колір поточного сервісу (--accent ← --h-accent з <body>).

  До смужки додалась кольорова заливка, що згасає вправо. Причина проста:
  смужка в 3px — це все, чим сторінка сервісу показувала свій колір, і на
  ширині в 1200px її не було видно. Заливка робить те саме, що й плитка на
  дашборді: ще до назви видно, у якому ти сервісі.

  Градієнт іде під кутом і гасне на 70% — не до правого краю. Рівна пляма
  на всю ширину читалася б як ще одна картка (їх на сторінці й так кілька),
  а та, що згасає, лишається тлом заголовка й ні з чим не конкурує.
*/
.page-head {
  margin-bottom: var(--s-5);
  padding: var(--s-4) var(--s-5);
  border-left: 3px solid var(--accent);
  border-radius: var(--r-lg);
  background: linear-gradient(
    100deg,
    var(--accent-wash-2),
    color-mix(in oklab, var(--accent) 3%, var(--surface)) 40%,
    transparent 70%
  );
}

/* Флекс, а не звичайний рядок: у заголовку поруч із назвою стоїть іконка
   сервісу, а подекуди й бейдж стану («чернетка» в картці місяця). */
.page-head h1 {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  margin: 0;
  font-size: var(--t-xl);
}

/* Іконка заголовка — тим самим кольором, що й смужка ліворуч. Разом вони
   читаються як «це сторінка такого-то сервіса», ще до самої назви.

   Коробка навколо іконки зроблена самим CSS, без жодного зайвого тега:
   .ico це <svg>, і padding із content-box перетворює його на чип рівно так
   само, як перетворив би <span>. Розміри в em, тож чип масштабується
   разом із заголовком — і при системному збільшенні шрифта теж. */
.page-head h1 .ico {
  color: var(--accent);
  box-sizing: content-box;
  padding: 0.32em;
  background: var(--accent-wash-2);
  border: 1px solid var(--accent-line);
  border-radius: var(--r-md);
}

.page-head p {
  margin: var(--s-1) 0 0;
  font-size: var(--t-md);
  color: var(--ink-muted);
}

/*
  Заголовок секції з дією праворуч: «Сервіси» з кнопкою налаштувань
  дашборду, «Прогрес» із бейджем стану, «Рядків: N» із кнопкою оновлення.
  Раніше це були два різні класи з однаковим змістом — .section-head і
  .console-head; лишився один.

  Відступи ті самі, що в голого h2 (20-base.css): заголовок у рядку з
  кнопкою не має з'їжджати відносно решти. Дія відтісняється до правого
  краю — тоді ряд заголовків на сторінці читається однією колонкою,
  а дії — другою.
*/
.section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  margin: var(--s-6) 0 var(--s-3);
}

.section-head > h2,
.section-head > h3 {
  margin: 0;
}

/*
  Позначка секції — коротка смужка акценту перед заголовком. Та сама
  форма, що й кант банера й смужка в шапці сторінки: скрізь, де щось
  починається, ліворуч стоїть вертикальна риска кольору сервісу.

  Псевдоелементом, а не тегом у розмітці: заголовків секцій на тридцяти
  сторінках сотні, і жоден із них не має знати про цю смужку.

  Тільки заголовки СЕКЦІЙ — ті, що стоять просто на сторінці або
  відкривають картку. h2 усередині врізки, комірки таблиці чи модалки
  правило не чіпає: там він підписує вміст коробки, а не відкриває розділ,
  і риска перед ним була б четвертим рівнем вкладеності, намальованим
  на рівному місці.
*/
.container > h2::before,
.card > h2::before,
.section-head > h2::before {
  content: "";
  display: inline-block;
  width: 3px;
  height: 0.85em;
  margin-right: var(--s-2);
  vertical-align: -0.06em;
  background: var(--accent);
  border-radius: var(--r-full);
}

.section-head .btn {
  margin-top: 0;
}

}
@layer components {
/* 40-components/chips.css */
/*
  Поле-чіпи (списки баєрів, сабів і кампаній у Keitaro). Розмітку будує
  shared/static/js/chips.js за атрибутом [data-chips]; тут — лише вигляд.

  .chips-field — обгортка з розмітки, .chips — коробка, яку створює JS.
  Тому без скрипта видно звичайний input, і форма лишається робочою.
*/
.chips-field {
  margin-top: var(--s-1);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
  padding: var(--s-1) var(--s-2);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  cursor: text;
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

.chips:hover {
  border-color: var(--ink-subtle);
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  max-width: 100%;
  padding: 0.15em var(--s-1) 0.15em var(--s-2);
  font-size: var(--t-cmp);
  font-weight: 500;
  color: var(--ink);
  background: var(--surface-3);
  border: 1px solid var(--border);
  border-radius: var(--r-full);
}

.chip span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chip button {
  display: inline-flex;
  align-items: center;
  justify-content: center;

  width: 20px;
  height: 20px;
  padding: 0;
  color: var(--ink-muted);
  background: none;
  border: 0;
  border-radius: var(--r-full);
  line-height: 1;
  cursor: pointer;
}

.chip button:hover {
  color: var(--fail-ink);
  background: var(--fail-bg);
}

/* Поле вводу тягнеться на вільне місце, але не вужче 8ch — інакше після
   кількох чіпів у ньому не лишалося б місця набирати. */
.chips input.chip-entry {
  flex: 1 1 8ch;
  min-width: 8ch;
  width: auto;
  padding: var(--s-1) var(--s-1);
  color: var(--ink);
  background: none;
  border: 0;
  font: inherit;
  font-size: var(--t-md);
}

/* Фокус із самого input знімаємо — але не втрачаємо: скрипт вішає клас
   .focus на коробку, і рамку малює вже вона (див. form.css). Інакше
   всередині поля світилася б друга рамка. */
.chips input.chip-entry:focus,
.chips input.chip-entry:focus-visible {
  outline: none;
  box-shadow: none;
  border: 0;
}

/*
  Стан чіпа після перевірки в довіднику (data-suggest у chips.js). Колір —
  не єдиний носій сенсу: причина лежить у title чіпа, а повний розбір усе
  одно приходить із прев'ю. Без підказок стану немає, і чіп лишається
  звичайним — саме так виглядає поле в «Створенні TG-груп».
*/
.chip.is-pending {
  opacity: .6;
}

.chip.is-one {
  color: var(--ok-ink);
  background: var(--ok-bg);
  border-color: var(--ok-line);
}

/* Колір тексту тут не прикраса: тло станів домішує колір лише на 12%, і на
   темній темі жовтий «двозначно» без нього не відрізнити від звичайного
   чіпа. Трійка ink + bg + line — та сама, що в .alert і .badge. */
.chip.is-many {
  color: var(--warn-ink);
  background: var(--warn-bg);
  border-color: var(--warn-line);
}

.chip.is-none {
  color: var(--fail-ink);
  background: var(--fail-bg);
  border-color: var(--fail-line);
}

/*
  Випадайка з варіантами. Вигляд навмисно той самий, що в .sel-panel
  (select.css): для людини це один і той самий «список під полем», і два
  різні кути чи тіні читались би як два різні інтерфейси. Класи все ж свої —
  структуру .sel-* будує select.js під власний <select>, і спільний клас
  зв'язав би два компоненти, які не мають нічого спільного всередині.

  У <body>, а не поруч із полем: усередині картки з overflow панель
  обрізалася б. Тому position: fixed і координати рахує скрипт.

  z-index 50 — той самий поверх, що й у календаря та випадайки.
*/
.chips-menu {
  position: fixed;
  z-index: 50;
  max-height: 20rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--s-1);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  box-shadow: var(--e-3);
}

.chips-option {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 32px;
  padding: var(--s-1) var(--s-2);
  font-size: var(--t-cmp);
  color: var(--ink);
  border-radius: var(--r-sm);
  cursor: pointer;
}

/* Підсвітка одна на мишу й на клавіатуру — як у .sel-option: інакше видимих
   «поточних» пунктів стає два. */
.chips-option.active,
.chips-option:hover {
  background: var(--surface-3);
}

.chips-option-label {
  flex: 1;
  min-width: 0;
}

/* Дрібне праворуч: id, група, пошта. Не переносимо — рядок має лишатись
   одним, інакше список стрибає висотою при наборі. */
.chips-option-hint {
  flex: none;
  max-width: 45%;
  overflow: hidden;
  font-size: var(--t-xs);
  color: var(--ink-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

}
@layer components {
/* 40-components/collapse.css */
/*
  Згортання великих блоків.

  <details> уміє головне сам, без жодного рядка JS, — скрипт поруч
  (static/js/collapse.js) лише запам'ятовує стан між сторінками. Тому блок,
  згорнутий один раз, лишається згорнутим і після редіректу з POST, і завтра.

  Велике й ЗАЗДАЛЕГІДЬ ВІДОМЕ велике (сотні рядків) шаблон має віддавати вже
  згорнутим — атрибутом open, якого немає. Розгортати скриптом після
  завантаження не можна: сторінка на чотириста рядків встигала б смикнутись
  людині в очі, і саме цього смикання ми й позбуваємось.

  Клас навішується на <details> і НЕ замінює .card — вони поєднуються:
  <details class="card collapse"> дає згортання всередині коробки.
*/
.collapse > summary {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  cursor: pointer;
  /* Стандартний трикутник прибираємо: замість нього шеврон зі спрайта, той
     самий, яким позначені розкривні блоки в решті інтерфейсу. list-style
     вимикає його у Firefox, ::-webkit-details-marker — у решті. */
  list-style: none;
  user-select: none;
}

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

.collapse > summary:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--r-sm);
}

/* Шеврон — єдине, що показує стан, тож повертається саме він. */
.collapse > summary .ico {
  color: var(--ink-muted);
  transition: transform var(--dur-1) var(--ease);
}

.collapse[open] > summary .ico {
  transform: rotate(180deg);
}

@media (prefers-reduced-motion: reduce) {
  .collapse > summary .ico {
    transition: none;
  }
}

/* Заголовок займає рядок цілком — клікабельна вся смуга, а не самі слова. */
.collapse-title {
  font-weight: 600;
}

/*
  Справжній заголовок усередині summary. Рівень лишається справжнім <h2>, щоб
  навігація по заголовках у скрінрідері не ламалась через те, що секцію
  зробили розкривною; від flex-рядка йому потрібно лише зняти власні відступи.
*/
.collapse > summary > h2,
.collapse > summary > h3 {
  margin: 0;
  font-size: var(--t-lg);
}

/*
  Лічильник праворуч. Він тут не для краси: згорнутий блок мусить сам казати,
  скільки в ньому всього, — інакше єдиний спосіб це дізнатись — розгорнути,
  тобто рівно те, від чого людина згортала.
*/
.collapse-count {
  margin-left: auto;
  font-size: var(--t-sm);
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* Відступ з'являється лише коли є від чого відступати. */
.collapse[open] > summary {
  margin-bottom: var(--s-3);
}

}
@layer components {
/* 40-components/console.css */
/*
  .log-panel — панель статичного логу: живий лог в адмінці та деталі
  запуску в історії.

  Тут був ще .console із розфарбуванням рядків за рівнем (.lvl-*) — під
  живий стрім AutoFlow. Стрім лишився й далі веде прогрес-бар, але рядки
  більше не малюються, тож і стилі до них пішли.

  Рядок заголовка над панеллю (.console-head) теж пішов: він робив те саме,
  що .section-head у card.css, — заголовок ліворуч, дія праворуч. Два класи
  на одне й те саме розходились у відступах, тож лишився один.
*/
/*
  Сама коробка й прокрутка — спільні .panel.scroll: до появи того компонента
  ця панель описувала їх у себе, і її 340px були одним із чотирьох різних
  значень висоти в інтерфейсі. Тут лишилось те, що властиве саме логу, —
  моноширинний шрифт і поводження з довгими рядками.

  У розмітці клас іде поруч: class="log-panel panel scroll".
*/
.log-panel {
  font-family: var(--font-mono);
  font-size: var(--t-cmp);
  line-height: 1.5;
  /* pre-wrap зберігає переноси з логу, але не дає довгому URL вилізти
     за межі — його добиває break-word. */
  white-space: pre-wrap;
  word-break: break-word;
  color: var(--ink);
}

}
@layer components {
/* 40-components/datepicker.css */
/*
  Календар для полів дати (shared/static/js/datepicker.js).

  Власний віджет замість <input type="datetime-local">: рідний малюється за
  локаллю браузера, а не за мовою сторінки, і давав американський порядок
  та AM/PM. Тут усе — токенами, тож панель перемикається разом із темою сама.

  Стилюємо ТІЛЬКИ панель. Саме поле лишається звичайним .field input і
  виглядає як усі інші — календар є надбудовою, а не окремим типом контролу.
*/

.dp-wrap {
  position: relative;
  display: block;
}

.dp {
  position: absolute;
  /* 50 — між панеллю (40) і тостом (60): календар має накривати каркас
     сторінки, але не перекривати сповіщення.
     Тут раніше стояло var(--e-3), а це токен ТІНІ, а не число: правило
     виходило недійсним і відкидалося, тобто панель лишалась без z-index
     узагалі. Симетрична плутанина була й у box-shadow нижче. */
  z-index: 50;
  top: calc(100% + var(--s-1));
  left: 0;
  min-width: 17rem;
  padding: var(--s-3);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  /* --e-3, а не --shadow-2: другий — це лише КОЛІР тіні, а box-shadow без
     зсувів недійсний, тож тіні в панелі не було зовсім. */
  box-shadow: var(--e-3);
}

/* Поле в кінці сторінки — панель відкривається вгору. */
.dp.dp-up {
  top: auto;
  bottom: calc(100% + var(--s-1));
}

.dp-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  margin-bottom: var(--s-2);
}

.dp-title {
  font-weight: 600;
  font-size: var(--t-cmp);
}

.dp-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  font-size: var(--t-lg);
  line-height: 1;
  color: var(--ink-muted);
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  cursor: pointer;
}

.dp-nav:hover {
  color: var(--ink);
  background: var(--surface-2);
}

.dp-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
}

/* Понеділок першим — європейський тиждень. Порядок задає JS, тут лише вигляд. */
.dp-week span {
  padding: var(--s-1) 0;
  font-size: var(--t-xs);
  text-align: center;
  color: var(--ink-subtle);
}

.dp-day {
  padding: var(--s-1) 0;
  font-size: var(--t-sm);
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  cursor: pointer;
}

.dp-day:hover {
  background: var(--surface-2);
}

/* Сьогодні — рамкою, обране — заливкою: два різні стани мають читатись
   одночасно, бо обраним часто і є сьогодні. */
.dp-day.is-today {
  border-color: var(--accent-line);
  color: var(--accent);
}

.dp-day.is-selected {
  color: var(--brand-ink);
  background: var(--brand);
  border-color: var(--brand);
  font-weight: 600;
}

.dp-time {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-3);
  padding-top: var(--s-3);
  border-top: 1px solid var(--border);
}

.dp-time-label {
  font-size: var(--t-sm);
  color: var(--ink-subtle);
}

/* 24 години без AM/PM — список і є всім поясненням формату. */
.dp-time select {
  padding: var(--s-1) var(--s-2);
  font-size: var(--t-sm);
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
}

.dp-colon {
  color: var(--ink-subtle);
}

.dp-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--s-2);
  margin-top: var(--s-3);
}

@media (max-width: 480px) {
  .dp {
    min-width: 0;
    width: 100%;
  }
}

}
@layer components {
/* 40-components/empty.css */
/*
  Порожні стани. .placeholder-panel — простий рядок «нічого немає»,
  .empty-state — розгорнутий варіант з іконкою, поясненням і дією.
  Пунктирна рамка навмисно: одразу читається як «тут поки порожньо».
*/
.empty-state,
.placeholder-panel {
  padding: var(--s-6) var(--s-4);
  text-align: center;
  color: var(--ink-muted);
  background: var(--surface-2);
  border: 1px dashed var(--border-strong);
  border-radius: var(--r-md);
}

.placeholder-panel {
  display: block;
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-2);
}

.empty-state .ico,
.placeholder-panel .ico {
  color: var(--ink-subtle);
}

.empty-state-title {
  font-weight: 600;
  color: var(--ink);
}

.empty-state-hint {
  max-width: 46ch;
  font-size: var(--t-sm);
}

/* Дія відбивається від пояснення трохи більше, ніж рядки самого блока —
   інакше вона читається як його третій рядок, а не як кнопка. */
.empty-state .btn,
.placeholder-panel .btn {
  margin-top: var(--s-3);
}

}
@layer components {
/* 40-components/filedrop.css */
/*
  Зона вибору файлу — єдиний вигляд файлового поля в CRM (UI.md, §3).
  Рідний <input type="file"> малює кнопку операційної системи з її ж мовою
  («Выберите файл») і не піддається стилям, тож він схований, а клікабельність
  дає <label>. Вибір працює й без скрипта: клік по підпису відкриває діалог
  сам браузер. Поведінку (перетягування, ім'я файлу) додає
  shared/static/js/filedrop.js.

  .dragover — файл тягнуть над зоною, .has-file — файл уже обрано.
  Обидва змінюють рамку з пунктирної на суцільну: «порожньо» → «є».
*/
.filedrop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-1);
  margin-top: var(--s-1);
  padding: var(--s-5) var(--s-4);
  text-align: center;
  font-weight: 500;
  color: var(--ink-muted);
  background: var(--surface-2);
  border: 2px dashed var(--border-strong);
  border-radius: var(--r-md);
  cursor: pointer;
  transition:
    border-color var(--dur-1) var(--ease),
    background-color var(--dur-1) var(--ease),
    color var(--dur-1) var(--ease);
}

/*
  Саме поле ховаємо ЗВІДСИ, а не атрибутом hidden у розмітці: hidden виносить
  контрол із порядку обходу, тобто до вибору файлу неможливо дійти з
  клавіатури — а саме так із формою працює частина людей. Тут поле лишається
  сфокусованим, просто невидимим, і фокус на ньому підсвічує всю зону.
*/
.filedrop input[type="file"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  border: 0;
}

.filedrop:hover {
  color: var(--ink);
  border-color: var(--brand-line);
}

.filedrop:focus-within {
  color: var(--ink);
  border-color: var(--brand);
  border-style: solid;
  box-shadow: 0 0 0 3px var(--brand-soft);
}

.filedrop.dragover {
  color: var(--brand);
  background: var(--brand-soft);
  border-color: var(--brand);
  border-style: solid;
}

.filedrop.has-file {
  color: var(--ok-ink);
  background: var(--ok-bg);
  border-color: var(--ok-line);
  border-style: solid;
}

/*
  Кнопка «Очистити» — з'являється лише коли щось обрано.

  Потрібна через накопичення файлів (static/js/filedrop.js): доки кожне
  перетягування замінювало список, скидання робилось само собою. Тепер
  помилково доданий файл лишається в списку, і прибрати його нема чим.

  Абсолютом у кутку зони, а не в потоці: зона має фіксовану висоту й підпис по
  центру, і кнопка в потоці зсувала б цей підпис із центру щоразу, як щось
  обрали.
*/
/* Опорна точка для кнопки. Приховане поле всередині теж абсолютне, але воно
   обрізане до 1px і clip-path, тож зміна опори на нього не впливає. */
.filedrop {
  position: relative;
}

.filedrop-clear {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  padding: var(--s-1) var(--s-2);
  font: inherit;
  font-size: var(--t-sm);
  color: var(--ink-muted);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-sm);
  cursor: pointer;
}

.filedrop:not(.has-file) .filedrop-clear {
  display: none;
}

.filedrop-clear:hover {
  color: var(--ink);
  border-color: var(--ink-subtle);
}

}
@layer components {
/* 40-components/form.css */
/*
  Поля форм. Стилізуємо теги напряму, без класів: розмітка сервісів
  лишається простою — <input>, <label>, <textarea> і все.

  Довгий ланцюжок :not() виключає ті типи, що мають власний вигляд:
  чекбокси й радіо (нижче), файлові поля (.filedrop) і кнопки (.btn).

  Виключення загорнуті в :where() НАВМИСНО, і це не косметика. :not() бере
  вагу свого аргументу, тож п'ять [type="..."] давали цьому правилу
  специфічність (0,5,1) — більше, ніж у будь-якого компонента, який захоче
  перевизначити вигляд поля. Саме через це не працював `border: 0` у
  .chips input.chip-entry (0,2,1): поле-чіпи малювало другу рамку всередині
  своєї коробки, попри прямий намір у chips.css.

  :where() не додає ваги взагалі, тож правило важить (0,0,1) — як звичайний
  селектор за тегом, яким воно й задумане. Набір елементів не змінився.
  Сусідні правила цього файлу (:hover, :focus-visible, :disabled) важать
  (0,2,1) і перекривають базове, як і раніше.
*/
input:not(:where([type="checkbox"], [type="radio"], [type="file"], [type="submit"], [type="button"])),
textarea,
select {
  width: 100%;
  padding: var(--s-2) var(--s-3);
  font: inherit;
  font-size: var(--t-md);
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

textarea {
  /* field-sizing: content — поле росте під вміст (де браузер це вміє).
     min-height у lh — «не менше чотирьох рядків», хай який шрифт. */
  field-sizing: content;
  min-height: 4lh;
  /* Тільки по вертикалі: горизонтальне розтягування ламало б сітку. */
  resize: vertical;
}

select {
  width: auto;
  min-width: 12ch;
}

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

/* У полів свій фокус замість загального outline: рамка з підсвіткою
   краще лягає на прямокутник поля. .chips.focus підмішаний сюди ж, щоб
   поле-чіпи виглядало так само, як звичайне. */
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
.chips.focus {
  outline: none;
  border-color: var(--brand);
  box-shadow: 0 0 0 3px var(--brand-soft);
}

input:disabled,
textarea:disabled,
select:disabled {
  color: var(--ink-subtle);
  background: var(--surface-2);
  cursor: not-allowed;
}

/* :user-invalid, а не :invalid — підсвічуємо помилку лише після того, як
   людина справді щось ввела. Інакше порожня форма червоніла б одразу. */
input:user-invalid,
textarea:user-invalid,
select:user-invalid {
  border-color: var(--fail-line);
}

/* Поле з кнопкою збоку — наприклад, «показати пароль» на сторінці входу. */
.input-affix {
  display: flex;
  align-items: center;
  gap: var(--s-1);
}

.input-affix > input {
  flex: 1;
  min-width: 0;
}

.input-affix > .icon-btn {
  flex: none;
  min-height: 38px;
  border-color: var(--border);
}

.hint {
  display: block;
  margin-top: var(--s-1);
  font-size: var(--t-sm);
  font-weight: 400;
  color: var(--ink-muted);
}

.field {
  margin-block: var(--s-4);
}

.field > label {
  margin-top: 0;
}

/*
  Перший підпис форми власного відступу згори не має: його вже дав падінг
  картки або заголовок над формою. Інакше форма в картці починалась на
  48px від краю, а така сама форма під заголовком — на 16px.

  Прихованих полів правило не помічає: вони не малюються, але :first-child
  собою займають — звідси другий селектор.
*/
form > label:first-child,
form > input[type="hidden"] + label {
  margin-top: 0;
}

/* auto-fit + minmax: кількість колонок підбирається сама під ширину,
   без жодного медіа-запиту. Поле ніколи не вужче за 240px. */
.form-grid {
  display: grid;
  gap: 0 var(--s-4);
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}

.form-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-5);
}

.form-actions .btn {
  margin-top: 0;
}

/*
  Форма в один рядок: поля й кнопка поруч. Вирівнювання по нижньому краю —
  щоб кнопка стала на лінію самих полів, а не підписів над ними.

  Цей клас замінив три однакові описи в сервісах: .fin-log у «Фінансах»,
  .cost-build у «Костах», .pc-search у «Доменах». Усі троє робили те саме,
  але з різними проміжками — і форми, що виглядають однаково, поводились
  по-різному.
*/
.form-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--s-2);
}

/* Поле в рядку відступів не має — проміжки задає сам ряд. */
.form-row .field {
  margin: 0;
}

.form-row .btn {
  margin-top: 0;
}

/* Поле без обгортки тягнеться на вільне місце. Вужчу ширину задає вже
   сервіс — там, де відомо, що в полі лежить сума або рік. */
.form-row > input,
.form-row > select {
  flex: 1;
  min-width: 0;
}

/* Те саме для поля в обгортці .field: воно забирає вільне місце рядка, а
   решта (кнопки, вузькі поля) лишаються при своєму розмірі. Клас, а не
   інлайновий style="flex: 1; min-width: 240px" у шаблоні — саме так це
   писалося доти, і ширина в кожному місці була своя. */
.form-row > .field.grow {
  flex: 1;
  min-width: 14rem;
}

/*
  Група полів під спільним підписом — набір радіо чи галочок, які читаються
  як одна відповідь («Хто бачить цей запис», «Ролі»).

  Саме <fieldset> з <legend>, а не <label> над блоком: підпис такої групи не
  вказує на жоден окремий контрол, і звичайний label лишав би її без назви
  для тих, хто читає форму з екрана. Браузерні рамка й падінги скинуті —
  вигляд групі дає сама сторінка.

  Цей самий скид тричі лежав у сервісах під власними іменами (двічі в
  «Календарі», з коментарем «дублюємо свідомо», і ще раз у вигляді голого
  <label> в адмінці).
*/
.fieldset {
  /* Без min-width fieldset відмовляється стискатись у flex- і grid-сітці. */
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

/*
  Підпис групи виглядає рівно як підпис поля (див. label у 20-base.css) — це
  той самий рівень форми, і різний вигляд означав би різний зміст. Доти
  «Хто бачить цей запис» був дрібним і сірим, а «Ролі» поруч у сусідній
  формі — звичайним підписом поля.
*/
.fieldset > legend {
  padding: 0;
  margin-bottom: var(--s-1);
  font-weight: 600;
  font-size: var(--t-md);
}

/* Варіанти в рядок — коли їх два-чотири й вони коротші за рядок. Довші
   набори лишаються в .checkbox-grid, яка сама добирає колонки. */
.fieldset.row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
}

/* Сітка чекбоксів — списки прав і ролей в адмінці. */
.checkbox-grid {
  display: grid;
  gap: var(--s-1) var(--s-4);
  margin: var(--s-2) 0 var(--s-4);
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

/* .compact — для ролей просто в комірці таблиці, де місця значно менше. */
.checkbox-grid.compact {
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  margin: var(--s-1) 0 var(--s-2);
  font-size: var(--t-cmp);
}

/* Скидаємо жирність і відступи, які label отримує в 20-base.css:
   там він — підпис поля, а тут просто текст біля галочки. */
label.checkbox {
  display: flex;
  /* baseline, а не center: підпис буває у два рядки, і галочка має
     стояти на рівні першого. */
  align-items: baseline;
  gap: var(--s-2);
  margin: 0;
  font-weight: 400;
  cursor: pointer;
}

label.checkbox:hover {
  color: var(--brand);
}

label.checkbox input {
  width: auto;
  margin: 0;

  accent-color: var(--brand);
}

label.checkbox code {
  font-size: var(--t-xs);
  color: var(--ink-muted);
  background: none;
  padding: 0;
}

}
@layer components {
/* 40-components/icon.css */
/*
  Іконки зі спрайта (shared/static/icons.svg), вставляє глобал icon().
  Розмір в em — іконка масштабується разом із текстом поруч.
  Колір не задаємо: SVG використовує currentColor і фарбується від тексту.
*/
.ico {
  /* flex: none — щоб у флекс-рядку іконку не стискало під текст. */
  flex: none;
  width: 1.15em;
  height: 1.15em;

  /* Підганяння під базову лінію: інакше іконка «висить» над текстом. */
  vertical-align: -0.22em;
}

.ico-lg {
  width: 1.6em;
  height: 1.6em;
  vertical-align: -0.35em;
}

}
@layer components {
/* 40-components/menu.css */
/*
  Випадайки в топбарі: меню користувача й стрічка повідомлень. Побудовані на
  нативному popover: відкриття, закриття по кліку повз і по Esc браузер бере
  на себе — свого JS немає.

  Стрічка — це варіант .menu (.menu.feed), а не власне вікно: обидва висять під
  тим самим краєм топбару, і два різні кути, тіні чи анімації читалися б як два
  різні інтерфейси. Відрізняються вони рівно шириною й тим, що вміст стрічки
  прокручується (див. notifications.css).
*/
.menu[popover] {
  position: fixed;
  /* inset: auto скидає дефолтне центрування popover — далі позиціонуємо
     самі, під кнопкою в правому куті топбару. */
  inset: auto;
  top: calc(var(--topbar-h) + var(--s-1));
  right: var(--s-4);
  min-width: 240px;
  margin: 0;
  padding: var(--s-1);
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--e-3);
}

/*
  Скло. Меню висить над контентом сторінки — тобто саме там, де під
  напівпрозорою поверхнею СПРАВДІ щось є. Суцільний --surface лишається
  запасним варіантом: без backdrop-filter крізь --glass просвічував би
  текст сторінки просто як текст, і меню стало б нечитаним.

  Стрічка повідомлень (.menu.feed) сюди входить разом із меню й окремого
  правила не потребує — вона й задумана як той самий компонент.
*/
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .menu[popover] {
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
  }
}

/* Стрічка повідомлень ширша за меню й не має власних падінгів: заголовок і
   рядки задають їх самі, а прокрутка має йти по краю вікна, а не всередині
   рамки з відступом. Вужче за 22rem рядок «Дмитро підтвердив оплату
   Cloudflare» ламається на три стрічки й перестає читатись з першого погляду. */
.menu[popover].feed {
  width: min(24rem, calc(100vw - 2 * var(--s-4)));
  padding: 0;
  overflow: hidden;
}

/* Підложка потрібна, щоб ловити клік «повз меню», але затемнювати
   сторінку заради випадайки з двох пунктів — надто. */
.menu[popover]::backdrop {
  background: transparent;
}

/* Поява/зникнення. allow-discrete — щоб анімувалися й display з overlay:
   без нього меню зникало б миттєво, без згасання. */
.menu[popover] {
  opacity: 0;
  translate: 0 -4px;
  transition: opacity var(--dur-1), translate var(--dur-1),
    overlay var(--dur-1) allow-discrete, display var(--dur-1) allow-discrete;
}

.menu[popover]:popover-open {
  opacity: 1;
  translate: none;
}

/* Стан, з якого починається поява. Без @starting-style браузеру нема
   від чого анімувати елемент, якого щойно не було в потоці. */
@starting-style {
  .menu[popover]:popover-open {
    opacity: 0;
    translate: 0 -4px;
  }
}

.menu-head {
  padding: var(--s-2) var(--s-3);
  border-bottom: 1px solid var(--border);
}

/* break-all — щоб довгий email переносився, а не розпирав меню. */
.menu-head strong {
  display: block;
  font-size: var(--t-cmp);
  word-break: break-all;
}

.menu-head .hint {
  margin-top: 0;
}

.menu-item {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  min-height: 34px;
  padding: 0 var(--s-3);
  font-size: var(--t-cmp);
  font-weight: 600;
  color: var(--ink-muted);
  text-decoration: none;
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  cursor: pointer;
}

.menu-item:hover {
  color: var(--ink);
  background: var(--surface-3);
}

.menu-sep {
  height: 1px;
  margin: var(--s-1) 0;
  background: var(--border);
}

.menu-item.danger:hover {
  color: var(--fail-ink);
  background: var(--fail-bg);
}

}
@layer components {
/* 40-components/modal.css */
/*
  Модальне вікно. Розмітка — .modal > .modal-overlay + .modal-content,
  показ і приховування — класом .open, який ставить JS.

  Інлайнового display у шаблоні немає навмисно: інакше стан вікна жив би
  у двох місцях одразу — в атрибуті style і в класі — і рано чи пізно
  вони б розійшлися.
*/
.modal {
  position: fixed;
  inset: 0;
  /* Вище за топбар (20) і за бічну шухляду (40) з 30-layout.css. */
  z-index: 100;
  display: none;
  align-items: center;
  justify-content: center;
  padding: var(--s-4);
}

.modal.open {
  display: flex;
}

.modal-overlay {
  position: absolute;
  inset: 0;
  background: oklch(0 0 0 / 0.5);
}

/* З розмиттям затемнення можна зробити слабшим: сторінка йде на задній план
   не тому, що її погасили, а тому, що вона розфокусована. Півсекунди на
   відкриття вікна — не та тривалість, за яку розмиття встигне щось
   коштувати; постійного backdrop-filter у проєкті немає ніде. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .modal-overlay {
    background: oklch(0 0 0 / 0.38);
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
  }
}

/* Вміст піднімаємо над накладкою через position, а не через від'ємний
   z-index у самої накладки: так вікно лишається зверху в будь-якому
   контексті накладання, а клік по накладці далі закриває його. */
.modal-content {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 520px;
  max-height: min(80vh, 680px);
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--border);
  /* Смужка згори — той самий акцент, що й у плиток дашборду. */
  border-top: 3px solid var(--accent);
  border-radius: var(--r-lg);
  box-shadow: var(--e-3);
}

/* Кольорова плівка під шапкою — та сама, що під шапкою сторінки й шапкою
   таблиці. Вікно відкривається поверх сторінки, і спільний прийом одразу
   каже, що воно з нею одного роду, а не чужий системний діалог. */
.modal-header {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  background: var(--accent-wash);
  border-bottom: 1px solid var(--accent-line);
}

.modal-header h2 {
  margin: 0;
  font-size: var(--t-lg);
}

.modal-close {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  font-size: var(--t-xl);
  line-height: 1;
  color: var(--ink-muted);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  cursor: pointer;
  transition:
    color var(--dur-1) var(--ease),
    background-color var(--dur-1) var(--ease),
    border-color var(--dur-1) var(--ease);
}

.modal-close:hover {
  color: var(--ink);
  background: var(--surface-3);
  border-color: var(--border);
}

.modal-body {
  flex: 1;
  padding: var(--s-5);
  overflow-y: auto;
  /* Прокрутка списку не «протікає» на сторінку під вікном. */
  overscroll-behavior: contain;
}

.modal-intro {
  margin: 0 0 var(--s-4);
  font-size: var(--t-md);
  color: var(--ink-muted);
}

.modal-footer {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  background: var(--surface-2);
  border-top: 1px solid var(--border);
}

/* Підпис ліворуч від кнопок: скільки сервісів обрано, а в разі збою —
   що саме пішло не так. Список галочок при цьому лишається на місці. */
.modal-note {
  margin-right: auto;
  font-size: var(--t-sm);
  color: var(--ink-muted);
}

.modal-note.error {
  font-weight: 600;
  color: var(--fail-ink);
}

.services-checklist {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

/* Заголовок підгрупи в списку галочок — той самий підпис, що над плитками
   й у сайдбарі. Перший відступу згори не має: він відкриває список. */
.services-group {
  margin: var(--s-3) 0 0;
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-subtle);
}

.services-group:first-child {
  margin-top: 0;
}

/* Скидаємо жирність і відступи, які label дістає в 20-base.css: там він
   підпис поля, а тут — ціла клікабельна картка сервіса. */
.service-check {
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  margin: 0;
  padding: var(--s-3);
  font-weight: 400;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: background-color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}

.service-check:hover {
  background: var(--surface-2);
  border-color: var(--border-strong);
}

/* Обране видно, не вчитуючись у галочки: підсвічений увесь рядок. */
.service-check:has(input:checked) {
  background: var(--accent-soft);
  border-color: var(--accent-line);
}

/* Фокус живе на прихованій галочці, тож рамку малюємо навколо рядка —
   інакше з клавіатури не видно, де ти. */
.service-check:has(input:focus-visible) {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
}

.service-check input[type="checkbox"] {
  flex: none;
  width: auto;
  /* Невеликий зсув униз — щоб галочка стала на рівень першого рядка
     назви, а не її верхнього краю. */
  margin: 0.2em 0 0;
  accent-color: var(--brand);
  cursor: pointer;
}

.service-check .service-title {
  display: block;
  font-weight: 600;
  font-size: var(--t-md);
  color: var(--ink);
}

.service-check .service-path {
  display: block;
  margin-top: var(--s-1);
  font-size: var(--t-sm);
  color: var(--ink-muted);
}

/* Стан списку: «завантажуємо», «нічого немає», помилка. */
.services-status {
  margin: 0;
  padding: var(--s-4) 0;
  font-size: var(--t-md);
  text-align: center;
  color: var(--ink-muted);
}

.services-status.error {
  color: var(--fail-ink);
}

/* На вузькому екрані вікно стає «шторкою» знизу: так до кнопок легше
   дотягнутись великим пальцем, ніж коли вони посеред екрана. */
@media (max-width: 640px) {
  .modal {
    align-items: flex-end;
    padding: 0;
  }

  .modal-content {
    max-width: 100%;
    max-height: 92vh;
    border-radius: var(--r-lg) var(--r-lg) 0 0;
  }

  .modal-header,
  .modal-body,
  .modal-footer {
    padding-inline: var(--s-4);
  }
}

}
@layer components {
/* 40-components/notifications.css */
/*
  Дзвіночок у топбарі й стрічка під ним.

  Сама коробка — це .menu.feed (див. menu.css): те саме нативне popover-вікно,
  що й у меню користувача, лише ширше й із прокруткою. Власного вікна тут не
  заводиться навмисно — випадайка в топбарі має бути одна, з однаковим кутом,
  тінню й анімацією.

  Головне правило вигляду: стрічка НЕ кричить. Єдиний насичений колір на весь
  компонент — крапка лічильника, і саме вона й несе всю терміновість. Тост, який
  тут стояв раніше, кричав кольором на кожній сторінці — і саме тому його
  навчились не бачити.
*/

/* ─── Кнопка з лічильником ───────────────────────────────────────── */

.icon-btn.bell {
  /* Лічильник позиціонується від кнопки, тож їй потрібна власна система
     координат. Решта .icon-btn її не має й не потребує. */
  position: relative;
}

.bell-count {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  font-size: 10px;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
  /* Текст кольором тла сторінки — не «білий». --bg і --fail-ink рухаються по
     світлості в протилежні боки (у темній темі перший темнішає, другий
     світлішає), тож контраст тримається сам, в обох темах і без другого
     токена. */
  color: var(--bg);
  background: var(--fail-ink);
  /* Коло на однозначному числі, «пігулка» на двозначному — з --r-full це
     виходить само. */
  border-radius: var(--r-full);
  /* Обвідка кольором топбару: без неї цифра зливається з іконкою під нею. */
  box-shadow: 0 0 0 2px var(--surface);
  pointer-events: none;
}

/* ─── Заголовок вікна ────────────────────────────────────────────── */

.feed-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  flex-wrap: wrap;
}

.feed-tools {
  display: flex;
  gap: var(--s-1);
}

/*
  Дві дії над стрічкою. Зроблені тихими — текст кольору підказки, без рамки,
  без заливки: це прибирання, а не те, заради чого вікно відкривали. Помітніша
  за них має лишатись сама стрічка.

  Це не .btn: .btn.small усе одно несе рамку й вагу кнопки форми, а поруч із
  заголовком на 240 пікселів дві такі кнопки читались би як головна дія вікна.
*/
.feed-tool {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  padding: var(--s-1) var(--s-2);
  font-size: var(--t-sm);
  color: var(--ink-subtle);
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  cursor: pointer;
}

.feed-tool:hover { color: var(--ink); background: var(--surface-3); }
.feed-tool.danger:hover { color: var(--fail-ink); background: var(--fail-bg); }
.feed-tool:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.feed-tool .ico { width: 1em; height: 1em; }

/* Кнопка, якій зараз нема чого робити (нічого не прочитано / усе прочитано),
   лишається на місці, але не вдає, що працює: зникнення смикало б заголовок,
   а зникла кнопка ще й змушує щоразу перевіряти, чи вона там була. */
.feed-tool[disabled] {
  opacity: 0.4;
  cursor: default;
  pointer-events: none;
}

/* ─── Стрічка ────────────────────────────────────────────────────── */

.feed-list {
  /* Одна висота на всі стани, як у .panel.scroll: вікно не має стрибати,
     коли прочитане прибрали. */
  max-height: min(60vh, 30rem);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.feed-status {
  margin: 0;
  padding: var(--s-4) var(--s-3);
  text-align: center;
}

/*
  Рядок. Клікабельна вся площа, а не заголовок: у вікні шириною 24rem влучити
  в текстовий рядок мишею дорожче, ніж здається. Клік гасить рядок і веде туди,
  де подія сталася.

  Тег у рядка буває двох видів (див. notifications.js): <a>, коли є куди вести,
  і <button>, коли вести нема куди («ваш запис видалили»), — але виглядають
  вони однаково, бо роблять однакове. Звідси й скидання кнопкових умовностей
  нижче: власного вигляду в <button> тут не лишається взагалі.
*/
.feed-item {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s-1) var(--s-2);
  width: 100%;
  padding: var(--s-2) var(--s-3);
  font: inherit;
  text-align: left;
  color: inherit;
  text-decoration: none;
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  cursor: pointer;
}

.feed-item:hover { background: var(--surface-3); }
.feed-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/*
  Іконка сервісу-джерела в його ж кольорі. --h-item ставить скрипт із accent,
  який прийшов у JSON, — тобто рівно того відтінку, яким сервіс підсвічений у
  навбарі. Сервіс без власного акценту (і невідомий слуг) успадковує --h-accent
  сторінки, тож дірки в кольорі не буває ніколи.

  Формула світлості — та сама, що в --accent (10-tokens.css): у темній темі
  колір має світлішати, інакше іконка сервісу тоне у власному тлі.
*/
.feed-item .ico {
  grid-row: span 2;
  width: 1.15rem;
  height: 1.15rem;
  margin-top: 2px;
  color: light-dark(
    oklch(0.55 0.2 var(--h-item, var(--h-accent))),
    oklch(0.7 0.16 var(--h-item, var(--h-accent)))
  );
}

.feed-title {
  font-size: var(--t-cmp);
  /* Не 600 і не 700: у стрічці з десяти рядків суцільний напівжирний читається
     гірше за звичайний — око не має за що зачепитись. Виділяє непрочитане
     крапка праворуч, а не вага шрифту. */
  font-weight: 500;
}

.feed-body {
  grid-column: 2;
  margin: 0;
  font-size: var(--t-sm);
  color: var(--ink-muted);
}

.feed-when {
  grid-column: 2;
  font-size: var(--t-sm);
  color: var(--ink-subtle);
}

/*
  Позначка непрочитаного. Крапка, а не заливка рядка: заливка десяти рядків
  поспіль перетворює список на суцільну пляму, у якій нічого не виділено.
*/
.feed-item.unread { position: relative; }

.feed-item.unread::after {
  content: "";
  position: absolute;
  top: 50%;
  right: var(--s-2);
  width: 6px;
  height: 6px;
  margin-top: -3px;
  background: var(--accent);
  border-radius: 50%;
}

/* Місце під крапку — щоб довгий заголовок не заповзав під неї. */
.feed-item.unread .feed-title,
.feed-item.unread .feed-body { padding-right: var(--s-3); }

/* Роздільник між рядками — волосина, а не відступ: у вікні заввишки 30rem
   відступи з'їли б два рядки зі списку. */
.feed-item + .feed-item {
  border-top: 1px solid var(--border);
  border-radius: 0;
}

/* «Показати ще» — на всю ширину, під останнім рядком. Не .btn: це
   продовження списку, а не дія над ним. */
.feed-more {
  display: block;
  width: 100%;
  padding: var(--s-2);
  font-size: var(--t-sm);
  font-weight: 600;
  color: var(--accent);
  background: none;
  border: 0;
  border-top: 1px solid var(--border);
  cursor: pointer;
}

.feed-more:hover { background: var(--surface-3); }

}
@layer components {
/* 40-components/pager.css */
/* Пагінація під таблицями адмінки (шаблон _pager.html). */
.pager {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  margin: var(--s-4) 0;
}

.pager-info {
  font-size: var(--t-sm);
  color: var(--ink-muted);

  /* Моноширинні цифри: підпис не смикається при зміні сторінки. */
  font-variant-numeric: tabular-nums;
}

}
@layer components {
/* 40-components/panel.css */
/*
  .panel — врізка: коробка на тлі картки. Список людей у «Відстеженні Sub»,
  список кампаній у «Заміні SS», JSON у «Генераторі анкет», вкладення й
  коментарі в «Календарі», лог запуску в історії — усе це одна й та сама
  річ: заглиблений прямокутник із власною межею всередині картки.

  Досі кожен сервіс описував її сам, і збігалося лише тло. Падінги були
  var(--s-2) var(--s-3), var(--s-3), var(--s-3) var(--s-4) — три різні
  всередині одного інтерфейсу, — а висоти прокрутки 320px, 340px, 420px і
  460px. Причин для цих чисел не було: вони просто писалися незалежно, і
  саме це видно, коли переходиш між сервісами по навбару.

  Тепер значення тут одні на всіх. Сервіс лишає собі лише те, що справді
  його: сітку всередині, шрифт, кольори станів.

  Клас додається ДО власного класу елемента (class="report panel scroll"),
  а не замість нього: за власними класами тримаються і сітка сервісу, і
  його скрипти.
*/
/*
  Внутрішня тінь замість зовнішньої — і це не той самий ефект навпаки.
  Картка ПІДНЯТА над сторінкою, тож тінь падає з-під неї назовні; врізка
  ЗАГЛИБЛЕНА в картку, і тінь у неї лягає всередину, по верхньому краю.
  Доти врізка була просто прямокутником іншого кольору й читалась як ще
  одна картка, покладена зверху, — тобто рівно навпаки до задуму.
*/
.panel {
  padding: var(--s-3);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: inset 0 1px 3px var(--shadow-1);
}

/*
  Врізка з власною прокруткою. Одна висота на всі: 22rem — це приблизно
  та сама смужка, до якої незалежно прийшли лог (340px) і список кампаній
  (320px), і водночас достатньо, щоб у списку людей було видно кілька
  рядків одразу.

  overscroll-behavior — доскроливши врізку до кінця, не тягнемо за собою
  сторінку під нею.

  Сторінка, де врізка займає весь екран (живий лог), перебиває max-height
  своїм класом у @layer service — той шар оголошений після components.
*/
.panel.scroll {
  max-height: 22rem;
  overflow: auto;
  overscroll-behavior: contain;
}

/*
  Врізка-список, у якої падінги мають самі рядки: вони тягнуться від краю
  до краю й розділені лініями, тож зовнішній падінг лишив би ці лінії
  висіти в повітрі, не доходячи до межі.
*/
.panel.flush {
  padding: 0;
}

/*
  Врізка з машинним текстом — JSON, технічний звіт про доступи, вивід
  скрипта. Стилюємо за тегом: <pre> у картці — це за визначенням текст, у
  якому важливі колонки й переноси, а не абзац.

  Рядки навмисно НЕ переносяться: у JSON вкладеність, а у звіті колонки
  тримаються саме позицією символів, і м'який перенос їх розсипає. Прокрутка
  по горизонталі тут не незручність, а умова читабельності.

  Ці ж чотири рядки лежали окремо у «Відстеженні Sub» і в «Генераторі
  анкет» — байт у байт однакові, з двома різними іменами класів.
*/
pre.panel {
  font-family: var(--font-mono);
  font-size: var(--t-xs);
  line-height: 1.55;
  white-space: pre;
}

}
@layer components {
/* 40-components/select.css */
/*
  Випадаючий список (shared/static/js/select.js).

  Рідний <select> у розкритому вигляді малює операційна система: на macOS це
  системний сірий список зі своїм шрифтом, який не знає ні про темну тему
  сторінки, ні про її акцент. Стилювати його вміст не дозволяє жоден браузер,
  тож панель тут намальована своя — токенами, як і решта компонентів, і разом
  із темою вона перемикається сама.

  Кнопка навмисно повторює вигляд поля з form.css (та сама рамка, радіус,
  фокус): для людини це те саме поле форми, а не окремий тип контролу.
*/

/*
  Обгортка заступає місце самого <select>, тож і поводитись має так само.
  Рідний список — inline-block, який сам розтягується під НАЙДОВШИЙ свій
  пункт (form.css: width: auto; min-width: 12ch). На цьому мовчки тримається
  ширина колонок у «Розподілі костів» і полів у «Генераторі анкет», тож
  обгортка повторює саме таку поведінку — інакше кнопка або розпирала б
  контейнер на всю ширину, або схлопувалась до підпису.
*/
.sel {
  position: relative;
  display: inline-block;
  max-width: 100%;
  min-width: 12ch;
  vertical-align: middle;
}

/*
  Невидима мірка: у ній лежить найдовший пункт списку, і саме вона задає
  ширину обгортки — так само, як у рідного select. Без неї кнопка мірялася б
  ПОТОЧНИМ значенням, і поле стрибало б туди-сюди на кожен вибір.

  height: 0 — мірка не займає жодного рядка; padding-inline і margin-right
  повторюють падінги кнопки та місце під стрілку, щоб текст найдовшого пункту
  вміщався в неї цілим.
*/
.sel-sizer {
  display: block;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  white-space: nowrap;
  font-size: var(--t-md);
  padding-inline: var(--s-3);
  margin-right: calc(1em + var(--s-2) + 2px);
}

/* Поле форми розтягується на всю ширину — як і будь-який input у .field. */
.field > .sel {
  display: block;
  width: 100%;
}

/* У рядку форми поле забирає вільне місце (пор. `.form-row > select`). */
.form-row > .sel {
  flex: 1;
  min-width: 0;
}

/* Рідний список лишається в DOM — у ньому значення й воно ж їде у формі.
   Ховаємо його візуально, але не через display:none: прихований так контрол
   у частині браузерів випадає з відправки форми. */
.sel-native {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  border: 0;
}

.sel-btn {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  min-height: 38px;
  padding: var(--s-2) var(--s-3);
  font: inherit;
  font-size: var(--t-md);
  color: var(--ink);
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  cursor: pointer;
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}

.sel-btn:hover:not(:disabled) {
  border-color: var(--ink-subtle);
}

.sel-btn:focus-visible {
  outline: none;
  border-color: var(--brand);
  box-shadow: 0 0 0 3px var(--brand-soft);
}

.sel-btn:disabled {
  color: var(--ink-subtle);
  background: var(--surface-2);
  cursor: not-allowed;
}

/* Розкритий список підсвічуємо так само, як фокус: поле й панель — одне ціле. */
.sel-btn[aria-expanded="true"] {
  border-color: var(--brand);
  box-shadow: 0 0 0 3px var(--brand-soft);
}

/* Текст вибраного займає весь вільний простір і обрізається трьома крапками:
   назви трафік-сорсів бувають довшими за поле, і без цього кнопка розпирала
   б рядок форми. */
.sel-value {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* «— виберіть сорс —» це підказка, а не вибір: показуємо її приглушено, як
   placeholder у звичайному полі. */
.sel-value.muted {
  color: var(--ink-subtle);
}

.sel-caret {
  flex: none;
  color: var(--ink-muted);
  transition: rotate var(--dur-1) var(--ease);
}

.sel-btn[aria-expanded="true"] .sel-caret {
  rotate: 180deg;
}

/*
  Панель. position: fixed, а координати виставляє скрипт — і це не примха.

  Частина полів живе всередині .table-scroll (overflow-x: auto). Абсолютно
  спозиційована панель обрізалась би таким предком: за специфікацією, коли по
  одній осі стоїть auto, друга з visible теж стає auto, — тобто випадайка
  зникала б рівно там, де таблиця вужча за екран. Fixed-елемент overflow
  предка не обрізає, тож панель однаково працює і в картці, і в комірці
  прокрученої таблиці.

  Плата за це — координати треба рахувати самим, і вони застарівають при
  прокрутці. Тому select.js на будь-яку прокрутку панель закриває.

  z-index 50 — той самий поверх, що й у календаря: накриває каркас сторінки,
  але лишається під тостами (60).
*/
.sel-panel {
  position: fixed;
  z-index: 50;
  display: flex;
  flex-direction: column;
  min-width: 14rem;
  max-height: 20rem;
  padding: var(--s-1);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-md);
  box-shadow: var(--e-3);
}

.sel-search-box {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-2);
  border-bottom: 1px solid var(--border);
}

.sel-search-box .ico {
  flex: none;
  color: var(--ink-subtle);
}

/* Поле пошуку всередині панелі — без власної рамки: рамку вже має панель,
   а друга всередині неї виглядає як вкладена коробка. */
.sel-search-box input {
  width: 100%;
  padding: var(--s-1) 0;
  font-size: var(--t-cmp);
  background: none;
  border: 0;
}

.sel-search-box input:focus-visible {
  outline: none;
  border: 0;
  box-shadow: none;
}

.sel-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* Прокрутка списку не має «протікати» на сторінку, коли дійшла до краю. */
  overscroll-behavior: contain;
  padding: var(--s-1) 0;
}

.sel-option {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-height: 32px;
  padding: var(--s-1) var(--s-2);
  font-size: var(--t-cmp);
  color: var(--ink);
  border-radius: var(--r-sm);
  cursor: pointer;
}

/* Підсвітка одна на мишу й на клавіатуру: інакше видимих «поточних» пунктів
   стає два — той, під курсором, і той, до якого дійшли стрілками. */
.sel-option.active {
  background: var(--surface-3);
}

.sel-option.selected {
  font-weight: 600;
  color: var(--brand);
}

.sel-option.placeholder {
  color: var(--ink-subtle);
}

.sel-option.disabled {
  color: var(--ink-subtle);
  cursor: not-allowed;
}

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

/* Галочка тримає своє місце завжди — інакше рядки зсувалися б по горизонталі
   залежно від того, який із них вибраний. */
.sel-check {
  flex: none;
  margin-left: auto;
  visibility: hidden;
  color: var(--brand);
}

.sel-option.selected .sel-check {
  visibility: visible;
}

.sel-empty {
  margin: 0;
  padding: var(--s-3);
  font-size: var(--t-sm);
  color: var(--ink-muted);
  text-align: center;
}

}
@layer components {
/* 40-components/steps.css */
/*
  Дві різні речі про кроки:

  ol.steps      — статичний підсумок на сторінці результату (що вже сталося);
  .steps-tracker — живий трекер у AutoFlow (що відбувається зараз).
  Класи .done / .active / .failed на трекері перемикає скрипт сторінки.
*/

ol.steps {
  margin: 0;
  padding-left: var(--s-5);
}

ol.steps li {
  margin: var(--s-2) 0;
}

.status {
  margin-right: var(--s-1);
  font-weight: 700;
}

.steps-tracker {
  margin: var(--s-2) 0;
  padding: 0;
  list-style: none;
}

.steps-tracker li {
  position: relative;
  margin: var(--s-1) 0;
  padding: var(--s-1) var(--s-3);
  font-size: var(--t-cmp);
  color: var(--ink-muted);
  border-left: 3px solid var(--border-strong);
  transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
}

.steps-tracker li.done {
  color: var(--ink);
  border-left-color: var(--ok-ink);
}

/* Позначки — псевдоелементами, щоб скрипту лишалося тільки перемикати
   класи, не чіпаючи вміст рядків. */
.steps-tracker li.done::after {
  content: "✓";
  margin-left: var(--s-2);
  color: var(--ok-ink);
  font-weight: 700;
}

.steps-tracker li.active {
  color: var(--ink);
  font-weight: 600;
  border-left-color: var(--warn-ink);
  background: var(--warn-bg);
}

.steps-tracker li.active::after {
  content: "…";
  margin-left: var(--s-2);
  color: var(--warn-ink);
  animation: steps-blink 1.2s ease-in-out infinite;
}

.steps-tracker li.failed {
  color: var(--fail-ink);
  font-weight: 600;
  border-left-color: var(--fail-ink);
  background: var(--fail-bg);
}

@keyframes steps-blink {
  50% {
    opacity: 0.3;
  }
}

.progress {
  height: 6px;
  margin: var(--s-2) 0;
  overflow: hidden;
  background: var(--surface-3);
  border-radius: var(--r-full);
}

/* Смужку рухає скрипт, задаючи width у відсотках; колір її завершення
   вирішують класи .done / .failed на самому .progress. */
.progress > span {
  display: block;
  width: 0;
  height: 100%;
  background: var(--brand);
  border-radius: inherit;

  /* Єдина тривалість у проєкті поза парою --dur-1/--dur-2, і це навмисно.
     Ті дві — про реакцію інтерфейсу на людину: курсор навели, кнопку
     натиснули. Тут нічого не наводили — це сам прогрес їде вперед, і 0.3s
     потрібні, щоб стрибок з 40% на 60% прочитався як рух, а не як нове
     значення. Взяти --dur-2 означало б підігнати смужку під ховер кнопки,
     у якої з нею нічого спільного. */
  transition: width 0.3s ease;
}

.progress.done > span {
  background: var(--ok-ink);
}

.progress.failed > span {
  background: var(--fail-ink);
}

}
@layer components {
/* 40-components/table.css */
/*
  Таблиці. Два класи історично: .table — списки в адмінці,
  .camp-table — таблиці результатів у сервісах. Стилі майже спільні.

  Підсвітка рядків станами: .row-fail, .degraded, .muted.
  Підсвітка комірок: .s-ok / .s-fail / .s-warning.
*/
.table,
.camp-table {
  width: 100%;
  margin: var(--s-3) 0;
  font-size: var(--t-cmp);
  border-collapse: collapse;
}

.table th,
.table td,
.camp-table th,
.camp-table td {
  padding: var(--s-2) var(--s-3);
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid var(--border);
}

/*
  Шапка таблиці отримала кольорову підкладку — ту саму плівку акценту, що
  й шапка сторінки. Доти заголовки колонок відрізнялися від даних лише
  розміром і регістром, і в довгій таблиці, прокрученій до середини, було
  не одразу видно, де вона починається.

  sticky — з тієї ж причини: у таблиці на сто рядків заголовок колонки
  потрібен саме тоді, коли його вже прокрутили. Працює всередині
  .table-scroll і в прокрутці .main; де прокрутки немає, поводиться як
  звичайна комірка. Тло тут обов'язкове й непрозоре — крізь прозору
  прилиплу шапку просвічували б рядки, що проїжджають під нею.
*/
.table thead th,
.camp-table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  font-size: var(--t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  background: var(--accent-wash);
  border-bottom-color: var(--accent-line);
  white-space: nowrap;
}

/* Кути шапки. Скруглюємо саме крайні комірки, а не таблицю: у
   border-collapse: collapse радіус на самій таблиці не малюється взагалі —
   комірки перекривають її межу. */
.table thead th:first-child,
.camp-table thead th:first-child {
  border-top-left-radius: var(--r-md);
}

.table thead th:last-child,
.camp-table thead th:last-child {
  border-top-right-radius: var(--r-md);
}

/* th у tbody — це «таблиця-картка» виду ключ/значення
   (деталі запуску), а не заголовок колонки. */
.table tbody th {
  width: 30%;
  font-weight: 600;
  color: var(--ink-muted);
}

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

/*
  Рядок під курсором світиться акцентом сторінки, а не сірим. У таблиці на
  півсотні рядків саме ця смуга тримає око на потрібному рядку, доки воно
  йде до правої колонки, — і кольорова робить це помітно краще за сіру.

  .camp-table сюди ж: таблиці результатів у сервісах читаються так само
  рядок за рядком, а різна поведінка двох таблиць в одному інтерфейсі — це
  рівно те, чого UI.md і не дозволяє.
*/
.table tbody tr:hover td,
.camp-table tbody tr:hover td {
  background: var(--accent-wash);
}

.table tbody td,
.camp-table tbody td {
  transition: background-color var(--dur-1) var(--ease);
}

.table tr.muted td {
  color: var(--ink-subtle);
}

.table tr.degraded td {
  background: var(--warn-bg);
}

.camp-table tr.row-fail td,
.table tr.row-fail td {
  background: var(--fail-bg);
}

/*
  Рядок зі своїм станом під курсором лишається СВОГО кольору, лише
  насиченішого. Колір стану несе зміст («цей запуск упав»), колір ховера —
  саму лише позицію курсора, і перефарбовувати перше в друге означає на час
  наведення ховати від людини те, заради чого вона в таблицю й дивиться.

  Правила потрібні явні: селектор ховера вище важить (0,2,3) проти (0,2,2)
  у станів, тобто без них саме ховер і вигравав би — так було й раніше,
  коли він був сірим.
*/
.table tbody tr.degraded:hover td {
  background: color-mix(in oklab, var(--warn-ink) 22%, var(--surface));
}

.table tbody tr.row-fail:hover td,
.camp-table tbody tr.row-fail:hover td {
  background: color-mix(in oklab, var(--fail-ink) 22%, var(--surface));
}

.s-ok,
.camp-table .s-ok {
  color: var(--ok-ink);
  font-weight: 700;
}

.s-fail,
.s-failed,
.camp-table .s-fail {
  color: var(--fail-ink);
  font-weight: 700;
}

.s-warning {
  color: var(--warn-ink);
  font-weight: 700;
}

.detail {
  color: var(--ink-muted);
}

/*
  Комірка з числом: праворуч і цифрами однакової ширини. Разом це дає
  стовпчик, який читається згори вниз одним поглядом — розряди стоять під
  розрядами, а не «пливуть» від рядка до рядка.

  Один клас на всі таблиці CRM. Доти те саме писалося в кожному сервісі під
  своїм іменем: .cost-num у картці місяця, .num у трьох таблицях довідників,
  ще одне правило у «Оплаті сервісів» — і, наприклад, у картці місяця числа
  вирівнювались праворуч, а в довідниках поруч — ні.

  На інлайновому <span> (число посеред речення) правило безпечне:
  text-align діє на блок, а не на рядковий елемент, тож там працює лише
  моноширинність цифр.
*/
.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Комірка з машинним рядком без пробілів — URL постбека, ключ, хеш. Рветься
   в будь-якому місці, бо інакше один такий рядок розпирає таблицю ширше за
   екран. Класом, а не інлайновим стилем у шаблоні: така комірка є не в
   одному сервісі. */
.detail.break,
td.break {
  word-break: break-all;
}

.table td code,
.camp-table td code {
  background: none;
  padding: 0;
  font-size: var(--t-cmp);
}

/* Обгортка для широких таблиць: скролиться сама, а не розпихає сторінку. */
.table-scroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
}

}
@layer components {
/* 40-components/theme-toggle.css */
/*
  Кнопка теми. Усі три підписи лежать у розмітці одразу, а видимий
  вибирається селектором за data-theme-state — скрипт лише перемикає
  атрибут і нічого не переписує в DOM.
*/
.theme-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);

  min-height: 34px;
  padding: var(--s-1) var(--s-3);
  font-size: var(--t-sm);
  font-weight: 600;
  color: var(--ink-muted);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--r-md);
  cursor: pointer;
  transition:
    background-color var(--dur-2) var(--ease),
    color var(--dur-1) var(--ease);
}

/* Без рамки на ховері — з тієї ж причини, що й у .icon-btn поруч: обидві
   кнопки стоять в одному ряду топбару, і поява рамки в однієї при
   відсутності в іншої читалась як різні контроли. */
.theme-toggle:hover {
  color: var(--ink);
  background: var(--surface-2);
}

.theme-toggle [data-mode] {
  display: none;
}

.theme-toggle[data-theme-state="system"] [data-mode="system"],
.theme-toggle[data-theme-state="light"] [data-mode="light"],
.theme-toggle[data-theme-state="dark"] [data-mode="dark"] {
  display: inline;
}

}
@layer components {
/* 40-components/tile.css */
/* Підгрупа плиток на дашборді: заголовок і своя сітка під ним. Відстань
   між секціями задана не тут, а в 50-rhythm.css — разом із рештою
   вертикального ритму сторінки й у шарі, який його перебиває.

   Той самий підпис, що й над групою в сайдбарі: дрібний, розріджений,
   капітеллю. Однакові написи в двох місцях читаються як одна навігація. */
.tile-group-head {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin: 0 0 var(--s-3);
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-subtle);
}

/* Лінія добігає до правого краю сітки: без неї заголовок висить над
   плитками сам по собі й не читається як межа секції. */
.tile-group-head::after {
  flex: 1;
  height: 1px;
  background: var(--border);
  content: "";
}

/* Лічильник стоїть ПЕРЕД лінією, поруч із назвою — це підпис до неї,
   а не окрема цифра десь у ряду. */
.tile-group-count {
  min-width: 1.5em;
  padding: 0 var(--s-1);
  font-size: var(--t-xs);
  font-weight: 700;
  line-height: 1.6;
  text-align: center;
  color: var(--ink-muted);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
}

/* Плитки сервісів на дашборді й розділів в адмінці.

   На широкому екрані колонки підбираються самі — auto-fill від мінімальної
   ширини плитки. Але сам auto-fill не знає про планшет: при 220px мінімуму
   на 768px виходило три вузькі колонки, у яких назва сервісу ламалась на два
   рядки. Тому дві межі задані руками, а «сам» лишається там, де він і має
   сенс, — на просторі, де плиток у ряд буває від трьох до чотирьох.

   Межа 900px — та сама, що й у каркаса (30-layout.css) і в shell.js: рівно
   там навбар стає шухлядою й контент отримує всю ширину. Розводити ці числа
   означало б мати діапазон, у якому сітка вже перебудувалась, а панель ще ні. */
.tiles {
  display: grid;
  gap: var(--s-4);
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

/* Планшет і вузький ноутбук: рівно дві колонки. auto-fill тут дав би три
   по ~240px — формально влазить, фактично не читається. */
@media (max-width: 900px) {
  .tiles {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s-3);
  }
}

/* Телефон. Стовпчик, а не дві колонки по 160px: плитка з іконкою, назвою і
   шляхом має мінімальну ширину, нижче за яку вона просто ламається. */
@media (max-width: 560px) {
  .tiles {
    grid-template-columns: 1fr;
  }
}

.tile {
  /* --h-tile ставить дашборд інлайном на кожну плитку сервісу — рівно так
     само, як --h-side на пункт навбару. Немає його (розділи адмінки) —
     береться акцент сторінки, тобто колір поточного сервісу.

     Похідний колір оголошений тут, на самій плитці, а не серед токенів:
     var() у значенні змінної підставляється там, де змінну ОГОЛОШЕНО, тож
     --accent із :root на інлайновий hue плитки вже не відреагував би. */
  --tile-accent: light-dark(
    oklch(0.55 0.2 var(--h-tile, var(--h-accent))),
    oklch(0.7 0.16 var(--h-tile, var(--h-accent)))
  );

  display: block;
  padding: var(--s-4);
  color: var(--ink);
  text-decoration: none;
  background: var(--surface);
  border: 1px solid var(--border);

  /* Смужка згори — колір сервісу. Доки дашборд був однією суцільною сіткою,
     усі плитки були однакові й колір лишався сторінці сервісу; з підгрупами
     саме він відрізняє сусідні плитки в ряду, як і в навбарі. */
  border-top: 3px solid var(--tile-accent);
  border-radius: var(--r-lg);
  box-shadow: var(--e-1);

  /* Властивості перелічені поіменно, а не transition: all. all зобов'язує
     браузер стежити за КОЖНОЮ анімовною властивістю елемента — включно з
     тими, які тут міняє не ховер: --tile-accent на плитці інлайновий, і
     на ньому all ловить ще й перефарбовування теми, накладаючись на
     [data-theme-anim]. Список із чотирьох рядків коштує рівно стільки,
     скільки в ньому написано. */
  transition:
    transform var(--dur-2) var(--ease),
    border-color var(--dur-2) var(--ease),
    background-color var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease);
}

/* Ховер і фокус з клавіатури — один стан: плитка це посилання, і той, хто
   ходить табом, має бачити те саме «підняте» положення, а не саму лише
   рамку фокуса. */
.tile:hover,
.tile:focus-visible {
  transform: translateY(-3px);

  /* Рамка й тло підбираються від кольору САМОГО сервісу, а не від сірого
     --border-strong: плитка не просто піднімається, а на мить стає своїм
     кольором. 4% домішки — на межі помітності, і це навмисно: тло тут
     підказує напрямок, а не малює другий бейдж. */
  border-color: color-mix(in oklab, var(--tile-accent) 32%, var(--border));
  border-top-color: var(--tile-accent);
  background: color-mix(in oklab, var(--tile-accent) 6%, var(--surface));

  /* Третій шар тіні — кольоровий і без зсуву вниз: він не «падає» під
     плитку, а стоїть навколо неї ореолом. Саме цим світіння відрізняється
     від тіні, і саме тому колір тут той самий, що й у плитки. */
  box-shadow:
    var(--e-3),
    0 0 0 1px color-mix(in oklab, var(--tile-accent) 18%, transparent),
    0 8px 28px -10px color-mix(in oklab, var(--tile-accent) 50%, transparent);
}

/* Натиснули — плитка сідає назад, майже до опори. Без цього кліку немає
   тактильної відповіді: курсор уже поїхав на нову сторінку, а плитка так
   і лишилась піднятою. Коротша тривалість — бо натискання має відгукнутись
   миттєво, на відміну від плавного підйому. */
.tile:active {
  transform: translateY(-1px);
  box-shadow: var(--e-2);
  transition-duration: var(--dur-1);
}

/* Іконка сервісу в кольоровому квадраті — той самий чип, що й у шапці
   сторінки, куди плитка веде. Обгортка .tile-icon у розмітці вже є, тож
   тут навіть svg-трюк не потрібен. */
.tile .tile-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6em;
  height: 2.6em;
  color: var(--tile-accent);
  background: color-mix(in oklab, var(--tile-accent) 12%, var(--surface));
  border: 1px solid color-mix(in oklab, var(--tile-accent) 28%, var(--border));
  border-radius: var(--r-md);
  transition:
    background-color var(--dur-2) var(--ease),
    border-color var(--dur-2) var(--ease),
    transform var(--dur-2) var(--ease);
}

/* Під курсором чип насичується, а плитка під ним світиться своїм кольором.
   Разом це читається як «плитка ожила», а не як «плитка змінила рамку». */
.tile:hover .tile-icon,
.tile:focus-visible .tile-icon {
  background: color-mix(in oklab, var(--tile-accent) 22%, var(--surface));
  border-color: color-mix(in oklab, var(--tile-accent) 45%, var(--border));
  transform: scale(1.06);
}

/* Запасний варіант, коли іконки немає: icon() повертає порожньо для
   невідомого імені, і тоді в плитці лишається текст або емодзі —
   збільшуємо його, щоб він виглядав як іконка. */
.tile .tile-icon:not(:has(svg)) {
  font-size: var(--t-2xl);
  line-height: 1;
}

.tile .tile-title {
  margin-top: var(--s-2);
  font-weight: 600;
  font-size: var(--t-md);
}

.tile .tile-path {
  margin-top: var(--s-1);
  font-size: var(--t-sm);
  color: var(--ink-muted);
}

/* Сервіс не піднявся (див. app.state.degraded). Плитка лишається
   клікабельною — просто попереджає. */
.tile.degraded {
  border-color: var(--fail-line);
  border-top-color: var(--fail-ink);
  background: var(--surface-2);
}

.tile .tile-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  margin-top: var(--s-2);
  font-size: var(--t-xs);
  font-weight: 600;
  color: var(--fail-ink);
}

.tiles-empty {
  color: var(--ink-muted);
}

/*
  Поява плиток каскадом. Дашборд — перше, що людина бачить за день, і
  сітка, яка проявляється зліва направо, читається як «сторінка зібралась»,
  а не як «сторінка була».

  backwards у fill-mode обов'язковий: без нього плитка з затримкою в 240мс
  цю чверть секунди стоїть у КІНЦЕВОМУ вигляді, потім стрибком повертається
  на початок анімації й лише тоді їде — тобто ефект виходить зворотним до
  задуманого. З backwards вона від самого початку тримає кадр «from».

  Затримка обрізана на восьмій плитці. Далі вона не потрібна: восьма
  з'являється вже за чверть секунди, а на дашборді буває й дванадцять
  сервісів — остання чекала б майже пів секунди й читалась би як гальмо.

  Своєї media-умови на «менше руху» правило не має: глобальний блок у
  20-base.css стискає animation-duration до 0.01ms, тож плитки просто
  з'являються на місці.
*/
@keyframes tile-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

.tile {
  animation: tile-in 0.32s var(--ease) backwards;
}

.tiles .tile:nth-child(2) { animation-delay: 40ms; }
.tiles .tile:nth-child(3) { animation-delay: 80ms; }
.tiles .tile:nth-child(4) { animation-delay: 120ms; }
.tiles .tile:nth-child(5) { animation-delay: 160ms; }
.tiles .tile:nth-child(6) { animation-delay: 200ms; }
.tiles .tile:nth-child(n + 7) { animation-delay: 240ms; }

}
@layer components {
/* 40-components/timeline.css */
/*
  Стрічка найближчих семи днів на дашборді.

  Компонент оболонки, а не сервісу, з тієї ж причини, що й .alerts-panel:
  малює її скрипт (static/js/timeline.js), а скрипт живе в shared — сама
  оболонка не знає, ЯКИЙ сервіс віддає стрічку, вона бачить лише контейнер
  із data-timeline-url.

  Сім однакових колонок, а не «скільки влізе»: тиждень має читатись як
  тиждень, і день, який поїхав на другий ряд, ламає саме це. На вузькому
  екрані стрічка від'їжджає горизонтальним скролом усередині себе.
*/
.timeline-panel {
  overflow-x: auto;
  /* Відступ під тінь картки: без нього нижня тінь зрізається скролом. */
  padding-bottom: var(--s-1);
}

.timeline-strip {
  display: grid;
  grid-template-columns: repeat(7, minmax(9rem, 1fr));
  gap: var(--s-2);
}

.timeline-day {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  min-height: 7rem;
  padding: var(--s-2) var(--s-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
}

/* Сьогодні виділене акцентною рамкою й тлом: стрічка існує, щоб відповісти
   «що зараз», і початок відліку має бути видно, не читаючи підписів. */
.timeline-day.is-today {
  background: var(--accent-soft);
  border-color: var(--accent-line);
}

.timeline-day.is-weekend .timeline-date {
  color: var(--warn-ink);
}

.timeline-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-1);
  padding-bottom: var(--s-1);
  border-bottom: 1px solid var(--border);
}

.timeline-label {
  font-size: var(--t-sm);
  font-weight: 600;
  color: var(--ink-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.timeline-day.is-today .timeline-label {
  color: var(--accent);
}

.timeline-date {
  font-size: var(--t-md);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.timeline-item {
  display: flex;
  align-items: baseline;
  gap: var(--s-1);
  padding: 2px var(--s-1);
  font-size: var(--t-sm);
  color: var(--ink);
  text-decoration: none;
  background: var(--surface-2);
  border-left: 2px solid var(--accent-line);
  border-radius: var(--r-sm);
}

.timeline-item:hover {
  background: var(--surface-3);
}

/* Запис без часу — інший вигляд, а не інша позиція: він не стоїть у
   розкладі й виглядати як подія о певній годині не повинен. */
.timeline-item.no-time {
  border-left-color: var(--border-strong);
}

.timeline-at {
  font-size: var(--t-xs);
  font-weight: 600;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.timeline-what {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.timeline-item .ico {
  width: 12px;
  height: 12px;
  flex: none;
  color: var(--ink-subtle);
}

/* Порожній день — не порожнеча, а відповідь («вільно»), тож підпис є, але
   ледь помітний: він не має конкурувати з днями, де щось є. */
.timeline-free {
  font-size: var(--t-xs);
  color: var(--ink-subtle);
}

.timeline-more {
  font-size: var(--t-xs);
  color: var(--ink-muted);
  text-decoration: none;
}

.timeline-more:hover {
  color: var(--accent);
}

}

@layer rhythm {
/* 50-rhythm.css */
/*
  Вертикальний ритм сторінки — один крок на всі сервіси.

  Досі відступ між блоками задавав кожен шаблон сам: де margin-bottom у
  власному @layer service, де інлайновий style="margin-top: …", а де взагалі
  нічого, і блоки злипались. Через це однакові за змістом сторінки
  відрізнялися відступами — рівно те, що видно, коли перемикаєшся між
  сервісами по навбару.

  Тут це сказано один раз: сусідні блоки верхнього рівня всередині
  .container розділені кроком --s-5. Компоненти власних зовнішніх відступів
  більше не потребують, а ті, що їх мають (.alert, .table), тут
  перекриваються — саме тому шар rhythm оголошений ПІСЛЯ components.
  У самих компонентах ці margin лишились: вони працюють, коли компонент
  вкладений у картку чи в комірку таблиці, а не стоїть блоком на сторінці.
*/

/*
  Заголовки виключені з обох боків правила:

    • сам заголовок зберігає власний, більший відступ згори (20-base.css) —
      він відкриває секцію, а не стоїть у ряд із блоками;
    • блок одразу ПІД заголовком не отримує нічого — інакше підпис
      відірвався б від того, що підписує.

  .section-head — той самий заголовок, лише з дією праворуч, тож поводиться
  так само. .modal у списку з іншої причини: це position: fixed з inset: 0,
  і margin у нього не «відступ», а зсув самого вікна від краю екрана.
*/
.container > *:not(h1, h2, h3, .section-head, .modal)
           + *:not(h1, h2, h3, .section-head, .modal) {
  margin-top: var(--s-5);
}

/*
  Заголовок, що відкриває свій контейнер, власного відступу згори не має:
  його вже дає падінг картки або сама сітка. Без цього дві колонки поруч
  починалися на різній висоті — та, що з заголовка, з'їжджала вниз на
  32px відносно тієї, що з форми (саме так було в AutoFlow і «Доменах»).

  Правило живе тут, а не в 20-base.css: .section-head описаний у шарі
  components, і скидання з ранішого шару його б не перебило.
*/
h2:first-child,
h3:first-child,
h4:first-child,
.section-head:first-child {
  margin-top: 0;
}

/*
  Підгрупи плиток на дашборді розсунуті ширше за звичайний крок: усередині
  групи плитки стоять за 16px одна від одної, і на 24px ритму групи ще
  читалися б як одна суцільна сітка. 32px — та відстань, з якої видно, що
  блоків кілька, і саме вона робить поділ поділом.

  Правило живе тут, а не в tile.css: секції — сусіди верхнього рівня в
  .container, тож правило вище вже дало їм свої 24px, а шар rhythm
  оголошений після components і компонентному margin просто не програє.
  .container у селекторі — з тієї ж причини: без нього специфічність нижча
  за правило вище, і 24px лишились би.
*/
.container > .tile-group + .tile-group {
  margin-top: var(--s-6);
}

/*
  Банер аварійного входу стоїть найпершим у .main, ще над .container, тож
  відступ згори йому не потрібен — його дає падінг самої області. Правило
  живе тут, а не в 30-layout.css: там воно програвало власному margin
  компонента .alert, бо шар components оголошений після layout.
*/
.breakglass {
  margin-top: 0;
}

/*
  Той самий крок усередині картки чи згорнутої секції — там, де блоки
  так само стоять один під одним. Опт-ін класом, а не автоматично: у
  картці частіше лежить текст і поля форми, у яких свій, щільніший ритм.
*/
.stack > * + * {
  margin-top: var(--s-4);
}

}

@layer utilities {
/* 90-utilities.css */
/*
  Утиліти — останній шар, тож перебивають будь-який компонент.
  Саме тому їх мало й вони точкові: це не «атомарний CSS», а виняток
  на випадок, коли компоненту потрібне разове відхилення.
*/

/* Приховати візуально, але лишити для скрінрідера. Не display: none —
   те, чого немає в потоці, скрінрідер теж не бачить. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* «Перейти до вмісту» — перше посилання на сторінці. Схований за верхнім
   краєм і виїжджає лише під фокусом, щоб той, хто ходить із клавіатури,
   міг проскочити навбар. */
.skip-link {
  position: absolute;
  left: var(--s-2);
  top: var(--s-2);
  z-index: 100;
  padding: var(--s-2) var(--s-3);
  color: var(--brand-ink);
  background: var(--brand);
  border-radius: var(--r-md);
  transform: translateY(-200%);
}

.skip-link:focus-visible {
  transform: none;
}

/* Один із двох !important у проєкті (другий — у блоці prefers-reduced-motion).
   Тут він доречний: .hidden ставить скрипт (блок результату в AutoFlow),
   і приховування має спрацювати незалежно від display самого компонента. */
.hidden {
  display: none !important;
}

.mono {
  font-family: var(--font-mono);
}

/*
  Один рядок із трьома крапками замість решти. Ці три властивості працюють
  лише разом: без nowrap текст переноситься й ніколи не «переповнюється»,
  без overflow ellipsis нема на чому спрацювати.

  Саме тому вони й розповзалися по сервісах трійками — логін користувача в
  «Відстеженні Sub», назва кампанії в «Заміні SS», підпис вкладення й запис
  у клітинці календаря. Тепер це одне ім'я.

  Сусіду по флекс-рядку майже завжди потрібен ще й min-width: 0 — інакше
  він відмовляється стискатись, і обрізати стає нічого. Це вже властивість
  розкладки, тож лишається в самого елемента.
*/
.truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

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

/*
  Згасання при перемиканні теми.

  Тема міняє кольори в :root через color-scheme, і без цього правила вся
  сторінка перефарбовується одним кадром — світлий фон блимає в темний
  різкіше, ніж будь-яка інша зміна в інтерфейсі.

  ЧОМУ ЧЕРЕЗ АТРИБУТ, А НЕ ПОСТІЙНО. Перехід кольору на кожному елементі
  сторінки коштує дорого й заважає: рядок таблиці під курсором мав би
  розгорятися 200мс замість 120, а на першому малюванні всі елементи
  анімувалися б із дефолтних кольорів у справжні. Тому атрибут ставить
  theme.js рівно на час перемикання — і лише по кліку, не на завантаженні.

  ЧОМУ САМЕ ТУТ. Компоненти оголошують власні transition (кнопка, пункт
  навбару, плитка), і в шарі components вони перебили б це правило хай
  якою специфічністю. utilities — останній шар, тож він виграє без
  !important, а поза 200мс перемикання правило просто не існує.

  prefers-reduced-motion це не оминає: блок у 20-base.css стискає
  transition-duration через !important, а важливі оголошення в РАНІШОМУ
  шарі сильніші за важливі в пізнішому — тобто база тут головніша.
*/
:root[data-theme-anim] *,
:root[data-theme-anim] *::before,
:root[data-theme-anim] *::after {
  transition:
    background-color var(--dur-2) var(--ease),
    border-color var(--dur-2) var(--ease),
    color var(--dur-2) var(--ease),
    fill var(--dur-2) var(--ease),
    box-shadow var(--dur-2) var(--ease);
}

}

/* Плавний перехід між сторінками там, де браузер це вміє.
   Суто косметика: де не підтримується — просто звичайна навігація. */
@view-transition {
  navigation: auto;
}
