/* Bootstrap-stage styling: enough to make the auth screens and the dashboard
   shell look intentional. The real design system replaces this later.

   Palette inherited from the main site's brand tokens (_variables.scss):
   purple #5632A0, dark #12141D, gray #5A5A5A, gray-lighter #FAFAFB,
   blue-lightest #E5E6EE, red #FF1E37, green #00C48C, gold #FFC448,
   orange-dark #FF9E2C, darker-dark #080A10, dark-gray #272930.

   Dark theme is derived, not given: the brand purple is unreadable on a dark
   surface (2.1:1), so dark uses the same hue lightened — #A98CE8 (7.2:1) with
   brand darker-purple #1B0F4A as its ink. Status colours that are only ever
   fills keep their brand value; the ones used as TEXT are darkened in light
   (#D31029, #00795A, #A8620A) so they clear AA.

   Theme resolution:
   - <html data-theme="light|dark">  — an explicit, stored choice (server-rendered)
   - no data-theme at all            — nothing chosen yet, follow the OS via
                                       prefers-color-scheme (first visit only) */

:root,
:root[data-theme='light'] {
  --bg: #fafafb;
  --fg: #12141d;
  --muted: #5a5a5a;
  --card: #ffffff;
  --line: #e5e6ee;
  --field: #ffffff;
  --accent: #5632a0;
  --accent-fg: #ffffff;
  --danger: #d31029;
  --danger-bg: #ffe1e4;
  --ok: #00795a;
  --ok-bg: #e1fff3;
  --warn: #a8620a;
  --warn-bg: #fff3d9;
  --shadow: 0 12px 40px rgb(18 20 29 / 8%);
  --logo: #5632a0;             /* brand mark: accent in light… */

  --toast-success-bg: linear-gradient(135deg, #effbf4 0%, #d3f3df 50%, #b7e9cb 100%);
  --toast-success-border: #9eddb8;
  --toast-success-text: #0b4b2a;
  --toast-success-accent: #178f57;
  --toast-success-shadow: rgb(23 143 87 / 16%);
  --toast-warning-bg: linear-gradient(135deg, #fffbee 0%, #faecc2 50%, #f2daa0 100%);
  --toast-warning-border: #dfc578;
  --toast-warning-text: #5c4506;
  --toast-warning-accent: #b38b13;
  --toast-warning-shadow: rgb(179 139 19 / 15%);
  --toast-error-bg: linear-gradient(135deg, #fff4f5 0%, #ffdadd 50%, #ffc1c5 100%);
  --toast-error-border: #f3a8ad;
  --toast-error-text: #721b21;
  --toast-error-accent: #d9363e;
  --toast-error-shadow: rgb(217 54 62 / 15%);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --bg: #080a10;
    --fg: #fafafb;
    --muted: #899db6;
    --card: #12141d;
    --line: #272930;
    --field: #0b0e15;
    --accent: #a98ce8;
    --accent-fg: #1b0f4a;
    --danger: #ff6b7d;
    --danger-bg: #2a1418;
    --ok: #3dd598;
    --ok-bg: #0d2a20;
    --warn: #ffc448;
    --warn-bg: #2a2110;
    --shadow: 0 12px 40px rgb(0 0 0 / 55%);
    --logo: #ffffff;           /* …plain white on dark, not the lightened accent */

    --toast-success-bg: linear-gradient(135deg, #b7f7d1 0%, #72e5a5 48%, #43c985 100%);
    --toast-success-border: #87e9b1;
    --toast-success-text: #06351d;
    --toast-success-accent: #08743d;
    --toast-success-shadow: rgb(47 209 121 / 24%);
    --toast-warning-bg: linear-gradient(135deg, #fff6ca 0%, #f6d978 50%, #e8bb4d 100%);
    --toast-warning-border: #f3d574;
    --toast-warning-text: #3d2e05;
    --toast-warning-accent: #9b7000;
    --toast-warning-shadow: rgb(249 235 178 / 22%);
    --toast-error-bg: linear-gradient(135deg, #ffc2c5 0%, #ff8f95 48%, #ef646c 100%);
    --toast-error-border: #ff9da2;
    --toast-error-text: #4a0b0f;
    --toast-error-accent: #a51f27;
    --toast-error-shadow: rgb(239 68 68 / 26%);
  }
}

:root[data-theme='dark'] {
  --bg: #080a10;
  --fg: #fafafb;
  --muted: #899db6;
  --card: #12141d;
  --line: #272930;
  --field: #0b0e15;
  --accent: #a98ce8;
  --accent-fg: #1b0f4a;
  --danger: #ff6b7d;
  --danger-bg: #2a1418;
  --ok: #3dd598;
  --ok-bg: #0d2a20;
  --warn: #ffc448;
  --warn-bg: #2a2110;
  --shadow: 0 12px 40px rgb(0 0 0 / 55%);
  --logo: #ffffff;             /* …plain white on dark, not the lightened accent */

  --toast-success-bg: linear-gradient(135deg, #b7f7d1 0%, #72e5a5 48%, #43c985 100%);
  --toast-success-border: #87e9b1;
  --toast-success-text: #06351d;
  --toast-success-accent: #08743d;
  --toast-success-shadow: rgb(47 209 121 / 24%);
  --toast-warning-bg: linear-gradient(135deg, #fff6ca 0%, #f6d978 50%, #e8bb4d 100%);
  --toast-warning-border: #f3d574;
  --toast-warning-text: #3d2e05;
  --toast-warning-accent: #9b7000;
  --toast-warning-shadow: rgb(249 235 178 / 22%);
  --toast-error-bg: linear-gradient(135deg, #ffc2c5 0%, #ff8f95 48%, #ef646c 100%);
  --toast-error-border: #ff9da2;
  --toast-error-text: #4a0b0f;
  --toast-error-accent: #a51f27;
  --toast-error-shadow: rgb(239 68 68 / 26%);
}

/* ---------------------------------------------------------- scrollbars */
/* THE SCROLLBAR IS THE PROJECT'S, AND QUIET UNTIL SOMETHING MOVES. Native bars
   come in the OS's grey and, with a mouse plugged in, never leave — the
   owner's screenshot had a fat grey rail parked under the kanban. This is
   said ONCE for every scroller, the document included, so no section ever
   dresses its own: the rail is invisible, the thumb is drawn from the tokens,
   and it shows only while its scroller is moving — `is-scrolling`, worn for a
   moment after the last scroll event (public/js/app.js). The rail keeps its
   width shown or hidden, so nothing jumps sideways when the thumb appears.

   Two dialects, one look. Chrome and Firefox speak the standard properties
   (and Chrome IGNORES the -webkit- pseudo-elements once it sees them — the
   two must not be mixed for one element); Safari still speaks only the
   pseudo-elements. `@supports` picks the dialect, the tokens keep them equal.
   `.tabs` and `.rail` hide their bars entirely and stay that way — a class
   outranks `*`. */
:root,
:root[data-theme='light'] {
  --scroll-thumb: #cdd0dc;
  /* The four label tones the palette does not already own (violet, green,
     amber, red ride on --accent, --ok, --warn, --danger). Chip TEXT at 11.5px
     over a 10% tint of itself: the dark values are lifted the way --accent
     is, because #2f6fd0 on #12141d does not reach 4.5:1. */
  --tone-blue: #2f6fd0;
  --tone-teal: #0d8a8a;
  --tone-pink: #b33a7a;
  --tone-slate: #5a6478;
}
:root[data-theme='dark'] {
  --scroll-thumb: #363a48;
  --tone-blue: #7fa9f0;
  --tone-teal: #4fc4c4;
  --tone-pink: #e07ab4;
  --tone-slate: #9aa4b8;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --scroll-thumb: #363a48;
    --tone-blue: #7fa9f0;
    --tone-teal: #4fc4c4;
    --tone-pink: #e07ab4;
    --tone-slate: #9aa4b8;
  }
}

@supports (scrollbar-color: auto) {
  * { scrollbar-width: thin; scrollbar-color: transparent transparent; }
  .is-scrolling { scrollbar-color: var(--scroll-thumb) transparent; }
}
@supports not (scrollbar-color: auto) {
  ::-webkit-scrollbar { width: 10px; height: 10px; }
  ::-webkit-scrollbar-track { background: transparent; }
  ::-webkit-scrollbar-corner { background: transparent; }
  ::-webkit-scrollbar-thumb {
    background: transparent;
    border: 3px solid transparent;    /* the rail's breathing room, on the thumb */
    border-radius: 999px;
    background-clip: padding-box;
  }
  .is-scrolling::-webkit-scrollbar-thumb { background-color: var(--scroll-thumb); }
}

/* --------------------------------------------------------- transitions */
/* Section changes, the language switch and light ↔ dark all run through the
   View Transitions API (see public/js/app.js). The default is already a
   crossfade; this only tunes the timing and opts out for reduced motion. */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 260ms;
  animation-timing-function: cubic-bezier(.22, 1, .36, 1);
}

/* The outgoing frame sits on top by default, which makes a crossfade look
   muddy — let both blend instead. */
::view-transition-old(root) { mix-blend-mode: normal; }
::view-transition-new(root) { mix-blend-mode: normal; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

* { box-sizing: border-box; }

/* THE `hidden` ATTRIBUTE MUST WIN, and without this line it loses to almost
   anything. `hidden` is `display: none` in the BROWSER'S stylesheet, and a
   browser's rules rank below the page's — so a single class saying
   `display: grid` shows an element the markup said to hide, silently and
   everywhere.
   Four places in this app were already hiding something with the attribute and
   styling it with a class: the drop overlay (which greeted every visit to
   Матеріали as a wall), the «читаємо назву…» line, the alternative sign-in
   block and one card in Профіль → Видалення. One rule answers all four and
   every future one — `!important` because the attribute is the last word about
   whether a thing is on screen, and nothing further down the file should be
   able to argue with it. */
[hidden] { display: none !important; }

html { height: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font: 16px/1.55 Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  /* THE BROWSER DOES NOT «HELP» WITH THE SCROLL. Scroll anchoring is the
     engine's habit of picking an element in view and moving the window so
     that element stays put when content above it changes height. Right for
     a news page whose pictures load late; wrong here, where the page is
     rebuilt under the reader on purpose: a form's answer swaps `#content`
     (new markup inserted before the old, then the old removed), and the
     editor on it grows from a textarea into a pane. Chrome anchored on the
     OLD content and followed it down as the new one was inserted above —
     «Зберегти» on the Documents tab took the window from 403 to 1118 inside
     the swap and to 1341 as the editor built, with no script scrolling at
     all (measured in Chrome 153, 20.09.2026); with this line, 403 stayed 403.
     On `body`, so that no subtree — content, shell, dialogs — can be picked
     as an anchor (AGENTS §4.9). */
  overflow-anchor: none;
}

a { color: var(--accent); }

/* THE PAGE HEADING. The 26px that stood here drew nowhere: every `h1` in the
   app is inside `.page-head` (30px) or `.auth-card` (22px), so the base rule
   was a number nobody could see and everybody could believe. It now says what
   a page heading is, and `.page-head h1` has nothing left to add. */
h1 { margin: 0 0 6px; font-size: 30px; font-weight: 650; letter-spacing: -.02em; }

/* THE CARD HEADING, DECLARED. It looked settled — `.card-head h2` and
   `.empty h2` both said 19px — but neither said a WEIGHT, so every card
   heading in the app was rendering at the browser's `bold` = 700. That is the
   one weight this system says it never uses, and it was on ten cards. Stating
   it here puts all three heading levels on 650 and leaves the two class rules
   with nothing to carry but their own margin. */
h2 { margin: 0 0 8px; font-size: 19px; font-weight: 650; letter-spacing: -.01em; }

/* A SECTION HEADING INSIDE A CARD. Without this rule `h3` took the browser's
   1.17em ≈ 18.7px — bigger than the 15px the system says, and close enough to
   the 19px card heading to read as a second one. Worse than the size were the
   browser MARGINS: inside a grid they do not collapse, they add to the gap, so
   16 above + 18 of step + 16 below put fifty pixels of nothing between a
   heading and its table. Five screens drew that. Overrides that need another
   size (`.modal-head h3`, `.widget-lede h3`, `.prices h3`) are all class-first
   and still win. */
h3 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
/* A CARD HEADING THAT OPENS WITH A GLYPH (`<h3>${icon(…, 18)} Слова</h3>` —
   eighteen of them across Білінг, the account's billing tab and three
   settings tabs) is a flex row, not a block with an image floating in the
   text: the glyph sits on the heading's centre line instead of the baseline,
   at 18px rather than 15 so it reads as a mark and not a speck, with 8px of
   air before the words (the owner, 16.09.2026 — «мала, не вирівняна, і
   відступ замалий», the same three things on every card). `:has(> .icon)`
   scopes it to exactly those headings; a plain h3 is untouched. */
h3:has(> .icon:first-child) { display: flex; align-items: center; gap: 8px; }
h3 > .icon:first-child { flex: none; }

.lead, .muted { color: var(--muted); }
.lead { margin: 0 0 22px; }

/* ------------------------------------------------------------- chrome */

.brand {
  display: inline-flex;
  align-items: center;
  color: var(--logo);
  line-height: 0;
}

.brand-logo { display: block; width: 100%; height: auto; max-width: 100%; }

/* The product name is a tooltip that follows the cursor (public/js/app.js).
   Without JS the element's title attribute does the same job, slower. */
.tip {
  position: fixed;
  z-index: 300;
  padding: 5px 9px;
  font-size: 12.5px;
  font-weight: 550;
  line-height: 1.2;
  color: var(--accent-fg);
  background: var(--accent);
  border-radius: 7px;
  box-shadow: 0 6px 18px rgb(0 0 0 / 22%);
  pointer-events: none;
  opacity: 0;
  transform: translateY(3px);
  transition: opacity .14s ease, transform .14s ease;
  white-space: nowrap;
}

.tip.is-on { opacity: 1; transform: none; }
.tip.tip-wrap { max-width: min(420px, calc(100vw - 16px)); white-space: normal; overflow-wrap: anywhere; }

@media (prefers-reduced-motion: reduce) {
  .tip { transition: none; }
}

/* ------------------------------------------------------- app layout */
/* No top bar anywhere: the sidebar is the only chrome, and it holds the brand,
   the menu, and — pinned to the bottom — preferences, account and sign out.

   SCROLLING: the DOCUMENT scrolls, as on any normal page — that is what gives
   the content its elastic overscroll on a trackpad, including on short pages
   where an inner scroll container would have nothing to bounce. The sidebar is
   taken out of that flow with `position: fixed`, so it stays put while the
   right-hand side drags. The grid column keeps the space reserved for it. */

.layout {
  display: grid;
  grid-template-columns: 264px minmax(0, 1fr);
  min-height: 100dvh;
}

.sidebar {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 18px;
  width: 264px;
  height: 100dvh;
  padding: 18px 14px;
  background: var(--card);
  border-right: 1px solid var(--line);
  /* Rock still: a trackpad drag over the sidebar must not bounce it. Only the
     content column on the right is allowed to rubber-band. */
  overscroll-behavior: none;
  /* Nothing in here is text to take away — it is navigation. A drag that starts
     on a menu item used to paint it blue and, on touch, raise the copy bubble
     instead of following the link. `-webkit-` is not decoration: Safari still
     needs it. */
  -webkit-user-select: none;
  user-select: none;
}

/* The logo fills the block: .brand is inline-flex (shrink-to-fit), so it has to
   be told to take the full width before the SVG's width:100% has anything to
   resolve against. 264px sidebar − 2×14px padding − 2×8px here = 220px. */
.side-brand { padding: 6px 8px 2px; }

/* The anchor around the brand adds no box of its own: in the sidebar it is the
   same full-width block the bare mark was, in the mobile bar the same
   inline-flex — so «became a link» changes what a click does and nothing about
   where a pixel sits. */
.side-brand .brand-home { display: block; width: 100%; }
.mobile-bar .brand-home { display: inline-flex; }
.side-brand .brand { display: block; width: 100%; }

.nav {
  display: flex;
  flex-direction: column;
  gap: 2px;
  overflow-y: auto;          /* a long menu scrolls; the foot below stays put */
  overscroll-behavior: none; /* …but it never bounces past its own ends */
  flex: 1;
}

/* Fade only the edges with more navigation beyond them. The brand, tenant
   switcher and footer are outside this scrolling mask. */
.sidebar .nav {
  --nav-fade-top: 0px;
  --nav-fade-bottom: 0px;
  mask-image: linear-gradient(to bottom, transparent, #000 var(--nav-fade-top),
    #000 calc(100% - var(--nav-fade-bottom)), transparent);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--nav-fade-top),
    #000 calc(100% - var(--nav-fade-bottom)), transparent);
}
.sidebar .nav.has-more-above { --nav-fade-top: 20px; }
.sidebar .nav.has-more-below { --nav-fade-bottom: 20px; }

/* Where the portal ends and running it begins — said with space and nothing
   else. Neither a rule nor a heading: a line draws a border where there is only
   a change of subject, and a caption over three items reads as a category to
   choose from. Empty space groups just as well and adds no ink.
   The gap on `.nav` is 2px, so this lands at ~16px between the two groups. */
.nav-sep { height: 12px; }

/* THE TWO HALVES OF A STAFF MENU, each behind a heading that folds it (the
   owner, 22.09.2026). The heading is a caption, not an item: small, muted,
   uppercase, with the same chevron the list folds turn. A client's menu has
   one half and no heading at all (views/layout.js). */
.nav-group { display: flex; flex-direction: column; gap: 2px; }
.nav-group > summary { list-style: none; }
.nav-group > summary::-webkit-details-marker { display: none; }
.nav-group-head {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px 4px;
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--muted);
  cursor: pointer;
  user-select: none;
  border-radius: 9px;
}
.nav-group-head:hover { color: var(--fg); }
.nav-group-head .icon { flex: none; opacity: .8; transition: transform .15s ease; }
.nav-group[open] > .nav-group-head .icon { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .nav-group-head .icon { transition: none; } }

.nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  font-size: 14.5px;
  font-weight: 500;
  color: var(--muted);
  text-decoration: none;
  border-radius: 9px;
}

.nav-item:hover { color: var(--fg); background: var(--bg); }

.nav-item.is-active {
  color: var(--fg);
  background: var(--bg);
  font-weight: 600;
}

.nav-item .icon { flex: none; opacity: .9; }

/* The open ring that stands in for a menu icon while its page is on the way
   (public/js/app.js). The same 18px box and 1.8 stroke as the icon it
   replaces, so nothing on the line moves — only spins. Under reduced motion
   the ring stands still: an open ring already reads as «unfinished». */
.nav-spin { animation: nav-spin .9s linear infinite; }

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

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

/* Pinned to the bottom of the sidebar. */
.side-foot { margin-top: auto; padding-top: 12px; }

/* Language and theme, the row directly above the account bar. Pushed apart to
   the same edges the bar below uses — the language sits over the profile link,
   the theme over sign-out — so the two rows read as one block rather than a
   control that happened to land there. */
/* The spotlight: the input on top, the groups under it, each row a door. */
.modal-backdrop:has(.gs-box) .modal { max-width: 560px; }
.gs-box { display: grid; gap: 12px; }
#gs-results { display: grid; gap: 12px; max-height: 48vh; overflow-y: auto; overscroll-behavior: contain; }
.gs-group { display: grid; gap: 2px; }
.gs-group h4 { margin: 0 0 4px; font-size: 12.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.gs-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: 8px;
  color: var(--fg);
  text-decoration: none;
}
.gs-row:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.gs-row .icon { flex: none; color: var(--muted); }
.gs-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs-hint-cell {
  margin-left: auto;
  max-width: 45%;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 12.5px;
  color: var(--muted);
  white-space: nowrap;
}
.gs-hint { margin: 0; font-size: 13px; color: var(--muted); }

/* THE TOP TOOLS — search and the bell, the last column of every page-head
   row (views/layout.js topTools). Round, wordless, a size up from the
   32px .icon-btn the sidebar uses for theme and sign-out: these two are the
   page's, not the chrome's, and they are pressed from anywhere. The card
   colour under them keeps the glyph readable when a page's own head is
   busy behind. The mobile bar draws the same pair at ≤900px and this
   cluster steps aside there. */
.top-tools { display: flex; align-items: center; gap: 8px; flex: none; margin-left: auto; }
.top-tool { width: 40px; height: 40px; background: var(--card); }
.top-bell { position: relative; }
.top-bell.is-active { border-color: var(--accent); color: var(--accent); }
.top-bell .notify-badge { top: -3px; right: -3px; box-shadow: 0 0 0 2px var(--bg); }
.mobile-tools { display: flex; align-items: center; gap: 4px; flex: none; }
.mobile-bell { position: relative; }
.notify-badge {
  position: absolute;
  top: 1px;
  right: 1px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--accent-fg);
  background: var(--accent);
}

/* The feed: one sentence per row, the unread ones tinted until the page's one
   verb runs. The icon sits in a quiet ring so five kinds of news scan apart. */
.notify-prefs th[scope="row"] { font-weight: 500; }
.notify-prefs th[scope="row"] .icon { vertical-align: -3px; margin-right: 6px; color: var(--muted); }
.notify-prefs-ch { text-align: center; width: 92px; }
/* The payment-recipients grid (Налаштування → Оплата) wears the matrix's skin
   and adds two things: a group line per role, and the address under a name. */
.staff-grid-group th { padding-top: 14px; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--muted); }
.staff-grid th[scope="row"] small { display: block; font-size: 12px; font-weight: 400; color: var(--muted); }
.staff-grid input:disabled { opacity: .35; cursor: not-allowed; }
/* The board's and the card's feed wear the bell-feed skin; the class exists
   so the two can drift apart the day one needs to. */
/* The wrapper exists to be SELECTED (hx-select) — the shell's boosted
   inheritance story, told in views/tasks.activityFeed. No box of its own. */
.feed-fragment { display: contents; }
.board-feed { margin: 0; }
/* The feed rows carry no icon column — the sentence takes the whole line. */
.board-feed .notify-row { grid-template-columns: minmax(0, 1fr); padding: 10px 2px; }
.notify-card { padding: 6px 0; }
.notify-feed { margin: 0; padding: 0; list-style: none; }
.notify-row {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr);
  gap: 12px;
  align-items: start;
  padding: 12px 16px;
}
.notify-row + .notify-row { border-top: 1px solid var(--line); }
.notify-row.is-unread { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.notify-icon {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--muted);
  background: var(--card);
}
.notify-row.is-unread .notify-icon { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.notify-body { display: grid; gap: 2px; min-width: 0; }
.notify-body a { font-weight: 600; color: var(--fg); text-decoration: none; overflow-wrap: anywhere; }
.notify-body a:hover { color: var(--accent); }
.notify-detail { font-size: 13px; color: var(--fg); overflow-wrap: anywhere; }
.notify-meta { font-size: 12.5px; color: var(--muted); }

.side-prefs { padding: 0 2px 10px; }
.side-prefs .locale-controls { justify-content: space-between; }

/* One wide control, split by a vertical rule: the account on the left, an
   icon-only sign-out taking the right ~22%. */
.account-bar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 22%;
  align-items: stretch;
  border: 1px solid var(--line);
  border-radius: 10px;
  overflow: hidden;
}

/* The left half is a link to the profile — accent on hover, never the
   destructive red the sign-out half uses. */
.account {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 8px 10px;
  min-width: 0;
  text-decoration: none;
  transition: background-color .15s ease;
}

.account:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.account:hover .account-email { color: var(--accent); }

.avatar {
  display: grid;
  place-items: center;
  flex: none;
  width: 30px;
  height: 30px;
  font-size: 13px;
  font-weight: 650;
  color: var(--accent-fg);
  background: var(--accent);
  border-radius: 50%;
}

.account-meta { display: grid; min-width: 0; }

.account-email {
  font-size: 13px;
  font-weight: 550;
  color: var(--fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.account-role { font-size: 11.5px; color: var(--muted); }

/* The divider is the button's own left border, so it spans the full height
   whatever the account block does. */
.account-logout {
  display: grid;
  place-items: center;
  padding: 0;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-left: 1px solid var(--line);
  cursor: pointer;
  transition: color .15s ease, background-color .15s ease;
}

.account-logout:hover { color: var(--danger); background: var(--danger-bg); }

/* The sidebar is `position: fixed`, so it is no longer a grid item — without an
   explicit column the content would auto-place into the FIRST (264px) track and
   end up hidden behind it. */
.content {
  grid-column: 2;
  /* The side gutter is a variable because one page (the Gantt, below) wants
     its bottom to match it; 48px at the bottom is breathing room at the END
     of a page that scrolls. */
  --gutter: 34px;
  padding: 32px var(--gutter) 48px;
  min-width: 0;
}

/* WHERE AM I — above the heading, quieter than everything under it. Crumbs are
   an instrument you use when lost and ignore when not, so they are sized and
   coloured to be skipped: `--muted`, 13.5px, no underline until hovered. */
.crumbs { margin-bottom: 12px; font-size: 13.5px; line-height: 1.4; }

.crumbs ol {
  display: flex;
  flex-wrap: wrap;      /* a long trail on a phone folds instead of scrolling */
  align-items: center;
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}

.crumbs li { display: inline-flex; align-items: center; }
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--fg); text-decoration: underline; }

/* The page you are on: same size, but it is the end of the sentence, so it is
   the one part drawn in the text colour. */
.crumbs [aria-current="page"] { color: var(--fg); font-weight: 500; }

.crumb-sep { padding: 0 8px; color: var(--muted); opacity: .6; }

/* ONE ROW AT THE TOP OF EVERY PAGE: the title block (or a page's own —
   .account-head, .person-head), then whatever control belongs to the PAGE
   rather than to anything on it (the dashboard's column picker, the
   Materials «додати» pair), then — always last, always rightmost — the top
   tools. The title block takes the width, so a control is never squeezed and
   the tools never move: the page's own control is what yields, stepping
   left of them (the owner's rule). */
.page-head { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.page-head-main { flex: 1 1 auto; min-width: 0; }
.page-head .lead { margin: 6px 0 0; font-size: 15px; }
.page-head-aside { flex: none; }

@media (max-width: 620px) {
  /* Under the title rather than beside it: at this width the two together leave
     the heading two words per line. */
  .page-head { flex-wrap: wrap; }
  .page-head-aside { flex-basis: 100%; order: 9; margin-top: -4px; }
}

/* A page whose subject is a person: the picture is part of the heading rather
   than a card under it. `.avatar` is 30px everywhere else, so the size is set
   here — on the block that asked for a bigger one — instead of adding a size
   modifier nobody else would use. */
.person-head {
  display: flex;
  align-items: center;
  gap: 14px;
}

.person-head .avatar {
  width: 56px;
  height: 56px;
  font-size: 22px;
}

/* The mobile bar and the scrim exist only on narrow screens. */
.mobile-bar,
.side-scrim { display: none; }

@media (max-width: 900px) {
  .layout {
    --bar-h: 56px;
    grid-template-columns: minmax(0, 1fr);
  }

  /* Fixed bar: the brand on the left, the burger next to it on the right. */
  .mobile-bar {
    position: fixed;
    inset: 0 0 auto 0;
    z-index: 70;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    height: var(--bar-h);
    padding: 0 14px;
    background: var(--card);
    border-bottom: 1px solid var(--line);
  }

  /* Slightly quieter than the main site: 220px on tablets, 200px on phones. Only
     the brand may shrink on very narrow screens; touch targets stay intact.
     Cover both the dashboard's bare mark and its linked version elsewhere. */
  .mobile-bar > .brand,
  .mobile-bar > .brand-home { width: 220px; min-width: 0; flex: 0 1 220px; }
  .mobile-bar .brand-home .brand { width: 100%; min-width: 0; }

  @media (max-width: 480px) {
    .mobile-bar > .brand,
    .mobile-bar > .brand-home { width: 200px; flex-basis: 200px; }
  }

  /* Three lines and nothing else. The ring that .icon-btn draws belongs to the
     small controls it was written for (theme, sign-out) — around the one thing
     on this bar that opens the whole navigation it reads as a chip in the
     corner, and it makes the lines look smaller than they are. Dropped, the
     lines carry the button on their own, so they are drawn bigger and heavier.
     The box stays larger than the mark for the thumb that presses it.

     Selector deliberately two levels deep. `.burger` alone ties with `.icon-btn`
     on specificity and loses on order — this block sits ABOVE that one and a
     media query adds no weight of its own, which is why the old `.burger {
     border: 0 }` here never actually removed anything and the ring stayed. */
  .mobile-bar .burger {
    width: 44px;
    height: 44px;
    border: 0;
    border-radius: 0;
    /* Optical, not geometric: the bars keep 9px of their own air inside that
       44px box, so without this pull they would sit further from their edge
       than the logo does from the other one. */
    margin-right: -9px;
  }
  /* The bar carries search and the bell here; the page-head cluster that
     holds them on desktop steps aside, or the pair would be drawn twice. */
  .top-tools { display: none; }

  /* The same 44px box as the burger beside it, for the same thumb — two touch
     controls 4px apart must not differ by a quarter of their target. */
  .mobile-tools .icon-btn { width: 44px; height: 44px; flex: none; }
  .mobile-bar .mobile-bell .notify-badge { top: 5px; right: 5px; }

  /* The bars are drawn here rather than taken from the icon set, because an
     open menu folds them into a cross and a fold needs three separate
     elements — one <path> cannot rotate a third of itself. Swapping in an
     ✕ icon would show the same two states without saying they are the same
     button; the fold does. */
  .burger-lines,
  .burger-lines::before,
  .burger-lines::after {
    display: block;
    width: 26px;
    height: 2.5px;
    background: currentColor;
    border-radius: 2px;
  }

  .burger-lines {
    position: relative;
    transition: background-color .12s linear;
  }

  .burger-lines::before,
  .burger-lines::after {
    content: '';
    position: absolute;
    left: 0;
    transition: transform .25s cubic-bezier(.22, 1, .36, 1);
  }

  .burger-lines::before { top: -8px; }
  .burger-lines::after { top: 8px; }

  /* Open: the middle bar goes, the outer two meet in the middle and cross.
     The middle one is hidden by colour and not by opacity or a transform —
     both of those would have to live on the parent, and the parent is what the
     other two bars are positioned against. */
  .is-side-open .burger-lines { background: transparent; }
  .is-side-open .burger-lines::before { transform: translateY(8px) rotate(45deg); }
  .is-side-open .burger-lines::after { transform: translateY(-8px) rotate(-45deg); }

  @media (prefers-reduced-motion: reduce) {
    .burger-lines,
    .burger-lines::before,
    .burger-lines::after { transition: none; }
  }

  /* The same sidebar, now a panel that slides in UNDER the bar. */
  .sidebar {
    top: var(--bar-h);
    bottom: 0;
    z-index: 60;
    width: min(280px, 84vw);
    height: auto;                /* top+bottom decide the height here */
    transform: translateX(-100%);
    transition: transform .25s cubic-bezier(.22, 1, .36, 1);
  }

  .layout.is-side-open .sidebar { transform: none; }

  /* The brand is already in the bar — don't repeat it inside the panel. */
  .side-brand { display: none; }

  .side-scrim {
    position: fixed;
    inset: var(--bar-h) 0 0 0;
    z-index: 50;
    display: block;
    background: rgb(0 0 0 / 45%);
  }

  .side-scrim[hidden] { display: none; }

  .content { grid-column: 1; --gutter: 18px; padding: calc(var(--bar-h) + 20px) var(--gutter) 40px; }

  /* The mobile bar owns the top-right corner, so the stack starts below it.
     A literal, not var(--bar-h): .toasts is a sibling of .layout, which is
     where that variable is declared. */
  .toasts { top: 68px; }

  /* Without JS the panel could never be opened, so keep the sidebar in normal
     flow below the bar instead of hiding it off-screen. */
  .no-js .sidebar { position: static; transform: none; width: auto; }
  .no-js .side-brand { display: block; }
  .no-js .mobile-bar { display: none; }
  .no-js .content { padding-top: 24px; }
}

.locale-controls { display: flex; align-items: center; gap: 8px; }

/* A control that is not a text box — a segmented switch, a round toggle —
   sitting inside a `.field` between the label and the hint. A grid item
   stretches to the column by default, and a language switch as wide as the card
   is not a language switch. Profile → Interface. */
.pref-control { justify-self: start; }

/* THE LANGUAGE SWITCH IS THE COLUMN PICKER'S PILL, one size down. It used to
   be its own edge-to-edge pill with a class called `.seg` — and the day the
   sites section named ITS joined segments `.seg` too, the switch inherited a
   border, a bigger padding and a hover fill it never asked for: square inner
   corners on the active half, a hover on the other half taller than the
   active one. Two controls that look the same should share the rule, not
   the class name by accident; so the switch is `.btn-group` + `.btn-opt-btn`
   (the shared pill), and this is only the compact sizing the sidebar and
   the sign-in screen need — the same numbers `.head-tools` uses. */
/* Both class names, so this outranks the shared rule declared further down. */
.btn-group.lang-seg { padding: 2px; border-radius: 10px; }
.lang-seg .btn-opt-btn { padding: 4px 11px; border-radius: 8px; font-size: 13px; font-weight: 600; }

.icon-btn {
  display: inline-grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  color: var(--fg);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 999px;
  cursor: pointer;
}

.icon-btn:hover { border-color: var(--accent); color: var(--accent); }

/* An inline SVG sits on the TEXT BASELINE, so its box reserves room underneath
   for descenders. Inside a round button that phantom strip counts as content,
   place-items centres the taller box, and the glyph ends up visibly above the
   middle. Making the SVG a block removes the baseline entirely.
   Scoped to icon-only buttons on purpose: elsewhere an icon really does sit
   inline beside text (.conn-state, .smtp-name), and block would break the row. */
.icon-btn svg,
.account-logout svg { display: block; }

/* Show the icon for the theme the button switches TO — including the
   not-chosen-yet state, where the OS decides.
   `grid`, not `block`: the wrapper is what the button centres, so it must be a
   box with no line-height of its own — otherwise it reintroduces exactly the
   gap the rule above just removed. */
.theme-sun,
.theme-moon { display: grid; place-items: center; line-height: 0; }

:root[data-theme='light'] .theme-moon,
:root[data-theme='dark'] .theme-sun,
:root:not([data-theme]) .theme-moon { display: none; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .theme-moon { display: grid; }
  :root:not([data-theme]) .theme-sun { display: none; }
}

/* --------------------------------------------------------------- auth */

.auth-shell {
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 32px 20px;
}

/* THE REFERENCE SPLIT (clients-kaplia-pro, page-authorization): the film
   fills the LEFT HALF, edge to edge, under a quarter-dark veil so the form's
   side reads brighter; the right half is a column that centres the card and
   pins the payment logos to the very bottom. Below 1024px the film bows out
   and the page is the plain centered screen again — the reference's own
   break, kept to the pixel. With no video uploaded none of this exists. */
.auth-shell.has-video {
  display: flex;
  align-items: stretch;
  padding: 0;
}

.auth-video {
  position: relative;
  width: 50%;
  flex: none;
}
.auth-video video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.auth-video::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 25%);
}

.auth-side { display: contents; }
.auth-shell.has-video .auth-side {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: relative;
  width: 50%;
  min-height: 100dvh;
  padding: 32px 20px 88px;
}
.auth-shell.has-video .auth-box { width: 100%; }

/* The payment logos — pinned to the bottom edge, quiet chips that hold their
   own in both themes; without the film they still stand under the card. */
.auth-payments {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 10px 20px;
}
.auth-shell.has-video .auth-payments {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
}
.auth-shell:not(.has-video) .auth-payments { margin-top: 18px; }
/* NO PILL PER LOGO — the reference strips the borders on this page
   (.paymentLogosWrapper.transparent): the images stand in one quiet row,
   and the row's gap does the separating. The settings tab keeps its chips —
   there they are previews, not decoration. */
.auth-payments i {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 32px;
}
.auth-payments img { display: block; width: auto; height: 15px; }

/* Two logo rows, one shown — the same switch that picks the sun or the moon
   in the header: an explicit data-theme wins, otherwise the OS decides. The
   server always sends both rows (an empty set borrows the other), so the
   toggle costs no script and the page never shows a bare bottom edge. */
:root[data-theme='dark'] .auth-payments--light,
:root[data-theme='light'] .auth-payments--dark,
:root:not([data-theme]) .auth-payments--dark { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .auth-payments--light { display: none; }
  :root:not([data-theme]) .auth-payments--dark { display: flex; }
}

@media (max-width: 1024px) {
  .auth-video { display: none; }
  .auth-shell.has-video .auth-side { width: 100%; }
}

.auth-box { width: 100%; max-width: 420px; }

/* 404 and 500 — one bare box, centred both ways, whoever is looking (the
   owner, 22.09.2026): the code in large type, one sentence, the way out and
   the language switch. No brand, no video, no logos — a lost visitor needs
   the exit, not a lobby (views/auth.errorPage). */
.error-shell { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px 16px; }
.error-box { width: 100%; max-width: 420px; display: grid; justify-items: center; gap: 14px; text-align: center; }
.error-code { margin: 0; font-size: 64px; font-weight: 650; line-height: 1; letter-spacing: -.02em; }
.error-lead { margin: 0 0 4px; font-size: 16px; color: var(--muted); }
.error-box .lang-seg { margin-top: 6px; justify-self: center; }

/* Налаштування → Авторизація: the conversion table and the logo rows. */
.auth-video-status th { font-weight: 500; white-space: nowrap; }
.auth-video-status .chip { white-space: nowrap; }
.auth-video-status td:last-child { text-align: right; }
.auth-logo-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.auth-logo-list li { display: flex; align-items: center; gap: 10px; }
.auth-logo-list form { display: flex; margin-left: auto; }
.auth-logo-chip {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 32px;
  padding: 0 8px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--card);
}
.auth-logo-chip img { display: block; width: auto; height: 15px; }
/* The dark set previews on the dark theme's own card colours, whatever theme
   the admin reads the tab in — white-on-white is not a preview. */
.auth-logo-chip--dark { border-color: #272930; background: #12141d; }
.auth-logo-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; }
/* The sub-heading of a settings card group — the quiet caps of .gs-group h4,
   sized for a form card. */
.form-card h4 { margin: 14px 0 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.form-card h4:first-of-type { margin-top: 4px; }

.auth-card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 28px;
  box-shadow: var(--shadow);
  text-align: center;
}

/* Brand above the card, switchers below it — the card itself holds only the
   form. Both blocks are centred on the card's axis. */
.auth-brand { display: flex; justify-content: center; margin-bottom: 30px; }

.auth-foot { display: flex; justify-content: center; margin-top: 18px; }

/* On the sign-in screen the two switchers stack instead of sitting side by
   side, so the column stays symmetric under the card. In the sidebar they keep
   the compact row — there the width is the constraint, not the axis. */
.locale-foot { flex-direction: column; gap: 12px; }

/* The logo carries the page now, so the heading is a quieter second line. */
.auth-card h1 { font-size: 22px; }

/* Data inside a card that centres everything else. The card's `text-align:
   center` is right for a heading and a button and wrong for four labelled
   values: centred, they have no shape and the eye cannot pair a label with its
   value. So this opts out — label left, value right, hairline between, the same
   two-column reading as the `facts` block in a letter. */
.auth-facts {
  width: 100%;
  margin: 4px 0 18px;
  border-collapse: collapse;
  text-align: left;
  font-size: 14.5px;
}

.auth-facts th,
.auth-facts td { padding: 10px 0; vertical-align: top; }
.auth-facts tr + tr th,
.auth-facts tr + tr td { border-top: 1px solid var(--line); }

.auth-facts th {
  font-weight: 500;
  color: var(--muted);
  white-space: nowrap;
  padding-right: 12px;
}

.auth-facts td { text-align: right; color: var(--fg); overflow-wrap: anywhere; }

.auth-card .form { text-align: left; }
/* `.form` carries 18px above itself to stand off a heading or a lead. On a
   screen that starts WITH the form (the first sign-in step has neither) that
   margin stacks on the card's padding into a gap nothing asked for — so
   when the form is the card's first child, the padding alone sets the top. */
.auth-card .form:first-child { margin-top: 0; }
/* No margin of its own: `.form` is a grid with one gap, and an extra 4px here
   made the space above the button differ from every other gap in the form —
   visible the moment a captcha sits between them. */
.auth-card .btn { width: 100%; }

.auth-links {
  margin: 18px 0 0;
  font-size: 14px;
  color: var(--muted);
}

.auth-links .dot { margin: 0 8px; }

/* -------------------------------------------------------------- forms */

.form { display: grid; gap: 14px; margin-top: 18px; }

.field { display: grid; gap: 6px; }
/* Input and its short action share a row, including inside narrow cards. */
.field-action { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.field-action > input { min-width: 0; }

.field > span { font-size: 13px; font-weight: 600; color: var(--muted); }

/* A secret box and the checkbox that deletes it are ONE item in the card grid,
   not two — otherwise the form's own `gap` would put as much air between a
   field and its own delete control as between two unrelated fields, and the
   checkbox would read as belonging to whatever comes next. */
.field-secret { display: grid; gap: 8px; }
.check-danger > span { color: var(--danger); }

/* Typed AND ticked: the server will change nothing there and say so. Marking
   the pair now saves the round trip — the outcome is identical either way. */
.field-secret.has-clash input[type="password"] { border-color: var(--danger); }

.field input,
.field textarea {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
  color: var(--fg);
  background: var(--field);
  border: 1px solid var(--line);
  border-radius: 10px;
}

/* A <select data-tomselect> is styled to look EXACTLY like the Tom Select
   control that replaces it — same padding, border, radius and chevron. That is
   what removes the flash during a view transition: the raw element and the
   upgraded widget are visually identical, so whichever one the browser
   snapshots, nothing jumps. It also means the no-JS path looks right. */
.field select {
  width: 100%;
  padding: 10px 34px 10px 12px;
  font: inherit;
  color: var(--fg);
  background-color: var(--field);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
  border: 1px solid var(--line);
  border-radius: 10px;
  appearance: none;
  cursor: pointer;
}

.field select:focus {
  outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
  outline-offset: 1px;
  border-color: var(--accent);
}

/* PROJECT RULE — textareas.
   No resize grip anywhere: the height is managed by the auto-grow in
   public/js/fields.js, so a user dragging the corner could only fight it.
   Empty means two lines; content grows the box. Secrets and tokens are pasted,
   not written, so monospace makes them easier to check character by character.
   The min-height below is exactly two lines: 2 × (13.5px × 1.5) + 20px padding. */
textarea,
.field textarea {
  min-height: 61px;
  resize: none;
  overflow-y: hidden;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 13.5px;
  line-height: 1.5;
}

.field input:focus,
.field textarea:focus {
  outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
  outline-offset: 1px;
  border-color: var(--accent);
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  /* A caption never wraps: a button squeezed by a narrow table column used to
     stack «Відкрити» one letter per line (the unpaid-payments widget). The
     column may scroll; the word may not fold. */
  white-space: nowrap;
  padding: 10px 16px;
  font: inherit;
  font-weight: 600;
  color: var(--accent-fg);
  /* A button can be an <a> — «open the bot» is one — and the browser underlines
     those. Underlined text inside a filled box reads as a link that someone
     also painted, so it goes. Where the destination is a new tab, that is said
     with an arrow after the label instead (views/profile-telegram.js). */
  text-decoration: none;
  background: var(--accent);
  border: 1px solid transparent;
  border-radius: 10px;
  cursor: pointer;
  /* For the busy sweep below: it is an inset overlay clipped to the button. */
  position: relative;
  overflow: hidden;
  /* Colour of that sweep. Declared per variant so it always contrasts with the
     surface it travels over — light-on-dark or dark-on-light follows the
     theme's own foreground token, not a hard-coded white. */
  --btn-sweep: color-mix(in srgb, var(--accent-fg) 42%, transparent);
}

.btn:hover { filter: brightness(1.07); }

/* An icon inside a button is a mark beside the label, never a thing that gets
   squeezed when the label is long. */
.btn .icon { flex: none; }

.btn-ghost {
  color: var(--fg);
  background: transparent;
  border-color: var(--line);
  /* Transparent surface: a foreground-coloured sweep would be invisible in one
     of the two themes, so tint it with the accent instead. */
  --btn-sweep: color-mix(in srgb, var(--accent) 28%, transparent);
}

/* -------------------------------------------------------------- modals */
/* The markup is already in the page and the script only toggles `.open`.
   visibility (not display) keeps the transition and takes a closed dialog out
   of the accessibility tree.

   SCROLLING, the way the reference project does it: a dialog taller than the
   viewport does NOT scroll inside itself — the BACKDROP scrolls and the whole
   dialog moves. Internal scrolling hides the header and the buttons, which is
   exactly where the important controls are. The trick is .modal-inner:
   centring with `place-items` on a scrollable box clips the top of tall content
   and makes it unreachable, while a min-height flex wrapper does not.
   Meanwhile the page behind is frozen — see html.is-modal-open.

   The dark layer and the scroller are two different elements on purpose. If the
   element that scrolls also painted the background, an elastic overscroll would
   drag that background too and the page would show through at the edge. So the
   backdrop only paints and never moves; .modal-inner scrolls above it. */

.modal-backdrop {
  position: fixed;
  inset: 0;
  /* 160 — above the support chat (150): the search, the notifications and
     every other dialog open on top of an open chat, not under it (the owner,
     22.09.2026). Dropdowns inside a dialog sit at 170; toasts at 200 and the
     chat's own picture viewer at 210 stay above them all. public/js/modal.js
     stacks a second open dialog one above this base. */
  z-index: 160;
  background: rgb(0 0 0 / 70%);        /* brand --color-popup-bg */
  backdrop-filter: blur(3px);
  opacity: 0;
  visibility: hidden;
  /* Visibility WAITS for the fade on the way out and FLIPS AT ONCE on the way
     in. A transitioned `visibility` starts at its old value, `hidden`, and a
     hidden element cannot take focus — so the synchronous focus() in
     public/js/modal.js landed on nothing, in every dialog: the spotlight
     opened with the caret still on the page. With the switch instant on
     open, the first control is focusable the moment the class lands. */
  transition: opacity .18s ease, visibility 0s linear .18s;
}

.modal-backdrop.open { opacity: 1; visibility: visible; transition: opacity .18s ease, visibility 0s; }

.modal-inner {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overscroll-behavior: contain;        /* bounces itself, never the page */
  display: flex;
  padding: 20px;
}

.modal {
  /* `margin: auto` centres it and, unlike align-items:center, does not clip the
     top when the dialog is taller than the viewport. */
  margin: auto;
  flex-shrink: 0;                      /* never squashed — the overlay scrolls instead */
  width: 100%;
  max-width: 460px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  box-shadow: 0 24px 60px rgb(16 24 40 / 28%);
  transform: translateY(12px) scale(.98);
  transition: transform .2s cubic-bezier(.22, 1, .36, 1);
}

/* The page behind a dialog must not scroll. padding-right replaces the width
   the scrollbar occupied, otherwise the layout jumps sideways on open. */
html.is-modal-open { overflow: hidden; }
html.is-modal-open body { padding-right: var(--sbw, 0px); }

.modal-backdrop.open .modal { transform: none; }

.modal-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 20px 0;
}

.modal-head h3 {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  font-size: 17px;
  font-weight: 650;
  letter-spacing: -.01em;
}

.modal-x { margin-left: auto; flex: none; border: 0; }
.modal-body { padding: 14px 20px; }
.modal-body p { margin: 0; color: var(--muted); }
.modal-foot { display: flex; align-items: center; justify-content: flex-end; gap: 9px; padding: 6px 20px 20px; }
/* A verb that belongs to the dialog but not to its outcome — «delete the
   board» beside Cancel and Save — stands at the LEFT of the same row, so the
   row reads: the destructive thing, then the two ways out. */
.modal-foot-start { margin-right: auto; }

/* The optional starter-set action stays on the left and wraps before it
   could squeeze the ordinary Create/Cancel controls on a narrow dialog. */
#list-new .modal-foot { flex-wrap: wrap; }
#list-new .modal-foot-start { white-space: normal; text-align: left; }
@media (max-width: 640px) {
  #list-new .modal-foot-start { flex-basis: 100%; justify-content: flex-start; }
}

@media (max-width: 900px) {
  /* The visible rectangle follows the keyboard and Safari's viewport pan.
     Keep the backdrop full-screen; only its content changes size/position.
     Without VisualViewport, the same 20px inset starts the dialog at the top. */
  .modal-search .modal-inner {
    inset: var(--search-view-top, 0px) auto auto var(--search-view-left, 0px);
    width: var(--search-view-width, 100%);
    height: var(--search-view-height, 100%);
  }
  .modal-search .modal {
    margin: var(--search-margin-top, 0px) auto auto;
    display: flex;
    flex-direction: column;
    max-height: 100%;
  }
  .modal-search .modal-head,
  .modal-search .modal-foot { flex: none; }
  .modal-search .modal-body { display: flex; min-height: 0; }
  .modal-search .gs-box {
    display: flex;
    flex-direction: column;
    width: 100%;
    min-height: 0;
  }
  .modal-search .gs-box > .field { flex: none; }
  .modal-search #gs-results { min-height: 0; max-height: none; }
}

@media (prefers-reduced-motion: reduce) {
  .modal-backdrop, .modal { transition: none; }
  .modal { transform: none; }
}

/* THE LANGUAGE CHOOSER (views/ai-translate — the editor's «Перекласти на…»):
   a vertical stack of ordinary ghost buttons, one per language, the flag
   before the native name as the reference drew it. Ghost buttons rather than a
   shape of their own so the busy sweep (§4.4) lands on the one that was
   pressed for free; the dialog itself freezes with `.modal-backdrop.is-busy`. */
.lang-choice { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.lang-choice-btn { justify-content: flex-start; width: 100%; padding: 12px 16px; }
.lang-choice-flag { font-size: 22px; line-height: 1; }

/* THE RIGHT DRAWER — a modal pinned to the trailing edge, full height, sliding
   in from the side instead of rising from the middle. It reuses the whole
   modal engine (backdrop, focus, Escape, scroll-lock, z-stack); only its
   geometry differs, so these few rules are all it costs. The bell opens it. */
.modal-drawer .modal-inner { padding: 0; overflow: hidden; display: block; }
.modal-drawer .modal {
  margin: 0 0 0 auto;                 /* pinned right, not centred */
  width: 100%;
  max-width: 400px;
  height: 100%;
  border-radius: 0;
  border-width: 0 0 0 1px;
  display: flex;
  flex-direction: column;
  transform: translateX(24px);        /* enters from the edge, not from below */
}
.modal-drawer .modal-head { padding: 18px 20px; border-bottom: 1px solid var(--line); flex: none; }
.modal-drawer .modal-body { flex: 1 1 auto; min-height: 0; padding: 0; display: flex; }

@media (max-width: 900px) {
  /* Slide the opaque panel all the way from the right, like the menu from
     the left. Fade only the scrim: fading/blurring the whole overlay also
     fades the moving panel and repaints the page underneath on mobile. */
  .modal-drawer {
    background: transparent;
    backdrop-filter: none;
    opacity: 1;
    transition: visibility 0s linear .25s;
  }
  .modal-drawer.open { transition: visibility 0s; }
  .modal-drawer::before {
    content: '';
    position: absolute;
    inset: 0;
    background: rgb(0 0 0 / 45%);
    opacity: 0;
    transition: opacity .25s cubic-bezier(.22, 1, .36, 1);
  }
  .modal-drawer.open::before { opacity: 1; }
  .modal-drawer .modal {
    transform: translateX(100%);
    transition: transform .25s cubic-bezier(.22, 1, .36, 1);
  }
  .modal-drawer.open .modal { transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
  .modal-drawer,
  .modal-drawer::before,
  .modal-drawer .modal { transition: none; }
}

/* The feed fills the drawer and scrolls inside it; the acts stay pinned atop. */
.notify-panel { display: flex; flex-direction: column; width: 100%; min-height: 0; }
.notify-panel-acts { display: flex; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); flex: none; }
.notify-panel .notify-feed { flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
.notify-empty { margin: 0; padding: 40px 20px; text-align: center; color: var(--muted); }

/* A row in the drawer is the page's row plus a tick: the sentence and its icon
   become one clickable face, and the unread dot sits at the trailing edge. */
.notify-panel .notify-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.notify-face {
  display: grid;
  grid-template-columns: 34px minmax(0, 1fr);
  gap: 12px;
  align-items: start;
  min-width: 0;
  text-decoration: none;
  color: inherit;
}
.notify-sentence { font-weight: 600; color: var(--fg); overflow-wrap: anywhere; }
a.notify-face:hover .notify-sentence { color: var(--accent); }
.notify-mark { display: flex; align-items: center; }
.notify-dot {
  width: 12px;
  height: 12px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  cursor: pointer;
}
.notify-dot:hover { filter: brightness(1.12); }

@media (max-width: 460px) {
  /* On a phone the drawer is the whole width — a 400px panel would leave a
     sliver of dimmed page nobody uses. */
  .modal-drawer .modal { max-width: none; }
}

/* White text on red, per request. The brand red (#FF1E37) gives white only
   3.0–3.8:1, which reads as muddy, so the gradient is a shade deeper — same
   colour family, but white now clears 4.8–7.1:1 and looks crisp in both themes. */
.btn-danger {
  color: #ffffff;
  background: linear-gradient(115deg, #d92b45, #b3081d);
}

.btn-danger:hover { filter: brightness(1.1); }

/* Always white ink on the red gradient, in both themes — so is the sweep. */
.btn-danger { --btn-sweep: rgb(255 255 255 / 40%); }

/* ----------------------------------------------------------- code boxes */

/* Six boxes rather than one field: they mirror the spaced code in the letter,
   and a mistyped digit is obvious at a glance. */
/* SIX BOXES ARE SIX BOXES, not a sixth of whatever is around them.
   `repeat(6, 1fr)` means «divide the whole width by six», which was right while
   this only ever stood in the 420px sign-in card and wrong the moment it also
   stood in a 560px one: the same six digits became finger-wide slabs.
   `minmax(0, 56px)` gives them a real size with permission to shrink on a narrow
   phone, and the row keeps its own width instead of taking the container's.

   THE ROW BEHAVES LIKE ONE FIELD, the reference's way (localgrid's sign-in,
   the owner's word 16.09.2026): no blinking caret — a one-character box does
   not need one, the focus ring already says where you are, and six carets
   fighting for attention is noise; nothing ever selected — with the caret
   hidden a selection would read as a glitch. The states are the script's
   (public/js/auth-code.js): `is-filled` reads as done so the eye can count
   what is left; `is-wrong` on EVERY box, because we never learn which digit
   was wrong, only that the code was; `is-arrived` flashes a pasted or
   autofilled code left to right, staggered, like something arriving rather
   than a block switching state. */
.code-boxes {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 56px));
  gap: 12px;
  justify-content: start;
}

/* The sign-in card is centred text throughout, so the row centres with it. */
.auth-card .code-boxes,
.modal .code-boxes { justify-content: center; }

/* The instruction above the boxes — the reference's `p.caption.middle`, which
   is what its popup leads with (`change-cred-confirmation.twig`). */
.code-caption { margin: 0 0 2px; font-weight: 600; text-align: center; }

.code-box {
  width: 100%;
  height: 64px;
  padding: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 24px;
  font-weight: 650;
  line-height: 1;
  text-align: center;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 16px;
  caret-color: transparent;
  transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease;
}
.code-box::selection { background: transparent; }

.code-box.is-filled { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); }

.code-box:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 16%, transparent);
}

.code-box.is-wrong { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger); }
.code-box.is-wrong:focus { border-color: var(--danger); box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent); }

.code-box.is-arrived { animation: code-arrive .8s ease-out both; }
.code-box.is-arrived:nth-child(2) { animation-delay: .045s; }
.code-box.is-arrived:nth-child(3) { animation-delay: .09s; }
.code-box.is-arrived:nth-child(4) { animation-delay: .135s; }
.code-box.is-arrived:nth-child(5) { animation-delay: .18s; }
.code-box.is-arrived:nth-child(6) { animation-delay: .225s; }
@keyframes code-arrive {
  0% { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); background: color-mix(in srgb, var(--accent) 10%, var(--bg)); }
  100% { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); box-shadow: 0 0 0 0 transparent; background: var(--bg); }
}
@media (prefers-reduced-motion: reduce) {
  .code-box { transition: none; }
  .code-box.is-arrived { animation: none; border-color: var(--accent); }
}

/* Why a code was refused — above the row, so the digits stay where the eye
   already is. Hidden until the script has something to say. */
.code-error { margin: 0 0 14px; font-size: 14px; line-height: 1.5; text-align: center; color: var(--danger); }
.code-error[hidden] { display: none; }

/* The code is being checked in place: the row and the button hold still. */
form[data-code-form].is-busy .code-box, form[data-code-form].is-busy .btn { opacity: .6; pointer-events: none; }

@media (max-width: 420px) {
  .code-boxes { gap: 8px; grid-template-columns: repeat(6, minmax(0, 44px)); }
  .code-box { height: 56px; font-size: 20px; }
}

.auth-lead { margin: 0 0 20px; font-size: 14.5px; line-height: 1.55; color: var(--muted); }
/* RENDERED RICH TEXT, wherever the administrator's own words land on a page.
   The allowlist behind it is short (lib/rich-text), so this is short too:
   paragraphs, lists, links. `.auth-note` below is the sign-in screen's own
   version — centred and 13px because it sits under a form on a narrow card;
   this one reads as body text inside one. */
.prose { display: grid; gap: 10px; font-size: 14px; line-height: 1.6; }
.prose p, .prose ul, .prose ol { margin: 0; }
.prose ul, .prose ol { padding-left: 20px; }
.prose li + li { margin-top: 4px; }
.prose a { color: var(--accent); }

.auth-note { margin: 16px 0 0; font-size: 13px; text-align: center; color: var(--muted); }

/* The administrator's version arrives as a small block of real markup, so the
   tags it may contain need to look like the rest of the screen rather than like
   a browser default dropped into a card. */
.auth-note p { margin: 0 0 6px; }
.auth-note p:last-child { margin-bottom: 0; }
.auth-note ul, .auth-note ol { margin: 6px 0; padding-left: 20px; text-align: left; }
.auth-note a { color: var(--accent); }
/* A pasted URL is one long word, and one long word in a fixed-width card pushes
   the card open. The note is the only place on this screen that holds text
   somebody else typed, so it is the only place that needs this. */
.auth-note { overflow-wrap: anywhere; }

/* A button that has to read as a link, because it sits in a row of them: the
   action posts (it mails a code), so it cannot be an <a>. */
.inline-form { display: inline; }

.linkish {
  padding: 0;
  font: inherit;
  color: var(--accent);
  background: none;
  border: 0;
  cursor: pointer;
  text-decoration: none;
}

.linkish:hover { text-decoration: underline; }


/* ---------------------------------------------------------- sub-tabs */
/* The second level inside a settings panel — a quieter strip than the page
   tabs above it, so the hierarchy reads at a glance. */

/* inline-flex, not flex: the strip hugs its buttons instead of stretching to
   the column. A pill that runs the full width of a wide settings page reads as
   a container, not as a switch. `justify-self: start` because the parent form
   is a grid, where an inline box would still be stretched by the default
   `stretch` alignment. */
.subtabs {
  display: inline-flex;
  justify-self: start;
  max-width: 100%;
  flex-wrap: wrap;
  gap: 4px;
  padding: 4px;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.subtab {
  padding: 7px 13px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.subtab:hover { color: var(--fg); }

/* Not set up yet — faded, but still perfectly clickable. */
.subtab.is-muted { opacity: .55; }

.subtab.is-active {
  color: var(--accent-fg);
  background: var(--accent);
  opacity: 1;
}

.subtab-panel { display: grid; gap: 18px; }
.subtab-panel[hidden] { display: none; }

/* A form with a price table needs the whole column, not the 560px a field
   column reads best at. */
.form-wide { max-width: none; }

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

/* ----------------------------------------------------------- prices */
/* Two ordinary fields per model, in and out. NO inner scroll: the page scrolls,
   as it does everywhere else — a scroll box inside a form hides half the fields
   and traps the wheel. */

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

.prices h3 {
  margin: 8px 0 0;
  font-size: 15px;
  font-weight: 600;
}

/* ------------------------------------------------------------- chat */

/* The AI test conversation — the one place a scroll box IS right, and the
   reference agrees: a thread that grows with every turn would otherwise push
   the dialog taller without end. Same bounds as the reference (300px / 48vh). */
.chat {
  display: grid;
  gap: 8px;
  align-content: start;
  min-height: 300px;
  max-height: 48vh;
  padding: 12px;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.bubble {
  max-width: 85%;
  padding: 9px 12px;
  font-size: 14px;
  line-height: 1.5;
  border-radius: 12px;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.bubble-user {
  justify-self: end;
  color: var(--accent-fg);
  background: var(--accent);
}

.bubble-assistant {
  justify-self: start;
  color: var(--fg);
  background: var(--card);
  border: 1px solid var(--line);
}

.bubble-error {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 40%, transparent);
  background: color-mix(in srgb, var(--danger) 8%, transparent);
}

/* --------------------------------------------------------- recorder */

.recorder { display: flex; align-items: center; gap: 12px; }
.recorder-state { font-size: 13px; color: var(--muted); }

/* Pulses while recording — the one place in the app where a control is
   actively capturing, and it should be impossible to forget it is on. */
.btn.is-recording {
  color: #ffffff;
  background: linear-gradient(115deg, #d92b45, #b3081d);
  border-color: transparent;
  animation: recording-pulse 1.4s ease-in-out infinite;
}

@keyframes recording-pulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, #d92b45 55%, transparent); }
  50% { box-shadow: 0 0 0 7px color-mix(in srgb, #d92b45 0%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .btn.is-recording { animation: none; }
}

audio { width: 100%; }

@media (max-width: 620px) {
  .card-head { flex-direction: column; }
  .head-actions { flex-wrap: wrap; }
}

/* ------------------------------------------------------------- captcha */

/* The widget is one of three stacked controls — field, captcha, button — so it
   spans the same width as the other two rather than sitting at Turnstile's
   default 300px: the odd one out is the one the eye stops at.
   `size: 'flexible'` (passed where the widget is rendered, in public/js/app.js)
   is Cloudflare's own option for that; the alternative would be overriding the
   inline width their script writes onto its iframe, which means fighting
   !important forever.
   The height is reserved up front so the button does not jump while the iframe
   is still loading. */
.captcha { display: block; min-height: 65px; }

.captcha iframe { width: 100%; max-width: 100%; }

/* ---------------------------------------------------------- busy forms */
/* PROJECT RULE (see also public/js/app.js): from the moment a form submits
   until the answer lands it is frozen and says so.

   Two halves:
   1. The whole form stops taking input — pointer-events, plus a JS guard that
      swallows a second Enter. Without it a slow save invites a second click and
      a second request.
   2. Its buttons run a light sweep across their own background. Deliberately
      NOT a spinner glued to the side of the label: that changes the button's
      width mid-request and makes the form jump. This paints inside the existing
      box, so the geometry is untouched.

   `.htmx-request` is htmx's own marker, kept as a second selector so a request
   fired by something other than a submit event (an hx-post on a button) is
   covered too. */

/* The freeze covers the whole form… */
form.is-busy,
form.htmx-request { pointer-events: none; }

/* …while both marks of "working" land on ONE button — the one that started the
   request. Animating its neighbours (a "Cancel" that is doing nothing) would
   say something untrue about what the app is busy with.
   `.htmx-request` is htmx's own marker, which it puts on the element that
   issued the request — so an hx-post on a lone button is covered too.

   Two marks, because they say different things: the sweep says "something is
   happening", the fade to .75 says "and it is not yours to press". Neither
   touches geometry — opacity does not reflow, the sweep is an inset overlay. */
.btn.is-loading,
.btn.htmx-request {
  opacity: .75;
  transition: opacity .15s ease;
}

.btn.is-loading::after,
.btn.htmx-request::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, transparent 28%, var(--btn-sweep) 50%, transparent 72%);
  animation: btn-sweep 1.05s linear infinite;
}

@keyframes btn-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(100%); }
}

/* Reduced motion: keep the signal, drop the travel — a steady wash over the
   button says "working" without anything sliding. */
@media (prefers-reduced-motion: reduce) {
  .btn.is-loading::after,
  .btn.htmx-request::after {
    background: var(--btn-sweep);
    animation: none;
    opacity: .55;
  }
}

/* ------------------------------------------------------------- toasts */
/* Design ported from Turbo General: a tinted gradient card with a matching
   border and dark ink, NO icons, a small round X drawn in CSS, and a 3px
   progress line along the bottom for the toast's lifetime.

   One deliberate difference: the stack is TOP-right, not bottom-right. The
   reference has a header up there and had to dodge it; we have no header on
   desktop, so the corner is free. */

.toasts {
  position: fixed;
  right: 16px;
  top: 16px;
  z-index: 200;
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: min(380px, calc(100vw - 32px));
  pointer-events: none;             /* clicks pass through the empty stack */
}

.toast {
  --toast-duration: 8s;
  position: relative;
  overflow: hidden;
  width: 100%;
  padding: 14px 52px 15px 18px;
  font-size: 14px;
  line-height: 1.35;
  color: var(--notify-text);
  background: var(--notify-bg);
  border: 1px solid var(--notify-border);
  border-radius: 8px;
  box-shadow: 0 18px 46px var(--notify-shadow), inset 0 1px 0 rgb(255 255 255 / 24%);
  pointer-events: auto;
  animation: notify-in .34s cubic-bezier(.16, 1, .3, 1) both;
}

.toast-success {
  --notify-bg: var(--toast-success-bg);
  --notify-border: var(--toast-success-border);
  --notify-text: var(--toast-success-text);
  --notify-accent: var(--toast-success-accent);
  --notify-shadow: var(--toast-success-shadow);
}

.toast-warning {
  --notify-bg: var(--toast-warning-bg);
  --notify-border: var(--toast-warning-border);
  --notify-text: var(--toast-warning-text);
  --notify-accent: var(--toast-warning-accent);
  --notify-shadow: var(--toast-warning-shadow);
}

.toast-error {
  --notify-bg: var(--toast-error-bg);
  --notify-border: var(--toast-error-border);
  --notify-text: var(--toast-error-text);
  --notify-accent: var(--toast-error-accent);
  --notify-shadow: var(--toast-error-shadow);
}

.toast-text {
  position: relative;
  z-index: 1;
  margin: 0;
  color: inherit;
  font-size: 14px;
  line-height: 1.35;
  overflow-wrap: anywhere;
}

/* Round close button; the X is two rotated bars that swing on hover. */
.toast-close {
  position: absolute;
  top: 50%;
  right: 14px;
  z-index: 2;
  width: 24px;
  height: 24px;
  margin-top: -12px;
  padding: 0;
  color: inherit;
  background: color-mix(in srgb, var(--notify-accent) 14%, transparent);
  border: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--notify-accent) 20%, transparent);
  cursor: pointer;
  transition: background-color .2s ease, box-shadow .2s ease, transform .2s ease;
}

.toast-close::before,
.toast-close::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 10px;
  height: 2px;
  margin: -1px 0 0 -5px;
  background: currentColor;
  opacity: .82;
  transition: transform .2s ease;
}

.toast-close::before { transform: rotate(45deg); }
.toast-close::after { transform: rotate(-45deg); }

.toast-close:hover {
  background: color-mix(in srgb, var(--notify-accent) 24%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--notify-accent) 34%, transparent);
  transform: scale(1.06);
}

.toast-close:hover::before { transform: rotate(135deg); }
.toast-close:hover::after { transform: rotate(45deg); }

/* The lifetime bar. It is also the timer: public/js/toast.js closes the toast
   when THIS animation ends, so hovering pauses the countdown along with it. */
.toast::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  z-index: 1;
  width: 0;
  height: 3px;
  background: var(--notify-accent);
  opacity: .28;
  animation: notify-progress var(--toast-duration) linear forwards;
}

.toast:hover::after,
.toast:focus-within::after { animation-play-state: paused; }

/* A toast opened with { timeout: 0 } stays until it is closed by hand. */
.toast.is-persistent::after { animation: none; }

.toast.is-leaving { animation: notify-out .24s ease-in forwards; }

@keyframes notify-progress {
  from { width: 0; }
  to { width: 100%; }
}

/* Same motion as the reference, mirrored vertically: a top-anchored stack
   should slide in from above-right, not from below. */
@keyframes notify-in {
  from { opacity: 0; transform: translate3d(18px, -6px, 0) scale(.98); }
  to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}

@keyframes notify-out {
  from { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
  to { opacity: 0; transform: translate3d(16px, -4px, 0) scale(.98); }
}

@media (prefers-reduced-motion: reduce) {
  /* The progress bar stays — it carries information and drives the close —
     but the entrance and exit movement goes. */
  .toast { animation: none; }
  .toast.is-leaving { animation: none; opacity: 0; }
  .toast-close, .toast-close::before, .toast-close::after { transition: none; }
}

/* ------------------------------------------------- horizontal sub-tabs */
/* Title, description, then the tab strip — the strip's hairline runs the full
   width and the active tab sits on top of it. No left accent border anywhere. */

/* SETTINGS HAS TWO LEVELS: a rail of groups down the left, the tab strip of the
   active group across the top of the right-hand column. Only Settings — Profile
   and Logs have few enough tabs that one strip still reads at a glance.

   `minmax(0, 1fr)`, not `1fr`. A grid track's default minimum is `auto`, which
   means "as wide as the widest thing inside" — and inside is the AI tab, a table
   of 102 models. With a plain `1fr` that table sets the column width, the layout
   grows past the viewport and the whole page scrolls sideways. The same reason
   `min-width: 0` is on the column itself. */
.settings-layout {
  display: grid;
  grid-template-columns: 208px minmax(0, 1fr);
  gap: 30px;
  align-items: start;
}

.settings-main { min-width: 0; }

/* Sticky, because the panel beside it can be very long and the rail is how you
   leave. `align-items: start` above is what gives it room to stick against. */
.rail {
  display: flex;
  flex-direction: column;
  gap: 2px;
  position: sticky;
  top: 22px;
}

/* Deliberately the sidebar's own menu item: same size, same gap, same radius.
   A second navigation style would say these are a different kind of thing, and
   they are not — both are lists of places, three metres apart on the screen. */
.rail-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  font-size: 14.5px;
  font-weight: 500;
  color: var(--muted);
  text-decoration: none;
  border-radius: 9px;
}

.rail-item:hover { color: var(--fg); background: var(--card); }
.rail-item .icon { flex: none; opacity: .9; }

.rail-item.is-active {
  color: var(--fg);
  font-weight: 600;
  background: var(--card);
}

/* The rail's answer to the strip's underline, turned through ninety degrees
   with it. Drawn as a pseudo-element rather than a border so it cannot move the
   text by a pixel when a tab becomes active. */
.rail-item.is-active::before {
  content: '';
  position: absolute;
  left: 0;
  top: 7px;
  bottom: 7px;
  width: 2px;
  background: var(--accent);
  border-radius: 2px;
}

/* Below this width 208px + 30px + the panel no longer fit, so the rail lies
   down and becomes what it replaced: a strip that scrolls sideways. Two strips
   stacked is still two decisions, and each is shorter than the twenty-one-tab
   row they came from. */
@media (max-width: 900px) {
  .settings-layout {
    grid-template-columns: minmax(0, 1fr);
    gap: 18px;
  }

  .rail {
    position: static;
    flex-direction: row;
    gap: 6px;
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }

  .rail::-webkit-scrollbar { display: none; }
  .rail-item { flex: none; }

  /* The accent moves to the bottom edge with the axis — a bar down the left of
     a horizontal item reads as a divider, not as "you are here". */
  .rail-item.is-active::before {
    left: 10px;
    right: 10px;
    top: auto;
    bottom: 2px;
    width: auto;
    height: 2px;
  }
}

.tabs {
  position: relative;
  display: flex;
  gap: 26px;
  margin: 0 0 26px;
  /* SIDEWAYS ONLY — and `overflow-x: auto` alone does not say that. The spec
     resolves the other axis from `visible` to `auto` the moment one axis is set,
     so the strip quietly became a vertical scroller too; the marker below sticks
     1px past the padding edge, which is exactly enough overflow to grab. A wheel
     over the tabs nudged them up and down, and on a phone a finger could drag
     the whole strip out of line. Naming the axis is the fix. */
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: none;                 /* the strip scrolls, quietly */
  /* The hairline is an inset shadow rather than a border, because `overflow-y:
     hidden` clips at the PADDING edge: with a 1px bottom border the 2px marker
     would be sliced in half by its own container. No border means the two boxes
     coincide and the marker stays whole. The shadow paints on the box, not the
     content, so it stays put while the tabs scroll under it. */
  box-shadow: inset 0 -1px 0 var(--line);
}

.tabs::-webkit-scrollbar { display: none; }

/* Icon, then word — the same pairing as `.nav-item` in the sidebar, and the
   same mechanism, because it is the one that centres exactly.

   FLEX IS WHAT MAKES THE ICON SIT TRUE. An inline SVG rides the TEXT BASELINE,
   so its box reserves descender room underneath and the glyph floats above the
   middle of the row — the same trap `.icon-btn svg { display: block }` was
   written for further down. Here it is solved for free: a flex item's display
   is blockified, so the SVG loses its baseline the moment this container
   becomes flex, and `align-items: center` then centres a plain 16×16 box
   against the label's line box. Half-leading is split equally above and below
   the em box, so that centre is the word's centre whatever the line-height. */
.tab {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 0 0 12px;
  font-size: 15px;
  font-weight: 500;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  border-bottom: 2px solid transparent;  /* sits over the strip's hairline */
  transition: color .15s ease;
}

/* Never squeezed when the strip runs out of room — the strip scrolls instead,
   and a half-width icon would be worse than a scrollbar. */
.tab .icon { flex: none; }

/* No colour of its own: `stroke="currentColor"` means the icon dims and lights
   up with its label, including on hover and when the tab becomes active. */
.tab:hover { color: var(--fg); }
.tab.is-active { color: var(--fg); font-weight: 600; }

/* Without JS every tab draws its own underline; with JS one shared underline
   glides between them instead (see public/js/app.js). */
.tabs:not(.has-indicator) .tab.is-active { border-bottom-color: var(--accent); }

.tab-underline {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 0;
  height: 2px;
  background: var(--accent);
  border-radius: 2px;
  display: none;
  /* Hand the marker to the View Transitions API as a shared element: the
     browser then morphs it between its old and new geometry, in step with the
     panel crossfade. Without this the CSS transition below starts on click and
     is frozen mid-flight the moment the transition snapshots the page — which
     is exactly the "broken glide". The transition stays as the fallback for
     browsers (and users) without view transitions. */
  view-transition-name: tab-underline;
  transition:
    transform .3s cubic-bezier(.22, 1, .36, 1),
    width .3s cubic-bezier(.22, 1, .36, 1);
}

/* Match the page crossfade so the marker and the panel move as one gesture. */
::view-transition-group(tab-underline) {
  animation-duration: 260ms;
  animation-timing-function: cubic-bezier(.22, 1, .36, 1);
}

.tabs.has-indicator .tab-underline { display: block; }

@media (prefers-reduced-motion: reduce) {
  .tab-underline { transition: none; }
}

/* Placeholder panel — an intentional empty state rather than a bare line. */
/* One readable column, not the whole monitor.
 *
 * The cap is on the PANEL — the block under the tab strip — and on nothing else:
 * the heading and the strip itself keep the full width of the page.
 *
 * Without it a card stretched to whatever the screen happened to be. On a 27"
 * display that is a metre of empty card with its button lost in the far corner,
 * and lines of text too long for the eye to find its way back. 960px is about a
 * hundred characters at this size.
 *
 * The cap belongs here and not on `.card`, which already declared 560px: the
 * rule right below overrides that inside a panel, and the more specific selector
 * wins regardless of order. Capping the container instead makes the width
 * independent of which classes the block inside happens to carry. Anything
 * genuinely wider (a log table) scrolls inside its own .table-wrap. */
.tab-panel,
.page-measure { max-width: 960px; }
.tab-panel .card,
.page-measure .card { max-width: none; }

/* A PAGE WITHOUT TABS NEEDS THE SAME MEASURE. The client's own sections — Sites,
   Domains, Mailboxes — have no tab strip, so they had no cap and ran the whole
   width of a wide display while every panel in Profile and Settings stopped at
   960px. Same list, same card, two different widths depending on which door you
   came through. `.page-measure` is that cap for them, written into the rule
   above rather than beside it so the number cannot drift into two numbers. */

/* …and the opt-out, for the one thing the cap is wrong about: a WIDE TABLE.
 *
 * 960px is a reading measure — it exists so a line of text and a card do not
 * stretch across a 27" display. A table is not prose: its width is decided by
 * how many columns it has, and squeezing eight of them into a reading measure
 * does not make it readable, it just moves the reading into a horizontal
 * scrollbar (`.table-wrap`). Then half the screen is empty beside a table you
 * have to drag.
 *
 * A modifier rather than a rule aimed at one page, and NEVER by lifting the cap
 * itself: `.tab-panel` holds every form in the app, so deleting that line would
 * un-cap all of them at once, invisibly and everywhere. Opting in is one class
 * on the panel that knows it is a table. */
.tab-panel-wide { max-width: none; }

/* Settings opts out wholesale, and by position rather than by a modifier on
   twenty panel files. The right-hand column of that page is ALREADY bounded —
   the rail takes 208px off the left — so capping what is left at a reading
   measure a second time left a strip of nothing down the right of every tab.
   One rule here covers the twenty-one tabs that exist and the next one too;
   twenty-one `tab-panel-wide` classes would have to be remembered each time. */
[data-settings-layout] .tab-panel { max-width: none; }

/* Two cards on one tab need air between them. `.card` deliberately carries no
   margin of its own — it is placed by whatever holds it — so until now a second
   card sat flush against the first and the only thing dividing them was a
   hairline and a shadow. Scoped to consecutive siblings, so a panel with one
   card is untouched, and written here rather than on the one tab that has two:
   the next panel to grow a second card gets it for free. */
.tab-panel > .card + .card,
.page-measure > .card + .card { margin-top: 18px; }

/* THE SAME AIR WHEN ONE FORM SAVES SEVERAL CARDS AT ONCE (Портал →
   Користувач, Портал → Оплата): there the cards are the FORM's children, not
   the panel's, so the sibling rule above never reached them — four cards sat
   flush with the Save button glued under the last one (19.09.2026). A form
   that holds cards is a stack with the panel's gap, and the actions row takes
   the same step. `.page-measure` joined the rule above for the same reason
   from the other side: a client page that stacks cards (the Telegram bots)
   had no rule of its own. */
.tab-panel > form:has(> .card),
.page-measure > form:has(> .card) { display: grid; gap: 18px; }

/* The one thing an empty state asks for, right under the sentence that asks
   it — an invitation and its button belong within one glance. */
.empty-action { margin-top: 18px; }

/* Empty states share the 960px reading measure, including full-width list
   pages such as Materials and Companies. Keep the same cap whether or not a
   caller already wraps the card in .page-measure; narrow screens fill their
   available space. .card.empty wins over the later .card default (560px). */
.card.empty { max-width: 960px; }

.empty {
  display: grid;
  justify-items: center;
  align-content: center;
  text-align: center;
  gap: 6px;
  min-height: 320px;
  padding: 56px 28px;
}

.empty-icon {
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  margin-bottom: 6px;
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-radius: 14px;
}

.empty h2 { margin: 0; }
.empty-hint { margin: 0; max-width: 44ch; color: var(--muted); }

.empty-note {
  margin: 8px 0 0;
  padding: 4px 10px;
  font-size: 12.5px;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
}

/* ---------------------------------------------------------- SMTP profiles */

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

.card-head h2 { margin: 0 0 4px; }
.card-head .muted { margin: 0; font-size: 13.5px; }
.card-head .btn { flex: none; }

/* Deployment facts already have a page title. Keep metadata and actions on
   one row when they fit; narrow cards wrap groups without stacking each verb. */
.deployment-head { flex-direction: row; align-items: center; flex-wrap: wrap; }
.deployment-head > .field-hint { flex: 1 1 240px; min-width: 0; overflow-wrap: anywhere; }
.deployment-head > .head-actions { margin-left: auto; max-width: 100%; }
.deployment-head .site-link { max-width: 100%; }


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

.smtp-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 11px 13px;
  border: 1px solid var(--line);
  border-radius: 11px;
}

.smtp-row.is-active { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }

.smtp-main { display: grid; gap: 3px; min-width: 0; }

.smtp-name {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 14.5px;
  font-weight: 600;
}

.smtp-meta {
  font-size: 12.5px;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.smtp-acts { display: flex; align-items: center; gap: 6px; flex: none; }

/* A chip is an inline-flex row, not an inline run of text: a glyph inside it
   («✓ Використовується» on the avatar card) then sits on the chip's centre
   line instead of the text's baseline, with a fixed 4px before the word. The
   task label (`.task-chip`) had already fixed this for itself alone — the
   sign it belonged on the base (the owner, 16.09.2026). `vertical-align:
   middle` keeps the chip level with the words beside it in a sentence, which
   an inline-flex box would otherwise sit a hair low on. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  vertical-align: middle;
  padding: 2px 8px;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1.4;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
}
.chip > .icon { flex: none; }

.chip-accent {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}

.btn-sm { padding: 6px 11px; font-size: 13px; }

/* Two fields on one line inside a dialog. */
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field-row .field-narrow { max-width: 120px; }

/* A CONTROL THAT IS SHORTER THAN THE ONE BESIDE IT sits on the same floor, not
   stretched to match it. A `.field` is a grid, and a grid item stretches by
   default — so a button group next to an input grew into a row of tall slabs.
   Only the pairing needs this, so the rule stays inside `.field-row`. */
.field-row > .field > .btn-group { align-self: end; }

/* …AND IT DOES NOT CLAIM HALF THE ROW EITHER. Two equal columns are right for
   two fields of the same kind — a port beside a host — and wrong the moment one
   of them is a button group: three buttons are as wide as their words, so the
   other half of their column is a hole, and the field beside them is squeezed
   to 50% for no reason a reader can see. The condition is the group itself, not
   a class somebody has to remember to add. */
.field-row:has(> .field > .btn-group) { grid-template-columns: 1fr auto; }

.check { display: flex; align-items: center; gap: 9px; font-size: 14px; cursor: pointer; }
.check input { width: 16px; height: 16px; accent-color: var(--accent); }

@media (max-width: 620px) {
  .smtp-row { flex-direction: column; align-items: stretch; }
  .smtp-acts { flex-wrap: wrap; }
  .field-row { grid-template-columns: 1fr; }
  .field-row .field-narrow { max-width: none; }
}

/* Dialogs that carry a whole form need more room than a confirmation. */
.modal-body .form-card { max-width: none; gap: 14px; }
.modal-backdrop:has(.form-card) .modal { max-width: 520px; }

/* TEN FIELDS ASK FOR MORE ROOM THAN A QUESTION DOES. The default dialog is
   460px because most of them hold a sentence and two buttons; this one holds a
   whole form, and at that width every label sat on its own line above a box
   that looked narrow for an IBAN. 560px is not a new number — it is the ceiling
   `.form-card` already uses for a form on a page, so a form is the same width
   wherever it is asked.
   Sized by `:has()` like the three dialogs above and below it, rather than by a
   parameter on the component: what a dialog needs follows from what is IN it. */
/* A slider with its number beside it (Безпека → спроби коду). The track is
   the browser's, tinted by accent-color; the readout is a real <output>. */
.range-row { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; }
.range-row input[type="range"] { width: 100%; margin: 0; accent-color: var(--accent); }
.range-out { min-width: 2.5ch; text-align: right; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }

.company-form { display: grid; gap: 18px; }
/* Grid items size their track by their content's intrinsic width unless told
   otherwise — and the payer picker's one-line value is a sentence long. */
.company-form > * { min-width: 0; }

/* The line above the switcher names it, so the two are one block at the 8px
   step — «заголовок і те, що під ним». Everything else in the dialog is a
   separate block and keeps the 18. Before this the body was a plain stack: the
   sentence, the pill and the first label all sat on top of each other with
   nothing between them, and the eye read them as one lump. */
/* The picker is a field of the form now (a select over eighteen forms), so it
   takes the row's width like the fields under it — `justify-items: start` was
   for the two-pill switch it replaced. */
.company-kind { display: grid; gap: 8px; }

/* --------------------------------------------------- files and documents */

/* The same two blocks the company dialog has, and deliberately not shared with
   it: `.company-form` is a stack of ten fields at 560px, this one is three at
   520. Both are «a dialog with a form in it», which is not enough of a thing to
   name — what they actually have in common is `gap: 18px`, and that is the page
   rhythm, not a component. */
.file-form { display: grid; gap: 18px; }
.file-kind { display: grid; justify-items: start; gap: 8px; }

/* FOURTEEN PICTURES, OPEN, instead of a closed list of fourteen words. An icon
   is chosen by recognising it, and a select hides thirteen of them behind a
   click. They wrap on their own, so a narrow phone gets more rows rather than a
   horizontal scroll.

   The same radio-behind-a-span trick `.btn-opt` uses — the input is what the
   form posts and what the keyboard drives, the span is what is drawn, and
   `:checked + span` is the whole state machine. */
.icon-pick { display: flex; flex-wrap: wrap; gap: 6px; }
.icon-opt { display: inline-flex; }
.icon-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.icon-opt > span {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--line);
  border-radius: 10px;
  color: var(--muted);
  cursor: pointer;
  transition: color .12s ease, background .12s ease, border-color .12s ease;
}
.icon-opt > span:hover { color: var(--fg); }
.icon-opt input:checked + span {
  color: var(--accent-fg);
  background: var(--accent);
  border-color: var(--accent);
}
/* The outline goes on the span, because the input it belongs to is a zero-sized
   box in the corner — the same reason `.btn-opt` does it. */
.icon-opt input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The row's own icon: muted, so the NAME is what the eye lands on. */
.file-ico { display: inline-flex; color: var(--muted); }

/* «PDF · 847 КБ», or the host of a link. Pushed to the far end of the line,
   where the eye can run down it as a column — and allowed to keep its own line
   rather than wrap, because «847 КБ» split across two lines is not a size any
   more. */
/* The far end of the line: what the row is, and when it arrived. A wrapper
   rather than `margin-left: auto` on each — two auto margins in a flex line
   SPLIT the free space between them, which would leave the size stranded in the
   middle of the row instead of beside the date. */
.file-tail {
  margin-left: auto;
  display: inline-flex;
  align-items: baseline;
  gap: 10px;
}
.file-meta {
  color: var(--muted);
  font-size: 13px;
  white-space: nowrap;
}

/* WHEN IT WAS ADDED. Its own element and not part of the line above, because
   it is what the list is sorted by out of the box — and a column the eye can
   run down is the only reason a date beats a sentence. Tabular figures so the
   digits stand under each other. */
.file-date {
  color: var(--muted);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The bar over the list: what is shown on the left, how it is narrowed and
   arranged on the right. Same two boxes as the log's, so a person who has
   filtered one table has filtered this one. */
.file-bar { margin-bottom: 14px; }

/* Everything that acts on the list, in one group. The bar spreads its children
   with `space-between`; three of them left the selects floating in the middle
   of the row with the switcher alone at the far edge — three unrelated things
   where there is one set. */
.file-bar-tools {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 14px;
}

/* WHICH SHAPE THE LIST TAKES — two links, not a third select. It is one binary
   choice made once and rarely revisited; a picker beside the two questions that
   ARE asked every visit would put them on the same footing.
   It is still shaped like the fields it stands next to, and that is not a
   coincidence to be re-derived by eye: a control here is `10px` of padding
   above and below one line box, inside a 1px border, with a `10px` radius. The
   pill it used to be (999px, 30px tall) stood a whole different height beside
   two selects and read as something borrowed from another screen. */
.file-views {
  display: inline-flex;
  background: var(--field);
  border: 1px solid var(--line);
  border-radius: 10px;
  overflow: hidden;
}
.file-views .seg {
  display: inline-grid;
  place-items: center;
  width: 42px;
  /* One line box (1.55em, the body's own) plus the same 10px above and below a
     field gives exactly the height of the selects beside it. */
  height: calc(1.55em + 20px);
  color: var(--muted);
  text-decoration: none;
  transition: color .12s ease, background .12s ease;
}
.file-views .seg:hover { color: var(--fg); }
.file-views .seg.is-active { background: var(--accent); color: var(--accent-fg); }

/* ------------------------------------------------- the page as a target */

/* THE WHOLE PAGE TAKES FILES, and this is what says so — hidden until one is
   actually over the window. An overlay that is always drawn would be a
   permanent invitation covering the very thing it invites you to.
   Fixed rather than absolute: what is being aimed at is the WINDOW, and the
   list underneath may be scrolled anywhere. */
.page-drop {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: grid;
  place-items: center;
  padding: 24px;
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(2px);
}

/* The dashes are the same shape a file field wears (`.dropzone`), for the same
   reason: a rectangle that is not quite a box reads as «put something in me»
   everywhere it has ever been drawn. */
.page-drop-face {
  display: grid;
  justify-items: center;
  gap: 8px;
  width: min(520px, 100%);
  padding: 40px 28px;
  text-align: center;
  background: var(--card);
  border: 2px dashed var(--accent);
  border-radius: 16px;
  box-shadow: var(--shadow);
}
.page-drop-icon { display: inline-flex; color: var(--accent); }
.page-drop-lead { font-size: 17px; font-weight: 650; }
.page-drop-note { font-size: 13px; color: var(--muted); }

/* ------------------------------------------------------------ the tiles */

/* A MEDIA LIBRARY, and the grid is what makes it one: `auto-fill` with a
   minimum means the same page holds four tiles on a laptop and two on a phone
   without a breakpoint being written for either. */
.file-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
  gap: 14px;
}

/* A TILE IS A CARD, and it says so in its class rather than by copying what a
   card looks like. It had its own background, border and radius — and no
   shadow, because that was the one line the copy forgot. Four properties
   restated is four chances to drift; two overridden is two. What a card is
   stays in one place. */
.card.file-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  /* The grid decides how wide a tile is, and the parts inside carry their own
     padding — the card's 28px would push the picture off its own face. */
  max-width: none;
  padding: 0;
  overflow: hidden;
}

/* THE WHOLE TILE IS THE LINK. The name under a thumbnail is the other half of
   the same thing, and a tile where only the top half answers is a tile you
   learn to aim at. A real anchor around both — not a transparent layer
   stretched over the card, which would take the text's selectability with it
   and hand a screen reader a link with the picture in and the name out. */
.file-open {
  display: flex;
  flex: 1;
  flex-direction: column;
  color: inherit;
  text-decoration: none;
}
.file-open:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  border-radius: 15px;
}

/* The card lifts as one thing, so what is clickable is never in doubt. */
.card.file-tile:hover { border-color: var(--accent); }
.card.file-tile:hover .file-tile-name { color: var(--accent); }
.card.file-tile:hover .file-thumb { color: var(--accent); }

/* The face of the tile.
   Was an anchor of its own until the whole tile became one.
   SIXTEEN BY NINE, because of what mostly lands in it: an `og:image` is 1200×630
   by convention, near enough 1.91:1, and a 4:3 face cut a third of it away for
   no reason anyone could see. Wider also means the tile is shorter, so a screen
   holds more of them — and what a picture loses at 16:9 is the very edge, where
   a card image has nothing. A scan is the other shape and is cropped either
   way; the whole file is one click behind it. */
.file-thumb {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  color: var(--muted);
  background: var(--field);
  border-bottom: 1px solid var(--line);
  transition: color .12s ease;
}

/* A PICTURE FILLS THE FACE, cropped rather than letterboxed: a grid of tiles
   reads as a grid only while the tiles are the same shape, and a scan of an A4
   page letterboxed into 4:3 leaves two grey bars doing nothing. What is lost to
   the crop is the margin of a document, and the whole file is one click away. */
/* TAKEN OUT OF THE FLOW so the FACE decides the height, not the picture.
   Centred in a grid, an image is not stretched, and its `height: 100%` then
   resolves against a row sized by… the image — so a square `og:image` made its
   tile taller than the one beside it holding a 4:3 icon. Absolute against the
   aspect-ratio box removes the question: the box is 16:9 and the picture fills
   it, cropped. */
.file-shot {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* OVER the face and not inside it: an anchor may not contain a button, and a
   person on a phone has to reach these without a hover that never comes — so
   they are always drawn, and carry the card's own background to stay legible. */
/* OVER the link, and that is what the z-index is for: the anchor now covers the
   whole card, so without it the buttons would be under their own tile. */
.file-tile-acts {
  position: absolute;
  z-index: 1;
  top: 8px;
  right: 8px;
  display: flex;
  gap: 4px;
}
.file-tile-acts form { display: flex; margin: 0; }
.file-tile-acts .icon-btn {
  width: 28px;
  height: 28px;
  background: var(--card);
}

.file-body {
  display: grid;
  gap: 2px;
  padding: 10px 12px 12px;
}

/* TWO LINES AND THEN AN ELLIPSIS. A contract's name is a sentence often enough
   that one line cuts most of them, and a tile that grows to fit the longest
   name drags every other tile in its row taller. */
.file-tile-name {
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.35;
  overflow: hidden;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}
.file-tile-meta { font-size: 12.5px; color: var(--muted); }
.file-bar .field > span { white-space: nowrap; }
/* WIDER THAN THE LOG'S, and the selector says why it has to out-rank it: the
   log's boxes hold a number of rows per page, these hold «Спочатку нові» and
   «Посилання · 1». The 88px rule below is written for one class and this for
   two, which is the only reason it wins — a rule of the same weight further
   down the file was quietly cutting every option to «Сп…». */
.file-bar .table-filter select,
.file-bar .table-filter .ts-wrapper { width: 170px; }

@media (max-width: 720px) {
  /* The date goes first when there is not enough line for everything: how big
     a file is answers «can I open this on my phone», which the size does and
     the day does not. */
  .file-date { display: none; }
  .file-bar .table-filter select,
  .file-bar .table-filter .ts-wrapper { width: 100%; }
}

/* ------------------------------------------------------------ drop zone */

/* A FIELD YOU CAN DRAG ONTO — and the native `<input type="file">` is still
   inside it. Nothing else opens a file picker, so the zone is the `<label>`
   around one: a click anywhere here opens the dialog with no script running.
   The dashes are the whole message — a rectangle that is not quite a box reads
   as «put something in me» in every application that ever drew one. */
.dropzone {
  display: block;
  position: relative;
  padding: 22px 18px;
  border: 1.5px dashed var(--line);
  border-radius: 12px;
  text-align: center;
  cursor: pointer;
  transition: border-color .15s ease, background .15s ease;
}

/* HIDDEN, BUT NOT `display: none`. A control that is not rendered cannot be
   focused, and Tab has to reach this one — the zone is otherwise unreachable
   from a keyboard. One pixel in the corner, fully transparent: clicking it does
   exactly what clicking the label does, so the overlap is harmless. */
.dropzone input[type="file"] {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.dropzone-face {
  display: grid;
  justify-items: center;
  gap: 6px;
}
.dropzone-icon { display: inline-flex; color: var(--muted); }
.dropzone-lead { font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.dropzone-note { font-size: 13px; color: var(--muted); }

.dropzone:hover { border-color: var(--accent); }
.dropzone:hover .dropzone-icon { color: var(--accent); }

/* The ring belongs to the zone, because the input it belongs to is a
   transparent pixel in the corner — same reason `.icon-opt` moves it. */
.dropzone:has(input:focus-visible) {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* A FILE IS OVER THE ZONE. The tint is the one the sortable slot uses, so
   «this is where it lands» looks the same in both places. */
.dropzone.is-over {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.dropzone.is-over .dropzone-icon { color: var(--accent); }

/* REFUSED — by the zone before the request, or by the server after it. Both
   paint the same thing: the sentence under the name says what was wrong. */
.dropzone.is-bad,
.dropzone:has(input[aria-invalid]) { border-color: var(--danger); }
.dropzone.is-bad .dropzone-note,
.dropzone:has(input[aria-invalid]) .dropzone-note { color: var(--danger); }

/* ------------------------------------------------- one card, rows inside
 *
 * A LIST THAT IS ONE CARD AND NOT A CARD PER ROW. Written for Companies —
 * three companies used to be three cards, each repeating the whole set of
 * facts, a column of near-identical blocks in which the one thing worth
 * comparing was the hardest to find. Files and documents is the second list
 * with the same shape, which is why these are `list-` and not `company-`: the
 * rename happened when the second consumer arrived, not after the fourth.
 *
 * WHAT A ROW MAY BE. Companies fold open (`<details>`, `.list-fold`) because
 * every one of them has eight facts underneath; a file has nothing to unfold
 * and its line is a plain div. Both use `.list-line` for the line itself, so
 * a row is the same height and the grip sits in the same place either way.
 *
 * Full width, because what opens inside a row is a table of pairs and 560px
 * would wrap every one of them. */
.card.list-card { max-width: none; padding: 4px 24px; }

/* TWO COLUMNS, NOT ABSOLUTE POSITIONS. The grip and the buttons used to
   be positioned over the row at hand-picked offsets — which is how the grip
   ended up sitting a few pixels above the name it belongs to, and why a long
   name needed 108px of reserved padding it could not see. A grid puts each of
   them in its own column and lets the first one have whatever is left.
   They still live OUTSIDE the `<summary>`: a button inside one is pressed AND
   folds the row in a single click, which would arm the delete and hide the row
   it belongs to at the same time.

   `align-items: start` keeps the grip and the buttons on the FIRST line when a
   row is open and a whole table of facts hangs below it. */
.list-row {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
  /* The gap the buttons stand off by. Without it whatever ends the line — a
     chip in Companies, «PDF · 571 КБ» in Files — sat flush against the pencil,
     and two unrelated things touching read as one control. */
  column-gap: 14px;
}
/* THE SECOND COLUMN EXISTS ONLY WHEN SOMETHING STANDS IN IT. Declared always,
   the empty track cost nothing — but the gap in front of it did: 14px of dead
   space at the end of every row in the sections that have no action buttons,
   which put a domain's caret 38px from the card's edge while its name stood at
   24. An asymmetry with no message in it reads as a mistake, because it is
   one. */
.list-row:has(> .list-acts) { grid-template-columns: minmax(0, 1fr) auto; }
/* Between rows, never above the first — and written as an adjacency rather than
   `:first-child`, because the first child of the card is the CSRF field the
   drag posts with, and a row is only ever the second thing in there. */
.list-row + .list-row { border-top: 1px solid var(--line); }

.list-line {
  display: flex;
  align-items: center;
  gap: 10px;
  /* An explicit line box: it is what the grip and the buttons beside it are
     centred against, and inheriting 1.55 would make that a fraction. */
  line-height: 24px;
  padding: 16px 0;
  list-style: none;
}
/* THE HAND BELONGS TO THE FOLD, NOT TO THE LINE. A row that opens is worth
   pointing at; a file's row is a line with a link in it, and a pointer over its
   empty half would promise something that does not happen. */
.list-fold > .list-line { cursor: pointer; }
/* Safari draws its own triangle and ignores `list-style`. */
.list-line::-webkit-details-marker { display: none; }
/* A row that folds answers anywhere on its line; a row whose name is a link
   answers on the name. Promising more than that would be a hand over dead
   space. */
.list-fold > .list-line:hover .list-name,
a.list-name:hover { color: var(--accent); }

/* Points right while closed and down while open — the same rotation the
   dashboard widgets use, so «folded» looks the same everywhere in the app.

   AND IT SITS AT THE FAR END OF THE LINE, where the dashboard widgets keep
   theirs: the name is what a reader scans the left edge for, the state of the
   fold is what the right edge answers. `order` does the moving — the caret
   stays FIRST in the markup, so the four views that draw it did not change,
   and the auto margin then opens the gap in front of it. An auto margin
   without the `order` would drag everything written after the caret to the
   right along with it. */
.list-caret {
  display: inline-flex;
  order: 99;
  margin-left: auto;
  color: var(--muted);
  transform: rotate(-90deg);
  transition: transform .15s ease;
}
.list-fold[open] .list-caret { transform: none; }

/* THE NAME MAY BE A LINK. In Files it is the row's whole action — the download
   address, or wherever the link points — and `a { color: var(--accent) }` would
   have painted every row blue. It reads as text and answers to hover instead. */
.list-name { font-weight: 600; overflow-wrap: anywhere; color: inherit; text-decoration: none; }
/* The favicon before a site's name — a fixed square so a stretched or absent
   image cannot bend the line, rounded the way small marks are here. */
.list-favicon { flex: none; width: 18px; height: 18px; border-radius: 4px; object-fit: contain; }
.list-panel { padding: 0 0 18px; }

.list-acts {
  display: flex;
  align-items: center;
  gap: 4px;
  /* A 32px button centred on the same 24px line: 16 + (24 − 32) / 2. */
  margin-top: 12px;
}
.list-acts form { display: flex; margin: 0; }

/* SIX DOTS AT THE START OF THE ROW — the one shape everybody already reads as
   «take hold of this». It sits before the caret and OUTSIDE the summary, which
   is what keeps «drag me» and «open me» from being the same gesture.
   `touch-action: none` is not optional: without it a finger on the handle
   scrolls the page instead of moving the row. */
/* A FLEX ITEM OF THE ROW'S OWN LINE. It used to be positioned over the row at a
   hand-picked offset, which is why the dots sat a couple of pixels above the
   name: a chip is taller than the word beside it, so the line's centre is not
   where a fixed number says it is. As an item it centres against whatever the
   tallest thing on that line turns out to be, and there is nothing to keep in
   step. `touch-action: none` is not optional — without it a finger on the
   handle scrolls the page instead of moving the row. */
.list-grip {
  display: inline-flex;
  margin-right: -2px;
  color: var(--muted);
  cursor: grab;
  touch-action: none;
}
.list-grip:hover { color: var(--fg); }

/* ------------------------------------------------------------- dragging */

/* THE SLOT THE ROW CAME FROM. Its contents go invisible rather than away, so
   the gap keeps exactly the height of what is being carried — including an open
   row with a whole table in it. The tint is drawn by a pseudo-element that
   bleeds past the card's 24px padding: what lights up has to read as the ROW,
   not as the text inside it. */
.list-row.is-placeholder > * { visibility: hidden; }
.list-row.is-placeholder::after {
  content: '';
  position: absolute;
  inset: 4px -24px;
  border-radius: 10px;
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 32%, transparent);
}

/* THE ROW IN THE AIR — a copy, following the pointer. Fixed to the viewport
   because it has to travel over anything the page happens to have scrolled to,
   and `pointer-events: none` so that `elementFromPoint` sees the list beneath
   it rather than the helper itself. */
.list-row.is-helper {
  position: fixed;
  z-index: 60;
  margin: 0;
  padding: 0 24px;
  border-top: 0;
  border-radius: 12px;
  background: var(--card);
  box-shadow: 0 16px 34px rgb(16 24 40 / 18%);
  pointer-events: none;
  cursor: grabbing;
}

.list-card.is-sorting { user-select: none; }
/* While a row is in the air, opening one under it would be an accident. */
.list-card.is-sorting .list-line { cursor: grabbing; }
/* The cursor belongs to the gesture, not to whatever it happens to pass over. */
body.is-sorting-body, body.is-sorting-body * { cursor: grabbing !important; }
.modal-backdrop:has(.company-form) .modal { max-width: 560px; }
.modal-backdrop:has(.file-form) .modal { max-width: 520px; }

/* --------------------------------------------------------- settings form */

/* State line at the top of a settings card — the card's own subject, not a
   card of its own. A second box next to the form reads as a second feature. */
.conn-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }

/* A form nested in a card already inside .form-card must not add its own
   padding or width cap — it is a section of the card, not another card. */
.form-card .form-card { max-width: none; }

/* The buttons of a nested form sit with it, not pinned to the card bottom. */
.form-card form > .form-actions { padding-top: 0; }

.conn-state {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 14px;
  font-weight: 550;
}

.conn-ok { color: var(--ok); }
.conn-idle { color: var(--muted); }

/* Two subjects in one card — Turnstile above, cache purging below (the
   Cloudflare tab). A hairline, not a second card: they share a vendor, a
   settings group and one Save button, and a box around each would say they are
   unrelated features that happen to sit together. `.form-card` is a grid, so
   this is a grid item and needs no margin of its own. */
.field-sep {
  height: 0;
  margin: 4px 0;
  border: 0;
  border-top: 1px solid var(--line);
}

/* Buttons under a single checkbox belong on the left, under the thing they act
   on. `.form-actions` pushes right, which is correct for a form's final Save
   and wrong for an action that answers the line above it. */
.form-actions-start { justify-content: flex-start; }

/* The count that answers "how many rows would go" — numbers line up under each
   other, which is what makes two of them comparable at a glance. */
.field-count { font-variant-numeric: tabular-nums; }

/* SEVERAL SEPARATE ACTIONS IN ONE CARD, told apart by air rather than by rules.
   A card is already a box; drawing lines inside it cuts one box into three and
   the eye starts reading them as three cards that lost their frames. Distance
   groups just as well and adds no ink — the same choice as the gap in the
   sidebar menu. The head keeps its own margin out of the gap so the first block
   does not sit twice as far down as the rest. */
.card-stack { display: grid; gap: 28px; }
/* A MARGIN INSIDE A GRID DOES NOT COLLAPSE INTO THE GAP — IT ADDS TO IT, and
   this reset is the answer for every grid in the app rather than for one of
   them. It used to name `.card-stack` alone, which is where the trap was first
   paid for; but the trap is not that class, it is «a head inside a grid». The
   deletion tab proved it by putting a `.card-head` inside `.form-card`: 16px of
   its own plus 18 of gap made 34 above the text and 18 below it, and the block
   read as if it belonged to the heading above rather than to the section below.
   A grid supplies the space between its children. A child that also supplies
   some is a second answer to one question. */
:is(.card-stack, .form-card, .form, .field-group, .conditional) > .card-head,
.card-stack > h2,
.card-stack > h3 { margin-bottom: 0; }

/* Two classes on one card (`card form-card card-stack`) and both set a gap.
   Raised specificity rather than relying on which rule is written lower in the
   file — a reorder should not silently change a layout. */
.card.card-stack { gap: 28px; }

/* A field and the tick that qualifies it are ONE thing: closer to each other
   than to the next field, so the pairing is visible before anything is read. */
.field-pair { display: grid; gap: 9px; }

/* An action and the fact it produced, on one line: "purge now" beside "last
   purged at …". Wraps rather than squeezing on a narrow card. */
/* AN ACTION AND WHAT IT DOES: the words first, the button under them.
   It used to be a row — note on the left, button pinned right — and on a wide
   card that put a destructive button an inch of empty space away from the
   sentence explaining it. Reading order and click order should be the same
   order, which is the rhythm the bare forms below already use. `start` keeps
   the button its own width instead of stretching it across the card. */
.conn-actions { display: grid; justify-items: start; gap: 12px; }
/* The term notice above the sections of an account (views/layout.termNotice):
   icon · sentence · one button, on the warn or danger tint. Full width like
   the page head it follows, so it reads as part of the page, not as a card. */
.term-notice {
  display: flex;
  align-items: center;
  gap: 14px;
  margin: 0 0 20px;
  padding: 14px 18px;
  border-radius: 16px;
  border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
  color: var(--fg);
}
.term-notice.is-bad {
  border-color: color-mix(in srgb, var(--danger) 35%, transparent);
  background: color-mix(in srgb, var(--danger) 9%, transparent);
}
.term-notice-icon { display: inline-flex; flex: none; color: var(--warn); }
.term-notice.is-bad .term-notice-icon { color: var(--danger); }
.term-notice p { margin: 0; flex: 1 1 auto; font-size: 14px; line-height: 1.5; }
.term-notice .btn { flex: none; }
@media (max-width: 640px) {
  .term-notice { flex-wrap: wrap; }
  .term-notice p { flex-basis: calc(100% - 36px); }
}

/* The dashboard of a client with no account (views/dashboard.noAccounts): the
   reference's «no accounts» page — a smiling title, a paragraph and the form
   that replaced the button, ALL INSIDE ONE CARD. The title is the first thing
   in the box rather than a heading floating over it: the owner's word of
   19.09.2026, «щоб текст був у боксі, починаючи з „Отакої!“». The form itself
   needs no rules of its own — `.card > form:not(.form-card)` above already
   stacks the fields and lets the button opt out of the stretch. */
/* The reading measure of Sites/Domains/Mailboxes (960px), not the card's
   own 560px: the owner asked for the same width as those pages (19.09.2026).
   Two classes on purpose — `.card { max-width: 560px }` is declared LATER in
   this file with the same weight, so a bare `.no-accounts` never won (the
   640px it used to name was never the width anybody saw). */
.card.no-accounts { max-width: 960px; }
.no-accounts h2 { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 22px; }
.no-accounts h2 .icon { color: var(--accent); flex: none; }
.no-accounts .prose { margin: 0 0 18px; }

/* The form the button became. Everything that stacks it is already the card's
   own rule; what it adds is the air between the owner's paragraph and the
   first field, because `.prose` above it ends a thought and this begins one. */
.support-form { margin-top: 2px; }

/* «або ви можете скористатися месенджерами нижче» — a sentence between the
   button and the row it introduces, so it keeps air on both sides. */
.support-or { margin: 10px 0 0; }
.support-messengers { display: flex; flex-wrap: wrap; gap: 8px; }
/* The messenger's own logo from the reference sprite, sized to the glyphs
   beside it; the button's gap does the spacing. */
.messenger-logo { width: 18px; height: 18px; flex: none; border-radius: 4px; }

.conn-note { font-size: 13px; color: var(--muted); }

/* The note's own two lines: a name and a sentence, the same pairing a field and
   its hint have. The name is an `h3` — it names a section inside a card, which is
   what that level is for. It was a `<strong>`, which looks like a heading and is
   not one: it never enters the heading tree, a reader never announces it as
   structure, and its size answered to nothing. Size and weight now come from the
   base rule; what stays here is the note's own colour, which the wrapper turns
   muted. */
.conn-note h3 { margin: 0 0 4px; color: var(--fg); }
.conn-note p { margin: 0; }

/* Button first, its note underneath — the same rhythm as a field and its hint,
   instead of the note trailing off to the right of the button. */
/* A bare action form (connect / disconnect) stacks its button and note.

   IT SHRINKS ONLY WHAT SHOULD SHRINK. The first version said `justify-items:
   start` on the form and so squeezed EVERY child to its content — right for a
   connect button with its note, and wrong for a field, a rich-text editor, a
   dropzone or a group of fields, which then stood at a third of the card: the
   payment rows on Білінг (16.09.2026) and, the day after, the requisites
   editor on Документи and the video and key forms beside it — the same rule
   each time, met one form at a time. So the grid stretches, and the two
   things that must not fill a card — a button, a hint or a line of text —
   opt out by themselves. A form that is a card's whole width by design (the
   renew card) needs no exception any more. */
.card > form:not(.form-card) { display: grid; justify-items: stretch; gap: 8px; }
.card > form:not(.form-card) > .btn,
.card > form:not(.form-card) > .btn-group,
.card > form:not(.form-card) > .conn-note,
.card > form:not(.form-card) > p,
.card > form:not(.form-card) > small { justify-self: start; }

.facts dd.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12.5px;
  overflow-wrap: anywhere;
}

.field-opt { font-style: normal; font-weight: 400; color: var(--muted); }

/* A value that exists to be copied, not typed: the field and its button on one
   line. The input stays a real input rather than becoming text, because
   selecting with the mouse has to keep working when the button cannot (a page
   not on https has no Clipboard API). */
.copy-line { display: flex; gap: 8px; align-items: stretch; }
.copy-line input { flex: 1 1 auto; min-width: 0; font-size: 13.5px; }
.copy-line .btn { flex: none; }

/* Both faces share the fixed icon button's cell: feedback never widens a table. */
[data-copy-icon] .copy-icon-face { display: grid; place-items: center; grid-area: 1 / 1; }
[data-copy-icon] .copy-icon-done { opacity: 0; }
[data-copy-icon].is-copied .copy-icon-face { opacity: 0; }
[data-copy-icon].is-copied .copy-icon-done { opacity: 1; }
[data-copy-icon].is-copied { color: var(--ok); border-color: var(--ok); }

/* The same row for a value you do not edit HERE: the reference's
   `label.readonly` with its pencil, which opens the dialog that does change it
   (views/profile-info). The box is quiet — it is a statement, not a field —
   and the pencil beside it is the control. */
.edit-line { display: flex; gap: 8px; align-items: center; }
.edit-line input { flex: 1 1 auto; min-width: 0; }
.edit-line input[readonly] { color: var(--muted); background: var(--bg); cursor: default; }
.edit-line .icon-btn { flex: none; }

/* Whether a token exists, in the same voice as .conn-state but inside a field. */
.token-state {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 0;
  font-size: 13.5px;
  color: var(--fg);
}

/* The connect command: a long single line that must wrap instead of widening
   the card. */
.field-hint code {
  display: inline-block;
  margin-top: 4px;
  padding: 7px 9px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.5;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow-wrap: anywhere;
}

.card form .btn[disabled] { opacity: .5; cursor: not-allowed; }

.form-card { display: grid; gap: 18px; max-width: 560px; }
.wordpress-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.wordpress-actions > :first-child { margin-inline-end: auto; }
/* The next block is a grid, so the shared card + card spacing does not apply. */
.wordpress-actions + .wordpress-layout { margin-top: 18px; }
.wordpress-layout { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr); gap: 20px; align-items: start; }
.wordpress-form { max-width: none; }
.wordpress-preview { position: sticky; top: 20px; min-width: 0; }
.wordpress-code-line { display: inline-block; width: 100%; padding-inline: 6px; vertical-align: top; border-radius: 3px; }
.wordpress-line-active {
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  box-shadow: inset 2px 0 var(--accent);
}
.wordpress-line-old { color: var(--danger); text-decoration: line-through; }
.wordpress-line-new { color: var(--ok); }
@media (max-width: 1000px) {
  .wordpress-layout { grid-template-columns: minmax(0, 1fr); }
  .wordpress-preview { position: static; }
}

/* THE SENTENCE A REFUSED FIELD WEARS. Same place and same size as the hint it
   sits beside, because it answers the same question — what to put here — only
   after an attempt. Colour and the ring on the control are what separate them;
   moving the message somewhere else would make the eye hunt for it. */
.field-error {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--danger);
}

.field [aria-invalid="true"],
.field [aria-invalid="true"]:focus { border-color: var(--danger); }
.field [aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }

.field-hint {
  margin: 0;
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--muted);
}

/* TEXT INSIDE A GRID HAS NO MARGINS OF ITS OWN.
   `.form-card`, `.card-stack` and `.field-group` all space their children with
   `gap`; a browser `<p>` margin does not collapse inside a grid, it ADDS — 16
   above plus 18 of gap plus 16 below is fifty pixels of nothing between a title
   and a line of text, and none of it is written anywhere.
   Scoped to DIRECT children on purpose: a plain `.card` is block flow, where
   those margins are still the only thing separating two paragraphs. */
.form-card > p,
.card-stack > p,
.field-group > p { margin: 0; }

.field-hint a { color: var(--accent); }

.form-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding-top: 2px;
}

/* A Tom Select control replaces the <select> inside .field, so it must inherit
   the same block layout the native element had. */
.field .ts-wrapper { width: 100%; }

/* A group of fields tied to one value of another field (see .conditional in
   public/js/fields.js). It repeats the form's own gap so a revealed block is
   indistinguishable from fields written inline.

   The [hidden] rule is not redundant: the UA stylesheet's `[hidden]{display:none}`
   loses to any class selector that sets display, so without it a hidden block
   would stay on screen. */
.conditional { display: grid; gap: 18px; }
.conditional[hidden] { display: none; }

/* ------------------------------------------------------------ data table */

/* The table draws its own frame, so it can sit directly on the page as well as
   inside a card. .table-wrap owns the border and the rounding; the table itself
   only draws the hairlines between rows — a border on the cells would show
   through the rounded corners. */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--card);
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13.5px;
}

.data-table th,
.data-table td {
  /* Padding on BOTH sides: a right-aligned column with no right padding runs
     straight into the next one. */
  padding: 11px 14px;
  text-align: left;
  vertical-align: middle;
}

/* Flush the outer edges with the frame instead of indenting twice. */
.data-table th:first-child, .data-table td:first-child { padding-left: 18px; }
.data-table th:last-child, .data-table td:last-child { padding-right: 18px; }

.data-table thead th {
  position: sticky;   /* the header survives a horizontal scroll */
  top: 0;
  padding-top: 12px;
  padding-bottom: 12px;
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
  background: color-mix(in srgb, var(--fg) 4%, var(--card));
  border-bottom: 1px solid var(--line);
}

/* The clock a column of times is read on — «ЧАС GMT+3» — said once in the
   header instead of after every row. Lighter and not upper-cased: it is an
   annotation on the column's name, not part of it. */
.th-zone {
  font-weight: 500;
  text-transform: none;
  letter-spacing: 0;
  opacity: .7;
}

/* A HEADING THAT SORTS (views/accounts.sortHead): the heading's own type,
   plus a chevron that says which way. Faint while the column is not the
   sort — «this can be asked» — full when it is, and turned upwards for
   ascending, since the glyph points down by itself. No underline: a header
   row of underlined words reads as a row of links, and these are headings
   first. */
.th-sort {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  color: inherit;
  text-decoration: none;
}
.th-sort:hover { color: var(--fg); }
.th-sort .icon { opacity: .35; transition: transform .18s ease, opacity .18s ease; }
.th-sort:hover .icon, .th-sort.is-asc .icon, .th-sort.is-desc .icon { opacity: 1; }
.th-sort.is-asc .icon { transform: rotate(180deg); }

.data-table tbody tr + tr td { border-top: 1px solid var(--line); }

/* A GROUP ROW — the list of tasks turns its pages by the week of the deadline
   (views/tasks.listView): one <th> across the table, inside the same table,
   so the columns stay one grid and the week reads as a ruled line with a
   name, the way a planner's page turns. Not the column heads' small caps —
   this names a stretch of time, not a column — but their quiet ground. The
   row under it draws the rule beneath (the tr + tr rule above); the group's
   own rule is on top, except for the first, which sits under the head. */
.data-table tr.table-group th {
  padding-top: 9px;
  padding-bottom: 9px;
  font-size: 12px;
  font-weight: 600;
  color: var(--fg);
  background: color-mix(in srgb, var(--fg) 2.5%, var(--card));
  border-top: 1px solid var(--line);
}
.data-table tbody tr.table-group:first-child th { border-top: 0; }
.table-group-note { font-weight: 500; color: var(--muted); }

.data-table td.nowrap, .data-table th.nowrap { white-space: nowrap; }
.data-table .num { text-align: right; font-variant-numeric: tabular-nums; }

/* Long free text (a message, a subject) must not push the table sideways —
   it is a preview, and the full value lives in the row's dialog. */
.data-table td { max-width: 420px; overflow-wrap: anywhere; }

/* .table-wrap IS the frame — never wrap it in a .card as well, or the page ends
   up with two nested borders and doubled padding. */

/* ------------------------------------------------ deletable log tables */

/* Two columns of controls around the data: ticks on the left, the row's own
   trash on the right. Both are as narrow as their control, so the reading
   columns lose almost nothing. */
.data-table th.col-pick, .data-table td.col-pick { width: 1%; padding-right: 0; }
.data-table th.col-row-act, .data-table td.col-row-act { width: 1%; text-align: right; }
.data-table td.col-pick input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }

/* The trash appears on hover — and on keyboard focus, and wherever hovering is
   not a thing. Hover ALONE would hide the control from every touch screen and
   from anyone navigating by keyboard, which is not "subtle", it is missing. */
.row-del { opacity: 0; transition: opacity .12s ease; }
tr:hover .row-del,
tr:focus-within .row-del,
.row-del:focus-visible { opacity: 1; }
@media (hover: none) { .row-del { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .row-del { transition: none; } }

/* A picked row is tinted, so a selection made three screens up is still visible
   when the button that acts on it comes back into view. */
.data-table tr.is-picked td { background: color-mix(in srgb, var(--danger) 7%, transparent); }

.log-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
.log-actions .btn[disabled] { opacity: .45; pointer-events: none; }

/* Inside the dialog: the sentence that says this one has no copy anywhere. */
.danger-note { color: var(--danger); display: flex; gap: 7px; align-items: flex-start; }

.chip-ok {
  color: var(--ok);
  border-color: color-mix(in srgb, var(--ok) 40%, transparent);
  background: color-mix(in srgb, var(--ok) 10%, transparent);
}

.chip-bad {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 40%, transparent);
  background: color-mix(in srgb, var(--danger) 10%, transparent);
}

.chip-warn {
  color: var(--warn);
  border-color: color-mix(in srgb, var(--warn) 40%, transparent);
  background: color-mix(in srgb, var(--warn) 10%, transparent);
}

.chip-info {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 35%, transparent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}

/* ------------------------------------------------------------------ logs */

/* Count on the left, page size on the right. */
.table-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 14px;
}

.table-bar .muted { margin: 0; font-size: 13.5px; }
.table-bar strong { color: var(--fg); }

/* The bar sits ABOVE the table in the logs and BELOW it in Profile → Sessions,
   so the single bottom margin left the second case glued to the table edge.
   Flip the margin when it follows one. */
.table-wrap + .table-bar { margin-top: 18px; margin-bottom: 0; }

/* The page-size label sits beside its control, not above it as in a form. */
.per-page, .table-filter { display: flex; align-items: center; gap: 10px; }
.per-page .field, .table-filter .field { display: flex; align-items: center; gap: 9px; }
.per-page .field > span, .table-filter .field > span { font-size: 13px; }
.per-page select, .per-page .ts-wrapper,
.table-filter select, .table-filter .ts-wrapper { width: 88px; }

/* The two dates in the log toolbar. Wider than the page size and narrower than
   a form field: dd.mm.yyyy is ten characters and never more, so the box is cut
   to it rather than left to stretch the bar. */
.table-filter { flex-wrap: wrap; justify-content: flex-end; gap: 10px 14px; }
.table-filter input[data-datepicker] { width: 118px; }
.table-filter-acts { display: flex; align-items: center; gap: 8px; }

/* A single live search uses the journals' toolbar and ordinary field style. */
.table-search { flex: 1; min-width: 0; max-width: 460px; justify-content: flex-start; }
.table-search .field { flex: 1; min-width: 0; }
.table-search input[type="search"] { width: 100%; min-width: 0; }
.table-filter .deployment-sites-filter select,
.table-filter .deployment-sites-filter .ts-wrapper { width: 280px; max-width: 100%; }

/* Narrow: the bar already stacks (see the media query below), and the filter
   stacks with it — three controls side by side do not survive a phone. */
@media (max-width: 720px) {
  .table-filter { justify-content: flex-start; }
  .table-filter .field { flex: 1 1 auto; }
  .table-filter input[data-datepicker] { width: 100%; }
}

/* Read by a screen reader, invisible to everyone else — a label that a sighted
   person gets from context and a listener does not. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

/* The cache line in an integration's head: what is stored, and the button that
   replaces it. Pushed to the right so the state chip keeps the left, and
   wrapping on narrow rather than squeezing the note to one word. */
.conn-cache { display: flex; align-items: center; gap: 10px; margin-left: auto; flex-wrap: wrap; }
.conn-cache small { font-size: 12.5px; }

/* ------------------------------------------------------------- accounts */

/* The tenant switcher, between the logo and the menu. Full width and quiet:
   it names where you are, it is not a call to action.

   NO PADDING BELOW IT. The sidebar is a flex column with `gap: 18px`, so any
   bottom padding here is ADDED to that gap — 14px of it made the space under
   the select half again as big as the space above it, and the switcher looked
   detached from the menu it heads rather than attached to it. The gap belongs
   to the column, and only one of them may set it. */
.side-tenant { display: flex; align-items: center; gap: 8px; padding: 0 4px; }
.side-tenant > form { flex: 1; min-width: 0; }
.side-tenant > form[data-tenant-pending] { position: relative; }
.side-tenant > form[data-tenant-pending]::after {
  content: ''; position: absolute; right: 15px; top: calc(50% - 8px);
  width: 16px; height: 16px; border: 2px solid var(--muted);
  border-right-color: transparent; border-radius: 50%;
  animation: nav-spin .9s linear infinite; pointer-events: none; z-index: 2;
}
body .side-tenant > form[data-tenant-pending] .ts-control,
.side-tenant > form[data-tenant-pending] select { background-image: none; }
@media (prefers-reduced-motion: reduce) {
  .side-tenant > form[data-tenant-pending]::after { animation: none; }
}
.side-tenant .field { display: block; }
.side-tenant .ts-wrapper, .side-tenant select { width: 100%; }
.side-tenant-settings { flex: none; width: 44px; height: 44px; }

/* The account's own mark on the card head and the dashboard — a tinted square,
   the same shape language as .avatar, so a tenant and a person read as two
   kinds of subject rather than two kinds of decoration. */
.account-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  border-radius: 12px;
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}

.account-head { display: flex; align-items: center; gap: 14px; }
.account-head-text { display: grid; gap: 6px; }
.account-head-text h1, .account-head-text h2 { margin: 0; }
/* METADATA SPEAKS QUIETER THAN ITS SUBJECT. The line inherited the body's
   16px, which made the date LARGER than the 15px account name above it — the
   least important number on the widget was its loudest. 13.5px is the scale
   the app already uses for supporting lines, and anything added to this row
   later inherits it. */
.account-head-meta {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin: 0;
  flex-wrap: wrap;
  font-size: 13.5px;
}

/* THE META IS A LINE, so the term cell flattens on it: `.expiry` is a
   two-storey cell built for tables, and on this line the second storey hung
   «лишилось N дн.» below the chip's middle — the head of the widget read as
   three things dropped from different heights. On one baseline the chip, the
   day and the note are one sentence. The table keeps its storeys. */
.account-head-meta .expiry {
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  line-height: inherit;
}

/* The date and how far away it is, stacked: the number is what you act on, the
   date is what you check it against. */
.expiry { display: inline-grid; gap: 1px; line-height: 1.25; }
.expiry small { color: var(--muted); font-size: 12px; }
.expiry.is-soon small { color: var(--warn); }
.expiry.is-over small { color: var(--danger); }


/* A segmented control made of real radios — three values that fit on a line,
   and the chosen one stays visible instead of hiding in a closed dropdown. The
   input itself is hidden; its <span> is what gets styled, so the keyboard still
   sees a radio group and the form still posts without JS. */
/* `justify-self: start` IS THE WHOLE POINT, and this project has paid for the
   lesson once already. `.field` is a grid, and a grid item stretches to its
   column unless told otherwise — so `display: inline-flex` counts for nothing
   here and the group ran the full width of the card with three buttons huddled
   at the left. The language switch had exactly this bug and exactly this fix
   (`.pref-control`); the difference is that it lives on the control itself this
   time, so every button group in the app is right without a wrapper. */
/* A SEGMENTED CONTROL IS A TRACK WITH A PILL IN IT, and the 3px of padding is
   the whole difference. Before, the group clipped its options against its own
   border (`overflow: hidden`) and separated them with hairlines: the chosen one
   then ran edge to edge, its corners fighting the outer radius, and the control
   read as two buttons stuck together rather than one control with a position.
   Now the frame holds the options rather than containing them — 3px around,
   3px between, and the inner radius is the outer one minus that padding (12−3=9)
   so the two curves are concentric instead of merely both round. */
.btn-group {
  display: inline-flex;
  justify-self: start;
  flex-wrap: wrap;
  gap: 3px;
  padding: 3px;
  background: var(--field);
  border: 1px solid var(--line);
  border-radius: 12px;
}
.btn-opt { display: inline-flex; }
.btn-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
/* TWO SHAPES, ONE PILL. An option is a radio and its label (`.btn-opt`) when
   the choice rides along with the form around it, and a plain <button>
   (`.btn-opt-btn`) when choosing IS the submit — the dashboard's column picker
   posts on the click. To the eye they are one control, so they share the rule
   rather than resemble each other: the button half kept the old edge-to-edge
   look, hairline separators and square corners, long after the track became a
   pill AROUND its options, and inside a rounded frame those corners were the
   whole giveaway. `font: inherit` and `border: 0` are for that half, which
   arrives carrying a browser's opinion of both. */
.btn-opt > span,
.btn-opt-btn {
  display: inline-flex;
  align-items: center;
  padding: 8px 14px;
  font: inherit;
  font-size: 13.5px;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 9px;
  cursor: pointer;
  transition: background .15s ease, color .15s ease;
  white-space: nowrap;
}
.btn-opt > span:hover,
.btn-opt-btn:hover { color: var(--fg); }
.btn-opt input:checked + span,
.btn-opt-btn.is-on { color: var(--accent-fg); background: var(--accent); }
/* The ring lands on the label, since the input it belongs to has no size of its
   own — and it sits OUTSIDE the pill, in the padding the track gained. */
.btn-opt input:focus-visible + span,
.btn-opt-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* One repeated group — a site with its databases and repositories. Framed so
   that "these three belong together" survives three groups in a column. */
.attach-group {
  display: grid;
  gap: 12px;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
}
.attach-group + .attach-group { margin-top: 12px; }

/* The strip that names the group and offers to remove it. The name is small and
   quiet on purpose: it is a counter, not a heading — «Сайт 2» tells you which
   of them you are editing and nothing else. */
.attach-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 2px;
}

.attach-group-name {
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Where a group would be, when there is none. A dashed box rather than a line
   of grey text: the empty state occupies the shape the thing would occupy, so
   the card does not change height by 90px the moment the first one arrives. */
.repeat-empty {
  margin: 0;
  padding: 26px 16px;
  text-align: center;
  color: var(--muted);
  border: 1px dashed var(--line);
  border-radius: 12px;
}

.form-actions-split { display: flex; align-items: center; justify-content: space-between; gap: 12px; }

/* ------------------------------------------------- remove, with a question */

/* A trash icon that asks before it acts: one click arms it, the ring counts
   three seconds backwards, the second click removes the row. Everything below
   is two faces stacked in the same box and a ring drawn over both.

   `position: relative` on a button that is already `inline-grid`: the two faces
   share one grid cell so the box never resizes as they swap, and the ring is
   positioned against the border rather than the content. */
/* Three seconds, said once. The script reads nothing from here — it holds the
   same number — but a variable makes the pair findable: change one, the grep
   for the other is `--repeat-confirm`. */
:root { --repeat-confirm: 3s; }

/* THE BUTTON STACKS ITS OWN TWO FACES. `grid-area: 1 / 1` only stacks when the
   PARENT is a grid — which `.icon-btn` happens to be (`inline-grid`) and `.btn`
   is not (`inline-flex`, plus an 8px gap). So the same markup that overlaid
   correctly on the repeater's trash laid the two labels SIDE BY SIDE on the
   text button, and «Видалити вибрані» came out as wide as itself plus «Ще раз,
   щоб видалити» plus the gap. The stacking belongs to the behaviour, not to
   whichever button class it is wearing. */
.repeat-remove { position: relative; display: inline-grid; place-items: center; }
.repeat-remove-face { display: grid; place-items: center; grid-area: 1 / 1; }
.repeat-remove-yes { opacity: 0; }
.repeat-remove.is-confirming .repeat-remove-face { opacity: 0; }
.repeat-remove.is-confirming .repeat-remove-yes { opacity: 1; }

/* Armed: the button says danger with its colour, not with its size — nothing
   here moves, so the second click lands where the first one did. */
.repeat-remove.is-confirming {
  color: var(--danger);
  border-color: transparent;
}

/* The clock. An SVG circle of r=16 in a 36-unit box: circumference 2πr ≈ 100.5,
   so a dash of 101 covers the whole ring and the keyframe walks the offset from
   nought to 101 — full circle to nothing, once, in three seconds.

   `rotate(-90deg)` starts it at twelve o'clock instead of three. The stroke is
   drawn OUTSIDE the button's own border (which is why that border goes
   transparent while it runs) so the two never sit on the same pixel. */
.repeat-ring {
  position: absolute;
  inset: -1px;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
  fill: none;
  stroke: var(--danger);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 101;
  opacity: 0;
  transform: rotate(-90deg);
  pointer-events: none;
}

.repeat-remove.is-confirming .repeat-ring {
  opacity: 1;
  animation: repeat-countdown var(--repeat-confirm, 3s) linear forwards;
}

@keyframes repeat-countdown {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: 101; }
}

/* The countdown still runs — it lives in a timer, not in this animation — the
   ring simply stops pretending to be a clock. A full circle standing still says
   «armed» just as well, and says it without motion. */
@media (prefers-reduced-motion: reduce) {
  .repeat-remove.is-confirming .repeat-ring { animation: none; }
}

/* One attached row — site + database + repository side by side while there is
   room, stacked when there is not. `minmax(0, 1fr)` and not `1fr`: the auto
   minimum is the widest option in the select, and a long repository name would
   otherwise push the row out of the card. */
.attach-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
  gap: 12px 16px;
}

/* A single person on a line — the owner block, where a list would be one item. */
.person-line { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.person-line-main { font-weight: 600; }

/* --------------------------------------------------------- profile: info */

/* A read-only line that belongs to the form but is not a field: same label
   rhythm, no control under it. */
.field-static { display: grid; gap: 6px; }
.field-static > span { font-size: 13px; font-weight: 600; color: var(--muted); }
.field-static p { margin: 0; }

/* What a confirmation dialog is about, above the buttons. */
.modal-subject { margin: 10px 0 0; }

/* The summary a confirmation states about what it is about to destroy. Same
   rows as anywhere else (`.facts`), just held away from the sentence above it:
   the question is prose, this is evidence, and they should not read as one
   paragraph. */
.modal-facts { margin-top: 14px; }

/* ------------------------------------------- sign-in: the passkey option */

/* "or" on a rule, then the alternative. The separator says these are two ways
   in, not two steps of one. */
.auth-alt { display: grid; gap: 12px; margin-top: 18px; }

.auth-or {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  font-size: 12.5px;
  color: var(--muted);
}
.auth-or::before,
.auth-or::after { content: ''; height: 1px; background: var(--line); }

.auth-alt .btn { width: 100%; }

/* ------------------------------------------------ profile: email / phone */

/* The value the tab is about — bigger than a hint, quieter than a heading. */
.contact-value { font-size: 16px; font-weight: 550; }

/* "Send again · Cancel" under the code boxes. Two real forms (both POST — one
   mails a code, the other drops the challenge), so they need a row that makes
   them read as one pair of links. */
.inline-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
}
.inline-actions .dot { color: var(--muted); }

/* Inside a card the boxes are left-aligned with the rest of the form, unlike
   the sign-in screen where they are the only thing on the page. */
.form-card .code-boxes { justify-content: flex-start; }

/* In a dialog the boxes are the point of the dialog, so they get room to
   breathe on both sides — pressed against the sentence above and the hint below
   they read as one dense block instead of the thing you are meant to fill in. */
.modal-body .code-boxes { margin: 20px 0 14px; }

/* What follows the boxes belongs to them, so it is centred under them rather
   than starting at the far-left edge of the dialog. */
.code-hint { text-align: center; }
.code-hint .linkish { margin-top: 4px; }

/* ---------------------------------------------------------- profile: 2FA */

/* The QR carries no inline style (our CSP would drop it), so its box lives
   here. `display:block` removes the baseline gap under an inline SVG. */
.qr { display: block; width: 100%; height: auto; max-width: 200px; border-radius: 8px; }

/* Code beside key: the QR for a phone with a working camera, the typed secret
   for one without. Both are the same step, so they share a row. */
.totp-setup {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 22px;
  padding: 18px;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
}

.totp-qr { flex: none; }
.totp-manual { display: grid; justify-items: start; gap: 10px; min-width: 0; }
.totp-manual .field-label { font-size: 13px; font-weight: 600; color: var(--muted); }

.totp-manual code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 14px;
  letter-spacing: 1px;
  overflow-wrap: anywhere;
}

/* Three or fewer codes left is worth noticing before it is zero. */
.totp-low { color: var(--warn); font-weight: 550; }

/* ----------------------------------------------------- profile: Telegram */

/* Three numbered steps that happen somewhere else (in Telegram), so they read
   as instructions rather than as controls on this page. */
.steps {
  display: grid;
  gap: 10px;
  margin: 0;
  padding-left: 22px;
  font-size: 14px;
  line-height: 1.5;
}
.steps li { padding-left: 4px; }
.steps li::marker { color: var(--accent); font-weight: 650; }

/* --------------------------------------------------------- profile: keys */

/* The enrolled keys live INSIDE the card, as a list — not a framed table beside
   it. One card per subject is the project's rule for these panels, and the state,
   the keys and the "add" button are all the one subject. */
.key-list { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }

.key-row {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 14px;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
}

.key-main { display: grid; gap: 2px; min-width: 0; flex: 1 1 auto; }
.key-label { font-weight: 550; overflow-wrap: anywhere; }
.key-main .muted { font-size: 12.5px; }

.key-meta {
  display: grid;
  gap: 2px;
  flex: none;
  font-size: 12.5px;
  color: var(--muted);
  text-align: right;
}

.key-acts { display: flex; gap: 6px; flex: none; }

/* Dates below the name on a narrow screen — three columns in ~340px would clip
   the label, which is the one thing that identifies the key. */
@media (max-width: 620px) {
  .key-row { flex-wrap: wrap; }
  .key-meta { text-align: left; flex: 1 1 100%; order: 3; }
  .key-acts { order: 2; }
}

/* ----------------------------------------------------- profile: sessions */

.sess-device { font-weight: 550; }
/* The device name and its "this device" chip share a line but wrap together on
   a narrow screen rather than pushing the column wider. */
.sess-device + .chip { margin-left: 8px; }

/* The action column holds one round button and should claim no more width than
   that — otherwise it steals room from the columns that carry text. */
.cell-actions { width: 1%; white-space: nowrap; text-align: right; }
.cell-actions .icon-btn:disabled { opacity: .45; cursor: not-allowed; color: var(--muted); border-color: var(--line); }

/* Destructive row action: neutral until pointed at, so a list of sessions is
   not a wall of red. */
.icon-btn.danger:hover { border-color: var(--danger); color: var(--danger); }

/* The current session is the one you are reading this in — tint it so the row
   with no Close button is obviously the exception, not a rendering slip. */
.data-table tr.is-current td { background: color-mix(in srgb, var(--ok) 7%, transparent); }

.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }

/* A log row opens its own dialog — the cells only ever show a clipped preview,
   and the response body is the reason you opened the page. */
/* A ROW THAT IS A LINK, without being a click handler.
   The anchor lives in the first cell and is stretched over the whole row by the
   overlay below, so the row keeps everything a link has and a handler does not:
   it works with JS off, middle-click opens a tab, the status bar shows where it
   goes, and hx-boost turns the ordinary click into a swap.

   `position: relative` on the <tr> is what the overlay resolves against. Table
   rows were long a grey area for positioning; every current engine handles it,
   and the fallback if one did not would be a link in the first cell — degraded,
   not broken. */
.row-link-row { position: relative; }
.row-link-row:hover td { background: color-mix(in srgb, var(--accent) 6%, transparent); }

.row-link {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--fg);
  text-decoration: none;
  font-weight: 500;
}

.row-link::after { content: ''; position: absolute; inset: 0; }
.row-link:focus-visible { outline: none; }
.row-link-row:focus-within td { background: color-mix(in srgb, var(--accent) 8%, transparent); }
/* Other links in a linked row — the account and the client of a payment
   (views/payments.js) — stand ABOVE the overlay, so they still go where they
   say; everything else in the row belongs to the row's own link. */
.row-link-row [data-row-guard],
.row-link-row td > a:not(.row-link) { position: relative; z-index: 1; }
/* A number cell keeps its right edge when the row's link sits in it. */
.num > .row-link { justify-content: flex-end; }

.row-link-text { display: flex; flex-direction: column; line-height: 1.25; }
.row-link-sub { font-size: 12px; font-weight: 400; color: var(--muted); }

/* The other kind of row: a name that is a link and verbs of its own at the end
   (the accounts table). Same two lines as `.row-link-text`, deliberately WITHOUT
   the `::after` that stretches the anchor over the row — here the row is not one
   link, and an invisible overlay would eat the clicks meant for the buttons. */
.cell-link {
  display: inline-flex;
  flex-direction: column;
  line-height: 1.25;
  color: var(--fg);
  text-decoration: none;
  font-weight: 500;
}

.cell-link:hover .cell-link-name { color: var(--accent); }
.cell-link-sub { font-size: 12px; font-weight: 400; color: var(--muted); }

/* …and the variant with a picture beside the two lines (the people table): the
   avatar and the text sit side by side, the two lines still stack. Same anchor,
   one axis different — a second class would have been a second set of colours
   and hover rules to keep in step. */
.cell-link-row { flex-direction: row; align-items: center; gap: 10px; }
.cell-link-text { display: flex; flex-direction: column; line-height: 1.25; }

/* The verbs, right-aligned and never wrapping: this column is as wide as its
   buttons and the row is read leftwards from it. `width: 1%` is the table
   idiom for "as narrow as the content allows" — the other columns take what is
   left. */
.row-acts-cell { width: 1%; text-align: right; }

.row-acts {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 6px;
  white-space: nowrap;
}

/* `display: contents` so the button inside is a flex item of `.row-acts`
   itself — a form box between them would add its own line box and put the
   button half a pixel out of line with the two icons beside it. */
.row-act-form { display: contents; }

/* Which row is the tenant the rest of the portal is currently reading. Barely
   there on purpose: it is an answer to "where am I", not a warning, and a row
   painted loudly enough to notice from across the table would compete with the
   state chips that are the actual content of this table. */
.data-table tr.is-current td { background: color-mix(in srgb, var(--accent) 5%, transparent); }

/* A contact and whether it is confirmed, in one cell: the mark is an annotation
   on the value, so it sits with it rather than in a column of its own. */
.contact-cell { display: inline-flex; align-items: center; gap: 6px; }
.contact-mark { display: inline-flex; flex: none; }

/* A PHONE NUMBER IS READ WITH ITS FLAG IN FRONT (views/html.phoneText) — the
   reference draws an SVG flag before the number; here it is the two
   regional-indicator symbols, which need no image. Inline-flex keeps the flag
   and the digits on one line whatever the cell does, and the glyph is pinned
   to a size from the registry (design.md) so a flag does not shrink to a
   smudge beside 14px table text. */
.phone-line { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.phone-flag { font-size: 16px; line-height: 1; flex: none; }
.contact-mark.is-ok { color: var(--ok); }
.contact-mark.is-warn { color: var(--warn); }

/* People have short facts and two potentially long text columns. Reserve
   space for the facts; let names and emails share what remains. On narrow
   screens the existing table wrapper scrolls instead of splitting words. */
.users-table { table-layout: fixed; min-width: 1240px; }
.users-table td { white-space: nowrap; overflow-wrap: normal; }
.users-table .user-col-phone { width: 206px; }
.users-table .user-col-birthday { width: 144px; }
.users-table .user-col-lang { width: 68px; }
.users-table .user-col-role { width: 132px; }
.users-table .user-col-status { width: 116px; }
.users-table .user-col-login { width: 166px; }
.users-table .row-acts-cell { width: 96px; }
.users-table .cell-link, .users-table .contact-cell { display: flex; min-width: 0; max-width: 100%; }
.users-table .cell-link-text { min-width: 0; }
.users-table .user-text-clip { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.users-table .user-text-clip .phone-line { display: inline; }
.users-table .user-text-clip .phone-flag { margin-right: 6px; }
.users-table .chip { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.list-count { margin: 0 0 14px; font-size: 13.5px; color: var(--muted); }

.row-open { cursor: pointer; }
.row-open:hover td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.row-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Long, unwrapped values inside the dialog: a provider answer is often one
   very long line, so it wraps rather than pushing the dialog sideways. */
/* Site metadata and the two timestamps each occupy their own line. */
.deployment-run-lines { display: flex; flex-direction: column; gap: 4px; }

/* Collapsible changes remain in the WordPress configuration preview. */
.log-fold > summary { cursor: pointer; color: var(--muted); font-size: 12.5px; }
.log-fold[open] > summary { margin-bottom: 6px; }
.log-fold .log-block { max-height: 320px; overflow: auto; }

/* The branch field of the deployment dialog — the box htmx refills when a
   repository is picked. A block of its own so the swap has one target;
   `min-width: 0` for the same reason every select wrapper here has it. */
.branch-wrap { display: block; min-width: 0; }

.log-block { margin-top: 16px; }
.log-block h4 { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--muted); }

.log-block pre {
  margin: 0;
  padding: 11px 13px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12.5px;
  line-height: 1.5;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 10px;
}

/* Re-indented JSON keeps its own line breaks; only the wrapping of one very
   long value (a message id, a URL) falls back to breaking anywhere. */
.code-json {
  white-space: pre-wrap;
  max-height: 360px;
  overflow: auto;
}

.j-key { color: var(--accent); }
.j-str { color: var(--ok); }
.j-num { color: var(--warn); }
.j-bool, .j-null { color: var(--danger); }
.j-punc { color: var(--muted); }

/* --------------------------------------------------------- email preview */

/* Log dialogs are wide across every channel: their content is dumps — a
   provider's JSON, a message body, a rendered letter — and the default 460px
   turns all of it into a narrow ribbon. The letter needs the most, because the
   template itself is 580px wide. */
.modal-backdrop:has(.log-block) .modal { max-width: 680px; }
.modal-backdrop:has(.mail-preview) .modal { max-width: 760px; }

/* The letter is framed from our own origin so it keeps its inline styles (see
   the preview route). White background regardless of theme: the template is
   light-only, exactly as a mail client renders it. The height is only the
   frame's FIRST word: the letter reports its own (public/js/app.js, the
   preview route), and the frame grows to it, so it never scrolls inside the
   dialog. */
.mail-preview {
  display: block;
  width: 100%;
  height: 560px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: #ffffff;
}

/* The files a logged letter carried (lib/email-attachments): one line per
   file — name, size, «Download» — the reference's list, in the dialog's own
   type. The name wraps anywhere: a 120-character invoice name must not push
   the button off the dialog. */
.mail-files { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.mail-files li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.mail-file-name { font-weight: 600; overflow-wrap: anywhere; }
.mail-file-size { color: var(--muted); }
.mail-files .btn { margin-left: auto; }

.empty-row { text-align: center; padding: 34px 28px; }
.empty-row .muted { margin: 0; }

/* ------------------------------------------------------------- pagination */

.pager {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-top: 16px;
}

.page-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 34px;
  height: 34px;
  padding: 0 10px;
  font-size: 13.5px;
  color: var(--fg);
  text-decoration: none;
  border: 1px solid var(--line);
  border-radius: 9px;
}

a.page-link:hover { border-color: var(--accent); color: var(--accent); }

.page-link.is-current {
  color: var(--accent-fg);
  background: var(--accent);
  border-color: var(--accent);
}

.page-link.is-off { color: var(--muted); opacity: .5; }
.page-gap { padding: 0 2px; color: var(--muted); }

@media (max-width: 620px) {
  .table-bar { flex-direction: column; align-items: stretch; }
}

/* ------------------------------------------------------ vendor fields */
/* Air Datepicker and Tom Select are third-party stylesheets loaded lazily,
   AFTER this file. The `body ` prefix (and matching class counts) makes these
   rules win anyway. Everything is expressed in our own tokens, so light/dark
   follow the app's theme automatically and there is no second palette. */

body .air-datepicker {
  --adp-font-family: inherit;
  --adp-color: var(--fg);
  --adp-color-secondary: var(--muted);
  --adp-accent-color: var(--accent);
  --adp-background-color: var(--card);
  --adp-background-color-hover: color-mix(in srgb, var(--accent) 10%, transparent);
  --adp-background-color-active: color-mix(in srgb, var(--accent) 16%, transparent);
  --adp-border-color: var(--line);
  --adp-border-color-inner: var(--line);
  --adp-border-color-inline: var(--line);
  --adp-border-radius: 12px;
  --adp-cell-border-radius: 9px;
  --adp-day-name-color: var(--muted);
  --adp-day-name-color-hover: var(--accent);
  --adp-color-current-date: var(--accent);
  --adp-cell-background-color-hover: color-mix(in srgb, var(--accent) 10%, transparent);
  --adp-cell-background-color-selected: var(--accent);
  --adp-cell-background-color-selected-hover: var(--accent);
  --adp-cell-background-color-in-range: color-mix(in srgb, var(--accent) 12%, transparent);
  --adp-cell-background-color-in-range-hover: color-mix(in srgb, var(--accent) 20%, transparent);
  --adp-btn-color: var(--accent);
  --adp-btn-color-hover: var(--accent);
  --adp-nav-arrow-color: var(--muted);
  box-shadow: var(--shadow);
}

/* THE WHITE DIAMOND UNDER THE FIELD.
 *
 * Air Datepicker draws a little pointer between the calendar and the input —
 * a square rotated 45°, sitting at `z-index: -1` so only its tip shows. Its
 * colour is the one thing in that stylesheet NOT expressed as a variable:
 *
 *     .air-datepicker--pointer:after { background: #fff; … }
 *
 * A literal, so the whole `--adp-*` palette above sails straight past it. On
 * light it happens to match the card and nobody sees it; on dark it is a white
 * lozenge floating under the field, which is exactly what it looks like — a
 * defect.
 *
 * It goes rather than gets repainted. Nothing else in this app points at its
 * own trigger: Tom Select's dropdown does not (and one of them sits in the very
 * same toolbar, so an arrow on one control and none on its neighbour is the
 * inconsistency this project keeps getting caught by), the modals do not. The
 * calendar opens flush under the field it belongs to, which says the same thing
 * without a decoration that has to be themed.
 *
 * One rule, so it is gone in every datepicker at once — the log filter, Profile
 * → Information, the birthday on a user's card, and whatever comes next. */
body .air-datepicker--pointer { display: none; }

/* THE SAME LITERAL, ONE ROW UP, AND NOBODY HAS COMPLAINED YET.
 *
 * The selected day is `color: #fff` — also written out, also unreachable by the
 * palette. In light that is white on our dark purple and correct by accident.
 * In DARK the accent inverts (`--accent` becomes a pale lilac, `--accent-fg` a
 * deep navy), so white text lands on a light background at roughly 1.9:1 —
 * legible only if you already know which number you clicked.
 *
 * `--accent-fg` is the token that exists precisely to answer "what goes ON the
 * accent", and it flips with the theme. Both selectors carry the class count
 * the vendor uses, plus `body`, so each wins its own duel. */
body .air-datepicker-cell.-selected-,
body .air-datepicker-cell.-selected-.-current-,
body .-selected-.air-datepicker-cell.-year-.-other-decade-,
body .-selected-.air-datepicker-cell.-day-.-other-month- {
  color: var(--accent-fg);
}

/* Tom Select, made to look exactly like our own .field input.

   Careful with specificity: the vendor stylesheet is injected lazily, i.e.
   AFTER this file, and several of its rules carry one class more than the
   obvious selector — notably
       .ts-control, .ts-wrapper.single.input-active .ts-control { background:#fff }
   which repaints the control white the moment the dropdown opens. So every
   state (idle, focus, input-active, dropdown-active) is overridden explicitly
   here; overriding only the idle state leaves an unreadable white box on dark. */

/* `min-width: 0` beside `width: 100%`, and it is not decoration: a selected
   value is one line that never wraps (below), so its intrinsic width is the
   whole sentence — and a grid or flex ancestor sizes its track by that
   intrinsic width unless told not to. In the company dialog (a grid) picking
   «Самозайнята особа — Адвокат, приватний нотаріус…» pushed the select and
   every field under it past the dialog's edge (the owner, 22.09.2026). The
   wrapper now shrinks to its box and the value clips with an ellipsis. */
body .ts-wrapper { width: 100%; min-width: 0; max-width: 100%; }

body .ts-wrapper.single .ts-control,
body .ts-wrapper.multi .ts-control,
body .ts-wrapper.single.input-active .ts-control,
body .ts-wrapper.single.dropdown-active .ts-control,
body .ts-wrapper.single.focus .ts-control {
  min-height: 0;
  padding: 10px 34px 10px 12px;
  font: inherit;
  color: var(--fg);
  background-color: var(--field);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: none;
  cursor: pointer;
  transition: border-color .15s ease, box-shadow .15s ease;
}

/* One line, always. `.ts-control` is a flex container with `flex-wrap: wrap`,
   so a long value like "Europe/Kyiv (GMT+3)" beside anything else wrapped onto
   a second row and the control grew to double height. Nothing here has to share
   the line any more (the search box moved into the dropdown, see below), but
   the guard stays: an item is still allowed to be longer than its box. */
body .ts-wrapper.single .ts-control {
  flex-wrap: nowrap;
  overflow: hidden;                  /* a long value must not push the chevron out */
}

body .ts-wrapper.single .ts-control .item {
  min-width: 0;                      /* a flex item may shrink below its sentence */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* THE SEARCH BOX OF A SINGLE SELECT, which lives at the top of the dropdown and
 * not in the control — the arrangement select2 has always had and the reason
 * the reference project never fought any of this (public/js/fields.js explains
 * the choice). The vendor draws it as a bare white strip with an inset shadow;
 * here it is the first row of our own dropdown, divided from the list by the
 * same hairline everything else uses. */
body .ts-dropdown .dropdown-input-wrap { padding: 0; }

body .ts-dropdown .dropdown-input {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
  color: var(--fg);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0;
  box-shadow: none;
  outline: none;
}

body .ts-dropdown .dropdown-input::placeholder { color: var(--muted); }

/* The control keeps our focus ring while the dropdown is open; the vendor
   replaces it with a flat grey border for this plugin. */
body .ts-wrapper.plugin-dropdown_input.focus.dropdown-active .ts-control {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

/* With the real input gone from the control, the plugin leaves a stand-in there
   to carry the placeholder while nothing is chosen. It is an <input>, so it
   inherits the field colours above; it must not be typed into. */
body .ts-wrapper.plugin-dropdown_input .items-placeholder {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0;
  background: transparent;
  border: 0;
  box-shadow: none;
  cursor: pointer;
}

/* The vendor draws its own caret as a CSS triangle; we already have the
   chevron above, so hide theirs instead of showing two. */
body .ts-wrapper.single .ts-control::after { display: none; }

body .ts-wrapper.focus .ts-control,
body .ts-wrapper.dropdown-active .ts-control {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

body .ts-wrapper .ts-control > input,
body .ts-wrapper .ts-control .item {
  color: var(--fg);
  font: inherit;
}

body .ts-wrapper .ts-control > input::placeholder { color: var(--muted); }
body .ts-wrapper.disabled .ts-control { opacity: .6; }

/* Multi-select chips. The vendor ships them in its own blue with a gradient and
   a text-shadow; repaint them in our accent so a multi-select matches everything
   else. `.remove` is the little × added by the remove_button plugin. */
body .ts-wrapper.multi .ts-control > div,
body .ts-wrapper.multi .ts-control [data-value] {
  margin: 0 4px 0 0;
  padding: 3px 4px 3px 9px;
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  background-image: none;
  border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
  border-radius: 7px;
  box-shadow: none;
  text-shadow: none;
  font-size: 13.5px;
}

body .ts-wrapper.multi .ts-control [data-value].active {
  background: color-mix(in srgb, var(--accent) 24%, transparent);
  background-image: none;
  border-color: var(--accent);
}

body .ts-wrapper.plugin-remove_button .item .remove {
  margin-left: 6px;
  padding: 0 5px;
  color: inherit;
  border-left: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
  border-radius: 0 6px 6px 0;
  opacity: .75;
}

body .ts-wrapper.plugin-remove_button .item .remove:hover {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  opacity: 1;
}

body .ts-wrapper.multi.has-items .ts-control { padding: 7px 34px 7px 9px; }

/* THE SEARCH BOX IN A MULTI-SELECT HAS TO FIT.
 *
 * Tom Select puts a real text input beside the chips — that is what makes a
 * multi-select searchable — and gives it `min-width: 7rem !important`. With
 * chips as long as `kapliavv_lushburychemicals` there are not seven rem left on
 * the line, so it wrapped out of sight and the control read as a list you can
 * only click through. Letting it shrink puts the caret back beside the chips;
 * letting the control wrap gives it a second line when even that is too tight.
 *
 * `!important` because the vendor's is — and ours wins on specificity, which is
 * how two importants are resolved. */
body .ts-wrapper.multi .ts-control {
  flex-wrap: wrap;
  row-gap: 4px;
}
body .ts-wrapper.multi .ts-control > input {
  min-width: 4rem !important;
  flex: 1 1 4rem !important;
}

body .ts-dropdown,
body .ts-dropdown.single {
  margin-top: 5px;
  overflow: hidden;
  font: inherit;
  color: var(--fg);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: var(--shadow);
  z-index: 170;                      /* above the modal backdrop (160) */
}

body .ts-dropdown .ts-dropdown-content { max-height: 260px; overscroll-behavior: contain; }
body .ts-dropdown .option { padding: 9px 12px; font-size: 14px; color: var(--fg); }

body .ts-dropdown .option.active,
body .ts-dropdown .option:hover {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}

/* The typed-substring highlight ships with a pale blue that vanishes on dark. */
body .ts-dropdown [data-selectable] .highlight {
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 22%, transparent);
}

body .ts-dropdown .no-results { padding: 9px 12px; font-size: 14px; color: var(--muted); }

/* Group headings («Фізичні особи», «Підприємства й товариства» — the payer
   picker of views/companies.js). The vendor paints them white on #303030,
   which is a white bar across the dark dropdown (the owner, 22.09.2026); in
   our tokens they read as a quiet caption, muted and small, with a hairline
   above every group but the first. */
body .ts-dropdown .optgroup { border-top: 1px solid var(--line); }
body .ts-dropdown .optgroup:first-child { border-top: 0; }
body .ts-dropdown .optgroup-header {
  padding: 9px 12px 4px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--card);
}

/* intl-tel-input — the country picker on every phone field
   (`[data-phone-mask]`, public/js/fields.js). Same arrangement as the two
   above: the vendor sheet arrives lazily and speaks in its own variables, so
   the palette is handed to it in our tokens and the dark theme costs nothing
   more. Declared on `.iti` rather than `:root`, because the vendor sets its
   defaults on `:root` AFTER this file — and both the inline widget and the
   detached full-screen popup carry the class, so one block reaches both.

   The flag sprite is ours to place: the vendored sheet is the «no-assets»
   build, which leaves the two paths undeclared, and the images sit under
   /img/ next to the rest of ours rather than at the vendor's `../img/`. */
:root {
  --iti-path-flags-1x: url(/img/flags.webp);
  --iti-path-flags-2x: url(/img/flags@2x.webp);
}

body .iti {
  --iti-border-color: var(--line);
  --iti-country-selector-bg: var(--card);
  --iti-hover-color: color-mix(in srgb, var(--accent) 10%, transparent);
  --iti-icon-color: var(--muted);
  --iti-spacer-horizontal: 12px;
  --iti-arrow-padding: 6px;
  display: block;
  width: 100%;
}

/* The widget's own dropdown, like Tom Select's: our card, our line, our
   shadow and our radius — the vendor's `box-shadow: 1px 1px 4px rgba(0,0,0,.2)`
   is a literal, so it is overridden here rather than themed. */
body .iti .iti__country-selector {
  margin-top: 5px;
  border-radius: 10px;
  box-shadow: var(--shadow);
  overflow: hidden;
  z-index: 170;                      /* above the modal backdrop (160) */
}
body .iti .iti__country-list { max-height: 260px; overscroll-behavior: contain; }
body .iti .iti__country { font-size: 14px; color: var(--fg); }
body .iti .iti__country.iti__highlight { color: var(--accent); }
body .iti .iti__dial-code { color: var(--muted); }
body .iti .iti__search-input {
  font: inherit;
  color: var(--fg);
  background: var(--field);
  border-radius: 0;
}
body .iti .iti__search-input::placeholder { color: var(--muted); }
body .iti .iti__search-input:focus { outline: none; }
body .iti .iti__no-results { color: var(--muted); }
/* The flag button hugs the left edge of the field: round the corner with it. */
body .iti .iti__selected-country { border-radius: 10px 0 0 10px; }
body .iti .iti__selected-country-primary { border-radius: 10px 0 0 10px; }
/* On a phone the list takes the screen; the backdrop is a literal too. */
body .iti.iti--fullscreen-popup { background-color: rgb(0 0 0 / 70%); }
body .iti.iti--fullscreen-popup .iti__country-selector { margin-top: 0; }

/* ---------------------------------------------------------- app pages */

.shell { padding: 40px 20px; display: grid; place-items: start center; }

.card {
  width: 100%;
  max-width: 560px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 28px;
  box-shadow: var(--shadow);
}

.facts {
  margin: 0;
  display: grid;
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: 10px;
  overflow: hidden;
}

.facts > div {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 10px 14px;
  background: var(--card);
}

.facts dt { color: var(--muted); }
.facts dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }

.foot { margin: 20px 0 0; font-size: 14px; }


/* ------------------------------------------------------- grouped fields */
/* Several inputs that answer ONE question — the sign-in note in Ukrainian and
   in English. A shared title above them says the question once, instead of
   repeating most of it in two labels. */

.field-group { display: grid; gap: 10px; }

/* Title on the left, the language switch on the right, one line. The switch
   belongs beside the question it re-asks, not above the answer. */
.field-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}

.field-group-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--fg);
}

/* The strip is the settings-wide one (views/subtabs.js); inside a field group it
   is a two-letter toggle rather than a row of provider names, so it is tightened
   to match the label it sits next to. */
.field-group-head .subtabs { padding: 3px; border-radius: 9px; }
.field-group-head .subtab {
  padding: 4px 10px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: .02em;
  border-radius: 6px;
}

/* Present for a screen reader, absent for everyone else. Each editor still needs
   a name — the strip above says which language is showing, but a label read out
   loud is what makes the field self-describing without it. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* -------------------------------------------------------- rich text (Quill) */
/* The vendor ships its own palette — #ccc borders, white surfaces, black ink —
   which is a light theme nailed down. Everything below re-points it at our
   tokens, so the editor follows the app into dark mode instead of glowing.
   Prefixed with `body` for the same reason Tom Select is: it beats the vendor
   sheet no matter which order the two get loaded in. */

body .ql-toolbar.ql-snow,
body .ql-container.ql-snow {
  border-color: var(--line);
  font-family: inherit;
}

body .ql-toolbar.ql-snow { border-radius: 10px 10px 0 0; background: var(--bg); }

body .ql-container.ql-snow {
  border-radius: 0 0 10px 10px;
  background: var(--card);
  color: var(--fg);
  font-size: 14px;
}

/* Two lines empty, growing with the content — the same shape the project's
   textarea rule (§4.3) gives every other multi-line field. */
body .ql-editor {
  min-height: 4.4em;
  max-height: 320px;
  padding: 10px 12px;
  line-height: 1.6;
}

/* WHAT IS TYPED LOOKS LIKE WHAT IS PRINTED. The vendor zeroes every block
   (`.ql-editor p { margin: 0 }`) and sets its own line-height, so three
   paragraphs ran together into one grey slab and a paragraph could not be
   told from a line break — the reference's TinyMCE showed them apart (the
   owner, 19.09.2026). The pane takes `.prose`'s rhythm instead: the same
   line-height above, the same 10px between blocks and 4px between items.
   Quill's own blocks — `<p>` and, for BOTH kinds of list, one `<ol>` with
   `data-list` on the items — so the child selector covers them without
   naming them. Anchored on the host, not on `body`: the vendor's sheet is
   appended by editor.js AFTER this one, and its `.ql-editor p` weighs the
   same as `body .ql-editor > * + *` would — a tie the later sheet wins.
   Two classes beat it whatever the order. */
.editor-host .ql-editor > * + * { margin-top: 10px; }
.editor-host .ql-editor li + li { margin-top: 4px; }

body .ql-editor.ql-blank::before { color: var(--muted); font-style: normal; left: 12px; }

/* The toolbar icons are strokes and fills on <svg>, not text, so they need
   pointing at the token individually. */
body .ql-snow .ql-stroke { stroke: var(--muted); }
body .ql-snow .ql-fill { fill: var(--muted); }
body .ql-snow .ql-picker { color: var(--muted); }

body .ql-snow .ql-button:hover .ql-stroke,
body .ql-snow button:hover .ql-stroke,
body .ql-snow button.ql-active .ql-stroke { stroke: var(--accent); }
body .ql-snow button:hover .ql-fill,
body .ql-snow button.ql-active .ql-fill { fill: var(--accent); }
body .ql-snow button:hover, body .ql-snow button.ql-active { color: var(--accent); }

/* The link tooltip is a floating card of its own. */
body .ql-snow .ql-tooltip {
  background: var(--card);
  border-color: var(--line);
  color: var(--fg);
  box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
  border-radius: 10px;
}

body .ql-snow .ql-tooltip input[type='text'] {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--fg);
  padding: 5px 8px;
}

body .ql-snow .ql-tooltip a.ql-action,
body .ql-snow .ql-tooltip a.ql-remove,
body .ql-snow .ql-tooltip a { color: var(--accent); }

/* The editor replaces a textarea; without this the field it grew out of would
   still take up space in the grid. */
.editor-host { min-width: 0; }

/* THE PANE SIZES TO ITS TEXT, not to its host. The vendor sets
   `.ql-container { height: 100% }` for people who give the editor a box of a
   fixed size. Here the host is a grid item, and a grid stretches its items to
   the row — so the host is definite, and 100% of it is the toolbar's height
   PLUS the pane's: the pane grew by one toolbar and ran out of the bottom of
   the host, under the dialog's buttons. Auto lets `.ql-editor`'s own min- and
   max-height (above) decide, which is what every other field here does. */
.editor-host .ql-container.ql-snow { height: auto; }


/* ---------------------------------------------------------------- avatar */
/* One class for all three sources: a <span> with a letter, or an <img> with a
   photo. Same box either way, so switching source never moves the layout. */

/* The letter is the element; the photo is a layer over it. So a picture that
   never arrives leaves the letter showing instead of a hole — see
   views/layout.avatarMark. */
.avatar { position: relative; overflow: hidden; }

.avatar-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

/* Hidden by public/js/app.js when the image fails to load. */
.avatar-photo[hidden] { display: none; }

/* The size used on the Avatar tab's option cards. */
.avatar-lg {
  width: 72px;
  height: 72px;
  font-size: 28px;
}

.avatar-empty {
  color: var(--muted);
  background: var(--bg);
  border: 1px dashed var(--line);
}

/* A UNIT INSIDE THE BOX, and the ONLY mechanism in the app for it. "Largest
   file" with "MB" against the right edge reads as one thing, while "Largest
   file, MB" reads as a question about units. Used by Медіа, Безпека, ціни ШІ
   and the price on an account card.
 *
   THERE WERE THREE OF THESE, and that is how the AI prices broke. Two of them
   were named `.input-suffix` — in one the class was the WRAPPER, in the other
   the SYMBOL — so the later rule made every price wrapper on the AI tab
   `position: absolute`, and the inputs flew out of the card. Nothing in a diff
   shows that; the two rules sat 1000 lines apart and each looked right.
 *
   Hence the rule, not just the repair: one idea, one pair of names, and
   `tests/test-interface.js` refuses the old ones. Two names for one thing are
   two things as soon as somebody styles one of them.
 *
   The symbol is a real `<span class="field-suffix">`, not `::after` on a data
   attribute: a span can be rewritten by script when the currency button group
   changes, and pseudo-element content cannot. */
.field-with-suffix {
  position: relative;
  display: block;
  /* `.field > span` is the LABEL style, and this span is a direct child too —
     without this it would be small, bold and muted. */
  font: inherit;
  color: var(--fg);
}

.field-with-suffix input { padding-right: 46px; }

.field-suffix {
  position: absolute;
  top: 50%;
  right: 12px;
  transform: translateY(-50%);
  font-size: 13px;
  color: var(--muted);
  pointer-events: none;      /* the whole box stays clickable */
}

/* A number never needs the width of a card. Inside `.field-row` the column has
   already decided the width, so the cap steps aside. */
.field-num .field-with-suffix { max-width: 190px; }
.field-row .field-num .field-with-suffix { max-width: none; }

/* Tick boxes that belong to one question, side by side until they run out of
   room. Formats, and whatever the next closed catalogue turns out to be. */
.check-row { display: flex; flex-wrap: wrap; gap: 10px 18px; }

.avatar-options { display: grid; gap: 12px; }

/* Picture, words, action — one row that becomes two on a narrow screen. */
.avatar-option {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: 14px 16px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 12px;
}

/* The one in use is marked by its border, not by a badge alone: the eye finds
   the outlined card before it reads any label. */
.avatar-option.is-current { border-color: var(--accent); }

.avatar-option-face { flex: none; }
.avatar-option-text { display: grid; gap: 2px; flex: 1 1 200px; min-width: 0; }
.avatar-option-text strong { font-size: 14px; }
.avatar-option-text small { font-size: 12.5px; color: var(--muted); }

.avatar-upload-actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* ------------------------------------------------------------ the cropper */

/* The canvas is 320×320 of real pixels but is shown at whatever width fits —
   the script converts pointer coordinates by the ratio, so dragging still
   tracks the finger exactly. */
.crop-stage {
  display: grid;
  place-items: center;
  padding: 4px 0 12px;
}

#crop-canvas {
  width: min(320px, 70vw);
  height: auto;
  aspect-ratio: 1;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 50%;
  cursor: grab;
  touch-action: none;      /* the drag is ours; the page must not pan under it */
}

#crop-canvas:active { cursor: grabbing; }

/* Uploading: the dialog stops accepting input and says so. Same two-part
   vocabulary as a busy form (§4.4) — frozen, and visibly not yours to press. */
.modal-backdrop.is-busy .modal { pointer-events: none; opacity: .75; }

#crop-zoom { width: 100%; accent-color: var(--accent); }

/* ------------------------------------------------------------ dashboard widgets

   THE ARRANGEMENT IS THE READER'S, and the server draws it — the column count,
   the order, which ones are folded (lib/dashboard). So there is nothing here
   for a script to correct after load, and nothing to see assembling itself.

   REAL COLUMN ELEMENTS, not CSS multi-column. `column-fill` decides where a
   column breaks, and "put it here" is precisely what a drag has to be able to
   say; with multicol a card dropped between two visible neighbours can land in
   a different column, and folding one widget reshuffles the ones nobody
   touched. Columns as elements cost one wrapper and answer both.

   `.widget` and not `.card`: a card is capped at 560px, which is right for a
   form and wrong for a column that already has a width. */

.dash {
  display: grid;
  grid-template-columns: repeat(var(--dash-columns, 2), minmax(0, 1fr));
  align-items: start;
  gap: var(--widget-gap);
}

.dash[data-columns="3"] { --dash-columns: 3; }

/* ONE GAP FOR BOTH AXES, declared once. Widgets sit in a grid across and in a
   flex column down, and two numbers for one visual rhythm drift apart the first
   time one of them is nudged. */
.dash { --widget-gap: 20px; }

.dash-col {
  display: flex;
  flex-direction: column;
  gap: var(--widget-gap);
  min-width: 0;
  /* An empty column still has to be a drop target, or a widget dragged out of
     one can never be dragged back in. */
  min-height: 72px;
}

.widget {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px;
  overflow: hidden;
}

/* THE HEADER IS THE DRAG SURFACE, so it says so with the cursor and refuses to
   select. Without `user-select: none` a drag that starts on the title selects
   the text and the browser drags a line of text instead of the card — the same
   lesson the sidebar already paid for. */
.widget-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px var(--widget-pad);
  background: color-mix(in srgb, var(--fg) 3.5%, var(--card));
  border-bottom: 1px solid var(--line);
  -webkit-user-select: none;
  user-select: none;
}

.widget-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: 15px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* THE GRIP IS SHOWN WHERE THE GESTURE EXISTS, and that is a question about the
   pointer, never about the width. A tablet in landscape is 1024pt and a touch
   laptop is wider still; hiding the grip below 900px would leave it visible on
   both and promise a drag that mouse-only code would never start. The gesture
   itself is pointer-based, so it works on touch either way — this only decides
   whether the mark is worth drawing. */
.widget-grip { display: none; color: var(--muted); }

@media (hover: hover) and (pointer: fine) {
  .widget-grip { display: inline-flex; cursor: grab; }
  .widget-head { cursor: grab; }
  .widget-head:active { cursor: grabbing; }
}

/* Nothing is draggable without scripts, so the mark must not be there either —
   and the header stops claiming to be a handle. */
.no-js .widget-grip { display: none; }
.no-js .widget-head, .no-js .widget-head:active { cursor: default; }

.widget-fold { display: flex; margin: 0; }

.widget-toggle svg { transition: transform .18s ease; }
.widget[data-folded] .widget-toggle svg { transform: rotate(-90deg); }
.widget[data-folded] .widget-head { border-bottom: 0; }

/* ONE INSET, ALL FOUR SIDES. `--widget-pad` is declared on the widget so the
   header and the body cannot drift apart, and so that what is inside can
   subtract it back where padding would otherwise stack (see the list below). */
.widget { --widget-pad: 14px; }

.widget-body { padding: var(--widget-pad); display: grid; gap: 16px; }
.widget-body[hidden] { display: none; }

/* The card being carried, and the hole it left. The placeholder keeps the
   column from collapsing under the pointer mid-gesture. */
.widget.is-dragging {
  opacity: .9;
  box-shadow: var(--shadow);
  border-color: var(--accent);
}

.widget-slot {
  border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 14px;
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}

.dash-col.is-target { outline: none; }

/* The column picker beside the page title. */
/* WHAT A WIDGET OPENS WITH: a mark, a name and the state of the thing named,
   read as one line rather than as two ends of a row. Its own class on purpose —
   `.conn-head` means "an action and the fact it produced" and pushes its two
   children apart, which is why this looked right only while it sat inside
   `.account-card` and wrong the moment it moved into a widget. */
.widget-lede { display: flex; align-items: center; gap: 14px; }
/* ONE LINE, ONE SENTENCE: the name, then the chip, then the day and how far it
   is — left to right, so the lede has no second storey and no hole at its
   right end. The stacked form this overrides belongs to the account CARD,
   where the name is a page heading and the metadata earns a line of its own;
   in a widget the whole lede is one glance. Baseline, not center: a chip and
   two type sizes only look level when their text sits on one line. Wrap stays
   on for a narrow column — the metadata folds under the name as a unit. */
.widget-lede .account-head-text {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 10px;
  min-width: 0;
}
/* The account name keeps the h3 size the rest of the app uses. It was 17px —
   larger than the widget's own `h2` title (15px), so the deeper heading was
   the bigger one. What separates the two now is WEIGHT (650 against 600),
   which is the axis this system keeps for exactly that. */
.widget-lede h3 { margin: 0; }

/* A LIST INSIDE A WIDGET DROPS ITS OWN FRAME. `.facts` draws a bordered, rounded
   box because elsewhere it stands alone on a plain card; inside a widget that is
   a box inside a box, and the eye reads the inner one as a second card that lost
   its title. The rows keep their hairlines — the separation is what carries the
   pairs — and the widget's own padding gives the list its edges. Same rule as
   §"групуємо простором, а не лініями": one subject, one frame. */
.widget-body .facts {
  border: 0;
  border-radius: 0;
  background: transparent;
  gap: 0;
}
.widget-body .facts > div {
  padding: 11px 0;
  border-top: 1px solid var(--line);
}
.widget-body .facts > div:first-child { border-top: 0; }

/* PADDING MUST NOT STACK AT THE EDGES. The body already insets by
   `--widget-pad`; a row that adds its own 11px on top of that puts the first
   line 25px from the top and 14px from the left, and the eye reads the widget
   as taller than it is wide-margined. So the rows that touch the body's own
   edges give theirs up — and only those: a row in the middle keeps the rhythm
   that makes the pairs readable.

   `> .facts:first-child` and not `.facts`, because the list is only AT the edge
   when nothing precedes it. With a lede above, the first row is in the middle of
   the body and keeps its padding. */
.widget-body > .facts:first-child > div:first-child { padding-top: 0; }
.widget-body > .facts:last-child > div:last-child { padding-bottom: 0; }

/* The lede already ends in a hairline of its own when a list follows it. */
.widget-body .widget-lede + .facts > div:first-child { border-top: 1px solid var(--line); }

/* The widget's one action, said quietly where the values already are: the
   right column, under the last row. A boxed button here was the loudest thing
   in the widget for the least consequential act on it, and the hole to its
   right read as something missing.

   NO MARGIN OF ITS OWN — the body is a grid, and a margin inside a grid ADDS
   to the gap instead of collapsing into it (the .card-head reset above paid
   for that lesson first). The 16px the body already deals between children is
   this line's whole distance from the table. Underline only on hover, the way
   .linkish and the crumbs do it: a resting row of the widget should not dress
   louder than the values above it. */
.widget-more { margin: 0; font-size: 13.5px; text-align: right; }
.widget-more a { text-decoration: none; }
.widget-more a:hover { text-decoration: underline; }

.dash-columns { display: flex; align-items: center; gap: 10px; margin: 0; }
.dash-columns-label { font-size: 13px; color: var(--muted); }

/* ONE COLUMN ON A PHONE, and the order comes from the server too — read across
   the saved columns rather than down them, so the top of the second column
   stays near the top here instead of falling below everything in the first
   (lib/dashboard.rowMajor). */
@media (max-width: 900px) {
  .dash { grid-template-columns: minmax(0, 1fr); }
  /* The saved 2/3-column choice only affects desktop. Hide its whole slot so
     an empty flex row cannot leave a gap below the mobile page heading. */
  .dash-columns,
  .page-head-aside:has(.dash-columns) { display: none; }

  /* The columns stop being boxes and hand their widgets straight to the grid,
     which is the only way `order` can reach across them. Without this the phone
     would read column one to the end, then column two — burying the top of the
     second column, which is exactly the spot somebody chose for being visible. */
  .dash-col { display: contents; }
}

/* `order` comes from the server (`data-row`, lib/dashboard.rowMajor) rather than
   from an inline style, because `style="…"` is blocked by our CSP. Twenty is not
   a limit on widgets — anything past it simply keeps document order, which for a
   twenty-first widget is the same answer. */
@media (max-width: 900px) {
  .widget[data-row="1"] { order: 1; }
  .widget[data-row="2"] { order: 2; }
  .widget[data-row="3"] { order: 3; }
  .widget[data-row="4"] { order: 4; }
  .widget[data-row="5"] { order: 5; }
  .widget[data-row="6"] { order: 6; }
  .widget[data-row="7"] { order: 7; }
  .widget[data-row="8"] { order: 8; }
  .widget[data-row="9"] { order: 9; }
  .widget[data-row="10"] { order: 10; }
  .widget[data-row="11"] { order: 11; }
  .widget[data-row="12"] { order: 12; }
  .widget[data-row="13"] { order: 13; }
  .widget[data-row="14"] { order: 14; }
  .widget[data-row="15"] { order: 15; }
  .widget[data-row="16"] { order: 16; }
  .widget[data-row="17"] { order: 17; }
  .widget[data-row="18"] { order: 18; }
  .widget[data-row="19"] { order: 19; }
  .widget[data-row="20"] { order: 20; }
}

/* ------------------------------------------------- the staff widgets (dw-) */
/* The owner's six wp-admin widgets, ported one to one (views/dashboard-widgets).
   The prefix is the reference's own — its styles were `.dw-*` too — and every
   colour it wrote as a hex literal is a token here. */

/* THREE TILES IN A ROW, and a row that wraps: a sum of seven digits at 22px
   does not fit a third of a two-column widget on a laptop, and a tile that
   folds under its neighbours reads better than a number that breaks. The
   reference's `flex: 1 1 140px`, kept. */
.dw-metrics { display: flex; flex-wrap: wrap; gap: 10px; }
.dw-metric {
  flex: 1 1 140px;
  min-width: 0;
  display: grid;
  gap: 4px;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: color-mix(in srgb, var(--fg) 3%, var(--card));
}
.dw-metric-value {
  font-size: 22px;
  font-weight: 650;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.dw-metric-label { font-size: 12px; color: var(--muted); }
.dw-metric.is-accent .dw-metric-value { color: var(--accent); }
.dw-metric.is-positive .dw-metric-value { color: var(--ok); }

/* The reference's «red / orange / green» for days left, and its green / red
   for active against expired — said through the tokens, never as a colour. */
.dw-ok { color: var(--ok); font-weight: 600; }
.dw-bad { color: var(--danger); font-weight: 600; }
.dw-soon { color: var(--warn); font-weight: 600; }

/* THE PERIOD BOX. A form with no button (the calendar submits it) and, under
   it, whatever the period holds. A grid of its own because the box is ONE
   item of the widget body's grid, and its parts need a rhythm of their own. */
.dw-range-box { display: grid; gap: 12px; min-width: 0; }
.dw-range { margin: 0; }
.dw-range-field { max-width: 280px; }
.dw-range-field input[readonly] { cursor: pointer; }
/* Without scripts the calendar never comes, so the box reads as the plain
   text it is — the default period, stated — rather than as a field that
   accepts nothing. */
.no-js .dw-range-field input {
  border-color: transparent;
  background: transparent;
  padding-left: 0;
  pointer-events: none;
}
.no-js .dw-range-field input[readonly] { cursor: default; }

.dw-total { margin: 0; font-size: 24px; font-weight: 650; line-height: 1.2; font-variant-numeric: tabular-nums; }
.dw-sub { margin: 0; font-size: 13px; }

/* A TABLE INSIDE A WIDGET DROPS ITS FRAME AND GOES TO THE EDGES — the
   reference's «flush tables: edge-to-edge, no own border, a top divider».
   The same reasoning as `.widget-body .facts` above: the widget is the frame,
   and a bordered box inside it reads as a second card. The negative margins
   give the body's inset back on three sides; the table's own head sits
   directly under the widget's when nothing precedes it. */
.widget-body .dw-table {
  border: 0;
  border-radius: 0;
  border-top: 1px solid var(--line);
  margin: 0 calc(-1 * var(--widget-pad)) calc(-1 * var(--widget-pad));
}
.widget-body > .dw-table:first-child { margin-top: calc(-1 * var(--widget-pad)); border-top: 0; }
.widget-body .dw-table .data-table td { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
/* Nothing folds inside a widget table — the reference's «white-space:nowrap»
   on every cell of the data widgets: a widget is a narrow column, and a table
   that folds names, chips and verbs to fit reads as a broken layout; one that
   scrolls sideways inside its .table-wrap reads as a table. */
.widget-body .dw-table .data-table th,
.widget-body .dw-table .data-table td { white-space: nowrap; }
.dw-rank { width: 1%; color: var(--muted); }
.dw-amount { font-weight: 600; white-space: nowrap; }
.dw-count { font-weight: 400; }

/* THE TEST PANEL: three small forms behind a strip, each the width of the
   widget rather than the 560px a settings form takes. */
.dw-test { max-width: none; gap: 12px; }
/* The outcome of a test, printed as the form's first line when the widget
   submits in place (routes/app.js testAnswer) — the reference's result box. */
.dw-test-result { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; font-weight: 600; }
.dw-test-result .icon { flex: none; }
.dw-test-result.is-ok { color: var(--ok); }
.dw-test-result.is-bad { color: var(--danger); }
/* A channel that cannot send says so over its disabled button — the
   reference's red sentence, in the token that means «this stops you». */
.dw-off { color: var(--danger); }

/* --------------------------------------------------- the tenant's own sites */

/* THE INSIDE OF A SITE — the reference portal's table, drawn with our tokens.
   A bordered, rounded frame; one row per kind; the label column separated from
   the values by a hairline, exactly the way the old portal ruled its table.
   The grid is what keeps the labels aligned across a site with two databases
   and one with none.

   `.site-card` and `.site-name` used to live here: a site was a card of its own
   and its name an `<h2>`. Both went when the section joined the shape Domains,
   Mailboxes and Companies already had. */
.site-facts {
  display: grid;
  /* The label column is AS WIDE AS ITS WIDEST LABEL, decided once for the
     whole table: rows adopt this template through subgrid below, so «Бази
     даних» and «Адреса сайту й піддомени» sit in one column instead of each
     row measuring its own. A nailed-down 190px did neither — the long label
     wrapped while the value column kept the leftover emptiness. */
  grid-template-columns: max-content minmax(0, 1fr);
  gap: 0;
  margin: 0;
  border: 1px solid var(--line);
  border-radius: 12px;
  overflow: hidden;
}

.site-fact {
  display: grid;
  grid-column: 1 / -1;
  /* The first line is the FALLBACK for a browser without subgrid: it reads an
     invalid second declaration, drops it, and keeps the fixed column — the
     table it gets is yesterday's, not a broken one. */
  grid-template-columns: 190px minmax(0, 1fr);
  grid-template-columns: subgrid;
  /* THE RHYTHM: 16 outside, 16 to the rule, 16 after it — the same third
     everywhere, so the hairline stands in the middle of a 32px gutter instead
     of grazing the longest label. With max-content sizing the widest label
     touches its column edge BY DEFINITION; the breathing room must therefore
     live in the gap, not in the column. */
  gap: 32px;
  padding: 14px 16px;
  /* The label faces the MIDDLE of its value: «Закінчується» against both the
     day and the «лишилось N дн.» under it, not against the first line alone.
     Said explicitly — the default stretch leaves where the words sit inside
     the cell to chance. */
  align-items: center;
}
.site-fact dt { white-space: nowrap; }

/* A LIGHT ZEBRA AND A QUIET HOVER, in the fold tables only. The stripe is a
   few percent of the foreground so it survives both themes without a second
   pair of values; the hover leans on the accent instead — a row under the
   mouse is a row being CHOSEN, and choice is what the accent means here.
   The admin data tables are untouched: this dresses the client's folds, and
   repainting every table in the app is a decision, not a side effect. */
.site-fact:nth-child(even) { background: color-mix(in srgb, var(--fg) 3%, transparent); }
.site-fact { transition: background-color .15s ease; }
.site-fact:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }

.list-panel .data-table tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--fg) 3%, transparent); }
.list-panel .data-table tbody tr { transition: background-color .15s ease; }
.list-panel .data-table tbody tr:hover td { background: color-mix(in srgb, var(--accent) 7%, transparent); }
/* The reference separated its rows and its columns with the same hairline. */
.site-fact + .site-fact { border-top: 1px solid var(--line); }
.site-fact dt { position: relative; }
.site-fact dt::after {
  content: '';
  position: absolute;
  top: -14px;
  bottom: -14px;
  right: -16px;   /* the middle of the 32px gutter — 16 from either neighbour */
  border-right: 1px solid var(--line);
}

.site-fact dt {
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
}

.site-fact dt svg { flex: none; opacity: 0.75; }

/* The values wrap: a site with five repositories is a normal site, and a line
   that scrolls sideways is not. */
.site-fact dd {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 10px;
  align-items: center;
  margin: 0;
  font-size: 14px;
}

.site-link {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  text-decoration: none;
}

.site-link:hover { text-decoration: underline; }

/* AND IT MAY BE A BUTTON. The database download has to be a POST — a GET would
   let any page in the world start a dump — but it stands on the same line as
   the repository's download, which is a link. Two controls doing the same job
   must not look like two different things, so the button borrows the link's
   look rather than gaining a class of its own. */
button.site-link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent);
  cursor: pointer;
}
.site-link svg { flex: none; opacity: 0.7; }

/* A PILL OF JOINED SEGMENTS — the reference portal's linksLine. One connected
   control per thing: the address first, then icon segments for what can be
   done with it. Joined by sharing borders (each segment after the first drops
   its left one), rounded only at the group's ends; the classes come from the
   server because a segment may be a FORM around a button, and :first-of-type
   cannot see positions through mixed tags (views/tenant-sites, segLine). */
.links-line { display: flex; align-items: stretch; }

/* SCOPED TO THE LINE. `.seg` on its own once leaked into the language switch
   and the materials view switch, which had used the same word for years —
   a border, a hover fill and a min-height arrived on controls that had no
   idea. The links line owns these segments, and the rule says so. */
.links-line .seg {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 12px;
  min-height: 28px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 500;
  line-height: 1;
  color: var(--fg);
  text-decoration: none;
  background: var(--bg);
  border: 1px solid var(--line);
  border-left-width: 0;
  cursor: pointer;
  transition: color .15s ease, background-color .15s ease, border-color .15s ease;
}
.links-line .seg svg { flex: none; opacity: 0.7; }

.links-line .seg-first { border-left: 1px solid var(--line); border-radius: 12px 0 0 12px; }
.links-line .seg-last { border-radius: 0 12px 12px 0; }
.links-line .seg-only { border-left: 1px solid var(--line); border-radius: 12px; }

/* The reference filled a hovered segment with its purple; ours fills with the
   theme's accent, and the icon gives up its transparency so it does not read
   as a faded copy of the text beside it. */
.links-line .seg:hover { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
.links-line .seg:hover svg { opacity: 1; }

/* The form half of a segment takes no room of its own — the button inside is
   the flex item that lines up with its `<a>` neighbours. */
.links-line .seg-form { display: contents; }

@media (max-width: 900px) {
  /* One mailbox per striped block: address and size above, actions below.
     Reuse the desktop markup, including the scripts-off password link. */
  .mailbox-table,
  .mailbox-table tbody { display: block; }
  .mailbox-table thead { display: none; }
  .mailbox-table tbody tr {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: start;
    gap: 12px;
    padding: 12px;
  }
  .mailbox-table tbody tr + tr { border-top: 1px solid var(--line); }
  .data-table.mailbox-table tbody tr > td {
    display: block;
    min-width: 0;
    padding: 0;
    border: 0;
  }
  .mailbox-table td:first-child { overflow-wrap: anywhere; }
  .mailbox-table td.num { text-align: right; }
  .mailbox-table td.cell-actions { grid-column: 1 / -1; width: auto; white-space: normal; }

  /* Tint the entire block, including the space between its grid cells. */
  .list-panel .mailbox-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--fg) 3%, transparent); }
  .list-panel .mailbox-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
  .list-panel .mailbox-table tbody tr:nth-child(even) td,
  .list-panel .mailbox-table tbody tr:hover td { background: transparent; }

  /* Independent touch controls can wrap without leaving half a joined pill
     on each line. Keep the same labels, icons and hover colours as desktop. */
  .mailbox-table .links-line { flex-wrap: wrap; gap: 8px; white-space: normal; }
  .mailbox-table .links-line .seg {
    max-width: 100%;
    min-height: 44px;
    border-left-width: 1px;
    border-radius: 10px;
    line-height: 1.25;
  }
}

/* Tenant bots follow the staff table's typography and controls. A tbody is one
   bot, so its optional prose and revealed token share the same stripe. */
.tenant-bots-table thead th { white-space: normal; }
.tenant-bots-table tbody:nth-of-type(even) { background: color-mix(in srgb, var(--fg) 3%, transparent); }
.tenant-bots-table tbody + tbody { border-top: 1px solid var(--line); }
.tenant-bots-table .bot-name strong { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.tenant-bots-table .bot-name .icon { flex: none; color: var(--muted); }
.tenant-bots-table .bot-name span,
.tenant-bots-table .site-link span { min-width: 0; overflow-wrap: anywhere; }
.tenant-bots-table .btn { white-space: normal; }
.tenant-bots-table .bot-detail > td { padding-top: 0; border-top: 0; }
.tenant-bots-table .bot-detail .prose { max-width: 100%; overflow-wrap: anywhere; }
.bot-token-value { display: flex; align-items: center; gap: 4px; margin-top: 12px; padding-right: 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--field); }
.bot-token-value input { flex: 1; min-width: 0; border: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.bot-token-value .icon-btn { flex: none; }

@media (max-width: 900px) {
  .tenant-bots-wrap { overflow: hidden; }
  .tenant-bots-table { display: block; }
  .tenant-bots-table thead { display: none; }
  .tenant-bots-table tbody {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
    padding: 16px;
  }
  /* One set of links/forms for both layouts. Flatten the two table rows so
     the description joins the heading above the full-width action stack. */
  .tenant-bots-table .bot-summary,
  .tenant-bots-table .bot-detail { display: contents; }
  .tenant-bots-table .bot-summary > td,
  .tenant-bots-table .bot-detail > td {
    display: block;
    min-width: 0;
    max-width: 100%;
    padding: 0;
    border: 0;
  }
  .tenant-bots-table .bot-name { order: 0; }
  .tenant-bots-table .bot-state { order: 1; }
  .tenant-bots-table .bot-detail > td { order: 2; }
  .tenant-bots-table .bot-detail .prose { color: var(--muted); }
  .tenant-bots-table .bot-telegram { order: 3; }
  .tenant-bots-table .bot-admin { order: 4; }
  .tenant-bots-table .bot-token { order: 5; }
  .tenant-bots-table .bot-telegram:not(.bot-unavailable),
  .tenant-bots-table .bot-telegram.bot-unavailable + .bot-admin:not(.bot-unavailable),
  .tenant-bots-table .bot-telegram.bot-unavailable + .bot-admin.bot-unavailable ~ .bot-token { margin-top: 8px; }
  .tenant-bots-table .bot-summary > .bot-unavailable { display: none; }
  .tenant-bots-table .btn,
  .tenant-bots-table .site-link {
    display: flex;
    justify-content: center;
    gap: 8px;
    width: 100%;
    min-height: 44px;
    padding: 10px 16px;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.5;
    color: var(--fg);
    background: transparent;
    border: 1px solid var(--line);
    border-radius: 10px;
    text-decoration: none;
  }
  .tenant-bots-table .bot-token-value { margin-top: 0; }
}

/* The download dialog's own prose — the owner's paragraphs from Settings →
   Портал → Завантаження, already rebuilt from the rich-text allowlist. */
.modal-prose { font-size: 14px; line-height: 1.6; }
.modal-prose p { margin: 0 0 10px; }
.modal-prose p:last-child { margin-bottom: 0; }

/* A picture in a task's description — the attachment's own WebP
   (lib/task-files.pictureFor), never wider than the column; `height: auto`
   because the vendor's rule caps the width alone and a tall screenshot would
   otherwise keep its intrinsic height, squashed sideways. The same rule for
   the editor, so what is typed is what is saved. */
.modal-prose img, body .ql-editor img { max-width: 100%; height: auto; border-radius: 8px; }

/* Under the reading measure the label stops being a column: 190px of empty
   space beside a domain name is the whole screen on a phone. And the column
   hairline goes with the column it separated. */
@media (max-width: 640px) {
  .site-fact { grid-template-columns: 1fr; gap: 6px; }
  /* One column again, so the label may wrap and the column hairline goes. */
  .site-fact dt { white-space: normal; }
  .site-fact dt::after { display: none; }
}


/* A DATABASE AND THE BUTTON THAT TAKES IT, on one line. The button is inside a
   form of its own — it has to be, because this is a POST — and a form is a
   block, so without this it would drop under the chip and each database would
   cost two lines. */
.db-row { display: inline-flex; align-items: baseline; gap: 8px; margin-right: 14px; }
.db-row form { display: inline; margin: 0; }

/* DOMAINS — the term sits at the far end of the line it belongs to.
   ONE auto margin, not two: the line holds exactly the name and this, so a
   single `margin-left: auto` gives all the free space to the gap between them.
   Two of them would SPLIT that space and leave the term floating in the middle,
   which is the trap `.file-tail` exists to avoid on a line that has more parts. */


/* ------------------------------------------------------------- task boards
 *
 * THE BOARD IS THE ONE THING IN THIS APP THAT IS WIDER THAN THE READING
 * MEASURE ON PURPOSE: columns are laid side by side and the row scrolls
 * horizontally inside its own box (`overflow-x`), the way every wide table
 * here scrolls inside `.table-wrap` — the page itself never grows a sideways
 * scrollbar. Everything is drawn from the same tokens as the cards around it;
 * a column is a quieter surface than a card (field, not card) so the CARDS on
 * it read as the figures and the column as the ground. */
/* THE CONTROL CORNER of an open board — everything top right beside the
   heading, one flex line (the owner's word: the board's verbs and people sit
   beside the view pill, never on a line of their own): the quiet trash link,
   the icon verbs, the pill. */
.head-tools { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.board-verbs { display: inline-flex; align-items: center; gap: 6px; }
.board-trash-link { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
/* ONE HEIGHT along the corner: the pill's track (3px + 1px + 32px = 40) stood
   taller than the round buttons (32) and the small link (34). The pill is
   trimmed to the buttons' height here and nowhere else — the dashboard keeps
   its own. */
.head-tools .btn-group { padding: 2px; border-radius: 10px; }
.head-tools .btn-opt-btn { padding: 5px 12px; border-radius: 8px; }
.head-tools .btn-sm { padding: 5px 11px; }
/* «You are here» for the trash — the pill has no segment for it, so the link
   carries the state itself. */
.board-trash-link[aria-current] { color: var(--accent); border-color: var(--accent); }

/* The «+» at the end of the boards strip — a control among tabs, not a tab:
   no underline of its own and never .is-active, just the same height. */
.tab-add {
  font: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

/* The pill's options are links here (a view is an address), so the shared
   .btn-opt-btn rule needs one more word an <a> brings with it. */
a.btn-opt-btn { text-decoration: none; }

/* The quiet danger verb of a dialog foot: same ghost shape, blood only on
   hover — a foot with a filled red button reads as «the point of this dialog
   is deletion», which it is not. */
.btn-danger-ghost { color: var(--danger, #c0392b); }
.btn-danger-ghost:hover { border-color: var(--danger, #c0392b); }

.kanban {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  overflow-x: auto;
  padding-bottom: 10px;
  /* THE STRIP SCROLLS EDGE TO EDGE (the owner, 16.09.2026): a scroller that
     stops at the page's side gutters cuts every column at the gutter as it
     goes by — a card sliced off at the margin. So the scroller steps out over
     the gutters (the negative margins) and puts the same gutter back INSIDE
     itself as padding: at rest the first column stands exactly where it did,
     and while scrolling the columns run under the margins to the sidebar on
     the left and the window's edge on the right. `scroll-padding` keeps a
     focused card from landing under that inner gutter. The two values must
     stay one number — a bleed without its padding shifts the board. */
  margin-inline: calc(-1 * var(--gutter));
  padding-inline: var(--gutter);
  scroll-padding-inline: var(--gutter);
}
/* THE COLUMN IS A GROUND AND THE CARD IS A FIGURE — Planka's grey list under
   white cards. `var(--field)` was white in the light theme, so column and card
   were one colour and only a hairline told them apart. A breath of foreground
   over the page ground is grey in the light and lifted in the dark, without a
   third token. */
.kanban-col {
  flex: 0 0 290px;
  display: grid;
  gap: 10px;
  padding: 12px;
  background: color-mix(in srgb, var(--fg) 3.5%, var(--bg));
  border: 1px solid var(--line);
  border-radius: 14px;
}
.kanban-col-head {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 28px;
  touch-action: none;      /* the drag is ours; a finger must not pan the board instead */
}
/* The column's colour — a quiet mark before the name, the same tone tokens
   the chips wear. A dot, not a painted header: the column is a GROUND for
   cards (see the note on .kanban-col), and a coloured ground would fight
   every label stripe on it. */
.kanban-col-tone {
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--tone);
}
.kanban-col-name { font-weight: 600; font-size: 14px; }
/* The pencil sits at the far edge and appears when the column is under the
   cursor — a control on every header at all times is nine pencils. */
.kanban-col-head .icon-btn { margin-left: auto; opacity: 0; }
.kanban-col:hover .kanban-col-head .icon-btn,
.kanban-col-head .icon-btn:focus-visible { opacity: 1; }
/* A done column is the same surface with its name quietly checked out. */
.kanban-col.is-done .kanban-col-name { color: var(--muted); }

.kanban-cards { display: grid; gap: 8px; }
.kanban-card-edit {
  position: absolute;
  top: 6px;
  right: 6px;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--muted);
  background: var(--card);
  opacity: 0;
  transition: opacity .15s ease;
}
/* INVISIBLE MUST MEAN UNTOUCHABLE: opacity keeps the hit area, and on a
   phone (no hover to reveal it) a hidden 26px control sat exactly under the
   thumb. Fine pointers get it back with the hover; coarse ones see it
   always, honestly. */
.kanban-card-edit { pointer-events: none; }
.kanban-card:hover .kanban-card-edit { opacity: 1; pointer-events: auto; }
.kanban-card-edit:hover { color: var(--accent); border-color: var(--accent); }
@media (hover: none) {
  .kanban-card-edit { opacity: 1; pointer-events: auto; }
}

.kanban-card {
  position: relative;
  display: grid;
  gap: 6px;
  padding: 10px 12px;
  background: var(--card);
  border: 1px solid var(--line);
  touch-action: none;      /* same as the sortable row: without it the finger scrolls and pointercancel ends the drag */
  border-radius: 10px;
  color: var(--fg);
  text-decoration: none;
  box-shadow: var(--shadow);
  transition: border-color .15s ease;
}
.kanban-card:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
.kanban-card-name { font-size: 13.5px; line-height: 1.35; }

.kanban-add-card,
.kanban-col-add {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  font: inherit;
  font-size: 13px;
  color: var(--muted);
  background: none;
  border: 1px dashed var(--line);
  border-radius: 10px;
  padding: 8px;
  cursor: pointer;
  transition: color .15s ease, border-color .15s ease;
}
.kanban-add-card:hover,
.kanban-col-add:hover { color: var(--fg); border-color: var(--muted); }
/* The add-column ghost keeps a column's width so the row ends with a promise
   of one, not with a button floating in space. */
.kanban-col-add { flex: 0 0 290px; min-height: 120px; padding: 18px; background: none; }
/* «+ Додати задачу» is a form (it posts a draft); the form is invisible to
   the layout so the button stretches across the column as it always did. */
.kanban-add-form { display: contents; }

/* The kanban drag (public/js/kanban.js) — the sortable states, one axis more.
   The real node travels as the tinted slot; the clone floats. */
.is-kanban-helper {
  position: fixed;
  z-index: 90;
  pointer-events: none;
  transform: rotate(2.5deg);
  box-shadow: 0 12px 32px rgb(0 0 0 / 22%);
}
.kanban-card.is-kanban-slot,
.kanban-col.is-kanban-slot {
  opacity: .45;
  background: color-mix(in srgb, var(--accent) 8%, var(--field));
  border-style: dashed;
}
.kanban.is-dragging { user-select: none; }

/* The Gantt (views/tasks.ganttView, geometry from lib/gantt) — a server-drawn
   SVG, so everything here is paint: the shapes and their sizes are decided in
   the markup, and the tokens make it belong to the page.

   THE CHART FILLS THE ROW («гант на весь екран» — the owner, 15.09.2026). The
   markup places the days in percent and keeps type, rows and strokes in
   pixels, and there is no viewBox — so `width: 100%` here widens the columns
   and inflates nothing. The svg's own width attribute is the floor under the
   squeeze: `min-width: max-content` holds the chart at least that wide, and
   past it the wrap scrolls sideways, like every wide table in this app — the
   page must never grow a sideways scrollbar because a task runs into March. */
.gantt-card { width: 100%; }
.gantt-wrap { overflow-x: auto; padding-bottom: 6px; }
.gantt-note { margin-top: 10px; }
.gantt-note + .gantt-note { margin-top: 4px; }
/* AND DOWN THE PAGE TOO — by layout, not arithmetic. The first cut guessed
   the chrome above the chart as a constant (calc(100dvh - 480px)) and left a
   strip of bare page under the card, because tabs wrap and translations run
   long. Instead: when the page holds a Gantt, the content column — already
   stretched to the window by the .layout grid — becomes a flex column, the
   panel and the card grow into the spare room, and the svg's floor is simply
   all the room the card has. The markup ends its grid lines and weekend
   stripes at y="100%", so the extra height is columns, not blank canvas; the
   bars keep their pixel rows at the top, and a board with more rows than
   window just grows the card and the page scrolls, as anywhere. */
.content:has(.gantt) { display: flex; flex-direction: column; padding-bottom: var(--gutter); }
/* The bottom gutter equals the side ones (the owner, 16.09.2026): a card cut
   to the window ends where the page's margins do, all four the same — the
   48px the column keeps elsewhere is for the end of a page that scrolls, and
   this one only scrolls when the rows outgrow the window. */
/* Block flow collapsed the filter's top margin into the tabs' bottom one
   (16 into 26); flex does not collapse, so the flex page drops it. */
.content:has(.gantt) .board-filter { margin-top: 0; }
.content:has(.gantt) .tab-panel { flex: 1 1 auto; display: flex; flex-direction: column; }
.content:has(.gantt) .gantt-card { flex: 1 1 auto; display: flex; flex-direction: column; }
.content:has(.gantt) .gantt-wrap { flex: 1 1 auto; }
.gantt { display: block; width: 100%; min-width: max-content; min-height: 100%; }
.gantt-band { fill: color-mix(in srgb, var(--fg) 3%, transparent); }
.gantt-month {
  font-size: 12px;
  font-weight: 600;
  fill: var(--muted);
}
/* 11.5px and not a smaller one invented for this: the type scale is frozen
   on purpose (test-typography), and a day number fits the column at the
   smallest size the app already owns. */
.gantt-day { font-size: 11.5px; fill: var(--muted); text-anchor: middle; }
.gantt-grid { stroke: var(--line); stroke-width: 1; }
.gantt-weekend { fill: color-mix(in srgb, var(--fg) 2.5%, transparent); }
.gantt-bar {
  fill: var(--accent);
  transition: filter .15s ease;
}
.gantt a:hover .gantt-bar { filter: brightness(1.12); }
/* A finished task keeps its place on the schedule and loses its voice. */
.gantt-bar.is-done { fill: color-mix(in srgb, var(--accent) 35%, var(--line)); }
/* The name rides ON the bar and is clipped BY the bar — a <clipPath> per bar in
   the markup; `clip-path: inset(0)` on the text clipped it to its own box,
   which is no clip at all, and long names ran on across the days after. */
.gantt-label { font-size: 12px; fill: var(--accent-fg); pointer-events: none; }
.gantt-label.is-done { fill: var(--fg); }
.gantt-today { stroke: var(--danger, #c0392b); stroke-width: 2; stroke-dasharray: 4 3; }


.kanban-card-who {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  font-size: 11.5px;
  font-weight: 650;
  color: var(--accent-fg);
  background: var(--accent);
  border-radius: 999px;
}

/* The board's filter is the journals' filter (.table-filter) under the tabs:
   same fields, same widgets — only its place differs. It sits left, where the
   reading starts, and stops at the reading measure even when the board beside
   it runs three screens wide. */
/* ONE LINE, not a stack. The journals' bar wraps because it shares a row with
   a sentence; this one stands alone under the tabs and the board beside it is
   wider than any screen, so there is no reason for the third field to drop
   to a second row. It wraps only where a phone leaves it no choice. */
.board-filter { justify-content: flex-start; flex-wrap: nowrap; margin: 16px 0 18px; }
.board-filter .field { flex: 0 1 auto; min-width: 0; }
.board-filter input[type="text"] { width: 260px; }
.board-filter select, .board-filter .ts-wrapper { width: 200px; }
@media (max-width: 720px) {
  .board-filter { flex-wrap: wrap; }
  .board-filter .field { flex: 1 1 100%; display: grid; }
  .board-filter input[type="text"],
  .board-filter select, .board-filter .ts-wrapper { width: 100%; }
}

/* ---------------------------------------------------- what hangs on a task
 *
 * THE TASK DIALOG IS PLANKA'S CARD: content on the left, what-can-be-done on
 * the right, 12/16 + 4/16, the rail sticky. Everything inside is the app's own
 * vocabulary — `.chip`, `.avatar`, `.field`, `.form-actions`, `.icon-btn` —
 * with modifiers here, never a second copy under another name. The first
 * version of this block re-drew five of those, and every seam showed. */
.modal-backdrop:has(.task-layout) .modal.modal { max-width: 880px; }
.task-layout {
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(200px, 1fr);
  gap: 24px 28px;
  align-items: start;
}
/* A viewer has nothing to press, so there is no rail — and no empty third of
   the width held for one. */
.task-layout.is-read { grid-template-columns: 1fr; }
.task-main { display: grid; gap: 18px; min-width: 0; }
.task-main > h3 { margin: 0; }
.task-main-form { display: grid; gap: 18px; }
.task-side { display: grid; gap: 18px; min-width: 0; position: sticky; top: 20px; }
.task-side > * { min-width: 0; }
@media (max-width: 720px) {
  .task-layout { grid-template-columns: 1fr; }
  /* On a phone the verbs come FIRST: assigning somebody must not mean
     scrolling past the whole conversation. */
  .task-side { position: static; order: -1; }
}
/* The name is the title — bigger than a field, still a field. */
.task-name-field input { font-size: 16px; }

/* The facts under the name: «Виконавці · Мітки · Дати», each a caption over
   its chips — the row Planka draws before the description. */
.task-facts { display: flex; flex-wrap: wrap; gap: 12px 22px; }
.task-fact { display: grid; gap: 6px; }

/* Each part of the card, divided by space and a hairline rather than by a box
   in a box. The h4 here is the one outside the journals (docs/design.md names
   the exception) — and it states its weight, because a size without a weight
   is the browser's bold, the exact mistake .card-head h2 once made. */
.task-part { display: grid; gap: 9px; padding-top: 16px; border-top: 1px solid var(--line); }
.task-part h4 {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
}
.task-part p.muted { font-size: 13px; }

/* Folds: a chevron that turns, the way every fold in the app opens. In the
   rail they are the row of quiet buttons Planka stacks under «Add to card»;
   on the account's billing tab (.pay-facts) the same fold hides an attempt's
   facts under its verbs — one behaviour, two names, so a third fold joins
   this list rather than inventing its own chevron. */
.task-picker > summary,
.pay-facts > summary {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  cursor: pointer;
  font-size: 13px;
  color: var(--muted);
  list-style: none;
}
.task-picker > summary::-webkit-details-marker,
.pay-facts > summary::-webkit-details-marker { display: none; }
.task-picker > summary > span,
.pay-facts > summary > span { flex: 1; }
.task-picker > summary > svg:last-child,
.pay-facts > summary > svg:last-child { transition: transform .15s ease; }
.task-picker[open] > summary > svg:last-child,
.pay-facts[open] > summary > svg:last-child { transform: rotate(180deg); }
.task-picker > summary:hover,
.pay-facts > summary:hover { color: var(--fg); }
.task-picker[open] { display: grid; gap: 9px; }

.task-side-group { display: grid; gap: 8px; }
.task-side-form { display: grid; gap: 10px; }
.task-side .task-picker > summary {
  width: 100%;
  padding: 7px 10px;
  font-size: 13.5px;
  color: var(--fg);
  background: color-mix(in srgb, var(--fg) 5%, var(--card));
  border-radius: 9px;
}
.task-side .task-picker > summary:hover { background: color-mix(in srgb, var(--fg) 8%, var(--card)); }

/* Chips on a task: `.chip` in the eight tones, worn (a span) or offered (a
   submit button) — one shape for both, and never wider than its column. */
.task-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.task-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  overflow-wrap: anywhere;
  text-align: left;
  font: inherit;
  font-size: 11.5px;
  font-weight: 600;
}
button.task-chip { cursor: pointer; background: transparent; }
button.task-chip:hover { border-color: var(--muted); color: var(--fg); }
.task-person .avatar { width: 18px; height: 18px; font-size: 11.5px; }

/* THE EIGHT TONES — an enum over the tokens this stylesheet already owns,
   never free hex (docs/roadmap.md). Drawn the way .chip-ok is. */
.tone-violet { --tone: var(--accent); }
.tone-blue   { --tone: var(--tone-blue); }
.tone-teal   { --tone: var(--tone-teal); }
.tone-green  { --tone: var(--ok); }
.tone-amber  { --tone: var(--warn); }
.tone-red    { --tone: var(--danger); }
.tone-pink   { --tone: var(--tone-pink); }
.tone-slate  { --tone: var(--tone-slate); }
/* The honest empty swatch of a tone picker that allows «none». */
.tone-opt.tone-none { border: 1px dashed var(--line); background: transparent; }
.tone-opt.tone-none:has(input:checked) { border-color: var(--muted); }

.list-sort-row { display: flex; flex-wrap: wrap; gap: 6px; }

.task-chip[class*="tone-"] {
  color: var(--tone);
  border-color: color-mix(in srgb, var(--tone) 40%, transparent);
  background: color-mix(in srgb, var(--tone) 10%, transparent);
}
.task-chip.is-on {
  color: var(--accent-fg);
  background: var(--tone, var(--accent));
  border-color: transparent;
}

/* The labels dialog: one grid per row — the word, the swatches, the verbs —
   so rows line up, and the new-label row is the same grid as the rest. */
.modal-backdrop:has(.task-labels-dialog) .modal { max-width: 560px; }
.task-labels-dialog { display: grid; gap: 14px; }
.task-labels { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task-label-row {
  display: grid;
  grid-template-columns: minmax(120px, 1fr) auto auto 32px;
  align-items: center;
  gap: 8px;
}
.task-label-row .field { min-width: 0; }
.tone-picker { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.tone-opt {
  position: relative;
  width: 20px;
  height: 20px;
  border-radius: 6px;
  cursor: pointer;
  background: var(--tone);
  border: 2px solid transparent;
  opacity: .8;
  transition: opacity .15s ease;
}
.tone-opt:hover { opacity: 1; }
/* `checked` is the ONE mark of the chosen swatch — a server-stamped class
   beside it left two swatches lit after the first click. */
.tone-opt:has(input:checked) { opacity: 1; border-color: var(--accent); box-shadow: 0 0 0 2px var(--card); }
.tone-opt input { position: absolute; opacity: 0; width: 0; height: 0; }

/* The people dialog: an email is one word and stays on one line. */
.modal-backdrop:has(.task-people-dialog) .modal { max-width: 560px; }
.task-people-dialog .avatar { width: 22px; height: 22px; font-size: 11.5px; vertical-align: middle; }

/* Checklists: the group, its ticks, and the line that adds one more. THE TICK
   IS ALWAYS VISIBLE — it is the point; only the «×» waits for the pointer, and
   answers focus the same way (a keyboard has no hover). */
.task-checklist { display: grid; gap: 6px; }
.task-checklist-head { display: flex; align-items: center; gap: 8px; }
.task-checklist-head .task-del { margin-left: auto; }
/* Attachments: a small 16:9 face (the same crop the Materials tile makes),
   the name as the download, the facts in one muted line. The verbs wait for
   the pointer, like every other row's here. */
.task-files { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task-files li { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 10px; align-items: center; }
.task-files li:hover .task-del { opacity: 1; }
.task-file-face {
  position: relative;
  display: grid;
  place-items: center;
  width: 64px;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--field);
  color: var(--muted);
}
.task-file-shot { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.task-file-favicon { width: 16px; height: 16px; }
.task-file-body { display: grid; gap: 2px; min-width: 0; }
.task-file-name { font-size: 13.5px; font-weight: 600; color: var(--fg); text-decoration: none; overflow-wrap: anywhere; }
.task-file-name:hover { color: var(--accent); }
.task-file-meta { font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; }
.task-side .dropzone { padding: 12px; }
.task-side .btn.is-on { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.task-items { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.task-items li { display: flex; align-items: center; gap: 8px; }
.task-items li.is-done .task-item-name { color: var(--muted); text-decoration: line-through; }
.task-item-name { flex: 1; font-size: 13.5px; }
.task-tick-form { display: inline-flex; }
.task-tick {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 19px;
  height: 19px;
  flex: none;
  padding: 0;
  color: var(--accent);
  background: var(--field);
  border: 1px solid var(--line);
  border-radius: 5px;
  cursor: pointer;
}
button.task-tick:hover { border-color: var(--accent); }
.task-del { display: inline-flex; gap: 4px; opacity: 0; transition: opacity .15s ease; }
.task-inline { display: inline-flex; }
.task-items li:hover .task-del,
.task-checklist-head:hover .task-del,
.task-comment-head:hover .task-del,
.task-del:focus-within { opacity: 1; }
.task-del .icon-btn { width: 26px; height: 26px; }
.task-add { display: flex; gap: 6px; align-items: stretch; }
.task-add .field { flex: 1; min-width: 0; }
.task-add-block { display: grid; gap: 8px; }
.task-add-block textarea, .task-add-block .editor-host { width: 100%; }

/* Comments are drawn as the conversation they are (the owner, 16.09.2026 —
   «has to look like a chat»): the face at the left, who and when over the
   words, the words in a bubble whose corner nearest the face is squared, and
   one's own bubble in the accent tint so your side of the talk reads at a
   glance. The head keeps the height of its hidden bin so nothing jumps on
   hover. */
.task-comments { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; }
.task-comment { display: flex; gap: 10px; align-items: flex-start; }
.task-comment > .avatar { margin-top: 2px; }
.task-comment-body { display: grid; gap: 4px; flex: 1; min-width: 0; }
.task-comment-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  min-height: 26px;
  font-size: 12.5px;
}
.task-comment-head .task-del { margin-left: auto; }
.task-comment-bubble {
  width: fit-content;
  max-width: 100%;
  padding: 9px 13px;
  background: color-mix(in srgb, var(--fg) 4%, var(--card));
  border-radius: 14px;
  border-top-left-radius: 4px;
}
.task-comment.is-mine .task-comment-bubble { background: color-mix(in srgb, var(--accent) 10%, var(--card)); }

/* A plain-text button for a link-like verb inside a hint. */
.btn-link {
  padding: 0;
  font: inherit;
  color: var(--accent);
  background: none;
  border: 0;
  cursor: pointer;
  text-decoration: underline;
}

/* …and what all of that looks like on the FACE of a kanban card: colour first,
   then the name, then the small facts. */
.kanban-card-labels { display: flex; flex-wrap: wrap; gap: 4px; }
.kanban-card-labels i {
  display: block;
  width: 22px;
  height: 5px;
  border-radius: 999px;
  background: var(--tone, var(--accent));
}
.kanban-card-foot {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  font-size: 12px;
  color: var(--muted);
}
.kanban-card-foot > span { display: inline-flex; align-items: center; gap: 4px; }
/* The dates as one chip, coloured by how the day stands against today —
   Trello's and Planka's due badge, without a word. */
.kanban-card-days { display: inline-flex; align-items: center; gap: 5px; }
.kanban-card-days.is-late { color: var(--danger); }
.kanban-card-days.is-soon { color: var(--warn); }
.kanban-card-days.is-done { color: var(--ok); }
.kanban-card-people { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; }
.kanban-card-people .avatar { width: 20px; height: 20px; font-size: 11.5px; }
/* «+N» is a count, not a face — quieter than the marks beside it. */
.kanban-card-more { font-size: 11.5px; font-weight: 600; color: var(--muted); }

/* ─────────────────────────────── Білінг ───────────────────────────────
   The paid term as a two-column fact table, a method chooser, and the
   payment history. Reuses the card, chip and button vocabulary; only the
   section's own bits are said here. */
/* THE PAGE IS FOUR WIDGETS IN ALL BUT NAME (the owner, 16.09.2026): two
   columns across the whole row, one even gap — the dashboard's rhythm, 20px.
   The reading measure comes off each card: 560px is right for a lone form and
   wrong for a column that already has a width — it is also why the cards used
   to sit glued and unequal. MASONRY, THE DASHBOARD'S OWN SHAPE (the owner,
   same day, after two wrong cuts — tops-aligned left a hole in the page,
   equal heights blew a short card up with air): two real column elements,
   each stacking its cards at their natural height, one under the other.
   Under 900px the columns hand their cards to the grid (display: contents)
   and `order` interleaves them back by importance: the term, the renew, the
   delivery, the history — the same trick the dashboard plays. */
.billing-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: 20px;
}
.billing-col {
  display: flex;
  flex-direction: column;
  gap: 20px;
  min-width: 0;
}
.billing-col > .card { max-width: none; }
@media (max-width: 900px) {
  .billing-grid { grid-template-columns: minmax(0, 1fr); }
  .billing-col { display: contents; }
  .billing-col:first-child > :first-child { order: 1; }
  .billing-col:last-child > :first-child { order: 2; }
  .billing-col:first-child > :last-child { order: 3; }
  .billing-col:last-child > :last-child { order: 4; }
}

.billing-term h3, .billing-renew h3, .billing-checking h3, .billing-delivery h3, .billing-history + h3 { margin-top: 0; }
/* КУДИ НАДСИЛАТИ ДОКУМЕНТИ — the reference's shape (the owner, 16.09.2026:
   the chips version was «збиті елементи», not a system): one framed group
   per document with the document's name as its legend, and inside it one
   row per road — tick, the road's glyph, its name — the same framed-row
   grammar as the payment chooser above it. A road that is not open stays a
   row, greyed, with the reason beside it in the muted voice. The lead under
   the heading gets its own air before the first group; a bare .field-hint
   has none, which is what glued the old version together. */
.billing-delivery .card-lead { margin-bottom: 16px; }
.doc-groups { display: grid; gap: 14px; }
/* The sample rows on Налаштування → Документи (views/settings-documents.js):
   the document's name, then its two doors — preview as a page, download as a
   PDF — the reference's pair, one row per document. */
.doc-samples { display: grid; gap: 8px; }
.doc-sample-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.doc-sample-label { display: inline-flex; align-items: center; gap: 6px; min-width: 150px; font-weight: 600; font-size: 14px; }
.doc-sample-label .icon { flex: none; }

.doc-group {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 14px 16px 16px;
  border: 1px solid var(--line);
  border-radius: 12px;
  min-width: 0;
}
.doc-group > legend { padding: 0 6px; margin-left: -6px; }
.doc-road {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 10px;
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--line);
  border-radius: 10px;
  cursor: pointer;
  transition: border-color .15s ease, background-color .15s ease;
}
.doc-road:hover { border-color: var(--muted); }
.doc-road:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.doc-road input { flex: none; width: 18px; height: 18px; margin: 0; accent-color: var(--accent); }
.doc-road-glyph { display: inline-flex; flex: none; color: var(--accent); }
.doc-road-name { font-weight: 500; }
/* The reason is a sentence, and it sits UNDER the road's name on its own
   line, indented past the tick — never crammed beside the name, which in a
   narrow column stretched the row to three times its height. */
.doc-road-why { flex-basis: 100%; padding-left: 28px; font-size: 12.5px; line-height: 1.4; color: var(--muted); }
.doc-road.is-closed { cursor: default; color: var(--muted); }
.doc-road.is-closed:hover { border-color: var(--line); }
.doc-road.is-closed .doc-road-glyph { color: var(--muted); }
.doc-road.is-closed input { opacity: .45; }
.doc-groups .form-actions { margin-top: 2px; }

/* THE ADDRESS UNDER THE ROADS — the reference's two boxes (.pdfInvoiceDeliveryEmail,
   .pdfInvoiceVchasnoEmail). They are rendered visible and the script hides the
   one whose road is not ticked, so a reader with no JS can still fill them; the
   invalid state is the same red the rest of the form speaks. */
.doc-address { display: grid; }
.doc-address[hidden] { display: none; }
.doc-address.is-invalid input { border-color: var(--danger); }

/* THE CHECK SCREEN, FOUR BLOCKS, ONE VISIBLE. The states are drawn together and
   public/js/pay.js moves `hidden` between them, so the owner's own markup is
   shown rather than a sentence pasted over another (views/billing). The tick and
   the cross take the palette's own tones — no literal colour, and none of the
   reference's background-image SVGs. */
.pay-state { display: flex; align-items: flex-start; gap: 10px; }
.pay-state[hidden] { display: none; }
.pay-state-icon { display: inline-flex; flex: none; margin-top: 1px; }
.pay-state-ok .pay-state-icon { color: var(--ok); }
.pay-state-bad .pay-state-icon { color: var(--danger); }
.pay-state-text { flex: 1; min-width: 0; color: var(--muted); }
.pay-state-title { display: flex; align-items: flex-start; gap: 10px; }
.pay-state-text p { margin: 0 0 8px; }
.pay-state-text p:last-child { margin-bottom: 0; }
/* Only the title shares a row with the status icon. Bank explanations use
   the full width; connection notices and owner-authored copy are separate. */
.billing-checking [data-pay-connection], .billing-checking [data-pay-note] { margin-top: 16px; }
.billing-checking [data-pay-note] p + p { margin-top: 8px; }

/* The year, said in parts: one domain per line on the term card, and the
   three-line sum above the method chips — a fact table, not a second card. */
.billing-domains div + div { margin-top: 4px; }
.billing-sum { margin-bottom: 12px; }
.billing-sum th, .billing-sum td { padding: 5px 0; }
.billing-incl { font-size: 12.5px; margin-top: 2px; }
/* A FACT TABLE READS AS TWO MARGINS (the owner, 16.09.2026): the names hug
   the left edge, the values hug the RIGHT — the whole row is the fact, and a
   value that starts wherever the longest name happens to end floats in the
   middle of a wide card. Four screens draw this table (both Білінг cards,
   the account's billing tab, Налаштування → Документи), so the rule is
   here once. A multi-line value (the domains) keeps its lines right-aligned
   too; a chip alone in a cell is a flex box that `text-align` cannot move,
   so it is pushed with `margin-left` — but only when it stands alone: beside
   a date («Оплачено · 16.09») it stays inline, and the pair aligns as text. */
.billing-facts { width: 100%; border-collapse: collapse; }
.billing-facts th, .billing-facts td { padding: 8px 0; vertical-align: top; }
.billing-facts th { font-weight: 500; color: var(--muted); white-space: nowrap; padding-right: 16px; width: 1%; text-align: left; }
.billing-facts td { text-align: right; }
.billing-facts td > .chip:only-child { margin-left: auto; }
.billing-term .billing-facts .chip { display: inline-block; max-width: 100%; text-align: center; text-wrap: balance; }
.billing-facts tr + tr th, .billing-facts tr + tr td { border-top: 1px solid var(--line); }

/* THE METHOD CHOOSER (views/pay-options — the renew card and the public
   invoice) IS THE REFERENCE'S, ROW FOR ROW: one full-width row per method
   (`grid-column: 1 / -1` there, one 1fr track here), a tall row (min 72px)
   with a big custom radio at the left, the name in a heavier voice, and the
   provider's own logo at the right at 32px, the whole row a button. Hover
   tints, the chosen one gets a 2px halo. «PDF-рахунок» wears our file glyph
   instead of a brand. The logos are the brands' true colours on transparent
   ground — fine on the light card, invisible on the dark one — so the dark
   theme shows a second file of each, drawn for dark ground (below).
   `.pay-methods` (the chips) stays for the delivery card's channel choice,
   which is a different question with no brands in it. */
.pay-options { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; width: 100%; margin: 6px 0 18px; }
.pay-option {
  display: flex;
  align-items: center;
  gap: 18px;
  width: 100%;
  min-height: 72px;
  padding: 14px 22px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 12px;
  cursor: pointer;
  transition: border-color .2s ease, background-color .2s ease, box-shadow .2s ease;
}
.pay-option:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 4%, var(--card)); }
.pay-option:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); }
/* The radio drawn by hand, as in the reference: a 22px ring, a dot that
   scales in when chosen. `appearance: none` and no `accent-color` — the
   native dot is too small for a 72px row. */
.pay-option input {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 22px;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2.5px solid var(--fg);
  border-radius: 50%;
  background: var(--card);
  display: grid;
  place-items: center;
  cursor: pointer;
}
.pay-option input::after { content: ''; width: 10px; height: 10px; border-radius: 50%; background: var(--fg); transform: scale(0); transition: transform .2s ease; }
.pay-option input:checked::after { transform: scale(1); }
.pay-option input:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 3px; }
.pay-option input[disabled] { opacity: .5; cursor: not-allowed; }
.pay-option-name { flex: 1; min-width: 0; font-size: 16px; font-weight: 600; line-height: 1.25; }
.pay-option-logo, .pay-option-mark { display: inline-flex; align-items: center; flex: 0 0 auto; }
.pay-option-logo img { display: block; height: 32px; width: auto; max-width: 160px; object-fit: contain; }
.pay-option-mark { color: var(--accent); }
.pay-option-mark .icon { width: 32px; height: 32px; }
/* DARK THEME GETS ITS OWN LOGO FILES, not a white pill under the light ones
   (the owner, 16.09.2026 — the pill was mine, and wrong): the two `<img>`
   elements are in the markup at once, and the theme shows one. `--dark`
   files drawn by the owner in Illustrator sit beside the light ones in
   public/img (views/pay-options names them). */
.pay-option-logo .is-dark-only { display: none; }
:root[data-theme='dark'] .pay-option-logo .is-light-only { display: none; }
:root[data-theme='dark'] .pay-option-logo .is-dark-only { display: block; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .pay-option-logo .is-light-only { display: none; }
  :root:not([data-theme]) .pay-option-logo .is-dark-only { display: block; }
}
@media (max-width: 640px) {
  .pay-option { gap: 12px; min-height: 60px; padding: 12px 14px; }
  .pay-option-name { font-size: 14.5px; }
  .pay-option-logo img { height: 26px; max-width: 120px; }
}
/* The pay button runs the row's full width, as the reference's «Продовжити
   на рік» does under its options. */
.billing-pay .btn { width: 100%; justify-content: center; }

.pay-methods { display: flex; flex-wrap: wrap; gap: 10px; margin: 4px 0 16px; }
.pay-method {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 8px 14px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer;
}
.pay-method:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.pay-method input { accent-color: var(--accent); }

.billing-history { width: 100%; border-collapse: collapse; font-size: 14.5px; }
.billing-history th { text-align: left; font-weight: 600; color: var(--muted); padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; }
.billing-history td { padding: 10px 12px; border-bottom: 1px solid var(--line); }
.billing-history .num, .billing-history th.num { text-align: right; white-space: nowrap; }
.billing-checking { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); }

/* ───────────────────── Рахунок за посиланням (публічна сторінка) ─────────────
   The card a client opens from a link we sent. It borrows the sign-in card's
   frame (.auth-card) and adds a fact table, a notice line and the muted state
   an invoice wears once it is paid or canceled. */
.invoice-card { max-width: 520px; text-align: left; }
.invoice-head h1 { font-size: 22px; margin: 0 0 14px; }
.invoice-facts { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
.invoice-facts th, .invoice-facts td { padding: 9px 0; vertical-align: top; text-align: left; font-size: 14.5px; }
.invoice-facts th { font-weight: 500; color: var(--muted); white-space: nowrap; padding-right: 14px; width: 1%; }
.invoice-facts tr + tr th, .invoice-facts tr + tr td { border-top: 1px solid var(--line); }

.invoice-notice { margin: 0 0 16px; padding: 10px 12px; border-radius: 10px; font-size: 14px; }
.invoice-notice--ok { color: var(--ok); background: var(--ok-bg); }
.invoice-notice--warn { color: var(--warn); background: var(--warn-bg); }
.invoice-notice--bad { color: var(--danger); background: var(--danger-bg); }

/* Paid or canceled: the controls stay, so the page still reads as an invoice,
   but nothing about them invites a click. */
.invoice-card.is-inactive .pay-options, .invoice-card.is-inactive .invoice-pay .btn { opacity: .5; pointer-events: none; }
.invoice-pay .btn { width: 100%; }
.invoice-card + .payment-result-summary .btn { width: 100%; }
.invoice-checking { padding: 8px 0; }
/* The bank handoff shows only the sidebar's open ring, centred in the viewport.
   Without JavaScript, explicit controls replace the loader. */
.payment-handoff {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 24px;
}
.payment-handoff-loader { display: flex; color: var(--accent); }

/* Bank returns use the same dialog on billing and public invoices. */
.payment-result .modal { max-width: 560px; }
.payment-result .billing-renew { padding: 0; margin-top: 24px; border: 0; box-shadow: none; background: none; }
.payment-result-summary h3 { margin-top: 0; }
.payment-result .modal-body [data-pay-resume] { margin-top: 20px; }
.payment-result [data-pay-resume] p { color: var(--muted); }
.payment-result .modal-foot { flex-wrap: wrap; gap: 12px; }
.payment-result .modal-foot .btn { white-space: normal; text-align: center; }
@media (max-width: 600px) {
  .payment-result .modal-foot { flex-direction: column; align-items: stretch; }
  .payment-result .modal-foot .btn { width: 100%; min-height: 44px; justify-content: center; }
}
.no-js .payment-result .modal-x { display: none; }

/* Payment celebration: above every portal overlay, never a click target.
   Only this canvas gets the layer; cropping and voice canvases stay put. */
.portal-confetti {
  --confetti-colors: #F74F9E, #FF5257, #61BA47, #FFB400, #00A3E0, #FF6F20, #A550A7;
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2147483647;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { .portal-confetti { display: none; } }

.billing-invoices .billing-invoice-new { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.billing-amount-row { display: flex; gap: 12px; }
.billing-amount-row .field:first-child { flex: 1 1 auto; }
.billing-inv-acts { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
.billing-inv-acts form { display: inline-flex; }

/* The chronology of a payment: one attempt, then what the provider said. */
.pay-attempt { padding: 12px 0; }
.pay-attempt + .pay-attempt { border-top: 1px solid var(--line); }
.pay-attempt-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 14.5px; }
/* The number is the door to the payment's own page (account-billing
   paymentDetail): ink-coloured like the head it sits in, so the row reads as
   one line, and the accent only on hover says it is a door. On the page
   itself the number is plain text — nowhere to go. */
.pay-attempt-link { color: inherit; text-decoration: none; }
.pay-attempt-link:hover strong { color: var(--accent); }
.pay-timeline { margin: 10px 0 0; padding: 0 0 0 2px; list-style: none; display: grid; gap: 6px; }
/* The paper's own timeline sits under the money's, separated by a line rather
   than by a heading — it is the same attempt, told twice. */
.pay-timeline-docs { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line); }
.pay-timeline li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.pay-when { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 128px; }
.pay-source { color: var(--muted); min-width: 108px; }
.doc-sign-preview { margin: 4px 0 12px; }
.doc-sign-preview img { max-width: 188px; height: auto; display: block; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: #fff; }
.pay-attempt-acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pay-attempt-acts form { display: inline-flex; }

/* THE FACTS OF AN ATTEMPT, folded under the verbs: the fold itself is the
   task card's (.task-picker rules above, shared by selector). Open, it is a
   short facts table and, under a second fold, the provider's last raw answer
   in the log dialog's JSON block. The per-event payload is the same inner
   fold inside a timeline row, pushed to its own full-width line so the raw
   JSON never squeezes in beside the status chip. */
.pay-facts { margin-top: 8px; }
.pay-facts[open] { display: grid; gap: 8px; }
.pay-facts .billing-facts td { overflow-wrap: anywhere; }
.pay-facts .billing-facts td .btn { margin-left: 6px; }
.pay-facts .log-block { margin-top: 0; }
.pay-facts-raw { margin-top: 0; }
.pay-facts-raw > summary { font-size: 12.5px; }
.pay-facts-domains { margin-top: 4px; }
.pay-timeline li > .pay-facts { flex-basis: 100%; margin-top: 0; }

/* THE PAYMENT'S OWN PAGE (views/payment.js). The first version was the list's
   folded block unfolded on a page — «все збито в купу» (the owner, 20.09.2026).
   Now: the account's head grammar, FOUR TILES that answer how much / paid /
   how / when before any tab, and a strip of tabs under them. The tiles stay
   put while the tabs swap (they are outside the panel), and they read at the
   same 960px the panel does. */
.pay-summary {
  display: grid;
  /* 200px, not less: at a narrow content width four tiles then fall 2+2
     instead of 3+1 with one orphan under the row. */
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  max-width: 960px;
  margin: 0 0 24px;
}
.pay-tile {
  display: grid;
  gap: 4px;
  min-width: 0;
  padding: 14px 16px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 12px;
}
/* The label wears the table header's voice — small caps, muted — so a tile
   reads as one cell of a summary, not as a fifth heading. */
.pay-tile-label {
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
}
.pay-tile-value {
  font-size: 19px;
  font-weight: 650;
  letter-spacing: -.01em;
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.pay-tile-value .chip { font-size: 12.5px; }
.pay-tile-sub { font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere; }
/* On a phone the four tiles go two by two — one tall column of four cards
   pushed the tabs a screen down. */
@media (max-width: 640px) {
  .pay-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  .pay-tile { padding: 12px 14px; }
  .pay-tile-value { font-size: 16px; }
}

/* A VERB WITH A LINE UNDER IT (the Дії tab): what it is, what it will do, and
   the button — one row per verb, hairlines between, the button on the right
   where the eye lands after reading. Four buttons in a row said none of it. */
.act-list { display: grid; }
.act-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  padding: 14px 0;
}
.act-row:first-child { padding-top: 4px; }
.act-row:last-child { padding-bottom: 0; }
.act-row + .act-row { border-top: 1px solid var(--line); }
.act-text { display: grid; gap: 3px; min-width: 0; }
.act-text strong { font-weight: 600; }
.act-text p { margin: 0; font-size: 13.5px; color: var(--muted); }
.act-do { flex: none; }
.act-do form { display: inline-flex; }
@media (max-width: 640px) {
  .act-row { flex-direction: column; align-items: flex-start; gap: 10px; }
}

/* A titled block of the Історія / Документи tabs: a heading, a sentence,
   and a framed table (the journal's grammar — `.table-wrap` draws its own
   frame, so no card around it). */
.pay-block + .pay-block { margin-top: 26px; }
.pay-block > h3 { margin-bottom: 6px; }
.pay-block > p.muted { margin: 0 0 12px; font-size: 13.5px; }
.pay-block > p.muted a { color: var(--accent); }
/* The mark at the end of a row that opens: says «this one has data», where
   a bare clickable row said nothing. */
.pay-open { color: var(--muted); font-size: 12.5px; text-align: right; }
.pay-open .icon { vertical-align: -2px; }
.row-open:hover .pay-open { color: var(--accent); }

/* A second line under a fact's name — «зона .pro» under a domain in the sum's
   composition: what the price was looked up by, quieter than the name. */
.facts dt small { display: block; font-size: 12.5px; opacity: .8; }

/* ------------------------------------------------------- the support chat */
/* THE WIDGET (views/support-widget.js, public/js/support-chat.js): a round
   button pinned to the bottom right of every page of somebody inside an
   account, and the panel it opens above itself. Fixed, and ABOVE THE PAGE'S
   OWN LAYERS: this at 150, dialogs at 160, toasts keep 200 — a refusal
   must still be readable over an open chat. The panel is a column with three
   named rows (head, thread, composer), like the reference's chat room: the
   thread scrolls inside itself and the composer is pinned where the hand
   looks for it. */
/* 150: above the page, UNDER every dialog (160) — the search and the
   notifications must open over an open chat. Toasts (200) and the chat's own
   viewer (210) are the only things higher. */
.sc { position: fixed; right: 20px; bottom: 20px; z-index: 150; }
.sc-launch {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 54px;
  height: 54px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-fg);
  box-shadow: 0 10px 28px rgb(16 24 40 / 28%);
  cursor: pointer;
  transition: transform .15s ease;
}
.sc-launch:hover { transform: translateY(-1px); }
.sc-launch .icon { width: 24px; height: 24px; }
.sc-badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 11.5px;
  font-weight: 650;
  line-height: 20px;
  text-align: center;
  box-shadow: 0 0 0 2px var(--bg);
}
.sc-badge[hidden] { display: none; }

/* A COLUMN OF FLEX, NOT A GRID OF NAMED ROWS. The first version named five
   rows — head, question, thread, composer, recorder — and two of them are
   `hidden` most of the time. A hidden element makes no box and takes no
   row, so the rows SHIFTED: the thread landed in an `auto` row (a black
   strip the height of its padding) and the composer in the `1fr` one,
   stretched over the whole panel with its controls at the bottom (prod,
   21.09.2026). A flex column has no rows to shift: whatever is shown stacks,
   and the thread alone grows. */
.sc-panel {
  position: absolute;
  right: 0;
  bottom: 68px;
  width: min(390px, calc(100vw - 32px));
  height: min(600px, calc(100vh - 110px));
  display: flex;
  flex-direction: column;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 16px;
  box-shadow: 0 24px 60px rgb(16 24 40 / 28%);
  overflow: hidden;
}
.sc-panel[hidden] { display: none; }
.sc-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 12px 12px 12px 16px;
  border-bottom: 1px solid var(--line);
}
.sc-title { display: inline-flex; align-items: center; gap: 8px; font-weight: 650; }
.sc-title .icon { color: var(--accent); }
.sc-tools { display: inline-flex; gap: 2px; }

/* The clearing question stands INSIDE the panel, not in a dialog over the
   page: the question is about the thread, and the answer must not take the
   eye anywhere else. */
.sc-confirm {
  display: grid;
  gap: 10px;
  padding: 12px 16px;
  background: color-mix(in srgb, var(--danger) 8%, var(--card));
  border-bottom: 1px solid var(--line);
  font-size: 13.5px;
}
.sc-confirm[hidden] { display: none; }
.sc-confirm p { margin: 0; }
.sc-confirm-acts { display: flex; justify-content: flex-end; gap: 8px; }

.sc-head, .sc-confirm, .sc-compose, .sc-record { flex: none; }
.sc-log {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 14px 14px 6px;
  background: var(--bg);
}
/* A line the thread says about itself — «the chat is not available right
   now», with Telegram's reason for staff — under the greeting, in the
   thread's own voice rather than a toast that is gone in a moment. */
.sc-notice {
  margin: 4px 0;
  padding: 8px 12px;
  font-size: 12.5px;
  color: var(--muted);
  background: color-mix(in srgb, var(--warn) 10%, var(--card));
  border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
  border-radius: 10px;
  overflow-wrap: anywhere;
}
.sc-thread { display: flex; flex-direction: column; gap: 8px; }
.sc-day { display: flex; justify-content: center; margin: 6px 0; }
.sc-day span { padding: 2px 10px; font-size: 11.5px; color: var(--muted); background: var(--card); border: 1px solid var(--line); border-radius: 999px; }

/* THE BUBBLES. Mine at the right in the accent, support's at the left on the
   card — the grammar every messenger taught. A bubble that holds a picture,
   a video or a gallery drops its padding so the picture fills it, and its
   caption gets the padding back. */
.sc-msg {
  position: relative;
  max-width: 82%;
  align-self: flex-start;
  padding: 8px 12px 6px;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px 14px 14px 4px;
  font-size: 14px;
  line-height: 1.45;
}
.sc-msg.is-staff { align-self: flex-start; }
.sc-msg.is-mine {
  align-self: flex-end;
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-fg);
  border-radius: 14px 14px 4px 14px;
}
.sc-msg-author { margin-bottom: 2px; font-size: 12px; font-weight: 650; color: var(--accent); }
.sc-msg-body { overflow-wrap: anywhere; }
.sc-msg-body a { color: inherit; }
.sc-msg-time { margin-top: 2px; font-size: 11.5px; color: var(--muted); text-align: right; }
.sc-msg.is-mine .sc-msg-time { color: color-mix(in srgb, var(--accent-fg) 75%, transparent); }
.sc-msg.is-welcome .sc-msg-body { color: var(--fg); }

.sc-msg.has-photo, .sc-msg.has-video, .sc-msg.has-animation, .sc-msg.has-gallery { padding: 0; overflow: hidden; }
.sc-msg.has-photo .sc-msg-body, .sc-msg.has-video .sc-msg-body, .sc-msg.has-animation .sc-msg-body, .sc-msg.has-gallery .sc-msg-body,
.sc-msg.has-photo .sc-msg-author, .sc-msg.has-video .sc-msg-author, .sc-msg.has-animation .sc-msg-author, .sc-msg.has-gallery .sc-msg-author,
.sc-msg.has-photo .sc-msg-time, .sc-msg.has-video .sc-msg-time, .sc-msg.has-animation .sc-msg-time, .sc-msg.has-gallery .sc-msg-time { padding: 4px 12px; }
.sc-msg.has-photo .sc-msg-time, .sc-msg.has-gallery .sc-msg-time { padding-top: 0; padding-bottom: 6px; }
.sc-photo { display: block; max-width: 280px; }
.sc-photo img, .sc-gallery img { display: block; width: 100%; height: auto; background: var(--line); }
.sc-gallery { display: grid; gap: 2px; max-width: 300px; }
.sc-gallery-2, .sc-gallery-3, .sc-gallery-4 { grid-template-columns: repeat(2, 1fr); }
.sc-gallery a { display: block; aspect-ratio: 1; overflow: hidden; }
.sc-gallery a img { height: 100%; object-fit: cover; }
.sc-video { display: block; max-width: 300px; width: 100%; height: auto; background: #000; }
.sc-animation { max-width: 240px; }

/* A sticker and a round video note step out of the bubble: no frame, no
   fill — only the picture, and the time under it. */
.sc-msg.has-sticker, .sc-msg.has-video_note { padding: 0; background: none; border-color: transparent; }
.sc-msg.has-sticker .sc-msg-author, .sc-msg.has-video_note .sc-msg-author { padding: 0 4px 2px; }
.sc-sticker img { display: block; width: 140px; height: auto; }

/* A file is a card: an icon, the name, the size, and the arrow that says
   «this downloads». */
.sc-file {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 200px;
  padding: 4px 0;
  color: inherit;
  text-decoration: none;
}
.sc-file-icon { display: inline-flex; flex: none; width: 38px; height: 38px; align-items: center; justify-content: center; border-radius: 10px; background: color-mix(in srgb, currentColor 12%, transparent); }
.sc-file-text { display: grid; min-width: 0; }
.sc-file-name { font-weight: 600; overflow-wrap: anywhere; }
.sc-file-size { font-size: 12px; opacity: .75; }
.sc-file-dl { flex: none; opacity: .7; }
.sc-file:hover .sc-file-name { text-decoration: underline; }

/* The voice row: play, waveform, time (public/js/voice.js). `--voice-*` are
   read by the canvas, because a colour in this project lives in the
   stylesheet and a canvas is not an exception. */
.sc-voice { --voice-played: var(--accent); --voice-rest: color-mix(in srgb, var(--accent) 30%, transparent); margin: 0; padding: 2px 0; }
.sc-msg.is-mine .sc-voice { --voice-played: var(--accent-fg); --voice-rest: color-mix(in srgb, var(--accent-fg) 40%, transparent); }
.sc-voice-row { display: flex; align-items: center; gap: 10px; min-width: 210px; }
.sc-voice-play {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-fg);
  cursor: pointer;
}
.sc-msg.is-mine .sc-voice-play { background: var(--accent-fg); color: var(--accent); }
/* The two states are drawn in CSS: a triangle of borders, and two bars. */
.sc-voice-play::before {
  content: '';
  width: 0;
  height: 0;
  margin-left: 3px;
  border: solid transparent;
  border-width: 6px 0 6px 9px;
  border-left-color: currentColor;
}
.sc-voice.is-playing .sc-voice-play::before {
  width: 9px;
  height: 11px;
  margin-left: 0;
  border: 0;
  background: linear-gradient(to right, currentColor 0 3px, transparent 3px 6px, currentColor 6px 9px);
}
.sc-voice-wave { flex: 1; height: 28px; cursor: pointer; }
.sc-voice-time { flex: none; font-size: 12px; font-variant-numeric: tabular-nums; opacity: .85; }
.sc-voice-audio { width: 100%; }
.sc-voice[data-voice-ready] .sc-voice-audio { display: none; }
.sc-voice-name { margin-top: 4px; font-size: 12px; opacity: .8; overflow-wrap: anywhere; }

/* THE COMPOSER IS ONE FIELD, the whole width of the panel's foot, with the
   clip inside it at the left and the microphone and the plane inside it at
   the right (the owner, 21.09.2026). The FORM wears the field's frame — the
   textarea inside is bare — so the buttons stand on the field's own
   background and the field grows up to five lines around them. */
.sc-compose {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  margin: 10px 10px 12px;
  padding: 3px 4px 3px 4px;
  border: 1px solid var(--line);
  border-radius: 16px;
  background: var(--bg);
}
.sc-compose:focus-within { border-color: var(--accent); }
.sc-compose[hidden] { display: none; }
.sc-compose textarea {
  flex: 1;
  min-width: 0;
  min-height: 34px;
  max-height: 120px;
  padding: 7px 6px;
  border: 0;
  background: transparent;
  color: var(--fg);
  font: inherit;
  font-size: 14px;
  line-height: 1.4;
  resize: none;
}
.sc-compose textarea:focus { outline: none; }
.sc-compose .icon-btn { flex: none; width: 34px; height: 34px; border: 0; background: transparent; }
.sc-compose .icon-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.sc-attach { cursor: pointer; }
.sc-send { color: var(--accent); }
.sc.is-sending .sc-send { opacity: .5; pointer-events: none; }

/* RECORDING: the composer gives way to a red dot, the running time, and the
   two answers — cancel or send — the way Telegram draws it. */
.sc-record {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 12px 12px 16px;
  border-top: 1px solid var(--line);
  background: var(--card);
  font-size: 13.5px;
}
.sc-record[hidden] { display: none; }
.sc-record-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: sc-pulse 1s ease-in-out infinite; }
.sc-record-label { color: var(--muted); }
.sc-record-time { font-variant-numeric: tabular-nums; margin-right: auto; }
.sc.is-recording .sc-mic { color: var(--danger); }
/* The two answers while recording are icons alone: a cross and the plane. */
.sc-record .icon-btn { flex: none; width: 36px; height: 36px; }
.sc-record-send { color: var(--accent); }
.sc-record-cancel:hover { color: var(--danger); }
@keyframes sc-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.7); } }

/* A phone: the panel fills the visible screen, including when the keyboard
   shrinks/pans it. Keep this fixed: an absolute override collapses the panel
   into the empty launcher wrapper as soon as the launch button is hidden. */
@media (max-width: 640px) {
  html.is-support-open { overflow: hidden; }
  .sc { right: 14px; bottom: 14px; }
  .sc.is-open .sc-launch { display: none; }
  .sc-panel {
    position: fixed;
    top: var(--sc-view-top, 0px);
    left: var(--sc-view-left, 0px);
    right: auto;
    bottom: auto;
    width: var(--sc-view-width, 100%);
    height: var(--sc-view-height, 100vh);
    height: var(--sc-view-height, 100dvh);
    padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
      env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
    border-radius: 0;
    border: 0;
  }
  .sc-head .icon-btn { width: 44px; height: 44px; }
  .sc-compose textarea { font-size: 16px; }
  .sc-msg { max-width: 88%; }
}

/* THE OPEN PANEL TAKES A DROP: a veil over the thread while a file is dragged
   across it, in the accent — the same word the Materials page says. */
.sc-drop {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 8px;
  color: var(--accent);
  font-size: 14px;
  font-weight: 600;
  background: color-mix(in srgb, var(--accent) 10%, var(--card));
  border: 2px dashed var(--accent);
  border-radius: 16px;
  pointer-events: none;
}
.sc-drop[hidden] { display: none; }
.sc-photo { cursor: zoom-in; }

/* A FILE ON ITS WAY: the sender's card while it travels — the name, the size
   and the state, and a bar that fills as the bytes go. It dresses like the
   file card it will become, so nothing jumps when the server's bubble takes
   its place. Failed: the danger tint and the reason, gone after a while. */
.sc-msg.is-pending { opacity: .85; }
.sc-pending-row { padding: 0; }
.sc-progress {
  height: 4px;
  margin: 6px 0 2px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent-fg) 25%, transparent);
  overflow: hidden;
}
.sc-progress > span { display: block; width: 0; height: 100%; background: var(--accent-fg); transition: width .2s ease; }
.sc-msg.is-pending.is-uploading { opacity: 1; }
.sc-msg.is-pending.is-failed { background: var(--danger); border-color: var(--danger); opacity: 1; }
.sc-msg.is-pending.is-failed .sc-progress { display: none; }

/* THE VIEWER: a picture over the page, the thread's other pictures a key or a
   swipe away. Above the widget (150) and the toasts (200): while it is open
   the picture is what the page is about. */
.sc-viewer {
  position: fixed;
  inset: 0;
  z-index: 210;
  background: rgb(0 0 0 / 82%);
}
.sc-viewer[hidden] { display: none; }
.sc-viewer-frame {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  padding: 16px;
}
.sc-viewer-figure {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  width: min(100%, 1200px);
  max-height: 100%;
  margin: 0;
  padding: 48px 56px 8px;
}
.sc-viewer-stage { display: grid; place-items: center; min-width: 0; min-height: 0; }
.sc-viewer-stage img {
  display: block;
  max-width: 100%;
  max-height: min(82vh, calc(100vh - 140px));
  max-height: min(82dvh, calc(100dvh - 140px));
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 8px;
  box-shadow: 0 24px 60px rgb(0 0 0 / 45%);
}
.sc-viewer-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  min-height: 28px;
  padding: 12px 0 0;
  color: #fff;
  font-size: 13.5px;
}
.sc-viewer-count { flex: none; opacity: .7; font-size: 12.5px; white-space: nowrap; }
.sc-viewer-btn {
  position: absolute;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid rgb(255 255 255 / 25%);
  border-radius: 50%;
  background: rgb(0 0 0 / 45%);
  color: #fff;
  cursor: pointer;
}
.sc-viewer-btn:hover { background: var(--accent); border-color: var(--accent); }
.sc-viewer-btn[hidden] { display: none; }
.sc-viewer-close { top: 16px; right: 16px; }
.sc-viewer-prev { top: 50%; left: 16px; transform: translateY(-50%); }
.sc-viewer-next { top: 50%; right: 16px; transform: translateY(-50%); }
.sc-viewer-prev .icon { transform: rotate(90deg); }
.sc-viewer-next .icon { transform: rotate(-90deg); }

/* THE ROUND VIDEO NOTE (public/js/video-note.js): the circle is the face, the
   ring is an SVG arc set by one number per timeupdate, the glyph lives on
   hover. The radius and the clip live on the wrapper, because WebKit ignores
   `border-radius` on a <video> in its own compositing layer. */
.sc-video-note { margin: 0; width: 200px; }
.sc-video-note-face {
  position: relative;
  width: 200px;
  aspect-ratio: 1;
  border-radius: 50%;
  overflow: hidden;
  isolation: isolate;
  background: #000;
}
.sc-video-note video {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  background: #000;
}
.sc-video-note-ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
  pointer-events: none;
  opacity: 0;
  transition: opacity .15s ease;
}
.sc-video-note.is-playing .sc-video-note-ring { opacity: 1; }
.sc-video-note-ring circle { fill: none; stroke: var(--accent); stroke-width: 3; }
.sc-video-note-toggle {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #fff;
  cursor: pointer;
  transition: background .15s ease;
}
.sc-video-note-toggle:focus-visible { outline-offset: -12px; }
/* Play: a triangle of borders. Pause: two bars. Both only under the pointer
   (or focus); a screen with no pointer keeps the play glyph while paused. */
.sc-video-note-toggle::before {
  content: '';
  width: 0;
  height: 0;
  margin-left: 5px;
  border: solid transparent;
  border-width: 11px 0 11px 17px;
  border-left-color: currentColor;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 45%));
  opacity: 0;
  transition: opacity .15s ease;
}
.sc-video-note.is-playing .sc-video-note-toggle::before {
  width: 16px;
  height: 20px;
  margin-left: 0;
  border: 0;
  background: linear-gradient(to right, currentColor 0 6px, transparent 6px 10px, currentColor 10px 16px);
}
.sc-video-note-face:hover .sc-video-note-toggle, .sc-video-note-toggle:focus-visible { background: rgb(0 0 0 / 22%); }
.sc-video-note-face:hover .sc-video-note-toggle::before, .sc-video-note-toggle:focus-visible::before { opacity: 1; }
@media (hover: none) {
  .sc-video-note:not(.is-playing) .sc-video-note-toggle { background: rgb(0 0 0 / 22%); }
  .sc-video-note:not(.is-playing) .sc-video-note-toggle::before { opacity: 1; }
}
.sc-video-note-time { display: block; font-size: 11.5px; color: var(--muted); text-align: center; padding-top: 4px; }
.sc-video-note[data-video-ready] video { pointer-events: none; }

/* THE CAMERA'S LIVE PREVIEW while a round note records: a small circle at the
   left of the recording bar, the person's own face, muted. Hidden for a
   voice note, where there is nothing to show but the time. */
.sc-record-preview {
  display: block;
  flex: none;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  object-fit: cover;
  background: #000;
}
.sc-record-preview[hidden] { display: none; }
.sc.is-recording-video .sc-camera { color: var(--danger); }

/* A full config stays in its protected document; use the same card/field scale. */
.wordpress-raw { display: grid; gap: 16px; }
.wordpress-raw form { display: grid; gap: 20px; }
.wordpress-raw .wordpress-source { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-height: 320px; tab-size: 4; }
.wordpress-raw-warning { color: var(--danger); }
.wordpress-raw .check-line { display: flex; align-items: flex-start; gap: 10px; }
.wordpress-raw .check-line input { flex: 0 0 auto; margin-top: 4px; }
.wordpress-raw button { justify-self: start; }
.wordpress-raw-actions { margin-top: 18px; padding-top: 0; justify-content: flex-start; flex-wrap: wrap; gap: 12px; }
.wordpress-raw-actions form, .wordpress-raw-actions .btn { max-width: 100%; }
.wordpress-raw-actions .btn { white-space: normal; }

/* Account permissions: independent checkboxes, with the form's spacing scale. */
.account-access-options { border: 1px solid var(--line); border-radius: 12px; padding: 16px; min-width: 0; }
.account-access-options legend { padding-inline: 6px; overflow-wrap: anywhere; }
.account-access-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 20px; }

/* Tenant filters keep one form. JS moves it into the usual modal on phones;
   without JS it stays inline with its native GET submit button. */
.mobile-filters, .mobile-filter-home { display: contents; }
.mobile-filter-toggle, .mobile-filter-apply { display: none; }
.mobile-filters.is-mobile .mobile-filter-toggle { display: inline-flex; margin: 16px 0 18px; }
.file-bar .mobile-filters.is-mobile .mobile-filter-toggle { margin: 0; }
.modal-filters .modal-inner { align-items: flex-start; }
.modal-filters .modal { max-width: 480px; }
.modal-filters .table-filter { display: grid; gap: 16px; margin: 0; }
.modal-filters .table-filter .field { display: grid; gap: 6px; }
.modal-filters .table-filter input,
.modal-filters .table-filter select,
.modal-filters .table-filter .ts-wrapper { width: 100%; min-width: 0; }
.modal-filters .table-filter-acts { justify-content: flex-end; margin-top: 4px; }
.modal-filters .mobile-filter-apply { display: inline-flex; }

/* Only account pages opt into stacked rows; staff tables keep their layout.
   The same cells/links serve both sizes, with visible labels on mobile. */
@media (max-width: 900px) {
  .tenant-table-cards-wrap { overflow: hidden; }
  .tenant-table-cards, .tenant-table-cards tbody { display: block; }
  .tenant-table-cards thead { display: none; }
  .tenant-table-cards tbody tr:not(.table-group) {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px 16px;
    padding: 16px;
  }
  .tenant-table-cards tbody tr + tr { border-top: 1px solid var(--line); }
  .tenant-table-cards tbody tr:not(.table-group):nth-child(even) { background: color-mix(in srgb, var(--fg) 3%, transparent); }
  .tenant-table-cards:is(.data-table, .billing-history) tbody tr > td {
    display: block;
    min-width: 0;
    max-width: 100%;
    padding: 0;
    border: 0;
    text-align: left;
    white-space: normal;
    overflow-wrap: anywhere;
  }
  .tenant-table-cards tbody tr > td:first-child { grid-column: 1 / -1; font-weight: 600; }
  .tenant-table-cards td[data-label]::before {
    content: attr(data-label);
    display: block;
    margin-bottom: 4px;
    color: var(--muted);
    font-size: 12.5px;
    font-weight: 500;
  }
  .tenant-table-cards .table-group,
  .tenant-table-cards .table-group th { display: block; }
  .tenant-table-cards .table-group th { padding: 12px 16px; border: 0; white-space: normal; }
  .tenant-table-cards .row-acts-cell { grid-column: 1 / -1; }
  .tenant-table-cards .row-acts { flex-wrap: wrap; justify-content: flex-start; }

  .tenant-materials .file-bar { align-items: center; flex-wrap: wrap; }
  .tenant-materials .file-bar-tools { flex-wrap: wrap; min-width: 0; }
  .tenant-materials .list-row,
  .tenant-companies .list-row { grid-template-columns: minmax(0, 1fr); }
  .tenant-materials .list-line { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 6px 10px; }
  .tenant-materials .file-ico { align-self: start; padding-top: 3px; }
  .tenant-materials .file-tail { grid-column: 2; margin-left: 0; min-width: 0; flex-wrap: wrap; }
  .tenant-materials .file-date { display: inline; }
  .tenant-materials .file-meta { white-space: normal; overflow-wrap: anywhere; }
  .tenant-materials .list-acts,
  .tenant-companies .list-acts { margin: 0 0 12px; gap: 8px; flex-wrap: wrap; }
  .tenant-materials .list-acts .icon-btn,
  .tenant-companies .list-acts .icon-btn { width: 44px; height: 44px; }
  .tenant-companies .list-line { flex-wrap: wrap; gap: 8px; }
  .tenant-companies .list-name { min-width: 0; flex: 1 1 calc(100% - 60px); }
  .tenant-companies .list-caret { flex: none; order: 1; }
  .tenant-companies .list-line > .chip { order: 2; }
  .tenant-companies .facts > div { display: grid; gap: 4px; }
  .tenant-companies .facts dd { min-width: 0; text-align: left; overflow-wrap: anywhere; }
  .tenant-billing .billing-facts th { white-space: normal; width: auto; }
  .tenant-billing .billing-facts td { overflow-wrap: anywhere; }
}

/* Public email preferences: a standalone form, no dashboard shell or scripts. */
.email-settings-page { min-height: 100dvh; }
/* Padding keeps the space below the last button inside the scrollable page. */
.email-settings { width: min(100% - 32px, 680px); margin: 0 auto; padding: 40px 0 calc(40px + env(safe-area-inset-bottom, 0px)); }
.email-settings-logo, .email-settings-language { display: flex; justify-content: center; }
.email-settings-logo { margin-bottom: 24px; }
.email-settings h1 { text-align: center; margin-bottom: 16px; }
.email-settings-address { text-align: center; overflow-wrap: anywhere; margin: 16px 0 24px; }
.email-settings-card { max-width: none; margin-top: 20px; }
.email-settings-card h2 { margin: 0 0 12px; }
.email-choice { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; cursor: pointer; }
.email-choice + .email-choice { border-top: 1px solid var(--line); }
.email-choice input { width: 18px; height: 18px; flex: none; margin: 3px 0 0; accent-color: var(--accent); }
.email-choice span { min-width: 0; overflow-wrap: anywhere; }
.email-choice strong { font-weight: 500; }
.email-choice small, .email-required .field-hint { display: block; color: var(--muted); margin-top: 4px; }
.email-required { cursor: default; }
.email-required input:disabled { opacity: .5; cursor: not-allowed; }
.email-settings-actions { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 24px; }
.email-settings-actions > *, .email-settings-actions .btn { max-width: 100%; }
.email-settings-actions .btn { white-space: normal; }
.email-settings-saved { padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; color: var(--ok); }
@media (max-width: 600px) {
  .email-settings { padding-top: 24px; padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); }
}

/* One audience control shared by file/link dialogs and batch uploads. */
.material-access:has([name="access_all"]:checked) .material-users { display: none; }
.material-users { max-height: 260px; overflow-y: auto; }
.material-users .check { align-items: start; }
.material-users .check input { flex: none; }
.material-users .check > span { min-width: 0; overflow-wrap: anywhere; }
