/* ============================================================================
   SAMM Admin — Design System
   ----------------------------------------------------------------------------
   Colour tokens (the --bg / --fg / --primary / ... palette) are NOT defined
   here — they live in themes.css, one block per theme, selected by the
   <html data-theme="..."> attribute. themes.css is linked before app.css so
   every var() below resolves. See themes.css + app/utils/theming.py.

   Direction: this stylesheet is bidirectional. Layout uses CSS logical
   properties (margin-inline-start, inset-inline-end, text-align:start, ...)
   so it mirrors automatically under <html dir="rtl">. The few cases logical
   properties cannot express are handled in the [dir="rtl"] block at the end.
   ============================================================================ */

* { box-sizing: border-box; }

/* The UA default [hidden] { display: none } ties on specificity (0,1,0) with
   class selectors like .alert / .badge that set display: flex, and loses
   because it comes earlier in the cascade — meaning <div class="alert" hidden>
   stays visible. Force it. Most visible on /admin/update where the
   "Update failed." banner stuck around while a fresh apply was downloading. */
[hidden] { display: none !important; }

html, body {
  margin: 0; padding: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  /* Global safety net: long prose tokens wrap instead of forcing the page
     wider than the viewport on mobile. Per-element overrides (.nowrap, the
     .text-mono break rule) refine this. */
  overflow-wrap: break-word;
}

/* 'IBM Plex Sans Arabic' sits in every monospace stack below, before the
   generic `monospace` keyword. Fira Code carries no Arabic or Persian glyphs,
   so Arabic text in a .text-mono / .num / .table--mono cell fell through to
   whatever the browser picked by default and looked wrong beside the same text
   in the UI face one column over. Font fallback is per GLYPH, so Latin and
   digits still render in Fira Code; only the Arabic-script runs land on the
   Arabic face. Harmless in the other locales — the family is simply absent, as
   base.html only loads it for ar/fa. */
code, pre, .mono { font-family: 'Fira Code', 'JetBrains Mono', Consolas, 'IBM Plex Sans Arabic', monospace; }

/* Mobile safety net: media and block-level code can never exceed the viewport
   (long IDs/URLs are handled by overflow-wrap above + .text-mono). NOTE: do not
   cap <table> width here — tables live in .table-wrap (overflow-x:auto) and must
   be free to exceed the container so the wrapper scrolls instead of squishing
   cells into a tall column. */
img { max-width: 100%; height: auto; }
pre { max-width: 100%; overflow-x: auto; }

a { color: var(--primary); text-decoration: none; }
/* Hover never recolours or underlines a word — anywhere in the panel.
   Affordance comes from the background, the cursor and the control's own
   border, exactly as Odoo does it. Fixing this per-component never held:
   the rule simply reappeared on the next element added. */
a:hover, a:focus, a:active { color: inherit; text-decoration: none; }
a { color: inherit; }
a.link, .link-strong { color: var(--primary); }

/* A hairline rather than a rail. The track is transparent so the bar reads as
   an indicator on top of whatever it is scrolling.
   `scrollbar-width` is deliberately NOT set globally: from Chrome 121 the
   standard property WINS over ::-webkit-scrollbar, so declaring `thin` here
   made Chrome ignore the width below and fall back to its own ~11px bar —
   indistinguishable from the 10px rail this replaced. It is scoped instead to
   engines that have no ::-webkit-scrollbar at all (Firefox), where `thin` is
   the only control there is. */
@supports not selector(::-webkit-scrollbar) {
  * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
}
::-webkit-scrollbar { width: 2px; height: 2px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
::-webkit-scrollbar-corner { background: transparent; }

/* ============================================================================
   Layout
   ============================================================================ */
.app { display: grid; grid-template-columns: var(--sb-w, 240px) 1fr; min-height: 100vh; }

.sidebar {
  background: var(--bg-elev);
  border-inline-end: 1px solid var(--border);
  display: flex; flex-direction: column;
  position: sticky; top: 0;
  /* 100vh on a phone is the LARGE viewport — it includes the strip behind the
     browser's collapsing address bar. The sidebar was therefore taller than the
     screen by exactly that strip, and its last two rows sat underneath the
     chrome where no amount of scrolling could reach them: the scroll container
     was already at its end. 100dvh is the height actually on screen right now.
     The 100vh line stays first as the fallback for browsers without dvh. */
  height: 100vh;
  height: 100dvh;
  overflow-y: auto;
  /* Momentum scrolling on older iOS, and don't chain the scroll to the page
     underneath when the drawer hits its end. */
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}

/* ── Collapsed sidebar: an icon rail that slides open on hover ───────────────
   The grid track narrows to --sb-w and the sidebar narrows with it. On hover it
   widens back to 240px and simply OVERFLOWS its track, painting over the main
   column: the track stays 58px, so nothing reflows and the page never jumps
   when the pointer crosses the rail.
   The sidebar deliberately stays a grid item (position: sticky, as when
   expanded). Taking it out of flow with position:fixed made .main slide into
   the 58px track and the whole page render inside a 58px column.
   :has(:focus-visible), not :focus-within — focus-within also matches the
   collapse button that was just clicked, so the rail sprang open again the
   instant it was collapsed. focus-visible is keyboard-only, so tabbing into the
   nav still opens it while a mouse click does not.
   .no-sb-anim is set by the boot script for the first paint only; without it an
   already-collapsed sidebar visibly slides shut on every page load. */
body.sb-collapsed { --sb-w: 58px; }
body:not(.no-sb-anim) .sidebar { transition: width .16s ease, box-shadow .16s ease; }
body.sb-collapsed .sidebar { width: 58px; overflow-x: hidden; z-index: 300; }
body.sb-collapsed .sidebar:hover,
body.sb-collapsed .sidebar:has(:focus-visible) {
  width: 240px;
  box-shadow: 8px 0 28px rgba(0, 0, 0, .18);
}
[dir="rtl"] body.sb-collapsed .sidebar:hover,
[dir="rtl"] body.sb-collapsed .sidebar:has(:focus-visible) {
  box-shadow: -8px 0 28px rgba(0, 0, 0, .18);
}
/* Everything that only means something with room for words. Faded rather than
   display:none so the icon column does not shift when the labels return. */
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__brand-text,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__brand-sub,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__group-text,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__search-text,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__search-kbd,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__collapse-text,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__footer,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__prefs,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__section > summary::after {
  opacity: 0; pointer-events: none;
}
/* Collapsed shows one glyph per SECTION, not per link — a flat rail of every
   link in the panel would be several screens tall. The `open` attribute is left
   untouched, so the current page's section is already open when the rail
   expands. */
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__nav { display: none; }
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__group { padding-inline: 19px; }
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__brand { padding: 20px 13px 16px; }
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__search {
  margin-inline: 13px; padding-inline: 0; justify-content: center; width: 32px;
}
/* These two are single-row flex containers, so a label left at opacity 0 still
   claims its flex space and pushed the glyph out of the 58px rail, where
   overflow-x:hidden clipped it. Take them out of the layout instead — nothing
   below them can shift, unlike the section labels which sit beside an icon that
   must stay put. */
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__search-text,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__search-kbd,
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__collapse-text {
  display: none;
}
/* The tagline sits on its own line UNDER the logo, so leaving it at opacity 0
   kept its two lines of height and pushed the brand's bottom border far below
   the logo. It has to leave the layout — unlike the labels beside an icon,
   nothing here can shift sideways as a result. */
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__brand-sub {
  display: none;
}
body.sb-collapsed .sidebar:not(:hover):not(:has(:focus-visible)) .sidebar__collapse { justify-content: center; }
body.sb-collapsed .sidebar__brand-text,
body.sb-collapsed .sidebar__brand-sub,
body.sb-collapsed .sidebar__group-text,
body.sb-collapsed .sidebar__search-text,
body.sb-collapsed .sidebar__search-kbd,
body.sb-collapsed .sidebar__collapse-text { transition: opacity .12s ease; white-space: nowrap; }

/* The collapse control itself, and the search field-button above it. */
.sidebar__collapse {
  display: flex; align-items: center; gap: 10px;
  margin: 0; padding: 10px 14px; width: 100%;
  background: var(--bg-elev); border: 0; border-top: 1px solid var(--border);
  color: var(--fg-dim); font: inherit; font-size: 12px; cursor: pointer;
  transition: background .15s, color .15s;
  /* Pinned to the foot of the sidebar's own scroll area, so it is reachable
     without scrolling past every section first. `margin-top: auto` alone only
     puts it last — on a panel with more nav than screen, last means "several
     screens down". Opaque background, or the links scroll through it. */
  position: sticky; bottom: 0; z-index: 2;
  /* NO margin-top:auto here. .sidebar__footer already owns the auto margin that
     pushes the tail of the sidebar down; a second one split the free space
     between the two and left the "Logged in as…" block floating in the middle
     with a gap under it. One auto margin, one gap, both blocks at the foot. */
}
.sidebar__collapse:hover { background: var(--bg-hover); color: var(--fg); }
.sidebar__collapse svg { width: 16px; height: 16px; flex-shrink: 0; }
body.sb-collapsed .sidebar__collapse svg { transform: scaleX(-1); }

.sidebar__search {
  display: flex; align-items: center; gap: 8px;
  margin: 12px 12px 2px; padding: 7px 10px;
  width: calc(100% - 24px);
  background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
  color: var(--fg-dim); font: inherit; font-size: 13px; cursor: pointer;
  transition: border-color .15s, background .15s, color .15s;
}
.sidebar__search:hover { border-color: var(--primary); color: var(--fg); }
.sidebar__search svg { width: 15px; height: 15px; flex-shrink: 0; }
.sidebar__search-text { flex: 1 1 auto; text-align: start; }
.sidebar__search-kbd {
  font-size: 10px; padding: 1px 5px; border-radius: 4px;
  border: 1px solid var(--border); color: var(--fg-dim); white-space: nowrap;
}

/* Section summaries carry an icon now, so the rail has a glyph to show. */
.sidebar__group-ico { width: 16px; height: 16px; flex-shrink: 0; opacity: .8; }
.sidebar__brand {
  padding: 20px 18px 16px;
  display: flex; flex-direction: column; gap: 6px;
  border-bottom: 1px solid var(--border);
}
.sidebar__brand-row {
  display: flex; align-items: center; gap: 10px;
}
.sidebar__brand-mark {
  width: 32px; height: 32px; border-radius: 8px;
  display: grid; place-items: center; overflow: hidden;
  flex-shrink: 0;
  animation: logo-glow 3.5s ease-in-out infinite;
}
.sidebar__brand-mark img { width: 100%; height: 100%; display: block; border-radius: inherit; }
.sidebar__brand-text {
  font-weight: 800; font-size: 26px; line-height: 32px;
  letter-spacing: -.5px;
  background: linear-gradient(135deg, var(--primary), var(--accent));
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
.sidebar__brand-sub  { font-size: 10.5px; color: var(--fg-dim); line-height: 1.35; max-width: 200px; }
/* Parent vs child. The two used to sit at 13px in the same column, so a group
   header and its links read as one flat list. The header is now larger and
   heavier with more space above it, and the children are indented from the
   INLINE start — logical, so the indent lands on the right in RTL instead of
   staying stubbornly on the left. */
.sidebar__group     { display: flex; align-items: center; gap: 9px;
                       padding: 18px 12px 6px; font-size: 12px; text-transform: uppercase;
                       letter-spacing: .9px; color: var(--fg); font-weight: 800; }
.sidebar__nav { padding: 4px 8px 6px; display: flex; flex-direction: column; gap: 2px;
                margin-inline-start: 14px; border-inline-start: 1px solid var(--border);
                padding-inline-start: 10px; }
.sidebar__link {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: 8px;
  color: var(--fg-muted); font-size: 12.5px; font-weight: 500;
  transition: background .15s, color .15s, transform .18s cubic-bezier(.25,.46,.45,.94);
}
.sidebar__link:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; transform: translateX(3px); }
.sidebar__link.is-active {
  background: linear-gradient(180deg, rgba(59,130,246,.15), rgba(59,130,246,.05));
  color: var(--fg);
  box-shadow: inset 2px 0 0 var(--primary);
}
.sidebar__link svg { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar__badge {
  margin-inline-start: auto; padding: 1px 7px; border-radius: 999px;
  background: var(--primary); color: #fff; font-size: 10px; font-weight: 600;
}
.sidebar__footer {
  margin-top: auto; padding: 12px;
  border-top: 1px solid var(--border);
  font-size: 11px; color: var(--fg-dim);
}

/* Phone-only language + theme controls, surfaced in the off-canvas sidebar
   because the topbar quick-switch is hidden < 560px. margin-top:auto pushes
   this + the footer to the bottom of the sidebar. */
.sidebar__prefs {
  display: none; margin-top: auto; padding: 12px 12px 0;
  border-top: 1px solid var(--border);
  flex-direction: column; gap: 2px;
}
@media (max-width: 560px) { .sidebar__prefs { display: flex; } }

.topbar {
  height: 56px; padding: 0 24px;
  background: var(--bg-elev);
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 16px;
  position: sticky; top: 0; z-index: 30;
  backdrop-filter: blur(8px);
}
/* min-width:0 so a long page title shrinks instead of shoving the clock and the
   topbar controls out of the bar. */
.topbar__heading { display: flex; align-items: center; gap: 10px; min-width: 0; }
.topbar__mark {
  width: 28px; height: 28px; border-radius: 7px;
  background: linear-gradient(135deg, var(--primary), var(--accent));
  display: grid; place-items: center;
  color: #fff; font-size: 13px; font-weight: 700;
  flex-shrink: 0;
}
.topbar__mark svg { width: 15px; height: 15px; }
/* Two lines maximum, then an ellipsis. The bar is a fixed 56px with
   overflow:hidden, so a 62-char card-group name (titleH 63) had its first line
   sliced through the middle and its last line hanging out of the bar. Two
   14px lines fit; app.js copies the full text into title= when it clamps. */
.topbar__title {
  font-size: 15px; font-weight: 600; animation: fade-up .25s ease-out backwards;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; min-width: 0;
}
.topbar__crumb { color: var(--fg-dim); font-size: 13px; margin-inline-start: 4px; }
.topbar__spacer { flex: 1; }
.topbar__clock {
  font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; font-size: 12px;
  color: var(--fg-muted); padding: 4px 10px; border: 1px solid var(--border);
  border-radius: 6px;
}
.topbar__icon-btn {
  width: 34px; height: 34px; display: grid; place-items: center;
  border-radius: 8px; color: var(--fg-muted); cursor: pointer;
  background: transparent; border: 1px solid transparent;
  transition: background .15s;
}
.topbar__icon-btn:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.topbar__icon-btn svg { width: 16px; height: 16px; }
/* ── Language / theme pickers in the header ───────────────────────────────
   Two text dropdowns reading "English" and "SAMM Default" took as much bar as
   the page title, for a value the operator sets once. They are icons now.

   A <select> was tried and dropped: whatever you do to the button, the list it
   opens is the operating system's, which lands as a white-and-blue box in the
   middle of a dark panel and cannot be themed at all. So the list is ours —
   built from the same tokens as .attn__panel, which is the other thing that
   drops out of this bar. That means the keyboard behaviour is ours to provide
   too, and it is, in the picker block in base.html: arrows move, Enter picks,
   Escape closes, focus returns to the button. Do not delete it. */
.pick { position: relative; }
/* The two of them are one control in the operator's head — "how this reads" —
   so they sit closer to each other than to anything else in the bar, which is
   on a 16px gap. Negative margin rather than a wrapper: the bar is a flex row
   and a wrapper would need its own alignment rules to change nothing.
   Scoped to .topbar — unscoped, it pulled the sidebar's second row 12px out of
   the drawer's gutter and broke the vertical the four rows share. */
.topbar .pick + .pick { margin-inline-start: -12px; }
.pick__btn {
  display: grid; place-items: center;
  width: 40px; height: 40px; border-radius: 9px;
  color: var(--fg-muted); cursor: pointer;
  background: transparent; border: 1px solid transparent;
  transition: background .15s;
}
.pick__btn:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.pick__btn svg { width: 22px; height: 22px; pointer-events: none; }
.pick.is-open .pick__btn { background: var(--bg-hover); color: var(--fg); border-color: var(--border-strong); }
.pick__menu {
  position: absolute; inset-block-start: calc(100% + 8px); inset-inline-end: 0;
  z-index: 250; min-width: 190px; max-width: min(280px, calc(100vw - 16px));
  max-height: min(420px, calc(100vh - 96px)); overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: 10px; box-shadow: var(--shadow-lg); padding: 6px;
  animation: pop .12s ease-out;
}
.pick__menu[hidden] { display: none; }
.pick__item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 10px; border-radius: 7px; border: 0; background: transparent;
  color: var(--fg); font-size: 13.5px; text-align: start; cursor: pointer;
}
.pick__item:hover, .pick__item:focus-visible { background: var(--bg-hover); outline: none; }
/* text-align: start, explicitly: the rows carry lang="ar" / lang="fa" for
   font selection, and without this the UA's own bidi handling still parks
   those two labels against the far edge while the other six sit at the near
   one. Every row starts on the same line. */
.pick__item .pick__label { flex: 1 1 auto; min-width: 0; text-align: start; }
/* The tick keeps its box whether or not it is drawn, so the labels of the
   other rows do not shift sideways as the selection moves. */
.pick__tick { flex: 0 0 auto; width: 15px; height: 15px; color: var(--primary); visibility: hidden; }
.pick__item.is-current { font-weight: 600; }
.pick__item.is-current .pick__tick { visibility: visible; }

/* Row form, for the sidebar: same control, but there is width here, so the
   icon gets to say what is actually selected instead of hiding it in a
   tooltip — which is the one thing a phone cannot show you. */
.pick--row { width: 100%; }
.pick--row .pick__btn {
  width: 100%; height: auto; grid-auto-flow: column;
  grid-template-columns: 22px 1fr; gap: 10px;
  justify-items: start; align-items: center;
  padding: 9px 10px; border-radius: 8px; font-size: 13.5px;
  line-height: 20px;
}
/* 18px, not the bar's 22px: down here the icon is a label's companion rather
   than the whole control, and it has to match Profile/Logout below it. */
.pick--row .pick__btn svg { width: 18px; height: 18px; justify-self: center; }
/* The bar's .pick__btn:hover only changes the background. These rows sit in a
   list whose every other row slides 3px and lifts its text on hover, and two
   rows that stay put read as disabled. */
.pick--row .pick__btn {
  transition: background .15s, color .15s, transform .18s cubic-bezier(.25,.46,.45,.94);
}
.pick--row .pick__btn:hover {
  background: var(--bg-hover); color: var(--fg); border-color: transparent;
  transform: translateX(3px);
}
[dir="rtl"] .pick--row .pick__btn:hover { transform: translateX(-3px); }
.pick--row .pick__now {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-align: start;
}
/* Anchored to the START edge and dropping UPWARD: this sits at the foot of the
   drawer, so a menu hanging below it would open off the bottom of the screen. */
.pick--row .pick__menu {
  inset-block-start: auto; inset-block-end: calc(100% + 6px);
  inset-inline-end: auto; inset-inline-start: 0;
  min-width: 100%; max-height: min(320px, 50vh);
}
.topbar__user {
  display: flex; align-items: center; gap: 10px;
  padding: 4px; padding-inline-end: 10px; border-radius: 24px;
  border: 1px solid var(--border); cursor: pointer;
}
.topbar__user:hover { background: var(--bg-hover); }
.topbar__user-avatar {
  width: 26px; height: 26px; border-radius: 50%;
  background: linear-gradient(135deg, var(--primary), var(--accent));
  display: grid; place-items: center; color: #fff; font-size: 11px; font-weight: 600;
}

.main { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
/* No width cap. There used to be a 1600px cap with margin:0 auto, which on a
   wide monitor parked the content in the middle and left a gap down the whole
   inline-end side — and narrowing the sidebar just turned the reclaimed 182px
   into more of that gap instead of more content. The page now always runs to
   the same 24px padding on both sides, at every width and in both sidebar
   states. */
/* The page gutter is a VARIABLE, not a number repeated in two places. A
   full-bleed card cancels it with a negative margin, and when the two got out
   of step — the gutter dropped to 16px on a tablet while the card still pulled
   -24px — the card hung 8px past each edge and the whole page scrolled
   sideways. It inherits, so the card can read it. */
.content { --content-pad: 24px; padding: var(--content-pad); width: 100%; flex: 1; }

/* ============================================================================
   Components
   ============================================================================ */

/* Card */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: var(--shadow-sm);
  /* As a grid/flex child the default min-width:auto lets a wide table inside
     .table-wrap stretch the card past the viewport; 0 lets it shrink so the
     table scrolls inside .table-wrap instead of overflowing the page. */
  min-width: 0;
}
/* Stacked sibling cards get the standard 14px rhythm EVERYWHERE by default.
   :where() keeps the rule at zero specificity, so anything explicit still
   wins: the 22px tab-panel rule below, mt-* utilities in modals, or an inline
   margin. Without this, every page had to remember its own margin-bottom and
   a dozen of them didn't (cards sat flush — see the agent My Account pages). */
:where(.card + .card) { margin-top: 14px; }
/* Containers that already space their children with a gap: a margin on top of
   the gap would double it, and in a multi-column grid it would push second
   cards in a row out of alignment. */
.grid-2 > .card + .card, .grid-3 > .card + .card,
.grid-aside > .card + .card, .stat-row > .card + .card,
.dash-grid > .card + .card,
[style*="gap:"] > .card + .card { margin-top: 0; }

/* Stacked full-width sections inside an accounting tab (Cash & Capital, etc.)
   sit flush by default; give consecutive ones a clear vertical gap. The `>`
   keeps this to direct-child cards only, so side-by-side cards inside a
   .grid-2 (and modals between cards) are untouched. */
[data-tab-panel] > .card + .card { margin-top: 22px; }

.card__header {
  padding: 14px 18px; border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 12px;
}
.card__title { font-size: 14px; font-weight: 600; margin: 0; display: inline-flex; align-items: center; gap: 8px; }
.card__title svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--fg-muted); }
.card__sub { font-size: 12px; color: var(--fg-dim); margin: 0; }
.card__body { padding: 18px; }
.card__footer {
  padding: 12px 18px; border-top: 1px solid var(--border);
  background: var(--surface-2); border-radius: 0 0 12px 12px;
  font-size: 12px; color: var(--fg-muted);
}

/* A card in a stretch grid whose FOOTER must sit on the bottom edge.
   Without this the card is stretched to the row height but its contents are
   not, so a card with less body text (a RouterOS version that fits on one line
   where its neighbours wrap to two) leaves its footer floating with white
   space beneath it, and the grey bars across a row do not line up. */
.card--stretch { display: flex; flex-direction: column; }
.card--stretch > .card__body { flex: 1 1 auto; }
.card--stretch > .card__footer { margin-top: auto; }

/* KPI tile */
.kpi {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 16px 18px;
  position: relative;
  overflow: hidden;
}
.kpi__label {
  font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
  color: var(--fg-dim); margin-bottom: 8px;
}
.kpi__value {
  font-size: 28px; font-weight: 600;
  font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace;
  letter-spacing: -.5px;
  line-height: 1;
  white-space: nowrap;   /* keep e.g. "123.4 Mbps" on one line (font steps down on mobile) */
  /* Never wider than its card: app.js (fitKpis) shrinks the font of a value that would not fit, e.g. Syrian
     pound totals in the hundreds of millions. */
  max-width: 100%; overflow: hidden; min-width: 0;
}
.kpi__delta { font-size: 11px; color: var(--fg-muted); margin-top: 6px; }
.kpi__delta--up   { color: var(--accent); }
.kpi__delta--down { color: var(--danger); }
.kpi__icon {
  position: absolute; top: 14px; inset-inline-end: 14px;
  width: 32px; height: 32px; border-radius: 8px;
  display: grid; place-items: center; color: var(--fg-muted);
  background: var(--bg-elev-2);
}
.kpi__icon svg { width: 16px; height: 16px; }
.kpi--primary .kpi__icon  { color: var(--primary); }
.kpi--accent  .kpi__icon  { color: var(--accent); }
.kpi--warn    .kpi__icon  { color: var(--warn); }
.kpi--danger  .kpi__icon  { color: var(--danger); }
.kpi--info    .kpi__icon  { color: var(--info); }
a.kpi,
a.kpi:link,
a.kpi:visited,
a.kpi:hover,
a.kpi:active,
a.kpi:focus {
  text-decoration: none;
  color: var(--fg);
  display: block;
  cursor: pointer;
  transition: border-color .15s ease, transform .1s ease, box-shadow .15s ease;
}
a.kpi:hover {
  border-color: var(--primary);
  transform: translateY(-4px) scale(1.01);
  box-shadow: 0 10px 30px rgba(0,0,0,.2), 0 0 0 1px var(--primary);
}

/* Buttons */
/* font-family: inherit because a <button> does not inherit the page font on
   its own -- browsers give it the system UI font -- while an <a class="btn">
   does. The two sit side by side in every toolbar (Crawl the network next to
   Back to map, Hide next to Add), and the mismatch showed at every width. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  padding: 8px 14px; border-radius: 8px;
  font-family: inherit;
  font-size: 13px; font-weight: 500; line-height: 1.4;
  min-height: 36px;
  border: 1px solid var(--border);
  background: var(--surface); color: var(--fg);
  cursor: pointer;
  box-sizing: border-box;
  position: relative; overflow: hidden;
  transition: background .15s, border-color .15s, transform .08s, box-shadow .15s;
  text-decoration: none; white-space: nowrap;
  vertical-align: middle;
}
.btn::after {
  content: ''; pointer-events: none;
  position: absolute; inset: 0; border-radius: inherit;
  background: rgba(255,255,255,.22);
  opacity: 0; transition: opacity .2s;
}
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); text-decoration: none; }
.btn:active { transform: translateY(1px); }
.btn:active::after { opacity: 1; transition: none; }
.btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.btn:disabled, .btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn svg { width: 14px; height: 14px; }

.btn--primary { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); }
.btn--primary:hover { background: var(--primary-hov); border-color: var(--primary-hov); color: #fff; }
.btn--accent  { background: var(--accent);  color: #fff; border-color: var(--accent); }
.btn--accent:hover  { background: var(--accent-hov);  border-color: var(--accent-hov); }
.btn--danger  { background: transparent;  color: var(--danger); border-color: var(--danger); }
.btn--danger:hover  { background: var(--danger); color: #fff; border-color: var(--danger); }
/* Info = the same cyan every other info surface uses (.badge--info, .alert--info,
   .toast--info all read var(--info)). It was a hardcoded #4f46e5, so the three
   card "Reset …" buttons stayed indigo in all 16 palettes while everything
   beside them re-tinted — and an "info" badge and an "info" button on one page
   were two different colours. OUTLINE, not filled: half the palettes give
   --info a light cyan (#38bdf8, #4dd4ff, #67c8e0) that white text cannot sit
   on, and outline is what the rest of the semantic family already does. */
.btn--info  { background: transparent; color: var(--info); border-color: var(--info); }
/* The border and the icon carry the theme's cyan; the LABEL is that cyan pulled
   78% of the way back toward --fg. Straight var(--info) is only 3.6–3.7:1 as
   text on the seven light palettes (their --info is a mid cyan chosen for a
   fill), and mixing toward the foreground lifts every theme over 4.5:1 in BOTH
   directions — darker on a light surface, lighter on a dark one — without
   losing the hue. The plain declaration above is the fallback for a browser
   without color-mix. */
.btn--info  { color: color-mix(in srgb, var(--info) 78%, var(--fg)); }
.btn--info:hover  { background: var(--info); color: #fff; border-color: var(--info); }
/* Enable = green, Disable = yellow — the same signal the action menu uses
   (.ctx-menu__item--ok / --warn), so a destructive-looking red is never used
   for a reversible suspend. */
.btn--ok    { background: transparent; color: var(--accent); border-color: var(--accent); }
.btn--ok:hover    { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn--warn  { background: transparent; color: var(--warn);   border-color: var(--warn); }
.btn--warn:hover  { background: var(--warn);   color: #fff; border-color: var(--warn); }

/* --- Ink on a FILLED button -------------------------------------------------
   Every filled .btn--* hardcoded `color:#fff`. That is one fixed foreground
   against sixteen palettes' worth of fills, and it was wrong in 65 of the 96
   (theme x variant) hover pairs — worst 1.56:1, nord's --warn. It was never
   only a nord problem: --warn hover was under 4.5:1 in ALL SIXTEEN themes and
   --info in fifteen. Measured by tests/gates/btn_hover_contrast.py.

   Neither obvious one-liner works, and the numbers say so — every fill shade in
   themes.css against every candidate ink (tests/gates/btn_hover_solve.py):
       color:#fff         85/128 below 4.5:1   (worst 1.56)
       color:var(--bg)    41/128 below 4.5:1   (worst 2.57 — the light palettes'
                                                --bg is near-white, so it is
                                                just a dimmer #fff there)
       color:var(--fg)   118/128 below 4.5:1
   Nor is the answer uniform per semantic: --danger wants black ink on nine
   palettes and white on seven. Per-palette --danger-fg/--ok-fg/--warn-fg/
   --info-fg tokens would be 80 hand-measured values that rot the moment a
   palette is retuned — which is precisely how nord's --danger ended up at
   2.09:1 here after being lightened to fix its RESTING contrast.

   So the ink is DERIVED FROM THE FILL. Each channel gets the fill's WCAG
   relative luminance, compared against 0.1791 — the crossover where black and
   white are equally legible, sqrt(1.05*0.05) - 0.05 — and sign() turns that
   into a hard 255 (white ink, dark fill) or 0 (black ink, light fill). Black-
   vs-white ratios on one colour always multiply to exactly 21, so the better of
   the two is never below sqrt(21) = 4.58:1. This cannot fail on any palette,
   including ones nobody has invented yet.

   sign() rather than "multiply by a big negative number and let paint clamp":
   lavender's --accent (#db2777) has a relative luminance of 0.17910, i.e. it
   sits ON the crossover to five decimals, so a x100000 overshoot resolved to a
   mid-grey 0.26 and measured 2.15:1. sign() is exact at any distance from the
   threshold; the -1e-6 nudge decides the exact tie toward white.

   --btn-fill is set by each variant rule; --btn-ink is declared on .btn, not on
   :root, because a custom property is substituted on the element it is declared
   on — from :root it would resolve against :root's --btn-fill and every button
   would get the same ink.

   @supports is load-bearing: without relative colour syntax the whole block is
   dropped and the `color:#fff` declarations above stay in force, i.e. exactly
   today's rendering rather than an unreadable semantic-on-semantic label. */
@supports (color: rgb(from red calc(127.5 - 127.5*sign(pow(r/255, 2.4) - .5)) g b)) {
  .btn { --btn-ink: rgb(from var(--btn-fill, var(--surface)) calc(127.5 - 127.5*sign(0.2126*pow((r/255 + .055)/1.055, 2.4) + 0.7152*pow((g/255 + .055)/1.055, 2.4) + 0.0722*pow((b/255 + .055)/1.055, 2.4) - .1791 - 1e-6)) calc(127.5 - 127.5*sign(0.2126*pow((r/255 + .055)/1.055, 2.4) + 0.7152*pow((g/255 + .055)/1.055, 2.4) + 0.0722*pow((b/255 + .055)/1.055, 2.4) - .1791 - 1e-6)) calc(127.5 - 127.5*sign(0.2126*pow((r/255 + .055)/1.055, 2.4) + 0.7152*pow((g/255 + .055)/1.055, 2.4) + 0.0722*pow((b/255 + .055)/1.055, 2.4) - .1791 - 1e-6))); }
  .btn--primary       { --btn-fill: var(--primary);     color: var(--btn-ink); }
  .btn--primary:hover { --btn-fill: var(--primary-hov); color: var(--btn-ink); }
  .btn--accent        { --btn-fill: var(--accent);      color: var(--btn-ink); }
  .btn--accent:hover  { --btn-fill: var(--accent-hov);  color: var(--btn-ink); }
  .btn--danger:hover  { --btn-fill: var(--danger);      color: var(--btn-ink); }
  .btn--ok:hover      { --btn-fill: var(--accent);      color: var(--btn-ink); }
  .btn--warn:hover    { --btn-fill: var(--warn);        color: var(--btn-ink); }
  .btn--info:hover    { --btn-fill: var(--info);        color: var(--btn-ink); }
}

/* Connection-status header pills — Online since / Last connected / Never connected */
.pill-online{display:inline-flex;align-items:center;gap:7px;margin-inline-start:10px;padding:3px 12px;
  font-size:12px;font-weight:800;letter-spacing:.02em;vertical-align:middle;white-space:nowrap;
  border-radius:999px;color:#fff;background:#15a34a;border:1px solid #0d7a37;
  text-shadow:0 1px 1px rgba(0,0,0,.28);box-shadow:0 1px 3px rgba(0,0,0,.18);
  animation:pillOnlineFlash 1.3s ease-in-out infinite;}
.pill-online__dot{width:8px;height:8px;border-radius:50%;background:#fff;
  box-shadow:0 0 0 0 rgba(255,255,255,.9);animation:pillOnlineRing 1.3s ease-out infinite;}
@keyframes pillOnlineFlash{0%,100%{opacity:1}50%{opacity:.7}}
@keyframes pillOnlineRing{0%{box-shadow:0 0 0 0 rgba(255,255,255,.9)}
  70%{box-shadow:0 0 0 7px rgba(255,255,255,0)}100%{box-shadow:0 0 0 0 rgba(255,255,255,0)}}
.pill-conn,.pill-never{display:inline-flex;align-items:center;gap:6px;margin-inline-start:10px;padding:3px 11px;
  font-size:12px;font-weight:600;vertical-align:middle;border-radius:999px;white-space:nowrap;}
.pill-conn{color:var(--fg-muted);background:var(--bg-hover);border:1px solid var(--border);}
.pill-never{color:#b91c1c;background:rgba(220,38,38,.10);border:1px solid rgba(220,38,38,.35);}
.pill-conn__ico{width:13px;height:13px;flex:none;opacity:.8;}
.table td .pill-online,.table td .pill-conn,.table td .pill-never{margin-inline-start:0;}
/* Tight mobile topbar: keep the date but shrink the whole pill so it fits. */
@media (max-width: 640px){
  .topbar__title .pill-online,.topbar__title .pill-conn,.topbar__title .pill-never{
    font-size:10px;padding:2px 7px;margin-inline-start:6px;gap:4px;}
  .topbar__title .pill-when{font-weight:600;}
  .topbar__title .pill-online__dot{width:7px;height:7px;}
  .topbar__title .pill-conn__ico{width:10px;height:10px;}
}
/* Users-table column chooser */
.col-dd{position:relative;display:inline-block;}
.col-dd>summary{list-style:none;cursor:pointer;}
.col-dd>summary::-webkit-details-marker{display:none;}
.col-dd__caret{width:0;height:0;margin-inline-start:4px;border-inline:4px solid transparent;border-top:5px solid currentColor;opacity:.7;display:inline-block;vertical-align:middle;}
.col-dd__menu{position:absolute;top:calc(100% + 5px);inset-inline-end:0;z-index:200;min-width:210px;
  max-height:min(70vh,460px);overflow-y:auto;
  background:var(--surface);border:1px solid var(--border);border-radius:9px;
  box-shadow:0 10px 28px rgba(0,0,0,.22);padding:8px;display:flex;flex-direction:column;gap:3px;}
.col-dd__item{display:flex;align-items:center;gap:9px;padding:6px 7px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;}
.col-dd__item:hover{background:var(--bg-hover);}
/* The menu also carries plain links (the Export formats). Without this they
   arrive underlined and in link-blue, which reads as a different control
   from the checkbox rows sitting directly above them. */
a.col-dd__item{color:inherit;text-decoration:none;}
/* Flipped variant: opens above the button when the viewport has no room below. */
.col-dd--up .col-dd__menu{top:auto;bottom:calc(100% + 5px);}
/* Custom checkbox so the box is ALWAYS visible — a clear bordered box when off,
   filled with a check when on (native unchecked boxes are near-invisible here). */
.col-dd__item input[type=checkbox]{appearance:none;-webkit-appearance:none;flex:none;margin:0;cursor:pointer;
  width:16px;height:16px;border:1.5px solid var(--fg-dim);border-radius:4px;background:var(--surface);
  position:relative;transition:background .12s,border-color .12s;}
.col-dd__item input[type=checkbox]:checked{background:var(--primary);border-color:var(--primary);}
.col-dd__item input[type=checkbox]:checked::after{content:'';position:absolute;left:4.5px;top:1px;
  width:4px;height:8px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg);}
.col-dd__foot{border-top:1px solid var(--border);margin-top:5px;padding-top:8px;display:flex;justify-content:flex-end;}
/* Sortable table headers */
.sort-th{display:inline-flex;align-items:center;gap:4px;color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap;}
.sort-th:hover{color:var(--fg);}
.sort-th.is-sorted{color:var(--primary);}
.sort-ind{font-size:11px;line-height:1;min-width:8px;}
.sort-th:not(.is-sorted):hover .sort-ind::before{content:'↕';opacity:.5;}
:root[data-theme="dark"] .pill-never{color:#f87171;background:rgba(248,113,113,.12);border-color:rgba(248,113,113,.35);}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .pill-never{color:#f87171;background:rgba(248,113,113,.12);border-color:rgba(248,113,113,.35);}}
.btn--ghost { background: transparent; border-color: transparent; }
.btn--ghost:hover { background: var(--bg-hover); border-color: var(--border); }
.btn--sm { padding: 5px 10px; font-size: 12px; border-radius: 6px; min-height: 30px; }
.btn--xs { padding: 3px 8px; font-size: 11px; border-radius: 5px; min-height: 24px; }
.btn--icon { width: 32px; height: 32px; padding: 0; }
.btn-group { display: inline-flex; gap: 0; }
.btn-group form { display: contents; }
.btn-group .btn { border-radius: 0; border-right-width: 0; }
.btn-group .btn:first-child { border-radius: 8px 0 0 8px; }
.btn-group .btn:last-child  { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.btn-group .btn.is-active { background: var(--primary); color: #fff; border-color: var(--primary); }

/* Inputs */
.field { display: block; margin-bottom: 14px; }
.field__label {
  /* flex, not block: a 16px round help-tip sitting on the baseline of a 12px
     label rode visibly high. Centring it against the text is the whole fix;
     wrap keeps labels that carry extra spans behaving as before. */
  display: flex; align-items: center; flex-wrap: wrap;
  font-size: 12px; font-weight: 500;
  color: var(--fg-muted); margin-bottom: 6px;
}
.field__hint { font-size: 11px; color: var(--fg-dim); margin-top: 4px; }
.field__error { font-size: 11px; color: var(--danger); margin-top: 4px; }
/* Live field verdicts (name availability). The word sits INSIDE the box on its
   trailing edge: a line underneath shifted every following field down a row the
   moment it appeared. inset-inline-end keeps it on the correct side in RTL, and
   pointer-events:none means it can never swallow a click meant for the input. */
.input-verdict-host { position: relative; }
/* The side is set in JS from the INPUT's own computed direction, not the
   page's: .input--mono is forced direction:ltr even under [dir="rtl"], so a
   logical inset-inline-end would land the verdict on top of the typed text. */
.input-verdict {
  position: absolute; top: 50%; transform: translateY(-50%);
  font-size: 11px; font-weight: 600; letter-spacing: .01em;
  pointer-events: none; white-space: nowrap;
}
.input-verdict--taken { color: var(--danger); }
.text-danger { color: var(--danger); }

/* Notification preview drawn as the client that will show it. Each app gets
   its own bubble colour and alignment, because "how does this land on a
   phone" is the question the preview exists to answer. Colours are the
   apps' own, so they are literal values rather than theme tokens. */
.msg-preview { border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: var(--surface-2); }
.msg-preview__chrome {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 12px; background: var(--surface); border-bottom: 1px solid var(--border);
  font-size: 12px; font-weight: 600; color: var(--fg-muted);
}
.msg-preview__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--fg-dim); }
.msg-preview__thread { padding: 14px 12px; min-height: 90px; display: flex; }
.msg-preview__bubble {
  /* Bottom padding — not side padding — makes room for the timestamp. The old
     30px on the trailing edge indented EVERY line to clear a mark that only
     sits on the last one, and being a physical side it landed on the wrong
     edge in RTL. */
  position: relative; max-width: 86%; padding: 8px 10px 18px;
  border-radius: 10px; font-size: 13px; line-height: 1.5;
  word-break: break-word; color: #111;
  /* text-align:start with dir="auto" on the element: the MESSAGE decides its
     own direction, not the panel. An Arabic message previewed on an English
     panel was inheriting the wrong alignment and reading as centred. */
  text-align: start;
}
/* pre-wrap belongs on the TEXT, not the bubble — on the container it also
   preserved the template's own indentation. */
#prev-body { white-space: pre-wrap; }
.msg-preview__meta { position: absolute; inset-inline-end: 8px; bottom: 4px; font-size: 10px; opacity: .55; }

/* WhatsApp — outgoing bubble, its green, a tail on the trailing edge. */
.msg-preview[data-channel="whatsapp"] .msg-preview__thread { background: #ECE5DD; justify-content: flex-end; }
.msg-preview[data-channel="whatsapp"] .msg-preview__bubble { background: #DCF8C6; }
.msg-preview[data-channel="whatsapp"] .msg-preview__dot { background: #25D366; }
.msg-preview[data-channel="whatsapp"] #prev-ticks { color: #34B7F1; }

/* Telegram — its blue on the light chat background. */
.msg-preview[data-channel="telegram"] .msg-preview__thread { background: #E7EBF0; justify-content: flex-end; }
.msg-preview[data-channel="telegram"] .msg-preview__bubble { background: #EFFDDE; }
.msg-preview[data-channel="telegram"] .msg-preview__dot { background: #2AABEE; }

/* SMS — grey incoming bubble, leading edge, no delivery ticks. */
.msg-preview[data-channel="sms"] .msg-preview__thread { background: #F2F2F7; justify-content: flex-start; }
.msg-preview[data-channel="sms"] .msg-preview__bubble { background: #E5E5EA; padding-bottom: 8px; }
.msg-preview[data-channel="sms"] .msg-preview__meta { display: none; }
.msg-preview[data-channel="sms"] .msg-preview__dot { background: #34C759; }

/* Email — a header block and a plain body, no bubble. */
.msg-preview[data-channel="email"] .msg-preview__thread { display: none; }
.msg-preview__mail { display: none; }
.msg-preview[data-channel="email"] .msg-preview__mail { display: block; background: var(--surface); }
.msg-preview[data-channel="email"] .msg-preview__dot { background: #EA4335; }
.msg-preview__mailhead { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 12px; line-height: 1.8; }
.msg-preview__mailbody { padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; text-align: start; }
.msg-preview__mailhead > div { text-align: start; }

/* WhatsApp renders *text* bold; the preview does the same so the subject line
   looks like what the user sees. */
.msg-preview__bubble b, .msg-preview__mailbody b { font-weight: 700; }
/* A card that is a <details>: the header doubles as the summary, so it needs
   the pointer and a caret. list-style:none kills the browser's default triangle
   (and ::-webkit-details-marker does the same on Safari). */
.card--collapsible > summary.card__header { cursor: pointer; list-style: none; }
.card--collapsible > summary.card__header::-webkit-details-marker { display: none; }
.card--collapsible > summary.card__header::after {
  content: ''; margin-inline-start: auto; width: 8px; height: 8px;
  border-inline-end: 2px solid var(--fg-dim); border-block-end: 2px solid var(--fg-dim);
  transform: rotate(45deg); transition: transform .18s ease;
}
.card--collapsible[open] > summary.card__header::after { transform: rotate(-135deg); }
.card--collapsible > summary.card__header:hover .card__title { color: var(--fg); }
/* Sortable headers for live tables (no server-side sort to defer to). The
   caret only appears on the column actually sorted, so the header row does not
   turn into a row of arrows. */
/* Matches .sort-th on the server-sorted lists, so a sortable column looks the
   same everywhere: a faint ↕ appears on hover, and the active column turns
   primary with a real direction arrow. Without the hover hint these headers
   read as plain labels and nobody discovers they can be clicked. */
.table th.is-sortable { cursor: pointer; user-select: none; white-space: nowrap; }
.table th.is-sortable:hover { color: var(--fg); }
.table th.is-sortable::after { content: ' \2195'; font-size: 10px; opacity: 0; margin-inline-start: 4px; }
.table th.is-sortable:hover::after { opacity: .5; }
.table th.is-sortable[data-sort-dir] { color: var(--primary); }
.table th.is-sortable[data-sort-dir]::after { content: ' \2191'; opacity: 1; }
.table th.is-sortable[data-sort-dir="desc"]::after { content: ' \2193'; opacity: 1; }
.input-verdict--ok    { color: var(--accent); }
.input.is-invalid,
.input.is-invalid:focus { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
.input, .select, .textarea {
  width: 100%; min-width: 0; padding: 8px 12px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--fg);
  font-family: inherit; font-size: 13px;
  transition: border-color .15s, box-shadow .15s;
}
.input:focus, .select:focus, .textarea:focus {
  outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--ring);
}
.input::placeholder { color: var(--fg-dim); }
/* A field the form fills and will not let you change (the approve dialog's
   "Same as router" pins host and user) must not look like one waiting for
   typing: no field background, a dashed edge, and no text cursor. */
.input[readonly] {
  background: transparent; border-style: dashed; color: var(--fg-muted);
  cursor: default;
}
.input[readonly]:focus { border-color: var(--border-strong); box-shadow: none; }
.input--mono { font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; }

/* ── Undo / redo widget (topbar on desktop, sidebar on a phone) ───────────── */
.undo-bar {
  display: inline-flex; align-items: stretch; gap: 0;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--bg-elev-2); overflow: hidden; flex: none;
}
.undo-bar__btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; padding: 0; border: 0; background: transparent;
  color: var(--fg-muted); cursor: pointer;
  transition: background .15s, color .15s;
}
.undo-bar__btn svg { width: 15px; height: 15px; }
.undo-bar__btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--fg); }
.undo-bar__btn:disabled { opacity: .35; cursor: default; }
.undo-bar__box {
  min-width: 96px; max-width: 190px; padding: 5px 9px;
  border: 0; border-inline: 1px solid var(--border);
  background: transparent; color: var(--fg-muted);
  font: inherit; font-size: 11.5px; line-height: 1.2;
  cursor: pointer; text-align: center;
  transition: background .15s, color .15s;
}
.undo-bar__box:hover { background: var(--bg-hover); color: var(--fg); }
.undo-bar__label {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* One widget per breakpoint: the topbar runs out of room on a phone, so the
   sidebar carries the copy there instead. */
/* The sidebar copy is the one you normally see, at every width — it sits under
   the search button and matches its 12px gutters, so the two line up rather
   than the bar running edge to edge.

   It used to be the phone-only copy, with the topbar carrying it on desktop.
   But the topbar version is a 150px box whose whole job is to read "No actions
   yet" most of the time, wedged between the magnifier and the clock: the
   widest thing in the bar saying the least. In the sidebar it has room for the
   label it actually wants, and the bar goes back to being icons. */
.undo-bar--side {
  display: inline-flex;
  margin: 8px 12px 4px;
  width: calc(100% - 24px);
}
.undo-bar--side .undo-bar__box { flex: 1; max-width: none; padding-block: 7px; }
.undo-bar--side .undo-bar__btn { width: 36px; }
/* .undo-bar--top is not dead code: it is the fallback for the collapsed rail,
   where the sidebar is 56px wide and the bar cannot fit at all. undo.js keeps
   every instance in sync, so the two copies never disagree. */
.undo-bar--top { display: none; }
body.sb-collapsed .undo-bar--side { display: none; }
body.sb-collapsed .undo-bar--top { display: inline-flex; }

/* History popup — fixed to the viewport so the same panel serves the topbar
   widget and the sidebar one without re-anchoring. */
.undo-pop {
  position: fixed; z-index: 2000;
  min-width: 290px; max-width: min(94vw, 400px);
  max-height: min(70vh, 460px); overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: 0 14px 40px rgba(0, 0, 0, .28);
  padding: 6px;
}
.undo-pop__head {
  padding: 6px 8px 8px; font-size: 11px; font-weight: 600;
  color: var(--fg-dim); text-transform: uppercase; letter-spacing: .4px;
}
.undo-pop__item {
  display: flex; align-items: center; gap: 8px;
  padding: 7px 8px; border-radius: 7px;
}
.undo-pop__item + .undo-pop__item { margin-top: 2px; }
.undo-pop__item:hover { background: var(--bg-hover); }
/* Steps ahead of the cursor are undone — dimmed, but still reachable. */
.undo-pop__item.is-undone .undo-pop__text { opacity: .45; }
.undo-pop__item.is-current { background: color-mix(in srgb, var(--primary) 12%, var(--surface)); }
.undo-pop__text { flex: 1; min-width: 0; }
.undo-pop__title {
  font-size: 12.5px; font-weight: 500; color: var(--fg);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.undo-pop__meta { font-size: 10.5px; color: var(--fg-dim); }
.undo-pop__empty { padding: 14px 10px; font-size: 12.5px; color: var(--fg-dim); text-align: center; }

/* Expiration × periods multiplier (user create/edit forms).
   Comfortable on a desktop; squeezed on a phone so the date and the stepper
   still share ONE row — the × only reads as a multiplier when they do. */
.periods-step { display: flex; gap: 6px; align-items: center; }
.periods-step > .btn { padding-inline: 12px; }
.periods-step > .js-periods { width: 86px; padding-inline: 6px; text-align: center; }
@media (max-width: 640px) {
  .periods-step { gap: 4px; }
  .periods-step > .btn { padding-inline: 9px; }
  .periods-step > .js-periods { width: 46px; padding-inline: 4px; }
}
/* number inputs without the up/down spinner */
.input--no-spin::-webkit-inner-spin-button,
.input--no-spin::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.input--no-spin { -moz-appearance: textfield; appearance: textfield; }
/* Date/time inputs: iOS Safari renders a native control whose intrinsic
   min-width can exceed narrow mobile layouts (grid/flex items honour
   min-content), pushing the field past the viewport. Let them shrink to
   their container and never overflow it. */
input[type="date"], input[type="datetime-local"], input[type="time"],
input[type="month"], input[type="week"] { min-width: 0; max-width: 100%; }
/* Masked datetime field + its calendar popover (see the mask script in base.html).
   The masked text input is always the one that submits; the popover only writes
   into it when Apply is pressed.
   Pinned LTR: a "YYYY-MM-DD HH:MM" value reads left-to-right in every locale,
   and .input--mono is already forced direction:ltr under [dir="rtl"] below — so
   the wrapper must agree, or the button lands on the opposite side from the
   padding reserved for it and overlaps the text on Arabic pages. */
.dt-field { position: relative; display: block; direction: ltr; }
.dt-field > .input { padding-inline-end: 36px; }
.dt-field__btn {
  position: absolute; inset-inline-end: 4px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
  background: none; border: 0; border-radius: 6px;
  color: var(--fg-dim); cursor: pointer;
  transition: color .15s, background .15s;
}
.dt-field__btn:hover { color: var(--fg); background: var(--bg-hover); }
.dt-field__btn svg { width: 16px; height: 16px; }
/* Hidden native input behind the clock button on data-mask="time" fields.
   Invisible but still rendered — showPicker() refuses a display:none element. */
.dt-field__native {
  position: absolute; inset-inline-end: 8px; bottom: 0;
  width: 1px; height: 1px; padding: 0; border: 0;
  opacity: 0; pointer-events: none;
}
/* The popover is appended to <body> and positioned fixed: .modal sets
   overflow-y:auto, which would clip it if it lived inside the field. z-index
   sits above .modal-backdrop (100). */
/* The popover follows the page direction (an Arabic calendar mirrors: the week
   starts on the right). Only the HH:MM box stays LTR — it holds a Latin value. */
.dt-pop {
  position: fixed; z-index: 1100; width: 264px; padding: 10px;
  background: var(--bg-elev);
  border: 1px solid var(--border); border-radius: 12px;
  box-shadow: var(--shadow-lg);
}
.dt-pop__head { display: flex; align-items: center; gap: 2px; margin-bottom: 6px; }
.dt-pop__title { flex: 1; text-align: center; font-size: 13px; font-weight: 600; color: var(--fg); }
.dt-pop__nav {
  width: 24px; height: 24px; padding: 0; flex: none;
  background: none; border: 0; border-radius: 6px;
  color: var(--fg-dim); font-size: 13px; line-height: 1; cursor: pointer;
}
.dt-pop__nav:hover { color: var(--fg); background: var(--bg-hover); }
/* RTL reorders the row so "previous" sits on the right; mirror the chevron
   glyphs to match, otherwise they point the wrong way. */
[dir="rtl"] .dt-pop__nav { transform: scaleX(-1); }

/* Directional arrows (→ "View all", ← "Back") must point the way the reader
   travels, so they mirror under RTL. Tagged with .icon-dir rather than flipping
   every icon in a button: a check, trash can or logo must NOT be mirrored. */
[dir="rtl"] .icon-dir { transform: scaleX(-1); }
.dt-pop__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.dt-pop__dow { font-size: 10px; color: var(--fg-dim); text-align: center; padding: 2px 0; }
.dt-pop__day {
  height: 30px; padding: 0; border: 0; border-radius: 6px;
  background: none; color: var(--fg);
  font-family: inherit; font-size: 12px; cursor: pointer;
}
.dt-pop__day:hover { background: var(--bg-hover); }
.dt-pop__day:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.dt-pop__day.is-today { box-shadow: inset 0 0 0 1px var(--border-strong); }
.dt-pop__day.is-sel { background: var(--primary); color: var(--primary-fg); font-weight: 600; }
.dt-pop__day:disabled { visibility: hidden; }   /* padding cells */
/* Days outside the field's min/max stay visible but unusable, so it's obvious
   the range is limited rather than the month looking half-empty. */
.dt-pop__day.is-out { visibility: visible; opacity: .32; cursor: not-allowed; }
.dt-pop__day.is-out:hover { background: none; }
.dt-pop__time {
  display: flex; align-items: center; gap: 8px;
  margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
}
.dt-pop__timelbl { font-size: 12px; color: var(--fg-muted); }
.dt-pop__timewrap { position: relative; flex: 1; display: block; direction: ltr; }
.dt-pop__timein {
  width: 100%; padding: 5px 30px 5px 8px; font-size: 12px;
  direction: ltr; text-align: start;
}
.dt-pop__timein.is-invalid { border-color: var(--danger); }
.dt-pop__clock {
  position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0;
  background: none; border: 0; border-radius: 5px;
  color: var(--fg-dim); cursor: pointer;
  transition: color .15s, background .15s;
}
.dt-pop__clock:hover { color: var(--fg); background: var(--bg-hover); }
.dt-pop__clock svg { width: 14px; height: 14px; }
/* Invisible but still rendered — showPicker() refuses a display:none element. */
.dt-pop__timenative {
  position: absolute; right: 8px; bottom: 0;
  width: 1px; height: 1px; padding: 0; border: 0;
  opacity: 0; pointer-events: none;
}
.dt-pop__foot { display: flex; gap: 4px; margin-top: 10px; }
.dt-pop__foot .btn { flex: 1; padding-inline: 6px; }
.dt-pop__foot .btn:disabled { opacity: .5; cursor: not-allowed; }
.select {
  appearance: none; -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239aa3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center;
  padding-inline-end: 30px;
}
.textarea { resize: vertical; min-height: 80px; }

.checkbox-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-row input { accent-color: var(--primary); width: 16px; height: 16px; }

/* Switch */
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch__track {
  position: absolute; cursor: pointer; inset: 0;
  background: var(--border-strong); border-radius: 999px;
  transition: background .15s;
}
.switch__track::before {
  content: ""; position: absolute; inset-inline-start: 2px; top: 2px;
  width: 16px; height: 16px; background: #fff; border-radius: 50%;
  transition: transform .15s;
}
.switch input:checked + .switch__track { background: var(--accent); }
.switch input:checked + .switch__track::before { transform: translateX(16px); }
.switch--lg { width: 46px; height: 26px; }
.switch--lg .switch__track::before { width: 20px; height: 20px; }
/* A disabled switch must not read as "on but greyed" — dim the whole control
   and drop the pointer cursor (App Filter uses this for the Download/Upload
   toggles of an app that is switched off). */
.switch input:disabled + .switch__track { opacity: .35; cursor: not-allowed; }
.switch--lg input:checked + .switch__track::before { transform: translateX(20px); }

/* Toggle rows (label + switch) */
.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
.toggle-section { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }

/* Form rows */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row--3 { grid-template-columns: 1fr 1fr 1fr; }
@media (max-width: 640px) { .form-row, .form-row--3 { grid-template-columns: 1fr; } }

/* Toggle chips — multi-select pills, a friendlier replacement for a row of
   raw checkboxes. Markup: <label class="chip"><input class="chip__cb"
   type="checkbox" ...><span class="chip__dot"></span>label</label> */
.chip-group { display: flex; flex-wrap: wrap; gap: 8px; }
/* NAS pickers: cap height and scroll when an install has many routers. */
.nas-chips { max-height: 220px; overflow-y: auto; padding: 2px; }
.chip {
  --chip-accent: var(--primary);
  position: relative;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 14px;
  font-size: 12.5px; font-weight: 500; line-height: 1;
  color: var(--fg-muted);
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  cursor: pointer; user-select: none;
  transition: background .15s, border-color .15s, color .15s;
}
.chip:hover { border-color: var(--border-strong); color: var(--fg); }
.chip__cb {  /* visually hidden, still focusable for keyboard users */
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  opacity: 0; pointer-events: none;
}
.chip__dot {
  width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
  background: var(--border-strong);
  transition: background .15s, box-shadow .15s;
}
.chip:has(.chip__cb:checked) {
  background: color-mix(in srgb, var(--chip-accent) 15%, var(--surface));
  border-color: var(--chip-accent);
  color: var(--fg);
}
.chip:has(.chip__cb:checked) .chip__dot {
  background: var(--chip-accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--chip-accent) 28%, transparent);
}
.chip:has(.chip__cb:focus-visible) {
  border-color: var(--chip-accent);
  box-shadow: 0 0 0 3px var(--ring);
}

/* Sectioned form — groups related fields under a quiet uppercase heading. */
.form-section { margin-bottom: 20px; }
.form-section:last-of-type { margin-bottom: 0; }
.form-section__title {
  font-size: 11px; font-weight: 600; letter-spacing: .07em;
  text-transform: uppercase; color: var(--fg-dim);
  margin: 0 0 12px; padding-bottom: 7px;
  border-bottom: 1px solid var(--border);
}
.form-grid {
  display: grid; gap: 12px 14px;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.form-grid .field { margin-bottom: 0; }
.form-grid__full { grid-column: 1 / -1; }
@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } }

/* Tables */
.table-wrap { overflow-x: auto; }
.table {
  width: 100%; border-collapse: collapse;
  font-size: 13px;
}
.table th {
  text-align: start; padding: 10px 14px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  font-weight: 600; font-size: 11px; text-transform: uppercase;
  letter-spacing: .6px; color: var(--fg-muted);
  position: sticky; top: 0;
  /* Above the row content. Without a z-index, row cells holding replaced or
     styled content (app logos, badges) painted OVER the stuck header while a
     block's table scrolled — visible in every dashboard block. */
  z-index: 2;
}
.table td {
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
  color: var(--fg);
}
/* ── Interlaced rows, on EVERY table ────────────────────────────────────────
   The eye tracks a long row across a wide table without a ruler. This lived on
   .table--dense alone, so the eight templated lists had it and the forty other
   tables in the panel — invoices, routers, admin users, plan windows, every
   dashboard block — did not, and a row read differently depending on which
   screen you were on. Promoted to .table so there is one answer.

   PAINTED ON THE CELL, NOT THE ROW, and that is forced rather than chosen: a
   <tr> background sits BEHIND its cells' backgrounds, so a stripe on the td
   would cover any hover set on the tr. Hence the hover rule below also targets
   td, and must stay AFTER this one — equal specificity, source order decides.

   :not(.o-grp) because a group header row carries its own band (.o-grp td,
   further down) and a stripe underneath it made every other group look like a
   different colour of heading. Nested levels have their own shades too. */
.table tbody tr:not(.o-grp) td { background: transparent; }
.table tbody tr:not(.o-grp):nth-child(odd) td {
  background: color-mix(in srgb, var(--fg) 3%, transparent);
}
.table tbody tr:not(.o-grp):hover td { background: var(--bg-hover); }
.table tbody tr:last-child td { border-bottom: none; }
.table--compact td { padding: 8px 14px; }
.table--mono td { font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; font-size: 12px; }
/* Numeric columns. text-align is `right`, NOT `end`, on purpose: the values in
   these cells are .text-mono, and the RTL rule further down isolates them as
   LTR runs and pulls them to the right edge. A header left on `end` therefore
   sat on the OPPOSITE side from its own column under RTL. Right in both
   directions keeps the two together.

   The monospace face is on the CELLS only. Fira Code carries no Arabic or
   Persian glyphs, so putting it on the header made every RTL heading fall back
   mid-word to whatever the browser chose, while the neighbouring text column
   used the proper UI face. Headers are words and belong in the UI font;
   digits are what needs tabular monospace. */
.table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.table td.num { font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; }
/* Column widths for the dashboard's Website/App table.
   Scoped to this table on purpose. Two failed attempts are worth recording:
   leaving it to `table-layout: auto` gave the name column 305px it could not
   use while four columns holding "0 bps" took 929px between them, so the
   values drifted far from their headings; making .num shrink-to-fit
   (width:1%) then collapsed all four against the right edge and left a canyon
   after the name, which is worse on a wide block.
   Fixed layout with explicit shares keeps the proportions the same at every
   block width — the name gets the room a name needs, and the four value
   columns are spread evenly across the rest instead of bunching at one end. */
.table--apps { table-layout: fixed; }
.table--apps th:first-child, .table--apps td:first-child { width: 36%; }
.table--apps th.num, .table--apps td.num { width: 16%; }
/* Headings may wrap; values may not. .table .num sets nowrap so a rate like
   "12.4 Mbps" never breaks across lines, but inheriting that on the heading
   meant "DOWNLOAD/S" overflowed its column and was clipped once the block got
   narrow — the words were unreadable while the numbers under them were fine. */
.table--apps th.num { white-space: normal; }
.table .actions { text-align: end; white-space: nowrap; }
.table tbody tr:hover td.actions:last-child { background: var(--bg-hover); }
.table__empty { padding: 36px; text-align: center; color: var(--fg-dim); }
/* On mobile, let cells take their needed width and scroll horizontally (via
   .table-wrap) rather than squeezing/wrapping. Explicit <br> breaks still stack.
   .table--stack opts out by name rather than by specificity: it turns the rows
   into cards below, where nowrap would defeat the whole point. */
@media (max-width: 768px) {
  .table-wrap .table:not(.table--stack) th,
  .table-wrap .table:not(.table--stack) td { white-space: nowrap; }
}

/* Data Log on a phone. The rows are (hostname, address, numbers) and the
   hostname is the long one, so at 390px the host column took ~85% of the
   width, the address was cut in half and every figure — the reason the row
   exists — sat off the right edge behind a scroll with no visible bar. The
   table still scrolls sideways (this is not a card list); it is simply sized
   so the first screenful carries the host, the address and the traffic, and a
   long hostname ellipses instead of pushing them away. Row height comes down
   with it: 15 rows per screen instead of 8. */
@media (max-width: 640px) {
  .table--datalog th, .table--datalog td { padding: 6px 8px; font-size: 12px; }
  .table--datalog td[data-col="dest"] {
    max-width: 42vw; overflow: hidden; text-overflow: ellipsis;
  }
  .table--datalog td[data-col="ip"] { max-width: 30vw; overflow: hidden; text-overflow: ellipsis; }
  .table--datalog td[data-col="last_seen"] { font-size: 11px; }
  /* The badge is the widest thing in a narrow cell; let it shrink too. */
  .table--datalog td[data-col="app"] .badge { max-width: 22vw; overflow: hidden; text-overflow: ellipsis; }
}

/* Row-per-card tables (.table--stack). For a table whose cells hold INPUTS,
   horizontal scrolling is not a usable answer on a phone: `table-layout: auto`
   hands the text column whatever is left after the fixed-width ones, which was
   31px — one glyph — on a 390px screen, and the action buttons ended up past
   the right edge where they could only be reached by scrolling a container with
   no visible scrollbar. Below the breakpoint each <tr> becomes a card and each
   <td> a label/value line, so nothing is ever wider than the viewport.
   The label comes from the cell's own data-label, because the <thead> that
   normally carries it is out of the flow here. Cells without one (the actions
   cell) get no label line at all. */
@media (max-width: 640px) {
  .table--stack, .table--stack tbody, .table--stack tr, .table--stack td { display: block; }
  .table--stack thead { display: none; }
  .table--stack tr {
    border: 1px solid var(--border); border-radius: 10px;
    margin: 10px 14px; background: var(--surface);
  }
  /* The card border already separates rows; a cell border would double it. */
  .table--stack td { border-bottom: none; padding: 9px 12px; white-space: normal; }
  .table--stack td + td { border-top: 1px solid var(--border); }
  .table--stack td[data-label] {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
  }
  .table--stack td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto;
    font-size: 11px; font-weight: 600; text-transform: uppercase;
    letter-spacing: .6px; color: var(--fg-muted);
  }
  /* min-width:0 so a flex input shrinks to the line instead of forcing the card
     wide, which is the overflow this whole block exists to remove. */
  .table--stack td[data-label] .input { flex: 1 1 auto; min-width: 0; width: auto; }
  /* Buttons wrap onto a second line rather than run off the edge. */
  .table--stack td.actions {
    display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px;
    white-space: normal;
  }
}

/* Firewall rule "match" cells pack many inline badges (proto, state, src, dst,
   ports, iface). Let them wrap as a group instead of forcing the row wide. */
.fw-match { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }

/* Badges & pills */
.badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 8px; border-radius: 999px;
  font-size: 11px; font-weight: 500; line-height: 1.5;
  background: var(--bg-elev-2); color: var(--fg-muted);
  border: 1px solid var(--border);
}
.badge--dot::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: currentColor; display: inline-block;
}
/* Load badges: a continuous ramp instead of three fixed steps, so 62% and 74%
   do not look identical. Built exactly like the badges beside it — the solid
   colour as text over a 10% tint of itself, 25% for the border — which is why
   those work unchanged in both themes: the alpha composites over whatever the
   card background is, so no separate dark rule is needed (an opaque dark
   background here looked wrong next to them). Only the hue varies; saturation
   and lightness are fixed so the whole ramp reads as one family, dark green
   through to dark red. --h comes from the template. */
.badge--load {
  /* Pushed hard away from the card on purpose: these digits sit in a dense list
     and are read at a glance, so legibility beats prettiness — the ramp lands
     around 9:1 on the light themes instead of the ~3:1 it had.

     The text colour is the hue MIXED INTO THE THEME'S OWN --fg, which is what
     makes one rule work everywhere: --fg is near-black on the light themes and
     near-white on the dark ones, so the same declaration comes out dark green
     on samm-default and bright green on midnight. There are twelve themes in
     themes.css and SAMM picks between them explicitly — the earlier
     prefers-color-scheme rule keyed off the OS instead, so a light theme opened
     on a machine set to dark mode got the pale dark-theme colours on a white
     card. That is exactly the washed-out pill in the report.

     The plain hsl() first is the fallback for anything without color-mix; it is
     the light-theme value, which is the shipped default. */
  color:        hsl(var(--h, 140) 100% 16%);
  color:        color-mix(in srgb, hsl(var(--h, 140) 100% 45%) 40%, var(--fg));
  background:   hsl(var(--h, 140) 90% 45% / .14);
  border-color: hsl(var(--h, 140) 90% 40% / .50);
  font-weight:  700;
}
/* The CPU/RAM trace that sits just before its badge. Same hue and the same
   mix as the digits, so the two read as one object rather than a chart that
   happens to be nearby. */
.spark {
  vertical-align: middle;
  margin-inline-end: 8px;
  overflow: visible;              /* a point AT 0% or 100% sits on the edge */
}
.spark polyline {
  fill: none;
  stroke: hsl(var(--h, 140) 100% 16%);
  stroke: color-mix(in srgb, hsl(var(--h, 140) 100% 45%) 40%, var(--fg));
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* The viewBox is stretched to the element box (preserveAspectRatio="none"),
     which would stretch the stroke with it — this keeps it an even 1.5px. */
  vector-effect: non-scaling-stroke;
}
.badge--accent  { color: var(--accent);  background: rgba(16,185,129,.10); border-color: rgba(16,185,129,.25); }
.badge--primary { color: var(--primary); background: rgba(59,130,246,.10); border-color: rgba(59,130,246,.25); }
.badge--warn    { color: var(--warn);    background: rgba(245,158,11,.10); border-color: rgba(245,158,11,.25); }
.badge--danger  { color: var(--danger);  background: rgba(239,68,68,.10);  border-color: rgba(239,68,68,.25); }
/* Two names for the two tones that were being asked for and did not exist:
   user_event_text.KIND_TONE emits "success"/"warning" and _mikrotik_wifi.html
   writes badge--success, so a renewed plan, a paid invoice, a wallet top-up
   and a running radio all rendered in the neutral grey of the base .badge —
   the colour that says "nothing to see here". Aliases rather than a rename:
   both vocabularies are in use across the templates. */
.badge--success { color: var(--accent);  background: rgba(16,185,129,.10); border-color: rgba(16,185,129,.25); }
.badge--warning { color: var(--warn);    background: rgba(245,158,11,.10); border-color: rgba(245,158,11,.25); }
/* Shared channel/gateway status pill (notifications + online payments). */
.ch-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; color: var(--fg-dim); }
.ch-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: 0 0 auto; }
.ch-dot--on   { background: var(--accent); box-shadow: 0 0 0 3px rgba(16,185,129,.18); }
.ch-dot--off  { background: var(--fg-dim); opacity: .6; }
.ch-dot--warn { background: var(--warn);   box-shadow: 0 0 0 3px rgba(245,158,11,.18); }
.badge--info    { color: var(--info);    background: rgba(6,182,212,.10);  border-color: rgba(6,182,212,.25); }
.badge--muted   { color: var(--fg-dim); }
.badge--rect    { border-radius: 4px; padding: 3px 8px; }
/* Advanced-speed pills under a speed cell — the plans list and the hotspot
   card-group list render the identical markup, one pill per div so each keeps
   its own line. With no margin they sat flush against the speed figure above
   and against each other, which read as one solid block. Spacing lives here
   rather than inline so both lists cannot drift apart. */
.speed-badge { margin-top: 5px; }

/* Current → Active plan pair (rendered in users.html when a next_plan swap is in effect). */
.plan-swap { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.plan-swap__arrow {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; color: var(--fg-dim); opacity: 0.7;
}
.plan-swap__arrow svg { width: 14px; height: 14px; stroke: currentColor;
  fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

.dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--fg-dim); margin-inline-end: 6px;
}
.dot--ok   { background: var(--accent); box-shadow: 0 0 0 3px rgba(16,185,129,.18); }
.dot--warn { background: var(--warn);   box-shadow: 0 0 0 3px rgba(245,158,11,.18); }
.dot--err  { background: var(--danger); box-shadow: 0 0 0 3px rgba(239,68,68,.18); }
.dot--live { background: var(--accent); animation: pulse 1.6s infinite; }
@keyframes pulse {
  0%,100% { box-shadow: 0 0 0 0 rgba(16,185,129,.6); }
  50%     { box-shadow: 0 0 0 6px rgba(16,185,129,0); }
}

/* Toolbar (above tables) */
.toolbar {
  display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
  padding: 12px 18px; border-bottom: 1px solid var(--border);
  background: var(--surface);
}
.toolbar .btn-group { min-width: 0; max-width: 100%; }
@media (max-width: 640px) {
  .toolbar .btn-group { flex-wrap: wrap; gap: 4px; }
  .toolbar .btn-group .btn { border-radius: 6px !important; border-width: 1px !important; }
  /* Card headers with a right-floated btn-group (e.g. the chart range
     picker on the MikroTik interface detail page) overflow on mobile
     because the header is flex-nowrap and the group has 8+ buttons.
     Drop the group onto its own row at full width and let its buttons
     wrap individually. Same mobile recipe as the toolbar above. */
  .card__header { flex-wrap: wrap; row-gap: 8px; }
  .card__header .btn-group {
    flex-wrap: wrap;
    gap: 4px;
    width: 100%;
    margin-inline-start: 0 !important;
  }
  .card__header .btn-group .btn { border-radius: 6px !important; border-width: 1px !important; }
  /* Toolbar controls sometimes carry inline max-width (e.g. style="max-width:160px")
     that prevents shrinking; cap them to the row and let them wrap rather than
     overflow the phone viewport. !important is needed to beat the inline cap. */
  .toolbar .input, .toolbar__search {
    max-width: 100% !important; min-width: 0; flex: 1 1 auto;
  }
  /* A select must be capped like the rest, but must NOT grow: `flex: 1 1 auto`
     made a two-word control ("20/page") stretch across the whole phone width
     on its own row, which reads as a broken layout rather than a filter. It
     sizes to its content and shares the row with the filter buttons instead. */
  .toolbar .select {
    max-width: 100% !important; min-width: 0; flex: 0 1 auto; width: auto;
  }
  /* The row counter is the least important thing in the toolbar; on a phone it
     belongs under the controls, not squeezed beside them. */
  .toolbar__spacer { flex-basis: 100%; height: 0; }
}
.toolbar__search {
  /* 244px, not 200: the search icon eats 32px of padding-inline-start, and at
     200 the users placeholder ("Search username, name, mobile…", 198px) was cut
     to "Search username, name, n". A crowded toolbar collapses this to its
     min-width, so the min-width is what the placeholder actually gets. */
  position: relative; flex: 1; min-width: 244px; max-width: 360px;
}
.toolbar__search svg {
  position: absolute; inset-inline-start: 10px; top: 50%; transform: translateY(-50%);
  width: 14px; height: 14px; color: var(--fg-dim);
}
.toolbar__search input { padding-inline-start: 32px; }
.toolbar__spacer { flex: 1; }

/* Pagination */
.pagination {
  display: flex; gap: 4px; align-items: center; justify-content: center;
  padding: 14px;
}
.pagination .page-link {
  min-width: 32px; height: 32px; padding: 0 10px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--border); border-radius: 6px;
  color: var(--fg-muted); font-size: 12px;
  background: var(--surface); cursor: pointer;
}
.pagination .page-link:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
.pagination .page-link.is-active { background: var(--primary); color: #fff; border-color: var(--primary); }
.pagination .page-link.is-disabled { opacity: .4; pointer-events: none; }

/* Tabs */
.tabs {
  display: flex; gap: 0; padding: 0 18px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
  overflow-x: auto;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;           /* Firefox */
}
.tabs::-webkit-scrollbar { display: none; } /* Chrome/Safari */
.tab {
  /* Unified 44 px height for consistent touch targets on all platforms */
  padding: 0 14px; height: 44px; flex-shrink: 0;
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 13px; font-weight: 500;
  color: var(--fg-muted); border-bottom: 2px solid transparent;
  cursor: pointer; transition: color .15s, background .15s, border-color .15s;
  white-space: nowrap; text-decoration: none; border-radius: 0;
}
.tab:hover { color: var(--fg); background: var(--bg-hover); text-decoration: none; }
.tab.is-active { color: var(--primary); border-bottom-color: var(--primary); }
/* <button class="tab"> — reset native chrome so it matches <a class="tab"> */
button.tab {
  background: none; border: 0; border-bottom: 2px solid transparent;
  font-family: inherit; color: var(--fg-muted);
}
button.tab:hover { background: var(--bg-hover); }
button.tab.is-active { color: var(--primary); border-bottom-color: var(--primary); }
.tab svg { width: 14px; height: 14px; flex-shrink: 0; }

/* ---- Tab scroll wrapper — injected by enhanceTabs() in app.js ----
   Hosts the bottom border, gradient-fade edge indicators, and arrow buttons.
   Keeps the template markup clean: no changes needed in individual pages. */
.tabs-wrap {
  position: relative;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}
/* The wrapper now owns the bottom border; remove it from the inner .tabs. */
.tabs-wrap > .tabs { border-bottom: none; padding-inline: 0; }
/* Gradient edge fades — hint that more tabs exist off-screen. */
.tabs-wrap::before,
.tabs-wrap::after {
  content: '';
  position: absolute; top: 0; bottom: 0; width: 48px;
  pointer-events: none; z-index: 4;
  opacity: 0; transition: opacity .2s;
}
.tabs-wrap::before {
  left: 0;
  background: linear-gradient(to right, var(--surface) 30%, transparent);
}
.tabs-wrap::after {
  right: 0;
  background: linear-gradient(to left, var(--surface) 30%, transparent);
}
.tabs-wrap.has-fade-left::before  { opacity: 1; }
.tabs-wrap.has-fade-right::after  { opacity: 1; }
/* Scroll arrow buttons (only on pointer/mouse devices). */
.tabs-arrow {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
  width: 36px; height: 36px; padding: 0; border-radius: 8px;
  background: var(--surface); color: var(--fg);
  border: 1px solid var(--border-strong);
  cursor: pointer; box-shadow: var(--shadow);
  display: none; place-items: center;
  transition: background .15s, color .15s, box-shadow .15s, transform .1s;
}
.tabs-arrow.is-visible { display: grid; }
.tabs-arrow:hover {
  background: var(--primary); color: #fff;
  border-color: var(--primary);
  box-shadow: var(--shadow-lg);
  transform: translateY(-50%) scale(1.08);
}
.tabs-arrow:active { transform: translateY(-50%) scale(0.96); }
.tabs-arrow svg { width: 16px; height: 16px; pointer-events: none; }
.tabs-arrow--left  { left:  6px; }
.tabs-arrow--right { right: 6px; }
/* On touch-only devices users swipe naturally — no arrows needed. */
@media (hover: none) { .tabs-arrow { display: none !important; } }

/* Sub-tabs - nested under .tabs (e.g. Hotspot Servers > Server / Login Page) */
.subtabs {
  display: flex; gap: 4px; padding: 8px 12px 0;
  border-bottom: 1px solid var(--border);
  /* Slightly lifted off the page base so the child-tab strip reads as its own
     surface (active tab still pops to --surface, connecting to the card below). */
  background: var(--surface-2);
  border-radius: 8px 8px 0 0;
  margin-bottom: 14px;
  overflow-x: auto;
}
.subtab {
  padding: 7px 14px; font-size: 12px; font-weight: 500;
  color: var(--fg-muted);
  background: transparent;
  border: 1px solid transparent;
  border-bottom: 2px solid transparent;
  border-radius: 6px 6px 0 0;
  cursor: pointer; transition: color .15s, background .15s, border-color .15s;
  white-space: nowrap; text-decoration: none;
}
/* Space the icon from the label without turning the whole pill into a flex
   container (that would also gap apart "Label (count)" pairs). */
.subtab svg { width: 14px; height: 14px; vertical-align: -2px; margin-inline-end: 6px; }
.subtab:hover { color: var(--fg); background: var(--bg-hover); text-decoration: none; }
.subtab.is-active {
  color: var(--primary);
  border-color: var(--border);
  border-bottom-color: transparent;
  background: var(--surface);
}

/* Router section menu — dropdown menu bar (replaces the old two-tier router
   tabs on the MikroTik detail page). Top-level buttons open a click-toggled
   dropdown of their children; a child that itself has children opens a hover
   flyout to the side. Toggling is wired in app.js. */
/* position/z-index lift the whole bar (and its absolutely-positioned dropdowns)
   above the content cards + form inputs below, which otherwise paint over the
   open panel. */
.router-menu {
  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  /* Above the page content (z-auto) so the Status/Network/Access menus open
     over it, but BELOW the topbar (30) so the bar slides under the sticky
     header instead of painting on top of it — at z-index 100 the whole tab row
     rode over the header while scrolling. Because this element is positioned
     with a z-index it forms a stacking context, so the dropdown panels below
     (z-index 200/210) are contained by it and cannot escape above the header
     either. Same rule as .page-header and the dashboard toolbar. */
  position: relative; z-index: 20;
}
.router-menu__group { position: relative; }
.router-menu__btn {
  display: inline-flex; align-items: center; gap: 8px;
  height: 38px; padding: 0 14px;
  background: var(--surface); color: var(--fg);
  border: 1px solid var(--border); border-radius: 9px;
  font-size: 13px; font-weight: 500; cursor: pointer;
  transition: background .15s, border-color .15s, color .15s;
}
.router-menu__btn:hover { background: var(--bg-hover); }
/* A childless group renders this as an <a>, which picked up the global link
   underline on hover. It is a tab, not prose. */
a.router-menu__btn, a.router-menu__btn:hover { text-decoration: none; color: var(--fg); }
.router-menu__group.is-current > .router-menu__btn {
  background: var(--primary); color: var(--primary-fg); border-color: var(--primary);
}
.router-menu__group.is-current > .router-menu__btn:hover {
  background: var(--primary-hov); border-color: var(--primary-hov);
}
.router-menu__group.is-open > .router-menu__btn { border-color: var(--primary); }
.router-menu__icon { width: 15px; height: 15px; flex-shrink: 0; }
/* CSS-drawn carets (no chevron glyph in the sprite). */
.router-menu__caret {
  width: 0; height: 0; margin-inline-start: 2px;
  border-inline: 4px solid transparent;
  border-top: 5px solid currentColor; opacity: .75;
  transition: transform .15s;
}
.router-menu__group.is-open .router-menu__btn > .router-menu__caret { transform: rotate(180deg); }

.router-menu__panel {
  position: absolute; inset-inline-start: 0; top: calc(100% + 6px);
  min-width: 210px; padding: 6px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-lg);
  display: none; flex-direction: column; gap: 2px; z-index: 200;
}
.router-menu__group.is-open > .router-menu__panel { display: flex; }

.router-menu__link {
  display: flex; align-items: center; gap: 9px;
  padding: 8px 10px; border-radius: 7px;
  font-size: 13px; color: var(--fg); text-decoration: none;
  white-space: nowrap; transition: background .12s, color .12s;
}
.router-menu__link:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
.router-menu__link.is-active { color: var(--primary); font-weight: 600; background: var(--bg-hover); }
.router-menu__link .router-menu__icon { opacity: .85; }

/* Sub-child flyout (used when a child node carries its own children). */
.router-menu__has-sub { position: relative; }
.router-menu__caret--side {
  margin-inline-start: auto;
  border-top: 0; border-block: 4px solid transparent;
  border-inline-start: 5px solid currentColor; border-inline-end: 0;
  opacity: .6;
}
.router-menu__flyout {
  position: absolute; inset-inline-start: calc(100% + 4px); top: -6px;
  min-width: 190px; padding: 6px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-lg);
  display: none; flex-direction: column; gap: 2px; z-index: 210;
}
.router-menu__has-sub:hover > .router-menu__flyout,
.router-menu__has-sub:focus-within > .router-menu__flyout { display: flex; }

/* Hotspot design gallery */
.design-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
}
.design-card {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface);
  overflow: hidden;
  display: flex; flex-direction: column;
  transition: border-color .15s, transform .05s;
}
.design-card:hover { border-color: var(--primary); }
.design-card__thumb {
  aspect-ratio: 4 / 3;
  background: var(--bg);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
}
.design-card__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.design-card__thumb svg { width: 60%; height: 60%; opacity: .35; }
.design-card__body { padding: 12px; flex: 1; display: flex; flex-direction: column; gap: 6px; }
.design-card__title { font-weight: 600; font-size: 14px; color: var(--fg); }
.design-card__desc { font-size: 12px; color: var(--fg-muted); line-height: 1.4; }
.design-card__footer {
  padding: 10px 12px;
  border-top: 1px solid var(--border);
  display: flex; gap: 6px; justify-content: flex-end;
  flex-wrap: wrap;
}
.design-card__active-badge {
  background: rgba(16,185,129,.15);
  color: rgb(16,185,129);
  border: 1px solid rgba(16,185,129,.4);
  border-radius: 4px;
  padding: 2px 6px;
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .04em;
}

/* Color swatch input wrapper */
.swatch-row { display: flex; gap: 10px; align-items: center; padding: 6px 0; }
.swatch-row label { min-width: 110px; font-size: 12px; color: var(--fg-muted); text-transform: capitalize; }
.swatch-row input[type=color] { width: 42px; height: 32px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; padding: 0; background: transparent; }
/* min-width:0 lets the hex field absorb the shrink instead of squishing the
   fixed-size swatch to a strip in the narrow editor side panel. */
.swatch-row input[type=text] { flex: 1; min-width: 0; font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; font-size: 12px; }

/* Hotspot editor: page switcher above the live preview */
.preview-pages { display: flex; flex-wrap: wrap; margin-bottom: 12px; }

/* Hotspot editor: prev / next template browser */
.tpl-nav { display: flex; align-items: center; gap: 8px; }
.tpl-nav__count { font-size: 12px; color: var(--fg-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.tpl-nav .btn {
  background: #dcfce7;
  color: #166534;
  border-color: #86efac;
}
.tpl-nav .btn:hover {
  background: #bbf7d0;
  color: #14532d;
  border-color: #4ade80;
}

/* Editor live preview iframe */
.design-editor-grid {
  display: grid;
  grid-template-columns: minmax(280px, 360px) 1fr;
  gap: 18px;
}
@media (max-width: 768px) {
  .design-editor-grid { grid-template-columns: 1fr; }
  .design-editor-grid > :first-child { order: 2; }
  .design-editor-grid > :last-child  { order: 1; }
}
@media (max-width: 480px) {
  /* Stack each color swatch row so the picker + hex field never overflow the
     narrow editor side panel on small phones. */
  .swatch-row { flex-direction: column; align-items: stretch; gap: 4px; }
  .swatch-row label { min-width: 0; }
}

.design-preview-frame {
  width: 100%;
  height: 70vh;
  min-height: 480px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: #fff;
}
@media (max-width: 768px) {
  .design-preview-frame { min-height: 320px; height: 55vh; }
}

/* Modal */
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(2,6,23,.55);
  display: flex; align-items: center; justify-content: center;
  z-index: 100; padding: 20px;
  backdrop-filter: blur(2px);
  /* Fade instead of snap — use visibility+opacity so the transition fires */
  visibility: hidden; opacity: 0; pointer-events: none;
  transition: opacity .22s ease, visibility 0s .22s;
}
.modal-backdrop.is-open {
  visibility: visible; opacity: 1; pointer-events: auto;
  transition: opacity .22s ease;
}
.modal {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--shadow-lg);
  width: 100%; max-width: 560px;
  /* Flex column so ONLY .modal__body scrolls. The whole modal used to scroll,
     which pushed the header (its ✕) and the footer (Save/Apply) off screen on
     phones — they now stay pinned and are always reachable. */
  display: flex; flex-direction: column;
  max-height: calc(100vh - 40px);
  /* dvh tracks the *visible* viewport as mobile browser chrome shows/hides;
     100vh is the large viewport, so it overshot and clipped top and bottom. */
  max-height: calc(100dvh - 40px);
  overflow: auto;
  animation: pop .15s ease-out;
}
.modal__header, .modal__footer { flex: none; }
/* 50 of the 70 modals wrap their body+footer in a <form>. Without this the flex
   chain stops at the form and the body never becomes the scrolling element. */
.modal > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.modal--lg { max-width: 800px; }
/* The New invoice dialog's width. It used to be declared inside accounting.html
   only, so the SAME dialog opened from a user's page fell back to 560px and
   looked like a different form. Declared once here, every page gets it; on a
   phone .modal's width:100% and the backdrop padding still take over. */
.modal--wide { max-width: 740px; }
.modal--xl { max-width: 1100px; }
.modal__header {
  padding: 14px 18px; border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 12px;
}
.modal__title { font-weight: 600; font-size: 15px; flex: 1; }
.modal__title svg { width: 18px; height: 18px; vertical-align: -3px; margin-inline-end: 4px; }
.modal__body {
  padding: 18px;
  flex: 1 1 auto; min-height: 0;      /* min-height:0 or flex refuses to shrink */
  overflow-y: auto;
  overscroll-behavior: contain;        /* don't scroll the page behind the modal */
  -webkit-overflow-scrolling: touch;
}
.modal__footer {
  padding: 12px 18px; border-top: 1px solid var(--border);
  display: flex; gap: 8px; justify-content: flex-end;
  background: var(--surface-2); border-radius: 0 0 14px 14px;
}
/* Phones: claim the extra room the 20px backdrop padding was eating, respect the
   notch/home-indicator insets, and let footer buttons wrap instead of squashing
   Save off the edge. */
@media (max-width: 640px) {
  .modal-backdrop {
    padding: 10px;
    padding-top: max(10px, env(safe-area-inset-top));
    padding-bottom: max(10px, env(safe-area-inset-bottom));
  }
  .modal {
    max-height: calc(100vh - 20px);
    max-height: calc(100dvh - 20px);
  }
  .modal__header { padding: 12px 14px; }
  .modal__body { padding: 14px; }
  .modal__footer { padding: 10px 14px; flex-wrap: wrap; }
  .modal__footer .btn { flex: 1 1 auto; justify-content: center; }
}
/* Close is not destructive. This used to be a hardcoded solid #dc2626 square —
   the only filled red in the panel, louder than the outline reds on the
   genuinely irreversible Delete buttons, and identical in all 16 themes. It is
   a quiet ghost icon-button now, tinted only on hover. */
/* The X and the Cancel button do the same thing -- dismiss without saving --
   so they read the same. Cancel already uses the danger tone the panel gives
   every cancel action (coa_outbox, notif_outbox, the update downloader); the X
   sitting muted beside it made the pair look like two different kinds of
   control. */
.modal__close {
  width: 32px; height: 32px; border-radius: 7px; flex-shrink: 0;
  display: grid; place-items: center; cursor: pointer;
  color: var(--danger); background: transparent; border: none;
  transition: background .15s, color .15s;
}
.modal__close svg { width: 16px; height: 16px; }
.modal__close:hover { background: var(--bg-hover); color: var(--fg); }
.modal__close:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
@keyframes pop { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }

/* Confirmation dialog — styled replacement for the native confirm() box.
   Built lazily by app.js (window.confirmDialog) and reused on both portals. */
.confirm-dialog { z-index: 300; }
.modal--confirm { max-width: 440px; }
.confirm-dialog__main {
  display: flex; gap: 14px;
  padding: 22px 22px 20px;
}
.confirm-dialog__icon {
  flex-shrink: 0;
  width: 44px; height: 44px;
  border-radius: 50%;
  display: grid; place-items: center;
  background: rgba(239, 68, 68, .14);
  color: var(--danger);
}
.confirm-dialog__icon svg { width: 24px; height: 24px; }
.confirm-dialog.confirm-dialog--primary .confirm-dialog__icon {
  background: rgba(59, 130, 246, .14);
  color: var(--primary);
}
.confirm-dialog__content { flex: 1; min-width: 0; padding-top: 1px; }
.confirm-dialog__title {
  font-weight: 600; font-size: 15px;
  color: var(--fg);
  margin-bottom: 5px;
}
.confirm-dialog__warning {
  margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13.5px; font-weight: 600; line-height: 1.45;
  color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent);
  border-inline-start: 3px solid var(--danger);
}
.confirm-dialog__message {
  font-size: 13px; line-height: 1.55;
  color: var(--fg-muted);
  white-space: pre-line;
  word-break: break-word;
}
.confirm-dialog .modal__footer .btn {
  min-width: 92px;
  justify-content: center;
  text-align: center;
}
.confirm-dialog .btn:disabled,
.confirm-dialog .btn[disabled] { opacity: .45; cursor: not-allowed; }
/* "type X to confirm" gate */
.confirm-dialog__field { margin-top: 13px; }
.confirm-dialog__field-label {
  display: block;
  font-size: 12px;
  color: var(--fg-muted);
  margin-bottom: 5px;
}
.confirm-dialog__field .input { width: 100%; }

/* Toast */
.toasts {
  position: fixed; top: 70px; inset-inline-end: 24px; z-index: 200;
  display: flex; flex-direction: column; gap: 8px;
  max-width: 360px;
}
.toast {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 10px; padding: 12px 14px;
  box-shadow: var(--shadow-lg);
  display: flex; align-items: flex-start; gap: 10px;
  animation: slide-in .25s ease-out;
}
@keyframes slide-in { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }

/* Reusable spinner — circle with one colored arc that rotates.
   Default size is 32px; override via inline style for smaller variants
   (e.g. inline status indicators). */
.samm-spinner {
  display:inline-block; width:32px; height:32px;
  border:3px solid var(--border);
  border-top-color: var(--primary);
  border-radius:50%;
  animation: spin .8s linear infinite;
  vertical-align:middle;
}

/* Full-viewport loading overlay. Hidden by default; pages add `.is-visible`
   to fade it in (typically right before a form-driven page navigation). */
.samm-loading-overlay {
  position: fixed; inset: 0;
  background: rgba(15, 23, 42, .55);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  display: flex; align-items: center; justify-content: center; flex-direction: column;
  gap: 14px;
  z-index: 9999;
  opacity: 0; pointer-events: none;
  transition: opacity .15s ease-in;
}
.samm-loading-overlay.is-visible { opacity: 1; pointer-events: auto; }
.samm-loading-overlay__label {
  color: #fff; font-size: 14px; font-weight: 500;
  text-shadow: 0 1px 2px rgba(0,0,0,.45);
}
/* Elapsed-seconds line under the label. Reserves its own height so the label
   doesn't jump when the counter appears a few seconds in. */
.samm-loading-overlay__sub {
  min-height: 16px; margin-top: -6px;
  color: rgba(255,255,255,.72); font-size: 12px;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px rgba(0,0,0,.45);
}

/* Wizard push progress modal — a live percentage bar + spinner shown while the
   one-shot config push runs, so the operator can see it is working (and how far
   along) rather than guessing whether it has hung. Reuses .samm-loading-overlay
   for the dimmed backdrop; the card below is built by app.js. */
.wiz-progress__card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--shadow-lg, 0 14px 48px rgba(0,0,0,.4));
  padding: 26px 28px;
  width: min(460px, 92vw);
  text-align: center;
}
.wiz-progress__spinner { margin: 0 auto 16px; width: 48px; height: 48px; border-width: 4px; }
.wiz-progress__title { font-size: 15px; font-weight: 600; color: var(--fg); margin-bottom: 4px; }
.wiz-progress__hint { font-size: 12px; color: var(--fg-muted); margin-bottom: 18px; }
.wiz-progress__bar-wrap {
  height: 10px; background: var(--bg-elev-2); border-radius: 999px; overflow: hidden;
  margin-bottom: 10px;
}
.wiz-progress__bar {
  height: 100%; width: 0%;
  background: linear-gradient(90deg, var(--primary), var(--accent));
  transition: width .25s ease;
}
.wiz-progress__meta { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 12px; color: var(--fg-dim); }
.wiz-progress__pct { font-weight: 600; color: var(--fg); flex-shrink: 0; }
.wiz-progress__label {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start;
}
.toast--success { border-left: 3px solid var(--accent); }
.toast--error   { border-left: 3px solid var(--danger); }
.toast--warn    { border-left: 3px solid var(--warn); }
.toast--info    { border-left: 3px solid var(--info); }
.toast__title { font-weight: 600; font-size: 13px; margin-bottom: 2px; }
.toast__msg { font-size: 12px; color: var(--fg-muted); white-space: pre-line; }

/* Detail / page header */
.page-header {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 18px; flex-wrap: wrap; gap: 12px;
  position: relative; z-index: 20;  /* lift header dropdowns above the content below */
}
.page-header__title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.page-header__sub { font-size: 13px; color: var(--fg-muted); margin: 0; }
.page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }

/* Reports page filter bar */
.reports-filter { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.reports-filter__date { width: 138px; }
/* The date input is wrapped by .dt-field (picker button), so the wrapper is the
   flex item now and has to carry the width. */
.reports-filter .dt-field { width: 138px; }
.reports-filter .dt-field > .reports-filter__date { width: 100%; }
.reports-filter__sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; flex-shrink: 0; }

/* Stat row */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 18px; }
.stat-row--6 { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }

/* Exactly two tiles: side by side on a desktop, stacked on a phone.
   The base row's auto-fit keeps a pair in ONE row all the way down to ~400px,
   where each tile is under 200px -- too narrow for what the licence bubbles put
   in them (a tier name at 28px, a status badge, a date and a row of limit
   chips), so the content wrapped into an unreadable column while the grid
   insisted it still had room. The threshold is set by that content rather than
   by a device size, which is why it sits between the 980px and 620px
   breakpoints the rest of this file uses. */
.stat-row--2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 700px) {
  .stat-row--2 { grid-template-columns: 1fr; }
}

/* Seven tiles. auto-fit is what the six-tile row used, but seven is prime: at
   every column count it wraps to a last row with one tile and a hole beside it,
   which is exactly why the "Routers" tile was dropped rather than kept. Fixed
   column counts per breakpoint make the wrap predictable, and the odd tile out
   is then stretched to fill its row — a wider last tile reads as deliberate,
   an empty cell reads as a bug. */
.stat-row--7 { grid-template-columns: repeat(7, 1fr); }
@media (max-width: 1400px) {
  /* 4 + 3: the seventh spans two tracks to close the row. */
  .stat-row--7 { grid-template-columns: repeat(4, 1fr); }
  .stat-row--7 > :nth-child(7):last-child { grid-column: span 2; }
}
@media (max-width: 980px) {
  /* 3 + 3 + 1: the seventh is alone, so it takes the full width. */
  .stat-row--7 { grid-template-columns: repeat(3, 1fr); }
  .stat-row--7 > :nth-child(7):last-child { grid-column: 1 / -1; }
}
@media (max-width: 620px) {
  /* 2 + 2 + 2 + 1: same again on a phone. */
  .stat-row--7 { grid-template-columns: repeat(2, 1fr); }
  .stat-row--7 > :nth-child(7):last-child { grid-column: 1 / -1; }
}

/* Plan name + its badges on one baseline (Tools -> Speed Boost).
   The name was block-level, so "disabled" / "throttle target" dropped onto
   their own line and the cell read as two separate fields. Flex keeps them
   inline and vertically centred with the name, and still wraps on a narrow
   column instead of overflowing the cell. */
.plan-name-line {
  display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px;
}

/* A name that is also a link, inside otherwise dimmed text (invoice header).
   It has to read as clickable without shouting: the surrounding line is
   text-dim, so an unstyled <a> there is nearly invisible. */
.link-strong { color: var(--fg); text-decoration: none; }
.link-strong:hover { color: var(--primary); text-decoration: none; }

/* Description list (key/value) */
.dl {
  display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 10px 18px;
  font-size: 13px;
}
/* minmax(0, 1fr), not 1fr: a grid track's default minimum is its CONTENT, so a
   value column holding an input + button row (the card batch's status actions)
   pushed the grid — and the page — 20px wider than the viewport at 768px.
   The definition list is not a table and must never be the thing that scrolls
   sideways. */
.dl dt { color: var(--fg-dim); font-size: 12px; }
.dl dd { margin: 0; color: var(--fg); min-width: 0; }

/* Progress bar */
.progress {
  height: 6px; background: var(--bg-elev-2); border-radius: 999px; overflow: hidden;
}
.progress__bar {
  height: 100%; background: linear-gradient(90deg, var(--primary), var(--accent));
  transition: width .3s ease;
}
.progress__bar--warn   { background: var(--warn); }
.progress__bar--danger { background: var(--danger); }

/* Code block / pre */
.code-block {
  background: var(--bg-elev-2); border: 1px solid var(--border);
  border-radius: 8px; padding: 10px 12px;
  font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; font-size: 12px;
  white-space: pre-wrap; word-break: break-all;
  color: var(--fg-muted);
}

/* Alert */
.alert {
  padding: 12px 14px; border-radius: 8px;
  border: 1px solid var(--border);
  font-size: 13px; display: flex; gap: 10px; align-items: flex-start;
  margin-bottom: 14px;
}
.alert svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
.alert--info    { background: rgba(6,182,212,.06);  border-color: rgba(6,182,212,.25);  color: var(--info); }
.alert--warn    { background: rgba(245,158,11,.06); border-color: rgba(245,158,11,.25); color: var(--warn); }
.alert--danger  { background: rgba(239,68,68,.06);  border-color: rgba(239,68,68,.25);  color: var(--danger); }
.alert--success { background: rgba(16,185,129,.06); border-color: rgba(16,185,129,.25); color: var(--accent); }

/* Login page */
.auth {
  min-height: 100vh; display: grid; place-items: center; padding: 24px;
  background:
    radial-gradient(1000px 400px at 70% 10%, rgba(59,130,246,.10), transparent 60%),
    radial-gradient(800px 400px at 20% 90%, rgba(16,185,129,.08), transparent 60%),
    var(--bg);
}
.auth__card {
  width: 100%; max-width: 420px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 16px; padding: 28px;
  box-shadow: var(--shadow-lg);
}
.auth__brand {
  display: flex; align-items: center; gap: 12px; margin-bottom: 18px;
}
.auth__brand-mark {
  width: 44px; height: 44px; border-radius: 10px;
  background: linear-gradient(135deg, var(--primary), var(--accent));
  display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 20px;
  box-shadow: 0 0 24px rgba(59,130,246,.4);
}
.auth__title { font-size: 20px; font-weight: 600; margin: 0; }
.auth__sub { font-size: 12px; color: var(--fg-muted); margin: 0; }
.auth__divider {
  height: 1px; background: var(--border); margin: 18px 0 14px;
}

/* Charts hosting */
/* direction:ltr is required even on RTL (Arabic) pages: charts are inherently
   left-to-right (time axis, numeric scales), and more importantly WebKit/iOS
   Safari under-measures an RTL flex/block container by ~20px, so Chart.js sized
   the canvas narrower than the wrap and — RTL-anchored to the right — left an
   empty gap on the left (the "chart squeezed right" bug). LTR here makes Chart.js
   measure the full width and draw natively at full size. No-op in LTR/Chromium. */
.chart-wrap { position: relative; width: 100%; direction: ltr; }
.chart-wrap canvas { max-width: 100%; }

/* Utility */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.mt-1 { margin-top: 4px; } .mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; }
.mb-1 { margin-bottom: 4px; } .mb-2 { margin-bottom: 8px; } .mb-3 { margin-bottom: 12px; } .mb-4 { margin-bottom: 16px; }
.text-sm { font-size: 12px; }
.text-md { font-size: 13px; }
.text-lg { font-size: 16px; }
.text-muted { color: var(--fg-muted); }
.text-dim { color: var(--fg-dim); }
.text-mono { font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; overflow-wrap: anywhere; }
/* Keep value pairs (speed/rate "10↑ / 5↓ Mbps", "2M/2M") on a single line.
   Declared after .text-mono so it wins the overflow-wrap when both are present. */
.nowrap { white-space: nowrap; overflow-wrap: normal; }

/* ONE LINE PER ROW, for the lists that are inventories rather than records.
   A cell that carries a second line doubles the height of every row in the
   table, and on a list an ISP opens with a thousand devices in it that is the
   difference between a screen that can be scanned and one that has to be
   scrolled. The secondary value is not thrown away: it moves into the cell's
   title and into the export, both of which hold more than a line ever could.
   Applied per ROW, not per table, so a grouped list's header rows are
   untouched. */
tr.nowrap-row > td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 32ch; }
tr.nowrap-row > td .card__sub { display: inline; margin-left: 6px; }
/* Except the buttons. An ellipsis over a row's last action hid Delete on a
   phone while leaving it clickable under the "…" -- a control you cannot see
   but can still press. The actions cell is as wide as its buttons. */
tr.nowrap-row > td.actions { max-width: none; overflow: visible; text-overflow: clip; }
/* JS-formatted bandwidth/byte values ("123.4 Mbps", "12.5 GiB") must never
   split between number and unit — applies everywhere these spans render. */
.js-bps, .js-bytes { white-space: nowrap; }
/* Inside a table, mono cells must NOT break-anywhere: that collapses their
   min-content width and squishes a wide table into a tall column instead of
   letting .table-wrap scroll it. Long IDs/MACs here scroll horizontally. */
.table .text-mono { overflow-wrap: normal; }
.text-right { text-align: end; }
.text-center { text-align: center; }
.text-bold { font-weight: 600; }
/* An anchor that isn't advertising itself — for a link sitting inside a row
   that already navigates to the same place. */
a.link-plain, a.link-plain:hover { color: inherit; text-decoration: none; }
/* It reads as text, so a long press on it must behave like a press on the row
   rather than raising the browser's link preview. */
a.link-plain { -webkit-touch-callout: none; }
.full-width { width: 100%; }
.hidden { display: none !important; }

/* Context menu (right-click & three-dots on tables) */
.ctx-menu {
  position: fixed;
  z-index: 250;
  min-width: 220px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  box-shadow: var(--shadow-lg);
  padding: 6px;
  font-size: 13px;
  display: none;
  animation: pop .12s ease-out;
}
.ctx-menu.is-open { display: block; }
.ctx-menu__item {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: 6px;
  color: var(--fg); cursor: pointer; user-select: none;
  background: transparent; border: 0; width: 100%; text-align: start;
  font-size: 13px;
}
.ctx-menu__item:hover { background: var(--bg-hover); }
/* Press-and-hold: the outline drawing itself around the item IS the progress
   bar, so holding to the end reads as "this has now been confirmed". */
.ctx-menu__item[data-hold-action] { position: relative; }
.ctx-menu__item.is-holding { background: var(--bg-hover); }
.ctx-menu__item[data-hold-action] {
  /* touch-action keeps the press from being handed to the scroller mid-hold;
     the callout/select rules keep the phone from treating it as text. */
  touch-action: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
}
/* Set for the duration of a touch press on a row, so a long press opens the
   actions menu instead of selecting the username under the thumb. */
[data-ctx-menu].is-pressing,
[data-ctx-menu].is-pressing * {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
/* Selector carries the item class on purpose: `.ctx-menu__item svg` sizes the
   leading icon to 14px and would otherwise shrink the ring into the icon slot. */
.ctx-menu__item svg.hold-ring {
  position: absolute; inset: 0; width: 100%; height: 100%;
  color: inherit; pointer-events: none; overflow: visible;
}
.hold-ring rect {
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round;
}
.ctx-menu__item svg { width: 14px; height: 14px; color: var(--fg-muted); flex-shrink: 0; }
.ctx-menu__item--danger { color: var(--danger); }
.ctx-menu__item--danger svg { color: var(--danger); }
.ctx-menu__item--warn { color: var(--warn); }
.ctx-menu__item--warn svg { color: var(--warn); }
.ctx-menu__item--ok { color: var(--accent); }
.ctx-menu__item--ok svg { color: var(--accent); }
.ctx-menu__sep { height: 1px; background: var(--border); margin: 6px 4px; }
.ctx-menu__label {
  padding: 7px 12px 3px;
  font-size: 10px; font-weight: 700; letter-spacing: .05em;
  text-transform: uppercase; color: var(--fg-dim);
}

.row-link { cursor: pointer; }
.row-link td { transition: background .1s; }
.row-link.is-target td { background: var(--bg-hover); }
.btn-icon-dots {
  width: 28px; height: 28px; padding: 0;
  border-radius: 6px; border: 1px solid transparent;
  background: transparent; color: var(--fg-muted);
  display: inline-grid; place-items: center; cursor: pointer;
}
.btn-icon-dots:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.btn-icon-dots svg { width: 14px; height: 14px; }

/* Inline reveal for a masked value (e.g. user password on user detail):
   monospace value + eye toggle + copy, laid out with logical props so it
   mirrors correctly under [dir="rtl"]. */
.pw-reveal { display: inline-flex; align-items: center; gap: 4px; }
.pw-reveal__val { user-select: all; letter-spacing: 1px; }
.pw-reveal__copy.is-ok { color: var(--ok, #16a34a); }

/* Inline click-to-copy button next to a value (infrastructure fields on the user
   page: MAC, Device SN, IPv6 prefixes, …). Handler: .copy-btn[data-copy] in app.js. */
.copy-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0; margin-inline-start: 4px;
  border: 1px solid transparent; border-radius: 5px; cursor: pointer;
  background: none; color: var(--fg-muted, #888); vertical-align: middle;
  flex-shrink: 0;
}
.copy-btn:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.copy-btn svg { width: 13px; height: 13px; }
.copy-btn.is-ok { color: var(--ok, #16a34a); }

.grid { display: grid; }
/* display:grid here too (not only on .grid), because half the templates write
   bare class="grid-2" — which silently rendered as block flow with NO gap.
   Same self-sufficiency .grid-aside and .stat-row always had. */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
/* Main-content + narrow side-panel (e.g. user detail, settings). Collapses to
   a single column on small screens so the side panel stacks below the content. */
.grid-aside { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; }
/* Grid items default to min-width:auto (= min-content), so a column refuses to
   shrink below the intrinsic width of a wide table/long line inside it and
   pushes the whole page wider than the viewport (horizontal scroll on mobile).
   0 lets each column shrink to its track, so wide tables scroll inside
   .table-wrap and long text wraps instead of overflowing the page. */
.grid-aside > * { min-width: 0; }

/* ── Customisable dashboard grid ────────────────────────────────────────────
   Four equal columns; one row unit is 160px, so a block's height is
   h*160 + (h-1)*gap. Each block carries its span in --dw / --dh, set inline
   from the admin's saved layout and rewritten live by dashboard-grid.js while
   dragging, which is why the spans are custom properties and not classes.
   minmax(0,1fr) — not 1fr — or a wide table inside a block refuses to shrink
   below its intrinsic width and pushes the page into horizontal scroll. */
/* Outer container is just a vertical stack of rows. */
.dash-grid { display: flex; flex-direction: column; gap: 14px; margin-top: 14px; }
/* Dropping a block into the space BETWEEN two rows is how it gets pulled out
   onto a row of its own, so for the duration of a drag those 14px gaps open up
   into a band big enough to aim at, and light up while the pointer is in one. */
.dash-grid { position: relative; }
body.dash-dragging .dash-grid { gap: 38px; }
/* Insertion line marking the exact gap a release would open a new row in.
   A striped background over the whole grid said "somewhere here"; this says
   "here". Placed from measured geometry by dashboard-grid.js. */
.dash-newrow {
  position: absolute; inset-inline: 0; height: 3px; border-radius: 3px;
  background: var(--primary); box-shadow: 0 0 0 4px rgba(59,130,246,.16);
  pointer-events: none; z-index: 5; display: none;
}
.dash-newrow.is-on { display: block; }
/* Each row is its OWN 4-column grid, which is what makes rows independent: in a
   single shared grid, auto-flow backfills, so shrinking two blocks in one row
   pulled the next row's first block up into the leftover space. Here that space
   stays empty and editing a row cannot disturb any other row.
   align-items:start so a block shorter than its tallest row-mate sits at the top
   of the row rather than being stretched to a height its content does not want. */
.dash-row {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  /* One row unit is 80px (was 160): half-size steps when resizing a block's
     height. Legacy layouts double their stored h on read, so nothing moved. */
  grid-auto-rows: 80px;
  gap: 14px;
  min-width: 0;
}
.dash-grid__bar {
  display: flex; justify-content: flex-end; align-items: center;
  gap: 8px; flex-wrap: wrap; margin-top: 14px;
  /* Above the grid rows (z-auto) so the Actions menu opens over the blocks;
     below the topbar (30) so scrolling under it stays clean. */
  position: relative; z-index: 20;
}
.dash-grid__name {
  margin-inline-end: auto; font-size: 14px; font-weight: 600;
}

/* Per-block controls (configure / remove), top inline-end corner. Hidden until
   the block is pointed at, like the resize grip — seventeen blocks each showing
   two permanent buttons would be a page full of chrome. */
/* Sits ON the header row, centred with the buttons beside it ("View all", the
   window pills) — parked in the extreme corner it floated above the row and
   looked unrelated to it. 18px top = 14px header padding + half the difference
   between a 30px header button and this 22px one; 12px inline matches the
   visual gap the header's own controls keep from the edge. */
.dash-tools {
  position: absolute; top: 18px; inset-inline-end: 12px; z-index: 4;
  display: flex; gap: 2px; opacity: 0; transition: opacity .12s ease;
}
.dash-block:hover .dash-tools, .dash-tools:focus-within { opacity: 1; }
.dash-tool {
  display: grid; place-items: center; width: 22px; height: 22px;
  /* padding:0 and line-height:1 are load-bearing: buttons inherit
     `padding: 1px 6px` globally, and with border-box that left a 10px content
     box for a 13px glyph, so the x sat off-centre (6px from the left edge, 3px
     from the right). */
  padding: 0; line-height: 1;
  border: 0; border-radius: 6px; cursor: pointer;
  background: var(--surface-2); color: var(--fg-dim);
  transition: background .12s, color .12s;
}
.dash-tool:hover { background: var(--bg-hover); color: var(--fg); }
.dash-tool svg { width: 13px; height: 13px; display: block; }
/* The remove button is the only destructive control on the block. */
.js-block-close:hover { background: var(--danger); color: #fff; }

/* A live chart's own toolbar: window pills plus its pickers. One ROW on
   desktop — the title yields (shrinking to an ellipsis) before the controls are
   allowed to wrap, because a toolbar folded onto two lines looked different on
   every block. The pill group never shrinks; the pickers do. */
.dash-tb { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.dash-tb .btn--sm { padding: 3px 8px; font-size: 11px; }
@media (min-width: 901px) {
  .dash-tb { flex-wrap: nowrap; }
  /* Shrink order matters: min-width:0 on everything let the pickers collapse
     UNDER their own labels and slide over the neighbouring select. The button
     pickers and the pills keep their size; only the select gives way (down to a
     floor), and before any of that the block title has already ellipsized. */
  .dash-tb .js-pick { flex: 0 0 auto; }
  .dash-tb .btn-group { flex-shrink: 0; }
  .dash-tb .js-pick-iface { flex: 0 1 auto; min-width: 72px; }
  .dash-tb .js-pick > summary { white-space: nowrap; }
  .dash-block > .card > .card__header { flex-wrap: nowrap; }
  .dash-block > .card > .card__header > .card__title {
    min-width: 0; flex: 0 1 auto;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;
  }
}

/* Narrow screens: the toolbar keeps ONE line and scrolls sideways.

   Below 901px the title and the toolbar shared a flex row and the toolbar got
   only what the title left over — about 170px on a 360px phone — so every
   pill wrapped onto a line of its own. Router health's three controls grew a
   178px header on a 361px block: half the block was chrome and the chart it
   belonged to was pushed off screen.

   The toolbar therefore keeps its natural width (flex:0 0 auto) and never
   wraps internally; when it no longer fits beside the title the whole strip
   drops to its own line, and if it is still too wide it scrolls horizontally
   instead of stacking. Header height is capped at two rows however many
   controls a block has.

   Deliberately NOT flex-direction:column — that spends a second row even on
   the eleven list blocks whose one small control fits beside the title, which
   cost them 21px each and made the page longer overall. Wrapping only pays
   for the row when it is actually needed. */
@media (max-width: 900px) {
  .dash-block > .card > .card__header {
    flex-wrap: wrap; gap: 8px 10px; padding: 10px 12px;
  }
  .dash-block > .card > .card__header > .dash-tb {
    flex: 0 0 auto; max-width: 100%;
    flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-x: contain;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;              /* a bar would cost more height than
                                           it earns on a 26px strip */
  }
  .dash-block > .card > .card__header > .dash-tb::-webkit-scrollbar { display: none; }
  /* Nothing in the strip may shrink, or the controls collapse under their own
     labels instead of scrolling. */
  .dash-block > .card > .card__header > .dash-tb > * { flex: 0 0 auto; }
  .dash-block > .card > .card__header > .dash-tb .js-pick > summary { white-space: nowrap; }
}
/* The interface <select> wears the same clothes as the picker buttons beside
   it — the native form-select look was the one control that did not match. */
.dash-tb .js-pick-iface {
  padding: 3px 8px; font-size: 11px; line-height: 1.4;
  /* Same min-height the .btn--sm pickers get, or the select sat 7px shorter
     than every button beside it. */
  min-height: 30px; height: auto; max-width: 130px;
  border: 1px solid var(--border); border-radius: 6px;
  background-color: var(--surface); color: var(--fg);
  overflow: hidden; text-overflow: ellipsis;
}
/* Stat-tile grid for figure blocks (Revenue). auto-fit, so a wide block gets
   one row of tiles and a narrow one wraps to two — the block's size decides,
   not a fixed column count. */
.dash-stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: 10px;
}
.dash-stat {
  padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
  background: var(--surface-2); min-width: 0;
}
.dash-stat--main { background: linear-gradient(180deg, rgba(59,130,246,.10), rgba(59,130,246,.03)); }
.dash-stat--warn { border-color: var(--warn); }
.dash-stat__label {
  font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
  color: var(--fg-dim); margin-bottom: 4px; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
.dash-stat__value { font-size: 19px; font-weight: 700; font-family: 'Fira Code', 'IBM Plex Sans Arabic', monospace; }
.dash-stat__value--dim { color: var(--fg-muted); }
.dash-stat__sub { font-size: 11px; margin-top: 3px; }

/* The bar's Actions menu — the layout-management verbs, one per row. */
.dash-actions .col-dd__menu { min-width: 190px; padding: 6px; gap: 2px; }
.dash-actions__item {
  display: flex; align-items: center; gap: 9px; width: 100%;
  padding: 8px 10px; border: 0; border-radius: 8px;
  background: none; color: var(--fg); font: inherit; font-size: 13px;
  text-align: start; cursor: pointer; white-space: nowrap;
}
.dash-actions__item:hover { background: var(--bg-hover); }
.dash-actions__item svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--fg-muted); }
.dash-actions__item--danger, .dash-actions__item--danger svg { color: var(--danger); }
.dash-actions__item--danger:hover { background: var(--danger); }
.dash-actions__item--danger:hover, .dash-actions__item--danger:hover svg { color: #fff; }

/* App/website row: logo beside the name, sized to the row. */
.app-cell { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.app-cell__logo {
  width: 18px; height: 18px; border-radius: 4px; object-fit: contain;
  flex-shrink: 0; background: var(--surface-2);
}

/* Room for the close control in the corner (the gear is gone). */
.dash-block > .card > .card__header { padding-inline-end: 36px; }

/* Add-block picker. A plain overlay rather than the app's modal machinery: it
   holds no form and submits nothing — every entry is a single click. */
.dash-picker {
  position: fixed; inset: 0; z-index: 430;
  background: rgba(0, 0, 0, .45);
  display: flex; align-items: center; justify-content: center; padding: 20px;
}
.dash-picker[hidden] { display: none; }
.dash-picker__panel {
  background: var(--surface); border-radius: 14px; width: 100%; max-width: 460px;
  max-height: min(80vh, 620px); display: flex; flex-direction: column;
  box-shadow: 0 24px 60px rgba(0, 0, 0, .35);
}
.dash-picker__head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.dash-picker__body { padding: 6px 10px 12px; overflow-y: auto; }
/* Group headings need to read as headings: a rule above them separates one group
   from the previous one, and the gap below the heading is smaller than the gap
   above it so each label binds to the items it introduces rather than floating
   between two groups. */
.dash-picker__group {
  margin-top: 18px; padding: 0 4px 6px;
  border-top: 1px solid var(--border); padding-top: 14px;
  font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .6px; color: var(--fg-muted);
}
.dash-picker__group:first-child { margin-top: 4px; border-top: 0; padding-top: 0; }
.dash-picker__item {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; padding: 9px 12px; margin-inline-start: 6px;
  width: calc(100% - 6px);
  border: 0; border-radius: 8px;
  background: none; color: var(--fg); font: inherit; font-size: 13px;
  text-align: start; cursor: pointer;
}
.dash-picker__item + .dash-picker__item { margin-top: 2px; }
.dash-picker__item:hover { background: var(--bg-hover); }
/* Column splitter — an overlay sitting in the gap between two blocks that share
   a row. Positioned by dashboard-grid.js from measured geometry, because a CSS
   grid gap cannot contain an element. Invisible until pointed at, so the grid
   does not look like it is full of dividers. */
.dash-row { position: relative; }
.dash-split {
  position: absolute; z-index: 4; cursor: col-resize;
  border-radius: 3px; background: transparent;
  transition: background .12s ease;
}
.dash-split::before {
  content: ""; position: absolute; inset-block: 0;
  inset-inline-start: 50%; width: 3px; transform: translateX(-50%);
  border-radius: 3px; background: var(--primary);
  opacity: 0; transition: opacity .12s ease;
}
.dash-split:hover::before, .dash-split.is-active::before { opacity: .85; }
/* While a splitter is being dragged the cursor must stay col-resize everywhere,
   not flip to grab/text as the pointer crosses a card header. */
body.dash-splitting, body.dash-splitting * { cursor: col-resize !important; user-select: none; }
/* Every block spans the ROW's height (--rh, inherited from .dash-row), not its
   own: mixing heights in one row left a tall block beside a short one with a
   large void under the short one. --dw is still per block. */
.dash-block {
  grid-column: span var(--dw, 2);
  grid-row: span var(--rh, 4);
  min-width: 0; position: relative;
}
/* The card fills the block it was given, and its scrollable region is whatever
   sits under the header — a body or a table wrapper. Without min-height:0 a
   flex child refuses to shrink below its content and the card overflows the
   block instead of scrolling inside it. */
.dash-block > .card { height: 100%; display: flex; flex-direction: column; margin: 0; }
.dash-block > .card > .card__header { flex: 0 0 auto; }
.dash-block > .card > .card__body,
.dash-block > .card > .table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }

/* Drag affordance: the whole header is the handle, so it gets the grab cursor —
   except the controls inside it, which must still look clickable. */
.dash-row .dash-block > .card > .card__header { cursor: grab; user-select: none; }
.dash-row .dash-block > .card > .card__header a,
.dash-row .dash-block > .card > .card__header button,
.dash-row .dash-block > .card > .card__header select,
.dash-row .dash-block > .card > .card__header input { cursor: pointer; }
.dash-block.is-dragging > .card > .card__header { cursor: grabbing; }

/* The block being dragged stays in the flow as its own preview: dimmed, with a
   dashed outline showing exactly the slot it will occupy on release. A separate
   placeholder element would have to mirror the spans and the border radius. */
.dash-block.is-dragging > .card {
  opacity: .28;
  outline: 2px dashed var(--primary);
  outline-offset: -1px;
}
/* The lifted copy that follows the pointer. pointer-events:none so
   elementFromPoint sees the grid underneath, not the proxy. */
.dash-proxy {
  position: fixed; z-index: 420; pointer-events: none;
  opacity: .92; border-radius: 12px; overflow: hidden;
  box-shadow: 0 18px 44px rgba(0, 0, 0, .30);
  transform: rotate(-.4deg);
}
.dash-proxy > .card { height: 100%; margin: 0; }

/* Resize grip, inline-end bottom corner — mirrored in RTL by inset-inline-end,
   and the diagonal cursor flips with it. */
.dash-resize {
  position: absolute; inset-block-end: 3px; inset-inline-end: 3px;
  width: 18px; height: 18px; cursor: nwse-resize; z-index: 3;
  opacity: 0; transition: opacity .12s ease;
  background:
    linear-gradient(135deg, transparent 45%, var(--fg-dim) 45%, var(--fg-dim) 55%, transparent 55%),
    linear-gradient(135deg, transparent 70%, var(--fg-dim) 70%, var(--fg-dim) 80%, transparent 80%);
}
[dir="rtl"] .dash-resize {
  cursor: nesw-resize;
  transform: scaleX(-1);
}
.dash-block:hover .dash-resize, .dash-block.is-resizing .dash-resize { opacity: .75; }
.dash-resize:hover { opacity: 1 !important; }
.dash-block.is-resizing > .card { outline: 2px solid var(--primary); outline-offset: -1px; }

/* Phones and tablets: one column, natural heights, no dragging. Rearranging a
   single column is meaningless and a drag would fight the page scroll. */
@media (max-width: 900px) {
  /* One column: every row collapses to a stack, and the rows themselves stack,
     so the whole dashboard reads top to bottom in layout order. */
  .dash-row { grid-template-columns: 1fr; grid-auto-rows: auto; }
  .dash-block { grid-column: 1 / -1 !important; grid-row: auto !important; }
  .dash-block > .card { height: auto; }
  .dash-block > .card > .card__body { overflow: visible; }
  /* Height goes back to natural, but the table keeps its OWN horizontal scroll:
     `overflow: visible` here let a wide table push the whole page sideways on a
     phone, which is the one thing .table-wrap exists to prevent. */
  .dash-block > .card > .table-wrap { overflow-x: auto; overflow-y: visible; }
  .dash-row .dash-block > .card > .card__header { cursor: default; }
  .dash-split { display: none; }
  .dash-resize { display: none; }
  /* The rail is a desktop idea. Here the sidebar is an off-canvas drawer that is
     either absent or full — there is nothing to narrow, and the drawer has its
     own control in the topbar. */
  .sidebar__collapse { display: none; }
  /* Dashboard MANAGEMENT is desktop-only. Arranging a four-column grid on a
     phone means dragging blocks that are all full width in a single column —
     there is nothing meaningful to arrange, and a stray tap on a close button
     would silently delete a block from the layout the admin uses at their desk.
     Reading the dashboard is unaffected; only the controls go. */
  .dash-grid__bar, .dash-tools, .dash-picker { display: none !important; }
}
/* Value input + unit selector (e.g. "5 | GB"). Stays 2-column at all sizes —
   both fields are narrow enough even on 320 px phones. */
.grid-value-unit { display: grid; grid-template-columns: 2fr 1fr; gap: 8px; }
/* 4-column quota row: [down val][down unit][up val][up unit].
   Reflows to 2×2 on small screens so each pair stays on its own line. */
.grid-quota-pair { display: grid; grid-template-columns: 2fr 1fr 2fr 1fr; gap: 8px; }
@media (max-width: 560px) { .grid-quota-pair { grid-template-columns: 1fr 1fr; } }
@media (max-width: 1100px) { .grid-3 { grid-template-columns: 1fr 1fr; } }
/* !important so the single-column collapse also wins over per-row inline
   `style="grid-template-columns: ..."` overrides (e.g. the dashboard grids),
   which otherwise force multi-column layout and overflow narrow screens. */
@media (max-width: 700px)  { .grid-3, .grid-2, .grid-aside { grid-template-columns: 1fr !important; } }
/* User portal spacing. The portal uses bare `class="grid-2"` (without the
   `.grid` helper), so it had NO display:grid and its `gap` was inert — the cards
   stacked flush ("very sticky"). Restore display:grid (2-col on desktop, 1-col
   on mobile via the !important rule above) and give the portal roomier gaps.
   Scoped to .portal so the admin layout is unchanged. */
.portal .grid-2 { display: grid; gap: 18px; }
@media (max-width: 700px) {
  .portal .stat-row, .portal .grid-aside { gap: 18px; }
  .portal .stat-row { margin-bottom: 22px; }
}

/* Responsive sidebar */
.sidebar-toggle {
  display: none; width: 34px; height: 34px;
  border-radius: 8px; background: transparent; border: 1px solid var(--border);
  color: var(--fg-muted); cursor: pointer; place-items: center;
}
.sidebar-toggle svg { width: 16px; height: 16px; }

/* Backdrop behind the mobile drawer — tapping it closes the sidebar. */
.sidebar-backdrop {
  display: none;
  /* Above every page-level layer — the router tab bar (100), its panels and
     flyouts (200/210), table column menus (200) and context menus (250) all
     used to paint straight through the open drawer on mobile. Still below the
     global search overlay (1000) and the loading overlay, which are app-level
     and should win. */
  position: fixed; inset: 0; z-index: 400;
  background: rgba(2, 6, 23, 0.45);
  backdrop-filter: blur(1px);
  -webkit-backdrop-filter: blur(1px);
}
.sidebar-backdrop.is-open { display: block; }

@media (max-width: 900px) {
  .app { grid-template-columns: 1fr; }
  .sidebar {
    position: fixed; left: 0; top: 0; z-index: 410;
    width: 240px; transform: translateX(-100%);
    transition: transform .2s ease;
  }
  .sidebar.is-open { transform: translateX(0); box-shadow: var(--shadow-lg); }
  .sidebar-toggle { display: grid; width: 40px; height: 40px; }
  .topbar__title { font-size: 14px; }
  .content { --content-pad: 16px; }
  /* Hide server clock — too wide on mobile */
  .topbar__clock { display: none; }
  /* Larger touch targets for topbar controls */
  .topbar__icon-btn { width: 40px; height: 40px; }
  .topbar .pick__btn { width: 44px; height: 44px; }
  /* Minimum touch-friendly button height (44px is ideal; 40px is acceptable for
     secondary/dense UI where space is constrained) */
  .btn { min-height: 40px; }
  .btn--sm { min-height: 34px; }
  .btn--xs { min-height: 28px; }
  /* 44px minimum height for text inputs and selects */
  .input, .select { min-height: 44px; }
  /* Prevent iOS Safari from auto-zooming the page when a text field is focused:
     it zooms whenever the focused input's font-size is < 16px. */
  input, select, textarea, .input, .select { font-size: 16px; }
  /* Pagination links: taller touch area */
  .pagination .page-link { min-width: 40px; height: 40px; }
  /* Three-dots menu button: bigger touch area */
  .btn-icon-dots { width: 36px; height: 36px; }
  /* Help-tip icon: slightly larger on mobile */
  .help-tip { width: 20px; height: 20px; font-size: 12px; }
}

/* Extra-small screens: collapse topbar to essentials only.

   The bar had seven things in it on a phone — burger, page icon, title, bell,
   magnifier, avatar and a sign-out door — and the title, which is the only one
   that tells you WHERE YOU ARE, was the one being squeezed onto two lines.
   Everything that already exists in the sidebar leaves: search has a sidebar
   entry, profile and sign-out are in .sidebar__account, language and theme are
   in .sidebar__prefs. The bell stays, because it is the one control that has
   something to say that is not on the current page. */
@media (max-width: 560px) {
  /* Language/theme quick-switch hidden here — surfaced in the sidebar
     (.sidebar__prefs) and on the Profile page. Scoped to .topbar: the sidebar
     carries .pick too now, and an unscoped rule turned the drawer's copy off
     as well, which left a phone with no way to change either. */
  .topbar .pick { display: none; }
  .topbar .gs-open { display: none; }
  .topbar__user, .topbar__logout { display: none; }
  /* .sidebar__account is turned ON at the foot of this file, next to its base
     rule — a media query adds no specificity, so an override here would lose
     to the later plain declaration. */
  /* Give the reclaimed width to the title, and let a long one truncate rather
     than wrap the whole bar to two rows. */
  .topbar__heading { min-width: 0; }
  .topbar__title { overflow: hidden; text-overflow: ellipsis; }
  /* Step the big KPI numbers down so the now-nowrap value fits a phone card. */
  .kpi__value { font-size: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important; animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}

/* ============================================================================
   Support tickets — user + admin portals (tickets.html / ticket_detail.html)
   ============================================================================ */

/* "Reply needed" status colour. */
.badge--violet { color: var(--violet); background: rgba(139,92,246,.12); border-color: rgba(139,92,246,.32); }

/* Admin list — status filter chips with live counts. */
.tk-filterbar { display: flex; flex-wrap: wrap; gap: 6px; padding: 14px 14px 0; }
.tk-count {
  display: inline-block; min-width: 17px; padding: 0 5px; margin-left: 3px;
  border-radius: 999px; background: rgba(127,127,127,.22);
  font-size: 10px; font-weight: 600; text-align: center;
}

/* Ticket description block, shown under the meta on both detail pages. */
.tk-desc {
  margin-top: 12px; padding: 12px 14px; border-radius: 10px;
  background: var(--surface-2); border: 1px solid var(--border);
  font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
}

/* Conversation / follow-up thread. */
.tk-thread { display: flex; flex-direction: column; }
.tk-event {
  text-align: center; font-size: 11.5px; color: var(--fg-dim);
  margin: 8px 0; padding: 0 12px;
}
.tk-msg {
  border: 1px solid var(--border); border-inline-start-width: 3px; border-radius: 10px;
  padding: 11px 14px; margin-bottom: 10px; background: var(--surface);
}
.tk-msg--customer { border-inline-start-color: var(--primary); }
.tk-msg--admin    { border-inline-start-color: var(--accent); }
.tk-msg--internal { border-inline-start-color: var(--warn); background: rgba(245,158,11,.06); }
.tk-msg__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px; }
.tk-msg__who { font-weight: 600; }
.tk-msg__time { margin-inline-start: auto; color: var(--fg-dim); font-size: 11px; white-space: nowrap; }
.tk-msg__body {
  font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
}

/* User "reply needed" call-to-action banner. */
.tk-reply-cta {
  margin-top: 14px; padding: 12px 14px; border-radius: 10px;
  background: rgba(139,92,246,.10); border: 1px solid rgba(139,92,246,.32);
  color: var(--violet); font-size: 13px; line-height: 1.55;
}

/* Priority picker (user new-ticket modal) + visibility picker (admin note).
   Native radios are visually hidden; the label is the control. */
.tk-prio-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.tk-vis-pick { display: flex; flex-direction: column; gap: 8px; }
.tk-prio, .tk-vis {
  position: relative;
  display: inline-flex; align-items: center; gap: 8px;
  padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
  cursor: pointer; font-size: 13px; user-select: none;
}
.tk-vis { width: 100%; }
.tk-prio input, .tk-vis input { position: absolute; opacity: 0; width: 0; height: 0; }
.tk-prio__dot, .tk-vis__dot {
  width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: var(--fg-dim);
}
.tk-prio--normal   .tk-prio__dot { background: var(--accent); }
.tk-prio--moderate .tk-prio__dot { background: var(--warn); }
.tk-prio--critical .tk-prio__dot { background: var(--danger); }
.tk-vis--public   .tk-vis__dot { background: var(--accent); }
.tk-vis--internal .tk-vis__dot { background: var(--warn); }
.tk-prio:has(input:checked), .tk-vis:has(input:checked) {
  border-color: var(--border-strong); background: var(--bg-hover);
  box-shadow: inset 0 0 0 1px var(--border-strong);
}
.tk-prio:has(input:focus-visible), .tk-vis:has(input:focus-visible) {
  outline: 2px solid var(--ring); outline-offset: 2px;
}

/* ============================================================================
   Help tips — a small blue "?" icon that opens a short popover explaining a
   non-obvious feature or control. Keeps the UI uncluttered: the help is one
   click away instead of sitting on the page. First used in the MikroTik tabs;
   now shared app-wide so every page uses the same affordance.

   Markup (the "?" glyph + a11y attributes are filled in by app.js):
     <span class="help-tip" data-help="Short explanation. \n New line."></span>
   A <button class="help-tip" ...>?</button> (the tip() macro) behaves the same.
   A literal "\n" in data-help renders as a line break.
   ============================================================================ */
.help-tip {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; padding: 0; margin: 0; margin-inline-start: 6px;
  background: rgba(59,130,246,.15);
  border: 1px solid rgba(59,130,246,.55);
  color: rgb(96,165,250);
  border-radius: 50%;
  font-size: 11px; font-weight: 700; line-height: 1;
  font-family: inherit;
  cursor: pointer;
  flex-shrink: 0;
  -webkit-tap-highlight-color: transparent;
}
.help-tip:hover { background: rgba(59,130,246,.30); }
.help-tip:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* Amber variant: the tip is not just background information, it warns about a
   consequence the admin should read before acting (see Tools -> Speed Boost). */
.help-tip--warn {
  background: rgba(245,158,11,.16);
  border-color: rgba(245,158,11,.60);
  color: rgb(217,119,6);
}
.help-tip--warn:hover { background: rgba(245,158,11,.32); }
/* In an uppercase table header / quiet label the glyph must stay upright. */
th .help-tip,
.form-section__title .help-tip { text-transform: none; letter-spacing: normal; }

/* The popover — one element created by app.js, anchored under the icon. */
.help-pop {
  position: absolute; z-index: 2000;
  max-width: min(320px, calc(100vw - 24px));   /* always fits a phone viewport */
  padding: 10px 12px;
  background: var(--bg-elev-2);
  color: var(--fg);
  border: 1px solid rgba(59,130,246,.45);
  border-radius: 8px;
  font-size: 13px; font-weight: 400; line-height: 1.5;
  letter-spacing: normal; text-transform: none;
  white-space: pre-line; overflow-wrap: anywhere;   /* long URLs/IDs wrap inside the popover */
  box-shadow: var(--shadow-lg);
  animation: fade-up .14s ease-out;
}
.help-pop::before {
  content: ""; position: absolute; top: -7px; left: calc(var(--arrow-x, 20px) - 6px);
  width: 12px; height: 12px; background: inherit;
  border-left: 1px solid rgba(59,130,246,.45);
  border-top:  1px solid rgba(59,130,246,.45);
  transform: rotate(45deg);
}
/* When app.js flips the popover above its icon (no room below), move the arrow
   to the bottom edge and point it down. --arrow-x (physical px from the popover
   left edge) follows the icon centre, so the arrow stays correct in LTR & RTL. */
.help-pop--above::before {
  top: auto; bottom: -7px;
  border-left: none; border-top: none;
  border-right:  1px solid rgba(59,130,246,.45);
  border-bottom: 1px solid rgba(59,130,246,.45);
}

/* ============================================================================
   Footer
   ============================================================================ */
.samm-footer {
  border-top: 1px solid var(--border);
  background: var(--bg-elev);
  /* Slim: with the list running edge to edge and to the bottom of the
     viewport, an 18px footer band was eating rows for no information. */
  padding: 6px 24px;
  flex-shrink: 0;
  animation: fade-up .3s ease-out backwards;
  animation-delay: 220ms;
}
.samm-footer__inner { gap: 12px; }
.samm-footer__brand-mark { width: 20px; height: 20px; }
.samm-footer__copy { font-size: 11.5px; margin: 0; }
.samm-footer__brand-name { font-size: 12.5px; }
.samm-footer__brand-by { font-size: 11px; }
.samm-footer__inner {
  max-width: 1600px; margin: 0 auto;
  display: flex; align-items: center; gap: 16px;
  flex-wrap: wrap;
}

/* Brand block */
.samm-footer__brand {
  display: flex; align-items: center; gap: 10px;
  text-decoration: none; color: inherit;
  opacity: .9; transition: opacity .15s;
}
.samm-footer__brand:hover { opacity: 1; text-decoration: none; }
.samm-footer__brand:hover .samm-footer__brand-mark { box-shadow: 0 0 22px rgba(59,130,246,.55); }
.samm-footer__brand-mark {
  width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
  display: grid; place-items: center; overflow: hidden;
  box-shadow: 0 0 18px rgba(59,130,246,.35);
}
.samm-footer__brand-mark img { width: 100%; height: 100%; display: block; border-radius: inherit; }
.samm-footer__brand-info { display: flex; align-items: baseline; gap: 5px; }
.samm-footer__brand-name {
  font-weight: 800; font-size: 14px; letter-spacing: -.3px;
  background: linear-gradient(135deg, var(--primary), var(--accent));
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
.samm-footer__brand-by { font-size: 11px; color: var(--fg-dim); }

/* Copyright */
.samm-footer__copy {
  flex: 1; font-size: 11.5px; color: var(--fg-dim);
  margin: 0; text-align: center;
}

@media (max-width: 640px) {
  .samm-footer { padding: 16px; }
  .samm-footer__inner {
    flex-direction: column; align-items: center;
    text-align: center; gap: 10px;
  }
  .samm-footer__copy { flex: none; }
}

/* ============================================================================
   Motion & Entrance animations
   ============================================================================ */

/* The entrance animation. Every user of this MUST use fill-mode `backwards`,
   never `forwards`/`both`: a forwards fill keeps the final keyframe applied
   forever, and an animated transform resolves to an identity MATRIX even when
   the keyframe says `none` (interpolating to `none` uses the identity matrix,
   per spec) — which is still a transform, so the element stays a permanent
   stacking context and containing block. That is what trapped the dashboard's
   Actions menu behind the grid rows. `backwards` gives the same visual (the
   `from` state is held during animation-delay) and, once the animation ends,
   the element reverts to its own style with no transform left behind. */
@keyframes fade-up {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
@keyframes row-in {
  from { opacity: 0; transform: translateY(5px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes logo-glow {
  0%, 100% { box-shadow: 0 0 18px rgba(59,130,246,.35); }
  50%       { box-shadow: 0 0 32px rgba(59,130,246,.65); }
}

/* Page content — each top-level section in .content fades + slides up */
.content > * {
  animation: fade-up .3s ease-out backwards;
}
.content > *:nth-child(1) { animation-delay: 0ms; }
.content > *:nth-child(2) { animation-delay: 55ms; }
.content > *:nth-child(3) { animation-delay: 110ms; }
.content > *:nth-child(4) { animation-delay: 160ms; }
.content > *:nth-child(n+5) { animation-delay: 200ms; }

/* Table rows — stagger as they enter on page load */
.table tbody tr {
  animation: row-in .22s ease-out both;
}
.table tbody tr:nth-child(1)  { animation-delay: 60ms; }
.table tbody tr:nth-child(2)  { animation-delay: 85ms; }
.table tbody tr:nth-child(3)  { animation-delay: 108ms; }
.table tbody tr:nth-child(4)  { animation-delay: 130ms; }
.table tbody tr:nth-child(5)  { animation-delay: 150ms; }
.table tbody tr:nth-child(6)  { animation-delay: 168ms; }
.table tbody tr:nth-child(7)  { animation-delay: 184ms; }
.table tbody tr:nth-child(n+8) { animation-delay: 198ms; }

/* ============================================================================
   RTL overrides
   ----------------------------------------------------------------------------
   The bulk of the stylesheet mirrors automatically because it uses logical
   properties. These rules cover the handful of cases that cannot: inset
   box-shadows, explicit translateX() distances, the physical
   background-position of the <select> chevron, and the off-canvas mobile
   drawer. They apply only under <html dir="rtl"> (Arabic and future RTL
   languages) — LTR rendering is untouched.
   ============================================================================ */
/* In RTL the sidebar sits on the right, and being an RTL scroll container its
   own scrollbar paints on its LEFT edge — mid-screen, right against the
   content, where it reads as the page's scrollbar rather than the sidebar's.
   Flipping just the scroll container to ltr moves the bar to the outer edge;
   the contents are put back to rtl immediately, so nothing else changes. */
[dir="rtl"] .sidebar {
  direction: ltr;
  /* ...but flipping the scroll container also flips its OWN logical
     properties, which the line above did not account for: border-inline-end
     resolved back to the RIGHT edge — the window edge — so in Arabic the
     divider sat on the outside of the panel and the boundary facing the
     content had no line at all. This element's direction is deliberately not
     the document's, so a logical property can no longer describe it; name the
     side physically. */
  border-inline-end: 0;
  border-left: 1px solid var(--border);
}
[dir="rtl"] .sidebar > * { direction: rtl; }
[dir="rtl"] .sidebar__link.is-active { box-shadow: inset -2px 0 0 var(--primary); }
[dir="rtl"] .sidebar__link:hover { transform: translateX(-3px); }

/* <select> chevron: move it to the (visual) left and pad on that side so the
   selected-option text clears the chevron instead of running under it.
   direction:rtl must be explicit (not just inherited) — Blink/Gecko take a
   different code path for appearance:none selects when direction is inherited
   vs. set directly, and may still render the value text at the physical left.
   text-align:right is a belt-and-suspenders for the same issue. Physical
   padding (not logical) removes any remaining browser ambiguity. */
[dir="rtl"] .select {
  direction: rtl;
  text-align: right;
  background-position: left 10px center;
  padding-right: 12px;
  padding-left: 30px;
}

/* Toggle-switch knob travels the opposite way. */
[dir="rtl"] .switch input:checked + .switch__track::before { transform: translateX(-16px); }
[dir="rtl"] .switch--lg input:checked + .switch__track::before { transform: translateX(-20px); }

/* Segmented button group: flip the rounded ends and the collapsed borders. */
[dir="rtl"] .btn-group .btn { border-right-width: 1px; border-left-width: 0; }
[dir="rtl"] .btn-group .btn:first-child { border-radius: 0 8px 8px 0; }
[dir="rtl"] .btn-group .btn:last-child  { border-radius: 8px 0 0 8px; border-left-width: 1px; }

/* Machine identifiers — usernames, MAC addresses, IPs, IDs, hashes — render
   with .text-mono / .input--mono. Under RTL the bidi algorithm reorders a MAC
   whose octets split into a pure-numeric leading run and a letter-bearing tail:
   e.g. "64:18:AE:BE:11:22" displays as "AE:BE:11:22:64:18" (the "64:18" run
   jumps to the end). The underlying text is fine — copy/paste is correct — it
   is purely a visual reorder. Isolate each identifier as its own left-to-right
   run so it always reads correctly; the surrounding RTL layout is untouched. */
[dir="rtl"] .text-mono,
[dir="rtl"] .input--mono,
[dir="rtl"] input.input--mono {
  direction: ltr;
  unicode-bidi: isolate;
}
/* Opt-out for PROSE that merely contains Latin units. The rule above exists for
   machine identifiers, and forcing direction:ltr on a sentence wrecks it: the
   speed hint "e.g. 256k, 6M, 1G — suffix k/M/G; 0 = unlimited" rendered in
   Arabic as "؛ 0 = غير محدودk/M/G اللاحقة — 1G ،6M ،k،مثال: 256" — the 256 at the
   wrong end, the k glued to مثال, the semicolon leading the line. `plaintext`
   ignores the inherited direction and takes the base from the first strong
   character instead, so English stays LTR and Arabic reads right-to-left.
   Listed with the [dir="rtl"] prefix as well so it outranks the rule above. */
.text-bidi-auto,
[dir="rtl"] .text-bidi-auto { unicode-bidi: plaintext; }
/* direction:ltr above defaults the block/cell text-align to left; restore the
   RTL start edge (right) so display mono values stay aligned with the rest of
   the RTL layout. Inline spans ignore text-align, and inputs are left as-is. */
[dir="rtl"] td.text-mono,
[dir="rtl"] th.text-mono,
[dir="rtl"] div.text-mono,
[dir="rtl"] p.text-mono { text-align: right; }

/* Same reasoning for INPUTS, which the rule above deliberately skipped: the
   value keeps direction:ltr so an address or a number reads correctly, but it
   must sit on the field's START edge — in RTL that is the right — otherwise
   every number in an Arabic form hangs off the far side of its box. */
[dir="rtl"] input.input--mono,
[dir="rtl"] input[type="number"],
[dir="rtl"] input.input--no-spin { text-align: right; }

/* Opt-out from the right-align above, for a value whose SHAPE is the same in
   every language and is read left-to-right by everyone: a dialling code is
   "+961" in Arabic exactly as it is in English, and the leading + belongs on
   the left of the box, not floated to the right of it. Deliberately NOT the
   default -- the rule above exists because ordinary numbers in an Arabic form
   look detached when they hang off the far edge. */
[dir="rtl"] input.input--force-ltr {
  direction: ltr;
  text-align: left;
}

/* Number fields go the whole way to RTL rather than being LTR-with-right-align:
   the browser paints the spinner on the direction's END edge, so an LTR input
   put the little up/down arrows on the right, jammed against the value. Digits
   are strong LTR characters and still read correctly inside an RTL field. */
[dir="rtl"] input[type="number"],
[dir="rtl"] input.input--mono[type="number"] { direction: rtl; }

/* Off-canvas mobile sidebar slides in from the right under RTL. */
@media (max-width: 900px) {
  [dir="rtl"] .sidebar {
    left: auto; right: 0;
    transform: translateX(100%);
  }
  [dir="rtl"] .sidebar.is-open { transform: translateX(0); }
  [dir="rtl"] .sidebar-backdrop.is-open { display: block; }
}

/* ============================================================================
   Arabic typography
   ----------------------------------------------------------------------------
   Match the SecuryTik ecosystem: IBM Plex Sans Arabic for Arabic locales,
   paired with Fira Sans for Latin fallback. Font is loaded from Google Fonts
   CDN by base.html / standalone auth pages only when locale=ar, so it's
   downloaded by the browser on first use and never bundled with the release.

   Brand wordmarks (sidebar logo + footer logo) stay locale-invariant in
   Fira Sans, matching the rest of the SecuryTik ecosystem.
   ============================================================================ */
/* Persian rides the same face. Both scripts are Arabic-script; leaving fa on
   the Latin stack rendered it in a fallback that sat badly next to the same
   UI in Arabic. */
:lang(ar) body, :lang(fa) body,
:lang(ar) input, :lang(fa) input,
:lang(ar) button, :lang(fa) button,
:lang(ar) select, :lang(fa) select,
:lang(ar) textarea, :lang(fa) textarea,
:lang(ar) optgroup, :lang(fa) optgroup,
:lang(ar) .btn, :lang(fa) .btn,
:lang(ar) .input, :lang(fa) .input,
:lang(ar) .select, :lang(fa) .select {
  font-family: 'IBM Plex Sans Arabic', 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

/* Arabic-script glyphs carry more detail per character than Latin at the same
   size, and the sidebar is the densest text in the panel — 10% back. */
:lang(ar) .sidebar__group, :lang(fa) .sidebar__group { font-size: 13.2px; }
:lang(ar) .sidebar__link,  :lang(fa) .sidebar__link  { font-size: 13.75px; }

/* Logo + footer logo opt out — wordmark stays in Fira Sans across all locales. */
:lang(ar) .sidebar__brand, :lang(fa) .sidebar__brand,
:lang(ar) .sidebar__brand *, :lang(fa) .sidebar__brand *,
:lang(ar) .samm-footer__brand, :lang(fa) .samm-footer__brand,
:lang(ar) .samm-footer__brand *, :lang(fa) .samm-footer__brand * {
  font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

/* System -> API: scope picker matrix (one row per resource domain) */
.scope-matrix { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.scope-row {
  display: grid; grid-template-columns: minmax(200px, 300px) 1fr;
  gap: 6px 16px; align-items: center;
  padding: 9px 12px;
  border: 1px solid var(--border); border-radius: 10px;
}
.scope-row__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
@media (max-width: 640px) { .scope-row { grid-template-columns: 1fr; } }

/* Clickable count pill (e.g. Admins page assigned-users column) — framed like
   a badge, but with link affordances so it reads as clickable. */
.count-link {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 28px; padding: 2px 10px; border-radius: 999px;
  font-size: 12px; font-weight: 600; line-height: 1.5;
  background: var(--bg-elev-2); color: var(--fg);
  border: 1px solid var(--border-strong);
  text-decoration: none; cursor: pointer;
  transition: border-color .15s, color .15s, background .15s;
}
.count-link:hover {
  border-color: var(--accent); color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, var(--bg-elev-2));
  text-decoration: none;
}

/* ── Global search (header magnifier) ─────────────────────────────────────
   An overlay rather than a dropdown: on a phone a dropdown anchored to the
   topbar has almost no room, and the same markup then serves both widths. */
.gs { position: fixed; inset: 0; z-index: 1000; display: flex;
      align-items: flex-start; justify-content: center; }
.gs[hidden] { display: none; }
.gs__backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.45); }
.gs__panel { position: relative; width: min(720px, calc(100% - 24px)); margin-top: 10vh;
             background: var(--bg-elev); border: 1px solid var(--border);
             border-radius: 14px; box-shadow: var(--shadow-lg); overflow: hidden;
             display: flex; flex-direction: column; max-height: 74vh; }
.gs__bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px;
           border-bottom: 1px solid var(--border); }
.gs__icon { width: 18px; height: 18px; flex: 0 0 auto; color: var(--fg-dim); }
.gs__input { flex: 1 1 auto; border: 0; outline: 0; background: transparent;
             color: var(--fg); font-size: 15px; min-width: 0; }
.gs__esc { border: 1px solid var(--border); background: var(--bg); color: var(--fg-dim);
           border-radius: 6px; font-size: 11px; padding: 2px 7px; cursor: pointer; }
.gs__results { overflow-y: auto; padding: 6px; }
.gs__hint, .gs__empty { padding: 18px 14px; color: var(--fg-dim); font-size: 13px; }
.gs__group { padding: 8px 10px 2px; font-size: 11px; font-weight: 700;
             text-transform: uppercase; letter-spacing: .06em; color: var(--fg-muted); }
.gs__item { display: flex; align-items: center; gap: 10px; width: 100%;
            padding: 8px 10px; border-radius: 9px; cursor: pointer; text-decoration: none;
            color: var(--fg); border: 1px solid transparent; background: transparent; }
.gs__item:hover, .gs__item.is-active { background: var(--bg-elev-2); border-color: var(--border); }
.gs__item b { font-weight: 800; color: var(--primary); }
.gs__crumb { font-size: 11px; color: var(--fg-dim); flex: 0 0 auto; }
.gs__title { font-size: 14px; }
.gs__sub { font-size: 12px; color: var(--fg-dim); margin-inline-start: auto;
           white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
.gs__field { font-size: 10px; color: var(--fg-muted); border: 1px solid var(--border);
             border-radius: 5px; padding: 1px 5px; flex: 0 0 auto; }
@media (max-width: 640px) {
  .gs__panel { margin-top: 0; width: 100%; max-height: 100%; height: 100%; border-radius: 0; }
  .gs__sub { display: none; }
}

/* Target highlight: search deep-links to #s-<key>, and landing on a long
   settings page with nothing marked leaves the operator hunting for the field
   they just clicked. */
@keyframes samm-target-flash {
  0%   { box-shadow: 0 0 0 3px rgba(59,130,246,.55); }
  100% { box-shadow: 0 0 0 3px rgba(59,130,246,0); }
}
.samm-target { animation: samm-target-flash 1.6s ease-out 2; border-radius: 8px; }

/* ── Recent & pinned sidebar section ──────────────────────────────────────
   The pin control sits beside the link rather than inside it: nesting a form
   in an <a> is invalid and would swallow the navigation click. */
.sidebar__recent { display: flex; align-items: center; gap: 2px; }
.sidebar__link--recent { flex: 1 1 auto; min-width: 0; }
.sidebar__recent-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar__pin-form { flex: 0 0 auto; margin: 0; display: flex; }
.sidebar__pin { background: none; border: 0; cursor: pointer; padding: 2px 6px;
                font-size: 14px; line-height: 1; color: var(--fg-dim); border-radius: 6px; }
.sidebar__pin:hover { color: var(--fg); background: var(--bg-elev-2); }
.sidebar__pin.is-pinned { color: var(--warn); }

/* Pin control uses an icon, not a glyph — the star read as a rating. */
.sidebar__pin svg { width: 14px; height: 14px; display: block; }

/* Touch targets. A 26x18 pin is comfortable with a mouse and a coin-toss with a
   thumb; the guidance everyone quotes is 24px, and the phone is where the
   sidebar is a drawer you are already fighting. Widened ONLY on coarse
   pointers, so the desktop sidebar keeps its density. */
/* Keyed on WIDTH as well as pointer type, deliberately. A coarse pointer is the
   honest signal, but at phone width the sidebar is a drawer being operated with
   a thumb whatever the browser claims about its pointer -- and emulators lie
   about it often enough that a rule which only fires on (pointer: coarse) is a
   rule that cannot be verified. */
@media (max-width: 700px), (hover: none) and (pointer: coarse) {
  .sidebar__pin { min-width: 32px; min-height: 32px;
                  display: inline-flex; align-items: center;
                  justify-content: center; padding: 6px; }
  .col-dd__item input[type="checkbox"],
  .router-menu__link input[type="checkbox"] { min-width: 24px; min-height: 24px; }
  /* Row-selection checkboxes. The label around them (.col-select__hit) already
     enlarges the CLICKABLE area, which is why this went unnoticed -- but the
     box itself is 13px, and on a phone you aim at the box you can see, not at
     the invisible label around it. Selecting rows is how bulk actions start,
     so this is the first tap of the most consequential flow in the panel.
     Surfaced by putting the Users and Live Sessions pages into ui_sweep. */
  .col-select input[type="checkbox"],
  .checkbox-row input[type="checkbox"],
  td input[type="checkbox"],
  th input[type="checkbox"] { min-width: 24px; min-height: 24px; }
  .col-select { width: 44px; }
}
.sidebar__pin.is-pinned { color: var(--primary); }

/* Selection column for bulk-action tables (users has its own copy inline). */
.col-select { width: 34px; text-align: center; }
.col-select__hit { display: flex; align-items: center; justify-content: center;
  padding: 2px 0; cursor: pointer; }
.col-select input { cursor: pointer; }

/* ── Attention bell ───────────────────────────────────────────────────────
   Everything waiting on this admin, on every page: alerts, agent approvals,
   unanswered tickets, queued commands. The whole widget is hidden while there
   is nothing to do, so a healthy install is not decorated with an empty bell.

   Colour is never the only signal — the count is always a number, and the
   panel rows carry their own label — so a red/amber distinction that a
   colour-blind operator cannot see costs them nothing. */
.attn { position: relative; display: inline-flex; }
.attn__btn { position: relative; }
.attn__count {
  position: absolute; inset-block-start: 2px; inset-inline-end: 2px;
  min-width: 16px; padding: 0 4px; border-radius: 999px;
  background: var(--warn); color: var(--primary-fg);
  font-size: 10px; font-weight: 700; line-height: 16px; text-align: center;
}
.attn__btn--crit .attn__count { background: var(--danger); }
/* A critical count should catch the eye of someone who is not looking at it.
   Two seconds, then still — a permanent animation in a topbar is a migraine. */
.attn__btn--crit { animation: attn-ring 1.6s ease-out 2; }
@keyframes attn-ring {
  0%, 100% { transform: rotate(0); }
  20% { transform: rotate(-12deg); }
  40% { transform: rotate(10deg); }
  60% { transform: rotate(-6deg); }
  80% { transform: rotate(3deg); }
}
.attn__panel {
  position: absolute; inset-block-start: calc(100% + 8px); inset-inline-end: 0;
  z-index: 250; min-width: 300px;
  /* Never wider than the screen, whatever the breakpoints do. */
  max-width: min(380px, calc(100vw - 16px));
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: 10px; box-shadow: var(--shadow-lg); padding: 6px;
  animation: pop .12s ease-out;
}
.attn__panel[hidden] { display: none; }
.attn__empty { padding: 14px; color: var(--fg-muted); font-size: 13px; }
.attn__row {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: 7px; color: var(--fg);
  text-decoration: none; font-size: 13px;
}
.attn__row:hover { background: var(--bg-hover); }
.attn__n {
  flex: 0 0 auto; min-width: 26px; padding: 2px 7px; border-radius: 999px;
  text-align: center; font-weight: 700; font-size: 12px;
  background: color-mix(in srgb, var(--info) 22%, transparent);
  color: color-mix(in srgb, var(--info) 45%, var(--fg));
}
.attn__row--warn .attn__n {
  background: color-mix(in srgb, var(--warn) 22%, transparent);
  color: color-mix(in srgb, var(--warn) 45%, var(--fg));
}
.attn__row--danger .attn__n {
  background: color-mix(in srgb, var(--danger) 22%, transparent);
  color: color-mix(in srgb, var(--danger) 45%, var(--fg));
}
.attn__txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.attn__txt b { font-weight: 600; }
.attn__txt em {
  font-style: normal; color: var(--fg-dim); font-size: 11px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ── Alert Log ────────────────────────────────────────────────────────────
   A to-do list, not a data grid. One card per alert, because a five-column
   table of severities reads as something to scroll past and this is something
   to act on. */
.alrt { display: flex; flex-direction: column; gap: 10px; }
.alrt__card {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 12px 14px; border-radius: 10px;
  background: var(--surface); border: 1px solid var(--border);
  border-inline-start: 3px solid var(--border-strong);
  /* The four action buttons are flex:0 0 auto and were winning the whole
     width, squeezing the text column to nothing — and a zero-width column with
     word breaking enabled renders one letter per line. Wrapping lets the
     buttons drop to their own row instead of crushing the sentence that says
     what is actually wrong. */
  flex-wrap: wrap;
}
.alrt__card--critical { border-inline-start-color: var(--danger); }
.alrt__card--important { border-inline-start-color: var(--warn); }
.alrt__card--digest { border-inline-start-color: var(--info); }
/* An acknowledged alert is still broken, so it stays in the list — it just
   stops shouting. Dimming it is the whole visual difference. */
.alrt__card--acked { opacity: .68; }
/* A real minimum before it is allowed to wrap: below this the message is
   unreadable, so the buttons go to the next line instead. */
.alrt__body { flex: 1 1 240px; min-width: 0; }
.alrt__title { font-weight: 600; margin-block-end: 2px; }
.alrt__subject {
  color: var(--fg-muted); font-size: 13px;
  /* anywhere, not break-word: only break INSIDE a word when there is no other
     way, rather than at every opportunity. */
  overflow-wrap: anywhere;
}
.alrt__meta {
  display: flex; flex-wrap: wrap; gap: 4px 12px; margin-block-start: 6px;
  color: var(--fg-dim); font-size: 11px;
}
.alrt__acts { display: flex; flex-wrap: wrap; gap: 6px; flex: 0 0 auto; }
@media (max-width: 700px) {
  /* Own row, full width, in reading order: what is wrong first, what you can do
     about it second. */
  .alrt__card { flex-direction: column; align-items: stretch; }
  /* flex-basis follows the MAIN axis, and the main axis just became vertical —
     so the 240px that was a sensible minimum WIDTH turned into a 240px minimum
     HEIGHT and opened a white chasm between the message and its buttons. */
  .alrt__body { flex: 0 0 auto; }
  .alrt__acts { width: 100%; }
  /* Two per row rather than three-and-a-lonely-one: 45% forces the wrap after
     the second button, which is a grid instead of a ragged edge. */
  .alrt__acts .btn { flex: 1 1 45%; }
  /* The date is the least useful column on a phone — keep it, but stop it
     forcing the table wider than the screen. */
  .alrt__when { white-space: normal; font-size: 12px; }
}
.alrt__ok {
  display: flex; align-items: center; gap: 12px; padding: 18px;
  border-radius: 10px; background: var(--surface);
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
}
.alrt__ok svg {
  width: 26px; height: 26px; flex: 0 0 auto;
  color: color-mix(in srgb, var(--accent) 55%, var(--fg));
}
.alrt__stale { color: color-mix(in srgb, var(--warn) 45%, var(--fg)); }
.alrt__dead { color: color-mix(in srgb, var(--danger) 45%, var(--fg)); }
.alrt__tl { list-style: none; margin: 0; padding: 0; font-size: 12px; }
.alrt__tl li {
  display: flex; gap: 10px; padding: 5px 0;
  border-block-end: 1px solid var(--border);
}
.alrt__tl li:last-child { border-block-end: 0; }
.alrt__tl time { color: var(--fg-dim); flex: 0 0 auto; }
.alrt__watched {
  display: flex; flex-wrap: wrap; gap: 6px 10px;
  font-size: 12px; color: var(--fg-muted);
}
.alrt__watched span {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 9px; border-radius: 999px;
  border: 1px solid var(--border); background: var(--bg-elev);
}
.alrt__watched span.is-off { opacity: .5; }
.alrt__dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
}
.alrt__watched span.is-off .alrt__dot { background: var(--fg-dim); }

/* Phone-only account block in the sidebar — the counterpart of the avatar and
   sign-out door the topbar drops below 560px. Hidden everywhere else, because
   on a wider screen those controls are still in the bar and a second copy is
   just a second thing to maintain. */
/* Language, Theme, Profile, Logout are FOUR ROWS OF ONE LIST, not two blocks
   that happen to be adjacent — so this carries the identical grid, padding,
   border box, icon size and row gap as .pick--row above it, and the two
   containers meet on the same 2px gap they use internally. Change one, change
   the other; a 4px difference anywhere here shows up as a seam.

   They were side by side and centred before, which put four controls into
   three visual rows with two alignments and gave the eye nothing to run down.

   padding-bottom, though: the block sat directly on the footer's top border,
   so Logout read as resting on the rule instead of above it. */
.sidebar__account {
  display: none; flex-direction: column; gap: 2px; padding: 2px 12px 14px;
}
.sidebar__account .sidebar__link {
  display: grid; grid-template-columns: 22px 1fr; gap: 10px;
  align-items: center; justify-items: start;
  width: 100%; margin: 0; font-size: 13.5px;
  /* Explicit, and identical to .pick--row's: a <button> and an <a> resolve
     `normal` to different line boxes, which made the last row 2px taller than
     the three above it — a stutter you see without being able to name. */
  line-height: 20px;
  /* border, not just padding: .pick__btn carries a 1px transparent border for
     its hover outline, and without a matching one these rows are 2px shorter
     and the pitch stutters halfway down the list. */
  padding: 9px 10px; border: 1px solid transparent;
}
.sidebar__account .sidebar__link svg { width: 18px; height: 18px; justify-self: center; }

/* Shown exactly where the topbar gives them up. This rule MUST come after the
   base one above: media queries carry no extra specificity, so the earlier
   560px block cannot switch it on. */
@media (max-width: 560px) {
  .sidebar__account { display: flex; }
}

/* Room to breathe at the foot of the drawer, and clearance for the home
   indicator on a notched phone — otherwise the version line sits flush against
   the bottom edge and reads as cut off even when it is fully scrolled. */
@media (max-width: 900px) {
  .sidebar__footer { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
}

/* Phone layout for the attention panel.

   MUST live after the base .attn__panel rule above, not up in the 560px block
   with the rest of the phone overrides: a media query adds no specificity, so
   a later plain rule beats an earlier one inside it, and the panel stayed
   anchored to the bell no matter what that block said.

   Anchored to the bell it was positioned from wherever the bell happened to
   sit — the middle of the bar, on a phone — so a 300px panel ran off the screen
   edge and took its first row with it. Pinned to the viewport it cannot: it is
   as wide as the phone, minus a margin, wherever the bell is. z-index clears
   .topbar (30) so it overlays the page rather than the bar.

   The breakpoint is 900px, not 560px: that is where the sidebar becomes a
   drawer, i.e. where the layout stops having room beside the content — and a
   tablet at 700px had exactly the same panel hanging off the edge. */
@media (max-width: 900px) {
  .attn { position: static; }
  .attn__panel {
    position: fixed;
    inset-block-start: 60px;
    inset-inline: 8px;
    min-width: 0;
    /* Full width on a phone, but a tablet at 800px does not want an 800px-wide
       list of four short rows — cap it and let the auto margin push it back
       under the bell it belongs to. */
    max-width: 420px;
    margin-inline-start: auto;
    z-index: 420;
  }
  .attn__row { padding: 12px; }
  .attn__count { inset-block-start: 4px; inset-inline-end: 4px; }
}

/* History filters. Three selects that answer "show me less than everything" —
   secondary to the list they filter, so they sit on one wrapping row at the
   size of their own labels. Stacked full-width they were a screenful of empty
   boxes above the thing the operator came to read, and looked like a form to
   fill in rather than a view control. */
.alrt-filters {
  display: flex; flex-wrap: wrap; gap: 8px; margin: 0;
  /* Pushed to the trailing edge: they are view controls, not part of the
     heading, and reading order should reach the title first. */
  margin-inline-start: auto; justify-content: flex-end;
}
.alrt-filters .select {
  width: auto; flex: 1 1 auto; min-width: 110px; max-width: 210px;
  font-size: 13px;
}
@media (max-width: 560px) {
  /* Still touch-sized, still not a wall. */
  .alrt-filters .select { font-size: 16px; min-width: 104px; }
}
/* Title and its view-controls on one line where there is room, stacked where
   there is not — the filters must never push the heading off the top. */
.alrt-head { flex-wrap: wrap; gap: 10px; align-items: flex-start; }

/* The settings link is the one thing an operator leaves this page FOR, so it
   is a button on the trailing edge rather than a word in a status sentence. */
.alrt__settings { margin-inline-start: auto; }
.alrt__meta.mb-2 { align-items: center; }

/* Card heading, its counts and its one action on a single line. The action is
   pushed to the trailing edge so the eye reads the state before the button. */
.alrt-counts { display: flex; flex-wrap: wrap; gap: 6px; }
.alrt-head__act { margin-inline-start: auto; flex: 0 0 auto; }

/* ── Hotspot design editor: the Note ──────────────────────────
   ONE textarea for the ISP's own words, shared by every published language.
   This is all that is left of a 284-box per-language wording panel
   (.lang-pane / .lbl-row / .lbl-group, cut during 4.2.2 before release): the
   wording is SAMM's, shared by all 100 designs, and nobody edits it. */
#design-form textarea[name="note"] { resize: vertical; min-height: 68px; }


/* ============================================================
   Diagnosis dialog (network_diagnose.html)

   Lives here rather than in a page's own <style> because the SAME fragment is
   opened from two places -- the health board and the topology map's right-click
   menu -- and when the rules sat in network_health.html the map rendered it
   unstyled: the all-clear tick came out as a 300x150 black checkmark.
   ============================================================ */
.dg__head{display:flex;align-items:flex-start;gap:12px;padding-bottom:12px;
  border-bottom:1px solid var(--border);margin-bottom:14px;}
.dg__name{font-size:15px;font-weight:650;}
.dg__state{margin-inline-start:auto;text-align:end;}
/* The all-clear. Centred, and the tick is deliberately modest: "nothing is
   wrong" is the most common answer this dialog gives, and a full-width black
   checkmark shouted it. Sized explicitly because an <svg> with no width or
   height renders at the browser's 300x150 default -- which is exactly what it
   did once this rule was missing on the map page. */
.dg__clear{display:flex;flex-direction:column;align-items:center;
  text-align:center;gap:9px;padding:26px 18px;
  border:1px solid var(--border);border-radius:10px;}
.dg__clear svg{width:34px;height:34px;flex:none;
  color:var(--accent);stroke:var(--accent);}
.dg__item{border:1px solid var(--border);border-radius:10px;padding:14px;
  margin-bottom:12px;}
.dg__item.s-critical{border-inline-start:3px solid var(--danger);}
.dg__item.s-important{border-inline-start:3px solid var(--warn);}
.dg__title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.dg__since{margin-inline-start:auto;}
.dg__reason{margin:9px 0 0;font-size:12.5px;color:var(--fg);}
.dg__block{margin-top:11px;}
.dg__lbl{font-size:10.5px;font-weight:700;letter-spacing:.04em;
  text-transform:uppercase;color:var(--fg-dim);margin-bottom:3px;}
.dg__block p{margin:0;font-size:12.5px;line-height:1.55;}
/* A background refresh in progress (bg_refresh.js): the button's icon turns while routers are read. */
@keyframes bg-spin{to{transform:rotate(360deg);}}
.bg-working svg{animation:bg-spin .9s linear infinite;}
.bg-working{cursor:progress;}
@media (prefers-reduced-motion:reduce){.bg-working svg{animation-duration:2.5s;}}
/* The command, whole and copyable: wraps instead of being cut with "...", and Copy sits beside it. */
.dg__cmd{display:flex;align-items:flex-start;gap:8px;margin-top:6px;padding:8px 10px;border-radius:8px;
  background:var(--bg-hover);border:1px solid var(--border);}
.dg__cmd pre{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;
  user-select:all;}
.dg__cmd .copy-btn{flex:none;}
.dg__fix{margin-top:12px;padding-top:11px;border-top:1px dashed var(--border);}
.dg__fixhead{display:flex;align-items:center;gap:9px;flex-wrap:wrap;}
.dg__caveat{margin-top:8px;font-size:12px;color:var(--warn);}
.dg__manual{margin-top:11px;font-size:12px;color:var(--fg-dim);font-style:italic;}
.dg__done{margin-top:12px;padding-top:11px;border-top:1px dashed var(--border);
  display:flex;align-items:center;gap:12px;}
.dg__done button{margin-inline-start:auto;}
.dg__plan{margin-top:11px;}
/* The commands are the point of the dialog: they are what the operator is
   agreeing to, so they get monospace and room to breathe, not a tooltip. */
.dg__cmds{background:var(--bg-elev-2);border:1px solid var(--border);
  border-radius:8px;padding:10px 12px;font-family:ui-monospace,monospace;
  font-size:12px;line-height:1.75;white-space:pre-wrap;word-break:break-all;}
.dg__cmds.is-undo{opacity:.85;}
.dg__hist{margin-top:14px;}
.dg__hist summary{cursor:pointer;font-size:12.5px;font-weight:600;}
.dg__err{color:var(--danger);font-size:12.5px;margin-top:9px;}


/* ── Facet chips ───────────────────────────────────────────────────────────
   The active search, shown as removable pills above the list.

   Its own class rather than .chip: notif_admin_alerts.html shadows .chip in a
   page-level <style>, so that name already means two different things
   depending on which page you are on, and a shared component cannot be built
   on a name that is not shared.

   The x is an <a href>, not a button, so it works with JavaScript off. Logical
   properties throughout — this sits above the list in Arabic too. */
.facets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
          padding: 8px 18px 0; }
.facets[hidden] { display: none; }
.facet { display: inline-flex; align-items: center; gap: 6px;
         border: 1px solid var(--border); border-radius: 999px;
         background: var(--bg-elev, var(--bg-hover));
         padding: 3px 4px 3px 10px; font-size: 12.5px; line-height: 1.6;
         max-width: 100%; }
.facet__k { color: var(--fg-muted); }
.facet__k::after { content: ":"; }
.facet__v { font-weight: 600; overflow: hidden; text-overflow: ellipsis;
            white-space: nowrap; max-width: 32ch; }
.facet__x { display: inline-flex; align-items: center; justify-content: center;
            width: 18px; height: 18px; border-radius: 999px;
            color: var(--fg-muted); text-decoration: none; font-size: 15px;
            line-height: 1; }
.facet__x:hover { background: var(--danger); color: #fff; }
.facet--clear { border-style: dashed; padding-inline-end: 10px;
                color: var(--fg-muted); text-decoration: none; }
.facet--clear:hover { color: var(--fg); border-color: var(--fg-muted); }

/* Comfortable to hit with a thumb; the list below is already touch-sized. */
@media (max-width: 700px), (hover: none) and (pointer: coarse) {
  .facet { padding-block: 6px; }
  .facet__x { width: 26px; height: 26px; }
}
/* An inline checkbox + its wording, sized to be tapped. A bare <label> around
   a 13px box reads fine on a desktop and is unhittable on a phone -- ui_sweep
   measures the INPUT, which is the thing a thumb actually aims at. */
.toggle-inline { display: flex; align-items: center; gap: 6px; cursor: pointer; }
@media (max-width: 700px), (hover: none) and (pointer: coarse) {
  .toggle-inline input[type="checkbox"] { min-width: 24px; min-height: 24px; }
}

/* ══ The control panel ══════════════════════════════════════════════════════
   One row: a search box that CONTAINS its facets, a caret opening
   Filters / Group By / Favorites side by side, then the pager and the view
   switcher. Replaces the eight separate controls the Users list had grown.
   Every selector here is list-agnostic — this is the template other pages
   adopt, so nothing may reference users, cards or invoices. */
.cp { background: var(--surface); border-bottom: 1px solid var(--border); }
.cp__row { display: flex; align-items: center; gap: 10px; padding: 8px 14px; flex-wrap: wrap; }

/* The search box. It is a BOX, not an input — the input is one child of it,
   sitting after however many facet pills are active, which is what lets the
   facets appear inside the search field the way Odoo's do. */
.cp__search {
  position: relative; display: flex; align-items: center; gap: 6px;
  flex: 1 1 380px; min-width: 240px; max-width: 780px;
  background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
  padding: 3px 6px 3px 10px; min-height: 36px;
}
.cp__search:focus-within { border-color: var(--primary); }
.cp__search-icon { width: 15px; height: 15px; flex: none; opacity: .55; }
.cp__facets { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; flex: 1; min-width: 0; }
.cp__input {
  flex: 1 1 90px; min-width: 90px; border: 0; background: transparent;
  color: inherit; font: inherit; padding: 4px 2px; outline: none;
}
.cp__clear, .cp__caret {
  flex: none; cursor: pointer; color: var(--fg-dim); background: none; border: 0;
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; border-radius: 6px; text-decoration: none; font-size: 17px;
}
.cp__clear:hover, .cp__caret:hover { background: var(--bg-hover); color: inherit; }
/* The menu is positioned against the whole search GROUP, not the caret it
   hangs from: the caret sits at the box's right edge and therefore moved every
   time a facet widened the box, dragging the open menu sideways with it. The
   group is centred and grows symmetrically, so its CENTRE is the one point
   here that never moves — and that is what the menu is pinned to. */
.cp__sg { position: relative; }
.cp__dd { position: static; }
.cp__dd > summary { list-style: none; }
.cp__dd > summary::-webkit-details-marker { display: none; }
.cp__caret i {
  border: solid currentColor; border-width: 0 1.5px 1.5px 0;
  padding: 3px; transform: rotate(45deg); margin-top: -3px;
}
.cp__dd[open] .cp__caret i { transform: rotate(-135deg); margin-top: 2px; }

/* One facet pill inside the box. */
.cp-facet {
  /* stretch, not center: the × carries more padding than the label, so a
     centred row left the blue key shorter than the pill -- a white hairline
     above and below it. Every part now fills the pill's full height and
     centres its own text. */
  display: inline-flex; align-items: stretch; max-width: 100%;
  border: 1px solid var(--primary); border-radius: 6px; overflow: hidden;
  font-size: 12px; line-height: 1.6; white-space: nowrap;
}
.cp-facet > * { display: inline-flex; align-items: center; }
.cp-facet > .cp-facet__v { display: block; align-self: center; }
.cp-facet__k { background: var(--primary); color: #fff; padding: 1px 7px; font-weight: 600; }
.cp-facet__v { padding: 1px 7px; overflow: hidden; text-overflow: ellipsis; }
.cp-facet__x {
  padding: 1px 7px 2px; color: var(--fg-dim); text-decoration: none;
  border-inline-start: 1px solid var(--border);
}
.cp-facet__x:hover { background: var(--bg-hover); color: var(--danger, #e5484d); }

/* The three-column menu.
   `display: none` while closed, explicitly: a closed <details> hides its
   content without removing the box of an absolutely-positioned child, so the
   menu still measured 620px hanging past the right edge of the page — invisible,
   but indistinguishable from a real overflow to anything checking geometry. */
.cp__dd:not([open]) .cp__menu { display: none; }
.cp__menu {
  position: absolute; top: calc(100% + 7px); left: 50%; transform: translateX(-50%);
  z-index: 300;
  display: flex; gap: 0; min-width: 620px;
  background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
  box-shadow: 0 12px 34px rgba(0,0,0,.32); padding: 8px 0;
}
.cp__col { flex: 1 1 0; min-width: 190px; padding: 0 6px; }
.cp__col + .cp__col { border-inline-start: 1px solid var(--border); }
.cp__col-h {
  display: flex; align-items: center; gap: 7px; padding: 4px 10px 8px;
  font-weight: 600; font-size: 13px;
}
.cp__col-h svg { width: 14px; height: 14px; opacity: .75; }
.cp__item {
  display: flex; align-items: center; gap: 8px; justify-content: space-between;
  padding: 6px 10px; border-radius: 6px; font-size: 13px;
  color: inherit; text-decoration: none; cursor: pointer;
}
.cp__item:hover { background: var(--bg-hover); }
.cp__item.is-on { color: var(--primary); font-weight: 600; }
.cp__item.is-on::after { content: '✓'; font-size: 12px; }
.cp__item--muted { color: var(--fg-dim); cursor: default; }
.cp__item--muted:hover { background: none; }
.cp__item--fav > a { flex: 1; color: inherit; text-decoration: none; }
.cp__n { color: var(--fg-dim); font-size: 12px; }
.cp__tag { color: var(--fg-dim); font-size: 11px; }
.cp__del { background: none; border: 0; color: var(--fg-dim); cursor: pointer; padding: 2px; }
.cp__del:hover { color: var(--danger, #e5484d); }
.cp__del svg { width: 13px; height: 13px; }
.cp__sep { height: 1px; background: var(--border); margin: 6px 8px; }
.cp__save > summary { list-style: none; }
.cp__save > summary::-webkit-details-marker { display: none; }
.cp__save-form { display: flex; flex-direction: column; gap: 6px; padding: 6px 10px 4px; }

/* Pager and view switcher — Odoo draws both as joined, bordered button
   groups (Bootstrap .btn-group of .btn-secondary), not as loose icons. The
   shared border is what makes them read as one control. */
.cp__pager { display: flex; align-items: center; gap: 8px; }
.cp__pager-l { font-size: 13px; color: var(--fg-dim); white-space: nowrap; }
/* The pager arrows, measured off Odoo's own (demo1.odoo.com, .o_pager):
   two btn-secondary buttons in a btn-group — 32x33, padding 5px 8px, a light
   grey FILL rather than a white box, a 1px border of the same colour, 4px
   radius on the group's outer corners only, and 1px between them. They read
   as one control with two halves, which is why they are drawn joined and not
   as two separate outlined squares. */
.cp__page {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; padding: 5px 8px;
  color: inherit; text-decoration: none; font-weight: 500;
  background: var(--btn-grey, color-mix(in srgb, var(--fg) 9%, transparent));
  border: 1px solid transparent; border-radius: 0;
}
.cp__page:first-of-type { border-start-start-radius: 4px; border-end-start-radius: 4px; }
.cp__page:last-of-type  { border-start-end-radius: 4px; border-end-end-radius: 4px;
                          margin-inline-start: 1px; }
.cp__page svg { width: 14px; height: 14px; }
.cp__page:hover { background: color-mix(in srgb, var(--fg) 16%, transparent); }
/* Odoo keeps a spent arrow in place and greys it out — the control must not
   change width when you reach the first or last page. */
.cp__page.is-off { opacity: .45; pointer-events: none; }

.cp__views { display: flex; }
.cp__view {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 30px; color: inherit; text-decoration: none;
  border: 1px solid var(--border); background: var(--surface);
}
.cp__view + .cp__view { border-inline-start: 0; }
.cp__view:first-child { border-start-start-radius: 7px; border-end-start-radius: 7px; }
.cp__view:last-child  { border-start-end-radius: 7px; border-end-end-radius: 7px; }
.cp__view svg { width: 15px; height: 15px; }
.cp__view:hover { background: var(--bg-hover); }
.cp__view.is-on { background: var(--primary); color: #fff; border-color: var(--primary); }

/* Odoo caps a facet value and ellipsises it (.o_facet_value max-width: 16rem)
   so one long plan name cannot push the input out of the box. */
.cp-facet__v { max-width: 16rem; }

/* The selection row. It sits in .cp__actions and TAKES THE SEARCH BOX'S SLOT
   when rows are ticked — Odoo hides the search view rather than stacking a
   second bar above the table — and stays CENTRED in that slot. */
.cp-sel { display: flex; align-items: center; justify-content: center;
          gap: 8px; flex-wrap: wrap; width: 100%; }
.cp-sel[hidden] { display: none; }
/* One connected pill: count · select-all · dismiss. */
.cp-sel__grp {
  display: inline-flex; align-items: stretch; overflow: hidden;
  border: 1px solid var(--primary); border-radius: 8px;
  font-size: 13px; white-space: nowrap;
}
.cp-sel__n { display: inline-flex; align-items: center; padding: 4px 11px; color: var(--primary); }
.cp-sel__n b { font-weight: 700; margin-inline-end: 4px; }
.cp-sel__all {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  background: var(--primary); color: #fff; border: 0;
  padding: 4px 11px; font: inherit; font-size: 13px;
}
.cp-sel__all svg { width: 12px; height: 12px; }
.cp-sel__all:hover { filter: brightness(1.08); }
.cp-sel__x {
  background: none; border: 0; color: var(--primary); cursor: pointer;
  font-size: 17px; line-height: 1; padding: 0 9px;
}
.cp-sel__x:hover { background: var(--bg-hover); }

@media (max-width: 760px) {
  .cp__menu { min-width: 0; width: calc(100vw - 28px); flex-direction: column; }
  .cp__col + .cp__col { border-inline-start: 0; border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
  .cp__pager { margin-inline-start: 0; display: flex;
             align-items: center; gap: 8px; }
  .cp__search { max-width: 100%; }
  /* A thumb needs a real target, and these are the controls a phone user
     actually reaches for. */
  .cp__view, .cp__page { width: 34px; height: 32px; }
  .cp-facet__x { padding: 3px 10px 4px; }
}
/* An expandable filter section (Plan, Owner) — Odoo folds long lists away so
   they cannot push the Group By column off the screen. */
.cp__sub > summary { list-style: none; }
.cp__sub > summary::-webkit-details-marker { display: none; }
.cp__sub[open] > summary { color: var(--primary); }
.cp__item--sub { padding-inline-start: 24px; font-size: 12.5px; }
.cp__sub { max-height: 260px; overflow-y: auto; }

/* ── Odoo's o_control_panel_main: three groups, one line ─────────────────────
   [New] · [search] · [pager + views]. Below 992px the search drops to a full
   row of its own while the pager and switcher stay beside the buttons — the
   order-1 / order-lg-2 swap Odoo does. */
.cp__lead, .cp__new { display: flex; align-items: center; gap: 6px; order: 0; }
/* A button in the leading slot never shrinks: squeezed, it clipped to a
   sliver ("ck to") with its icon cut off. When the slot is narrower than its
   buttons, app.js (fitLeadingSlot) folds labels to icons instead -- the
   secondary entries first (--tight), then all of them (--tighter). The label
   is clipped, not removed, so it stays the button's accessible name. */
.cp__new > * { flex-shrink: 0; }
.cp__new--tight .btn:not(.btn--primary) .cp__new-lbl,
.cp__new--tighter .btn .cp__new-lbl {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
.cp__new--tight .btn:not(.btn--primary),
.cp__new--tighter .btn { gap: 0; padding-inline: 8px; }
.cp__actions { order: 2; flex: 1 1 100%; min-width: 0; }
.cp__nav { order: 1; display: flex; align-items: center; gap: 10px; margin-inline-start: auto; }
@media (min-width: 992px) {
  /* The search box GROWS with what is in it — a fourth facet should widen the
     box, not wrap immediately — up to a ceiling, after which the facets wrap
     inside it onto a second line.
     Its LEFT edge is what must not move: it sits directly after the New
     buttons (whose width never changes) and grows rightwards, so the dropdown
     anchored to that edge stays put no matter how many facets come and go.
     Centring it was what made every added chip slide the whole box sideways
     under the pointer. */
  /* Equal flex on BOTH sides is what actually centres the middle: the box sits
     on the row's centre line whatever the New buttons or the pager happen to
     be showing. It then grows SYMMETRICALLY about that line, so it stays
     centred while it widens and its centre never moves — which is the point,
     because the menu below is centred on the same line. */
  .cp__lead, .cp__new { flex: 1 1 0; min-width: 0; }
  .cp__actions {
    order: 1; flex: 0 1 auto; justify-content: center;
    width: max-content; min-width: 620px; max-width: min(1000px, 100%);
  }
  .cp__nav { order: 2; flex: 1 1 0; justify-content: flex-end;
             margin-inline-start: 0; }
}
.cp__pager { margin-inline-start: 0; display: flex;
             align-items: center; gap: 8px; }

/* ── A group header that IS the group ───────────────────────────────────────
   The Hotspot Cards list, where the header carries the batch's counts, its
   settings and its whole right-click menu — the separate Groups page was
   removed and this is where it went.

   THE LAYOUT IS INSIDE THE CELL, NEVER ON IT. A `display:flex` on the <td>
   takes it out of the table's own layout, so the cell shrinks to its content
   and the header's band stops halfway across the table with the last columns
   showing through it. The td stays a table-cell with its colspan; .o-grp__bar
   is what lays the parts out. */
/* ONE LINE, LAID OUT FREELY. The header spans every column, so nothing in it
   should line up with the columns underneath — it is a row about the GROUP,
   not a row of cells. Read left to right (or right to left):

     [caret name (12)]  [badges speed] ......... [show all] [⋮]

   `.o-grp__btn` is width:100% on the other lists, where the count belongs at
   the far end the way Odoo's Count column does. Here that pushed the count
   into the middle of the row and left the badges, the speed and the menu
   fighting over what was left — they wrapped onto a second line and collided.
   Inside a bar the button is only as wide as its own text. */
.o-grp__bar { display: flex; align-items: center; gap: 10px; width: 100%;
              flex-wrap: nowrap; }
.o-grp__bar > .o-grp__btn { width: auto; flex: 0 0 auto; }
.o-grp__bar .o-grp__n { margin-inline-start: 6px; }
/* Shrinks before anything else and never wraps: the batch's settings are the
   least important thing on the row, and a second line here doubles the height
   of every group header on the page. */
.o-grp__meta { display: inline-flex; align-items: center; gap: 6px;
               flex-wrap: nowrap; min-width: 0; flex: 0 1 auto;
               overflow: hidden; white-space: nowrap; }
.o-grp__meta .badge { font-size: 10.5px; padding: 1px 6px; }
/* Pushed to the trailing edge, and it swallows the click so the ⋮ does not
   also toggle the group open. */
.o-grp__acts { display: inline-flex; align-items: center; gap: 6px;
               margin-inline-start: auto; flex: none; }
/* A disabled batch: every card in it is refused at auth, so the header says so
   without shouting — the badge states it, this only lowers the row. */
.o-grp--off .o-grp__name { opacity: .6; }
@media (max-width: 700px) {
  /* On a phone the batch's settings are the first thing to go: the name, the
     count and the menu are what the row is for. */
  .o-grp__meta { display: none; }
}

/* The page's own tools — Print, Export, a bulk Actions button. Always on the
   row, never behind a selection: they are about the LIST, and the selection
   pill beside them is about the selection. */
/* The page's tools, INSIDE the selection group and therefore only on screen
   while rows are ticked. Inline beside the count, never a row of its own.
   Wraps rather than overflowing: at 390px a count plus three buttons does not
   fit on one line, and the panel is allowed to grow downward (it measures its
   own height into --cp-h) but the PAGE is never allowed to scroll sideways. */
/* A one-line statement of what the filter matched, sitting between the panel
   and the rows. Not a .card__header: a header would draw a second heavy rule
   directly under the panel's own, and the point of merging the two into one
   card was to stop the filter looking unrelated to its result. */
.cp__note {
  padding: 8px 14px; font-size: 13px; font-weight: 600;
  color: var(--text); border-block-start: 1px solid var(--border);
  background: var(--bg-subtle, transparent);
}

.cp-sel__tools { display: inline-flex; align-items: center; gap: 6px;
                 flex-wrap: wrap; justify-content: center; }
.cp-sel__tools:empty { display: none; }
@media (max-width: 991px) {
  /* Below the desktop breakpoint the search box already takes its own full
     row, and the tools do not fit beside it — on a 390px phone the CoA
     queue's row ran 234px past the edge and the whole PAGE scrolled sideways,
     which is the one thing this panel must never do. They drop to their own
     line and wrap within it. */
  .cp__sg { min-width: 0; }
}

/* Bulk buttons that act on THE CURRENT FILTER rather than on a selection.
   They belong under the panel that states that filter — inside it they would
   sit in the selection slot, which is for actions on ticked rows and is hidden
   until something is ticked. */
.cp__bulkbar { display: flex; gap: 8px; flex-wrap: wrap;
               padding: 8px 12px 0; }

/* ── The column chooser, Odoo-style: a bare icon at the end of the header row,
   and a tick applies immediately (no Apply button). */
.colsel { position: relative; display: inline-block; }
.colsel > summary { list-style: none; cursor: pointer; }
.colsel > summary::-webkit-details-marker { display: none; }
.colsel__btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; border-radius: 5px; color: inherit; opacity: .75;
}
.colsel__btn svg { width: 14px; height: 14px; }
.colsel__btn:hover { background: var(--bg-hover); opacity: 1; }
.colsel__menu {
  /* FIXED, NOT ABSOLUTE, AND ANCHORED BY SCRIPT (app.js).
     The chooser lives in the LAST <th> of the table, and the table sits inside
     `.table-wrap { overflow-x: auto }` — inside the list container, which is
     itself the scroller in the fixed shell. An absolutely-positioned child is
     clipped by every one of those, so the menu was cut off by the edge of the
     table: on a short list you saw two of its rows and the rest was under the
     footer. Fixed positioning has no clipping ancestor at all; the trade is
     that it must be re-anchored when anything scrolls, which app.js does. */
  position: fixed; top: 0; left: 0; z-index: 320;
  min-width: 210px; max-height: 70vh; overflow-y: auto; padding: 6px;
  background: var(--surface); border: 1px solid var(--border); border-radius: 9px;
  box-shadow: 0 12px 34px rgba(0,0,0,.32); text-align: start;
}
/* Until it has been placed, it must not flash in the corner. */
.colsel:not([open]) > .colsel__menu { display: none; }
.colsel__item {
  display: flex; align-items: center; gap: 9px; padding: 5px 8px;
  border-radius: 6px; font-size: 13px; font-weight: 400; cursor: pointer;
  text-transform: none; letter-spacing: 0; white-space: nowrap;
}
.colsel__item:hover { background: var(--bg-hover); }
.colsel__item input[type=checkbox] {
  appearance: none; -webkit-appearance: none; flex: none; margin: 0; cursor: pointer;
  width: 16px; height: 16px; border: 1.5px solid var(--fg-dim); border-radius: 4px;
  background: var(--surface); position: relative;
}
.colsel__item input[type=checkbox]:checked { background: var(--primary); border-color: var(--primary); }
.colsel__item input[type=checkbox]:checked::after {
  content: ''; position: absolute; left: 4.5px; top: 1px; width: 4px; height: 8px;
  border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
}
@media (max-width: 700px), (hover: none) and (pointer: coarse) {
  .colsel__item input[type=checkbox] { min-width: 24px; min-height: 24px; }
  .colsel__btn { width: 32px; height: 30px; }
}

/* ── ONE look for every advanced-search list ────────────────────────────────
   The lists had drifted into three: .table (13px rows, 12px padding, 60px
   tall), .table--compact (8px) and .table--dense (12.5px header text, 5px).
   Same data, same panel, three row heights and two header sizes.

   Scoped to the list container ([data-list-root]) and to its OWN table by
   child selectors, so a dashboard block, a form table or a table nested in a
   cell keeps its spacing. Desktop only: the phone layouts below are tuned per
   list. Horizontal padding is left to each variant -- the Users and Cards
   lists stay narrow because they carry the most columns -- and a cell that
   asks for smaller text (.text-sm, .text-xs) still gets it. */
@media (min-width: 861px) {
  [data-list-root] > .table-wrap > .table > thead > tr > th {
    font-size: 11px; font-weight: 600; letter-spacing: .6px;
    text-transform: uppercase; padding-block: 9px; line-height: 1.4;
  }
  [data-list-root] > .table-wrap > .table > tbody > tr > td { padding-block: 8px; }
  [data-list-root] > .table-wrap > .table > tbody > tr > td:not(.text-sm):not(.text-xs) {
    font-size: 13px;
  }
}

/* ── Denser list rows: more data on screen without shrinking the text into
   unreadability. Odoo's table-sm equivalent. Applied per table, not globally,
   so forms and detail pages keep their breathing room. */
.table--dense th, .table--dense td { padding: 5px 8px; font-size: 12.5px; }
.table--dense th { padding-top: 7px; padding-bottom: 7px; }
.table--dense .badge { font-size: 11px; padding: 1px 6px; }
.table--dense .btn--sm { padding: 2px 7px; font-size: 11.5px; }

/* ── Kanban: the same rows as cards. Same checkbox class as the list, so
   Select-all and every bulk action behave identically in both views. */
.kb { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
      gap: 10px; padding: 12px; }
.kb__group { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px;
             padding: 6px 2px 0; font-size: 13px; }
/* Equal-height cards: the grid stretches every cell to the tallest in its
   row, and the card fills that height, so the tiles line up instead of
   stepping up and down. */
.kb { align-items: stretch; }
.kb__card { background: var(--surface); border: 1px solid var(--border);
            border-radius: 9px; padding: 10px 12px; cursor: pointer;
            display: flex; flex-direction: column; gap: 6px; height: 100%; }
.kb__meta { flex: 1; align-content: flex-start; }
.kb__card:hover { border-color: var(--primary); }
.kb__top { display: flex; align-items: center; gap: 8px; }
.kb__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.kb__sub { font-size: 12.5px; color: var(--fg-dim); }
.kb__meta, .kb__foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; }
.kb__foot { justify-content: space-between; border-top: 1px solid var(--border); padding-top: 6px; }

/* ── Odoo's o_cp_searchview: an input-group, not a caret floating in a field.
   The box carries the left radius and no right border; the toggler is welded
   to its right edge with the matching radius. */
.cp__sg { display: flex; align-items: stretch; width: 100%; }
.cp__search {
  flex: 1 1 auto; min-width: 0;
  border-start-end-radius: 0; border-end-end-radius: 0; border-inline-end: 0;
}
.cp__dd { display: flex; }
.cp__caret {
  height: 100%; width: 34px; border: 1px solid var(--border);
  border-start-start-radius: 0; border-end-start-radius: 0;
  border-start-end-radius: 8px; border-end-end-radius: 8px;
  background: var(--surface);
}
.cp__search:focus-within + .cp__dd .cp__caret { border-color: var(--primary); }

/* THE WHOLE BOX MUST BE TYPEABLE. The input lives in a tiny <form> (it needs
   one to submit without JS), and a form is a flex item like any other — left
   at its default `flex: 0 1 auto` it hugged the text, so clicking the right
   two thirds of the search box did nothing at all. */
.cp__search-f { flex: 1 1 90px; min-width: 60px; display: flex; }
.cp__search-f .cp__input { width: 100%; }
.cp__facets { cursor: text; }


/* ── The actions column scrolls with everything else ────────────────────────
   It used to be pinned to the trailing edge with position:sticky. Unpinned on
   request: a pinned cell needs an opaque background of its own, which then has
   to be kept in step with the striped and hovered row underneath it, and it
   sat over the last data column instead of beside it. It is an ordinary column
   now — same background, same hover, same scrolling — and the columns-control
   button in its header goes with it.

   `inherit` is for the BODY cell only: it takes the striped or hovered
   background off the <tr> it sits in, which is the whole point. The HEADER
   cell must not inherit — a <tr> has no background of its own, so inheriting
   made the one header cell transparent while every other header cell was
   --surface-2, and the rows scrolled visibly under the column-control button.
   It keeps the .table th background like every other heading. */
.table td[data-col="actions"] { background: inherit; }

/* The selection group takes the search box's slot — so it is the same height
   to the pixel. Anything shorter shrinks the panel, and the sticky header and
   the column chooser under it jump up the moment a row is ticked. */
.cp__actions { display: flex; align-items: center; min-height: 37px; }
.cp__sg, .cp-sel { min-height: 37px; }
/* Past the ceiling the chips take a second line rather than the box growing
   past the pager. The box grows downward with them; the panel measures its own
   height (--cp-h in app.js), so the sticky header follows. */
.cp__facets { flex-wrap: wrap; }
.cp__search { height: auto; min-height: 37px; align-items: center; }
.cp-sel { width: 100%; }
.cp-sel__done { color: var(--primary); font-weight: 600; padding: 4px 11px;
                display: inline-flex; align-items: center; }

/* ── The list table, matched to Odoo's o_list_table ─────────────────────────
   Its markup is: table-sm table-hover table-striped, style="table-layout:
   fixed", and the trailing controller cell is width:32px. Each is doing a job:
     · fixed layout   — columns stop jumping as you page through the data
     · striped        — the eye tracks a long row without a ruler
     · 32px th        — the columns control takes a hairline, not a column
   Odoo also pins that cell to the trailing edge; here it deliberately does
   not, and scrolls with the rest (see the actions-column rule above). */
.table--dense { table-layout: auto; }
/* The stripe and the hover used to be declared here, for this class only.
   They are on plain .table now (see "Interlaced rows" above) — every table in
   the panel gets them, and this block keeps only what is genuinely dense. */
.table--dense th[data-col="actions"],
.table--dense td[data-col="actions"] { width: 32px; text-align: center; padding-inline: 4px; }

/* Odoo's sort caret is invisible until you hover the header, so a wall of
   arrows does not compete with the labels. */
.table--dense .sort-th .sort-ind { opacity: 0; transition: opacity .12s; }
.table--dense .sort-th:hover .sort-ind,
.table--dense .sort-th.is-sorted .sort-ind { opacity: 1; }
.table--dense .sort-th:not(.is-sorted):hover .sort-ind::before { content: '↓'; opacity: .55; }

/* ── The list fills the page, and both bars pin ─────────────────────────────
   The table has its OWN scroller, bounded to what is left of the viewport.
   That was the hard part: `position: sticky` resolves against the nearest
   scrolling ancestor, so an unbounded overflow:auto wrapper made itself the
   sticky context and the header stuck to a box that was itself scrolling away.
   Bounding it is what makes both work at once — the header sticks to the top
   of a box that does not move, and a wide table scrolls sideways INSIDE it
   instead of dragging the whole page (and with it the panel, the sidebar and
   the topbar) off to the right on a phone.

   The height is computed from measurements, not from magic numbers: app.js
   writes the topbar, panel and footer heights into --topbar-h / --cp-h /
   --footer-h on every layout change, so a panel that wraps to two lines
   shortens the table rather than pushing it off the bottom. 100dvh, not
   100vh: on mobile Safari and Chrome the URL bar is part of vh and the last
   row would sit under it. */
.card--full {
  margin: calc(var(--content-pad, 24px) * -1);
  border-radius: 0; border-inline: 0; border-block-start: 0;
}
/* The height is taken, not calculated. It WAS calculated -- viewport minus the
   topbar, the panel and the footer -- and that arithmetic oscillated: too tall
   by a pixel and the page grew a scrollbar, which narrowed the viewport, which
   re-wrapped the panel, which changed the number it was subtracting. A flex
   chain has no number in it. Each link needs min-height:0, because a flex item's
   default min-height is its CONTENT, which is exactly what stops a long table
   from letting its own container shrink. */
.app:has(.card--full) { height: 100dvh; min-height: 0; overflow: hidden; }
.app:has(.card--full) .main { min-height: 0; height: 100%; }
.app:has(.card--full) .content { min-height: 0; display: flex; flex-direction: column;
                                 overflow: hidden; }
.card--full { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
/* Under a page's TAB STRIP the full-screen list does not pull itself up and out
   to the edges: that glued it to the tabs (-8px) and made it wider than them.
   It sits inset below the strip with the strip's own gap, as Accounting's tabs
   always have -- one look for every tabbed page. */
/* KPI cards above a full-screen list (agent statement, 2026-10-15): the list must sit
   BELOW the cards, not pull itself up over them with the edge-to-edge margin. */
.content > .stat-row { flex: 0 0 auto; }
.content > .stat-row ~ .card--full { margin: 0; border: 1px solid var(--border); border-radius: 12px; }
.content > .tabs-wrap ~ .card--full,
.content > .tabs ~ .card--full {
  margin: 0; border: 1px solid var(--border); border-radius: 12px;
}
/* Plain notices on a full-screen list are shown as toasts (app.js,
   noticesToToasts); hidden here so they never flash as a clipped strip. */
.app:has(.card--full) .content > .alert:not(:has(form, button, a, input, select)) { display: none; }
.alert[data-toast]:not(:has(form, button, a, input, select)) { display: none; }
/* THE LIST CONTAINER is the scroller, not the table wrapper inside it. The
   list renders as a table OR as kanban cards, and only the table is wrapped —
   so a scroller on the wrapper left the kanban with none at all, clipped at
   the fold with forty of its fifty cards unreachable. One scroller around
   both, and the sticky header sticks to it.

   Matched by ATTRIBUTE, not by id. The container's id is <key>-list and the
   key is the descriptor's, so an id selector here would have to be edited once
   per adopting list -- which is exactly the kind of edit that gets forgotten
   until a page ships with no scroller at all. `data-list-root` is what a list
   declares; app.js reads the same container off the panel's data-cp-target. */
.card--full > [data-list-root] {
  flex: 1 1 auto; min-height: 0; overflow: auto;
  overscroll-behavior: contain;
}
.card--full .table-wrap { overflow: visible; }
@supports not selector(:has(*)) {
  /* Older engines keep the page scroll they always had — the table is wide
     rather than clipped, which is the safe way to be wrong. */
  .card--full .table-wrap { overflow-x: auto; }
}

/* ── The control panel pins under the topbar, the table header under it ─────
   The topbar is already sticky at top:0 with height 56px, so the panel sits
   at 56 and the header at 56 + the panel's own height. That height varies —
   the row wraps on a narrow screen — so JS measures it into --cp-h rather
   than a magic number that is wrong at every other width. */
.cp { position: sticky; top: var(--topbar-h, 56px); z-index: 25; }
/* ...but inside the fixed shell nothing scrolls PAST it — the table scrolls
   inside itself — so it is simply the first row of the column. Left sticky it
   was being offset by its own `top` against a scrollport that does not move,
   and it ended up drawn below the header it is supposed to sit above. */
.app:has(.card--full) .cp { position: static; }
/* top:0 — the header sticks to the top of its own scroller now, so it needs
   no knowledge of what is above it. It was an offset built from the topbar
   and panel heights, and it was wrong the moment either of them changed. */
.table--dense thead th { top: 0; }
@media (max-width: 700px) { .cp { top: 0; } }

/* ── Grouped rows, Odoo-style ───────────────────────────────────────────────
   A caret, the value, the count in brackets — spanning the table — and the
   group starts collapsed. Grouping is how a long list is made short; opening
   every group by default just makes it longer with headings in it. */
.o-grp td { background: color-mix(in srgb, var(--fg) 6%, transparent);
            padding: 4px 8px !important; }
.o-grp__btn {
  display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
  background: none; border: 0; color: inherit; font: inherit;
  font-size: 12.5px; font-weight: 600; padding: 3px 4px; border-radius: 5px;
}
.o-grp__btn:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); }
.o-grp__caret {
  border: solid currentColor; border-width: 0 1.5px 1.5px 0; padding: 2.5px;
  transform: rotate(-45deg); margin-inline-start: 2px; transition: transform .12s;
}
.o-grp__btn[aria-expanded="true"] .o-grp__caret { transform: rotate(45deg); margin-top: -3px; }
/* THE CARET IS A DIRECTION, NOT A GLYPH. It is drawn as two borders of a box
   and rotated, so `scaleX(-1)` (what the pager arrows use) would flip the box
   rather than aim the arrow — the rotation itself has to change. Collapsed, it
   points the way the group will open: right in English, LEFT in Arabic.
   Expanded it points down, which is the same in both. */
[dir="rtl"] .o-grp__caret { transform: rotate(135deg); }
[dir="rtl"] .o-grp__btn[aria-expanded="true"] .o-grp__caret { transform: rotate(45deg); }
.o-grp__btn { width: 100%; justify-content: flex-start; }
/* The count sits at the far end of the row, as Odoo's Count column does. */
.o-grp__n { font-weight: 600; margin-inline-start: auto; opacity: .8; }
/* Money beside the count on lists that total it (Invoices): it takes the free
   space, and the count follows it at the far end. */
.o-grp__money { margin-inline-start: auto; font-weight: 400; }
.o-grp__money + .o-grp__n { margin-inline-start: 14px; }
/* A phone shows the table scrolled sideways, so the heading's money would sit
   off-screen and cut mid-word; the totals line under the list carries it. */
@media (max-width: 640px) { .o-grp__money { display: none; } .o-grp__money + .o-grp__n { margin-inline-start: auto; } }
.o-grp__more { margin-inline-start: 10px; font-size: 12px; font-weight: 400; }
.o-grp { cursor: pointer; }
.o-grp__foot td {
  background: color-mix(in srgb, var(--fg) 8%, transparent);
  font-weight: 600; font-size: 12.5px; padding: 6px 8px !important;
  border-top: 1px solid var(--border);
}
.o-grp__foot .o-grp__n { text-align: end; }

/* ── Group By: a tick when on, an equal-width gap when off ──────────────────
   Odoo puts the check at the END of the row and shows nothing at all next to
   an inactive one. The space is reserved either way, so switching a group on
   does not shove its own label sideways. */
.cp__item--tick { display: flex; align-items: center; gap: 8px; }
.cp__item--tick .cp__item-t { flex: 1 1 auto; }
/* The generic active-item check would be a SECOND tick on these rows. */
.cp__item--tick.is-on::after { content: none; }
.cp__tick { flex: 0 0 12px; width: 12px; height: 12px; position: relative; }
.cp__item--tick.is-on .cp__tick::after {
  content: ""; position: absolute; inset-inline-start: 3px; top: 0;
  width: 5px; height: 10px; border: solid currentColor;
  border-width: 0 2px 2px 0; transform: rotate(45deg);
}

/* Save current search: private unless the box is ticked. */
.cp__chk { display: flex; align-items: center; gap: 6px; font-size: 12.5px; }
.cp__chk input { margin: 0; }

/* Nested group headers: each level a shade lighter and a step in, the way a
   nested Odoo group reads. The indent itself is inline, because it is a
   function of the depth rather than a fixed set of classes. */
.o-grp--l1 > td { background: color-mix(in srgb, var(--fg) 3%, transparent); }
.o-grp--l2 > td { background: color-mix(in srgb, var(--fg) 2%, transparent); }
.o-grp--l1 .o-grp__name, .o-grp--l2 .o-grp__name { font-weight: 600; }

/* While a menu click is being applied in place. */

/* ── Add Custom Filter: the rule builder dialog ─────────────────────────────
   "Match <all|any> of the following rules:", one row per rule, then New Rule
   and Add / Cancel. A dialog rather than a fold-out inside the menu: a builder
   that grows a row at a time would push Group By and Favorites off a menu
   whose whole job is to stay on one screen. */
.cp__cf-match { display: flex; align-items: center; gap: 6px;
                margin-bottom: 12px; color: var(--fg); }
.cp__cf-joinsel { width: auto; padding: 2px 22px 2px 6px; height: 26px;
                  font-size: 12.5px; }
.cp__cf-row {
  display: grid; grid-template-columns: 1fr 1fr 1.4fr 26px 26px;
  gap: 6px; align-items: center; margin-bottom: 6px;
}
.cp__cf-row .select, .cp__cf-row .input { min-width: 0; }
.cp__cf-ico {
  border: 0; background: none; color: var(--muted); cursor: pointer;
  height: 26px; border-radius: 5px; display: inline-flex;
  align-items: center; justify-content: center; font-size: 16px; line-height: 1;
}
.cp__cf-ico svg { width: 14px; height: 14px; }
.cp__cf-ico:hover { background: var(--bg-hover); color: var(--fg); }
.cp__cf-x:hover { color: var(--danger, #e5484d); }
/* One rule is not a list: there is nothing to remove down to. */
.cp__cf-rows .cp__cf-row:only-child .cp__cf-x { visibility: hidden; }
.cp__cf-new {
  border: 0; background: none; color: var(--primary); cursor: pointer;
  font-size: 13px; padding: 4px 2px; margin-top: 2px; border-radius: 4px;
}
.cp__cf-new:hover { background: var(--bg-hover); }
.cp__cf-clear { margin-inline-start: auto; }

/* The default saved search wears the same tick as an active Group By, and
   every other favourite reserves its width so the names stay on one edge. */
.cp__item--fav { display: flex; align-items: center; gap: 8px; }
.cp__fav-tick { flex: 0 0 12px; width: 12px; height: 12px; position: relative; }
.cp__item--fav.is-default .cp__fav-tick::after {
  content: ""; position: absolute; inset-inline-start: 3px; top: 0;
  width: 5px; height: 10px; border: solid var(--primary);
  border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.cp__item--fav.is-default > a { font-weight: 600; }

/* The pager's two halves: the range is what you click and type over, the
   total beside it never changes while you do. */
/* Odoo's o_pager_counter: the editable range carries a BOTTOM BORDER and
   nothing else — that underline is the whole affordance, which is why a grey
   pill around it looks wrong. The total beside it is plain text. */
.cp__pager-v {
  cursor: text; display: inline-block;
  border-bottom: 1px solid currentColor; margin-bottom: -1px;
}
.cp__pager-t { color: var(--muted); }
/* Editing happens IN PLACE: the input is the text. No border, no fill, no
   focus ring, no height of its own — the caret simply appears in the range
   where it already was, which is what makes Odoo's pager feel like typing
   over a label rather than opening a form control. `all: unset` first, so
   nothing the theme applies to `input` can put a box back. */
.cp__pager-in {
  all: unset;
  display: inline-block;
  font: inherit; color: inherit; text-align: end;
  /* Sized to the range it holds, so the total beside it does not shift when
     the caret appears. */
  width: 5.5ch;
  caret-color: currentColor;
}
.cp__pager-in::-webkit-outer-spin-button,
.cp__pager-in::-webkit-inner-spin-button { appearance: none; margin: 0; }

/* ── The rule builder's field picker ────────────────────────────────────────
   A button that looks exactly like the select it replaces, and a panel that
   always opens DOWNWARD with a search box as its first row. The real <select>
   is still in the DOM — it is what submits, and it is what shows when there is
   no script — so it is hidden rather than removed. */
.cf-combo { position: relative; min-width: 0; }
.cf-combo__native { display: none; }
.cf-combo__btn {
  display: flex; align-items: center; gap: 6px; width: 100%;
  text-align: start; cursor: pointer;
}
.cf-combo__lbl { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis;
                 white-space: nowrap; }
.cf-combo__pop {
  position: fixed; z-index: 4000; display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 8px; box-shadow: var(--shadow-lg); overflow: hidden;
}
.cf-combo__qwrap { padding: 6px; border-bottom: 1px solid var(--border); }
.cf-combo__q {
  width: 100%; box-sizing: border-box; font: inherit; font-size: 12.5px;
  padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
  background: var(--bg); color: var(--fg); outline: none;
}
.cf-combo__q:focus { border-color: var(--primary); }
.cf-combo__list { overflow-y: auto; padding: 4px; }
.cf-combo__opt {
  display: block; width: 100%; text-align: start; border: 0; background: none;
  color: inherit; font: inherit; font-size: 13px; cursor: pointer;
  padding: 5px 8px; border-radius: 5px; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
.cf-combo__opt:hover, .cf-combo__opt.is-active {
  background: color-mix(in srgb, var(--fg) 8%, transparent);
}
.cf-combo__opt.is-on { font-weight: 600; }
.cf-combo__none { padding: 10px 8px; font-size: 12.5px; color: var(--muted); }

/* ── Narrow screens ─────────────────────────────────────────────────────────
   The control panel is Odoo's desktop layout; on a phone it has to become a
   phone layout rather than a scaled-down one.

   Measured on a 390x844 device: the menu was three 120px columns and 865px
   tall in an 844px viewport — so the bottom column was unreachable — and the
   facet ✕, the clear-all and the menu rows were all under 32px, well under the
   40px a finger actually hits. */
@media (max-width: 860px) {
  /* One column, stacked, scrolling inside itself. Three columns side by side
     stop being three columns at this width; they become three unreadable
     ones. */
  .cp__menu {
    min-width: 0;
    width: calc(100vw - 16px);
    max-width: calc(100vw - 16px);
    flex-direction: column;
    max-height: calc(100dvh - var(--topbar-h, 56px) - var(--cp-h, 54px) - 24px);
    overflow-y: auto;
    /* The menu owns the gesture: `contain` stops a flick that reaches the end
       of the list from carrying on into whatever is behind it, and momentum
       scrolling makes a 1000px menu usable in a 600px box. */
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
  }
  /* AND THE BASE IS LOCKED WHILE IT IS OPEN. A finger that starts its swipe a
     few pixels outside the menu — or on a row of it that happens not to be
     scrollable — was scrolling the user list behind the menu instead of
     the menu itself, which reads as "the popup will not scroll". An open menu
     is a modal surface; the thing underneath it does not move. */
  .app:has(details.cp__dd[open]) [data-list-root] { overflow: hidden; }
  .cp__col + .cp__col {
    border-inline-start: 0;
    border-top: 1px solid var(--border);
    margin-top: 4px; padding-top: 4px;
  }
  /* The search group is the whole row on a phone — the New buttons and the
     pager wrap above and below it rather than fighting it for width. */
  .cp__actions { flex: 1 1 100%; max-width: none; width: auto; }
  .cp__sg { position: relative; }
}

/* Touch targets.
   BOTH conditions, not either alone. Pointer type catches the 1024px tablet
   that a width rule would miss; width catches the narrow viewport that reports
   a fine pointer — an emulator, a touchscreen laptop, a phone in desktop mode.
   Keyed on pointer alone, a 13px checkbox survived on a 390px screen. */
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  .cp__item, .cp__item--sub, .cp__item--fav { min-height: 40px; }
  .cp__item, .cp__item--sub { display: flex; align-items: center; }
  .cp-facet__x, .cp__clear {
    min-width: 32px; min-height: 32px;
    display: inline-flex; align-items: center; justify-content: center;
  }
  .cp__page, .cp__view { width: 40px; height: 40px; }
  .cp__caret { width: 44px; }
  .colsel__btn { min-width: 40px; min-height: 40px; }
  .cp__cf-ico { width: 36px; height: 36px; }
  .cf-combo__opt { min-height: 40px; display: flex; align-items: center; }
  .cf-combo__btn, .cp__cf-row .select, .cp__cf-row .input { min-height: 38px; }
  /* The row-select checkbox is the one an operator misses most often. */
  .col-select__hit { display: flex; align-items: center; justify-content: center;
                     min-width: 40px; min-height: 40px; margin: -8px; }
  /* A 13px checkbox is a 13px checkbox however big the label beside it is —
     the whole label is the target, and the box itself grows with it. */
  .cp__chk { min-height: 40px; }
  .colsel__item { min-height: 40px; display: flex; align-items: center; }
}

/* The rule builder on a phone: one field per line instead of five columns
   squeezed into 340px, with the two icons on their own row at the end. */
@media (max-width: 620px) {
  .cp__cf-row {
    grid-template-columns: 1fr 1fr; gap: 6px;
    padding-bottom: 8px; margin-bottom: 8px;
    border-bottom: 1px solid var(--border);
  }
  .cp__cf-row .cf-combo, .cp__cf-row > .cp__cf-val, .cp__cf-row .dt-field {
    grid-column: 1 / -1;
  }
  .cp__cf-rows .cp__cf-row:last-child { border-bottom: 0; }
  .cp__cf-ico { justify-self: end; }
}

/* The timeline's Details column: a phrase, in the body colour, not a dimmed
   blob of JSON. Dimming it said "this is secondary" about the one column that
   explains what actually happened. */
.tl-detail { color: var(--fg); }
/* A one-line note under the control panel (the Data Log's collection age).
   Inside the fixed shell, so it is a row of the flex column and never steals
   the list's scroller. */
.tl-note { padding: 8px 18px; border-bottom: 1px solid var(--border);
           background: var(--surface); }
/* The two period totals, above the timeline's rows and inside the fixed
   shell: a plain row that wraps on a phone rather than a card, because the
   card--full shell gives its only scroller to the list. */


/* ── Mobile: sizes, zoom, and which way an arrow points ─────────────────────
   All four of these were reported from a real phone, which is the only place
   any of them shows up. */

/* iOS ZOOMS THE PAGE when a focused input's font-size is under 16px. It is not
   a preference and there is no way to opt out except by being 16px — which is
   why the whole layout lurched sideways the moment the operator tapped the
   page-size box or a rule's value. Every field that can take focus on a touch
   device is 16px; the visual size is held by padding and height instead, so
   nothing looks bigger. */
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  .cp__cf-row .select,
  .cp__cf-row .input,
  .cp__cf-val,
  .cf-combo__q,
  .cf-combo__btn,
  .cp__input,
  .cp__pager-in,
  .cp__save-form .input,
  .cp__cf-joinsel,
  select.select,
  input[type="text"], input[type="search"], input[type="number"],
  input[type="date"], input[type="datetime-local"], input[type="password"] {
    font-size: 16px;
  }
  /* Kept compact by geometry rather than by type size. */
  .cp__cf-row .select, .cp__cf-row .input { padding: 4px 6px; }
  .cp__pager-in { width: 6ch; }
}

/* The tick must fit its box. The column chooser drew a 28px box around a
   14px glyph, so the check floated in the middle of an empty square; the row
   checkbox was sized for a finger and looked like a button. Both are set
   explicitly rather than left to the browser's default, which differs per
   platform and is what let them drift apart. */
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  /* The row selector: a finger-sized TARGET around the box. The box's own
     size is --chk, set once above, so this cannot drift from the mark. */
  .col-select__hit { min-width: 40px; min-height: 40px; }
}
/* Everywhere, not only on touch: one size, so the glyph and the box agree. */
.colsel__menu input[type="checkbox"] { accent-color: var(--primary); }
.col-select__hit input[type="checkbox"], #sel-all { accent-color: var(--primary); }

/* THE PAGER ARROWS IN RTL. `previous` and `next` are directions, not glyphs:
   in Arabic the page before this one is to the RIGHT. The icons are drawn
   left/right, so they are mirrored — and only the icon is mirrored, never the
   button, or the two would swap places as well and land back where they
   started. */
[dir="rtl"] .cp__page svg { transform: scaleX(-1); }

/* ── The user page's action row on a phone ────────────────────────────
   Seven buttons wrapping into three ragged lines pushed hard against one
   edge, each a different width, with a lot of empty gutter beside them. On a
   390px screen there is no width to waste: they share it in a grid instead,
   so every row is full-bleed and every button is the same size and easy to
   aim at. Wallet keeps a row of its own — it opens a menu, and a menu opener
   that shares a row with an action is the one people mis-tap. */
@media (max-width: 860px) {
  .page-header__actions {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 6px;
    width: 100%;
  }
  .page-header__actions > * {
    width: 100%;
    justify-content: center;
    min-height: 40px;
    white-space: nowrap;
  }
  /* Anything that is a whole control rather than one button of several. */
  .page-header__actions > form,
  .page-header__actions > .wallet-dd,
  .page-header__actions > details { grid-column: 1 / -1; }
  .page-header__actions > form > .btn,
  .page-header__actions > details > summary { width: 100%; justify-content: center; }
  /* A segmented control is NOT one button: it is 4-8 of them welded together,
     and a grid cell one third of a 390px screen wide cannot hold "Everything"
     let alone six labels. Squeezed into that cell the labels ran into each
     other and were clipped mid-word ("rythoonePlanPlanimitcou" on the user
     Timeline, reported 2026-09-16). Give the group the whole row and let its
     buttons wrap as separate chips -- the same recipe .toolbar and
     .card__header already use for their own groups. */
  .page-header__actions > .btn-group {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    width: 100%;
    margin-inline-start: 0 !important;
  }
  .page-header__actions > .btn-group > .btn,
  .page-header__actions > .btn-group > form > .btn {
    /* 1 0 auto: grow to share the row, never shrink below the label. */
    flex: 1 0 auto;
    width: auto;
    border-radius: 6px !important;
    border-width: 1px !important;
  }
}
@media (max-width: 420px) {
  .page-header__actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ── Phones scroll like phones ──────────────────────────────────────────────
   The fixed app shell (topbar + panel + one bounded scroller) is right on a
   desktop: the header stays, the table scrolls under it, nothing moves that
   the pointer is aiming at. On a phone it is wrong, and three separate
   complaints were all this one thing:

     * "touching the table moves it around" — with the page frozen, EVERY
       touch went to the table's scroller, in both axes at once, so a thumb
       that meant to scroll down also slid the columns sideways.
     * "I cannot scroll to the end of the column control" — the menu is
       absolutely positioned inside a box with overflow:hidden, so its tail
       was simply cut off.
     * the footer and the page below the table were unreachable.

   Above 860px the shell stays. Below it the page scrolls the way every other
   page on the phone does, and only the table scrolls sideways. */
@media (max-width: 860px) {
  .app:has(.card--full) { height: auto; overflow: visible; }
  /* `min-height` back with it. The desktop shell rule above sets
     `min-height: 0` (it wants the column to be exactly the viewport, not at
     least it), and this block only ever restored `height: auto` — so on a
     phone the column collapsed to its content and a SHORT list left the footer
     stranded halfway up the screen with the page background under it. Nobody
     saw it on Users because that list is always longer than the screen;
     an empty Live Sessions is what showed it.

     100dvh, not 100vh: on a phone the URL bar collapses as you scroll, and vh
     is frozen at the tall value — which would leave the footer just off the
     bottom instead of on it. */
  .app:has(.card--full) .main { height: auto; min-height: 100vh; min-height: 100dvh; }
  .app:has(.card--full) .content { display: block; overflow: visible; }
  .card--full { display: block; }
  .card--full > [data-list-root] {
    display: block; overflow: visible; height: auto;
  }
  /* Only the table itself scrolls, and only sideways. */
  .card--full .table-wrap {
    overflow-x: auto; overflow-y: visible;
    -webkit-overflow-scrolling: touch;
  }
  /* THE HEADER DOES NOT STICK ON A PHONE, and saying so is the fix.
     It used to claim it stuck "to the page instead, under the control panel"
     via `top: var(--cp-h)`. It never did, and the offset actively broke the
     table.

     `.table-wrap` above asks for `overflow-x: auto; overflow-y: visible`. CSS
     does not allow that pair: when one axis is not `visible`, the other's
     used value becomes `auto` (CSS Overflow 3 §3). So the wrap is a scroll
     container in BOTH axes, and a sticky thead inside it resolves against
     THAT scrollport, not the page. Two things followed, both reported from a
     user's phone on 5.0.1:
       * the header never stuck — it scrolled away with the rows;
       * worse, `top: 151px` shoved it 151px DOWN inside its own scrollport,
         about three 47px rows, so it painted over rows 3 and 4 while leaving
         a blank gap where it belongs. That is the "header is not in place"
         in the screenshot.
     Making it static costs nothing that worked, and puts the header back on
     top of its own table. A genuinely sticky mobile header needs the wrap to
     become the bounded VERTICAL scroller too, which is a different scroll
     model from the one this breakpoint deliberately chose (the page scrolls,
     the table only pans sideways). */
  .app:has(.card--full) .cp { position: sticky; top: 0; }
  .table--dense thead th { position: static; top: auto; }

  /* ROOM TO READ. There is width to spare the moment the table can be panned,
     so cells stop being squeezed into two-line stacks. */
  .table--dense th, .table--dense td { white-space: nowrap; }
  .table--dense td { padding-inline: 10px; }
  .table--dense { min-width: max-content; }
}

/* The column chooser: a tick that fits its box, and a list that can be
   scrolled to the end. */
.colsel__menu {
  max-height: min(60vh, 420px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
.colsel__menu input[type="checkbox"] {
  width: 17px; height: 17px; flex: 0 0 17px; margin: 0;
}
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  .colsel__menu input[type="checkbox"] { width: 19px; height: 19px; flex-basis: 19px; }
  .colsel__menu { max-height: min(55vh, 380px); }
}

/* The footer on a phone: one line, both halves on it, out of the way. */
@media (max-width: 860px) {
  .samm-footer {
    display: flex; align-items: center; justify-content: space-between;
    gap: 8px; padding: 4px 10px; font-size: 10.5px; line-height: 1.2;
    flex-wrap: nowrap; white-space: nowrap; overflow: hidden;
  }
  .samm-footer > * { margin: 0; }
  .samm-footer img, .samm-footer svg { height: 14px; width: auto; }
  .samm-footer :is(br) { display: none; }
}

/* The health page's reason column: prose needs a line to live on, not a
   six-word-tall column. It reads on one line and the table pans. */
@media (max-width: 860px) {
  .table td, .table th { white-space: nowrap; }
  .table td.wrap, .table td[data-wrap] { white-space: normal; min-width: 320px; }
}

/* ── One answer for checkbox size ───────────────────────────────────────────
   Two requirements that look contradictory and are not:

     * ui_sweep requires a tap target of at least 24px AT PHONE WIDTH, and it
       measures the INPUT itself, not the label around it.
     * on a pointer-precise screen a 24px box is simply too big -- it reads as
       a button sitting in every row rather than as a checkbox.

   Both are satisfied by drawing the control instead of stretching the
   browser's, and by making the size ONE NUMBER that the mark is derived from.
   `--chk` is 18px on a mouse and 24px on a finger; every dimension below is a
   fraction of it, so the tick can never float in an oversized box again the
   way it did when the box grew and the platform glyph did not.
   appearance:none is what makes the glyph ours; everything below is the mark. */
.colsel__menu input[type="checkbox"],
.cp__chk input[type="checkbox"],
.col-select__hit input[type="checkbox"],
#sel-all,
.col-select input[type="checkbox"] {
  --chk: 18px;
  appearance: none; -webkit-appearance: none;
  width: var(--chk); height: var(--chk);
  min-width: var(--chk); min-height: var(--chk);
  flex: 0 0 var(--chk); margin: 0; padding: 0; position: relative; cursor: pointer;
  border: 1.5px solid var(--border-strong);
  border-radius: calc(var(--chk) * 0.25);
  background: var(--surface);
  transition: background-color .12s ease, border-color .12s ease;
}
/* A finger aims at the box it can see, not at the invisible label around it,
   so on touch the box itself grows -- and the mark grows with it. */
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  .colsel__menu input[type="checkbox"],
  .cp__chk input[type="checkbox"],
  .col-select__hit input[type="checkbox"],
  #sel-all,
  .col-select input[type="checkbox"] { --chk: 24px; }
}
.colsel__menu input[type="checkbox"]:checked,
.cp__chk input[type="checkbox"]:checked,
.col-select__hit input[type="checkbox"]:checked,
#sel-all:checked,
.col-select input[type="checkbox"]:checked {
  background: var(--primary); border-color: var(--primary);
}
/* The mark: proportioned to the box rather than to the platform's default. */
.colsel__menu input[type="checkbox"]:checked::after,
.cp__chk input[type="checkbox"]:checked::after,
.col-select__hit input[type="checkbox"]:checked::after,
#sel-all:checked::after,
.col-select input[type="checkbox"]:checked::after {
  content: ""; position: absolute;
  left: calc(var(--chk) * 0.333); top: calc(var(--chk) * 0.146);
  width: calc(var(--chk) * 0.25); height: calc(var(--chk) * 0.5);
  border: solid #fff;
  border-width: 0 calc(var(--chk) * 0.104) calc(var(--chk) * 0.104) 0;
  transform: rotate(45deg);
}
/* Select-all when only some rows are ticked. */
#sel-all:indeterminate {
  background: var(--primary); border-color: var(--primary);
}
#sel-all:indeterminate::after {
  content: ""; position: absolute;
  left: calc(var(--chk) * 0.208); top: calc(50% - var(--chk) * 0.052);
  width: calc(var(--chk) * 0.458); height: 0;
  border-top: calc(var(--chk) * 0.104) solid #fff; transform: none;
}
.colsel__menu input[type="checkbox"]:focus-visible,
.col-select__hit input[type="checkbox"]:focus-visible,
#sel-all:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* The row selector's hit area still exceeds the box on a finger. */
@media (hover: none) and (pointer: coarse), (max-width: 860px) {
  .col-select__hit { min-width: 40px; min-height: 40px;
                     display: flex; align-items: center; justify-content: center; }
}

/* The footer is ONE line on a phone: brand on one side, copyright on the
   other, nothing stacked. .samm-footer__inner is the flex row -- styling the
   <footer> itself did nothing, because the inner div was the one wrapping. */
@media (max-width: 860px) {
  .samm-footer { padding: 0; }
  .samm-footer__inner {
    display: flex; flex-direction: row; flex-wrap: nowrap;
    align-items: center; justify-content: space-between;
    gap: 8px; padding: 5px 10px; min-height: 0;
  }
  .samm-footer__inner > * { margin: 0; }
  .samm-footer__brand { flex: 0 0 auto; gap: 5px; }
  .samm-footer__brand-info { flex-direction: row; gap: 4px; align-items: baseline; }
  .samm-footer__brand-mark img { width: 15px; height: 15px; }
  .samm-footer__brand-name { font-size: 11px; }
  .samm-footer__brand-by, .samm-footer__copy {
    font-size: 9.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .samm-footer__copy { flex: 1 1 auto; text-align: end; }
}

/* A hairline between a toolbar's filters and its actions. Two buttons that act
   on what the filters select belong on the same row as them — but they are a
   different kind of control, and the rule says so without a second row. */
.toolbar__sep {
  width: 1px; align-self: stretch; margin-inline: 4px;
  background: var(--border); flex: 0 0 1px;
}
@media (max-width: 700px) { .toolbar__sep { display: none; } }

/* ── A count that lives INSIDE a button ─────────────────────────────────────
   The generic .badge is built for a table cell: its own pale fill, its own
   border, its own dark text. Dropped into a solid green button it reads as a
   sticker someone stuck on afterwards — the colours belong to neither the
   button nor the page.

   A count on a button is part of the button, so it takes its colour FROM the
   button: `currentColor` is white on a solid one and red on an outlined one,
   so a single rule dresses both correctly and any future variant for free.
   No border — the fill already separates it — and tabular figures with a
   min-width so 7 and 70 occupy the same box and the label never shifts. */
.btn > .badge,
.btn > .badge--rect {
  background: color-mix(in srgb, currentColor 16%, transparent);
  color: inherit;
  border: 0;
  border-radius: 999px;
  padding: 1px 7px;
  margin-inline-start: 2px;
  font-size: 11px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  min-width: 20px;
  justify-content: center;
  line-height: 1.5;
}
/* On a filled button the tint would be washed out against the fill; a touch
   more of it keeps the count legible without becoming a second button. */
.btn--primary > .badge, .btn--accent > .badge,
.btn--danger:not(.btn--ghost) > .badge, .btn--warn > .badge, .btn--info > .badge {
  background: color-mix(in srgb, currentColor 22%, transparent);
}
.btn:disabled > .badge { opacity: .65; }

/* ── Export dialog: the All Data column list ───────────────────────────────
 * A two/three-column grid rather than inline wrapping. Twenty-four checkboxes
 * reflowed as running text are legible only by accident: the eye cannot scan a
 * list it has to re-find on every line, and the operator is being asked to make
 * a deliberate choice about what leaves the building. Capped in height with its
 * own scroll so a long catalogue cannot push the footer off a laptop screen.
 */
.export-cols {
  display: grid;
  /* FOUR uniform columns, filled row by row. `minmax(0, 1fr)` rather than
   * `1fr`: a plain 1fr takes its floor from the content, so one long label
   * ("Uptime used (seconds)") would widen its column and leave the other three
   * narrower -- the grid has to be even for the eye to scan it as a grid.
   * Truncation is handled on the row instead. */
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 2px 14px;
  max-height: 42vh;
  overflow-y: auto;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-soft, transparent);
}
.export-cols__row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 2px;
  font-size: 13px;
  line-height: 1.3;
  cursor: pointer;
  border-radius: 4px;
  min-width: 0;
}
.export-cols__row:hover { background: var(--bg-hover); }
.export-cols__row > span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.export-cols__row input { flex: none; }
/* The sensitive badge must survive the ellipsis above it. */
.export-cols__row .badge { flex: none; }

/* Four columns need ~800px. Step down rather than let the row overflow: the
 * page must never scroll sideways, and a clipped checkbox is unusable. */
@media (max-width: 820px) {
  .export-cols { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
  .export-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 430px) {
  .export-cols { grid-template-columns: minmax(0, 1fr); max-height: 38vh; }
}

/* ── The admin tree (5.1.0) ──────────────────────────────────────────────
   IT IS THE SAME TABLE. `.admin-tree` is a `.table`, so the font, the header,
   the cell padding and the striping are one definition shared with the list
   and cannot drift apart. Everything below is ONLY what a tree needs that a
   flat list does not. */

/* No rule under a row. A border on every cell chops the family lines into a
   ladder of separate ticks; without it each vertical guide runs unbroken from
   a parent down to its last child, which is the whole point of drawing them.
   The striping goes too, for the same reason — a family must read as one
   block, not as alternating bands. */
.admin-tree tbody td { border-bottom: 0; padding-block: 7px; }
/* ONE HOVER, THE SAME ON EVERY ROW.
   `.table` paints striping and hover on the CELL, and the "this is you" row
   carried its own tint at equal specificity — so that row did not react to
   hover at all while its neighbours did, and the highlight looked different
   depending on which row the cursor was over. All three now resolve in one
   place, with hover last and specific enough to win on every row. */
.admin-tree tbody tr:not(.o-grp):nth-child(odd) td { background: transparent; }
/* WHAT A ROW IS, SAID BY THE ROW ITSELF. A badge on every row spent a whole
   column to carry one bit and had to be read line by line. Three tones
   instead — blue for a superadmin, grey for a branch, plain for staff — and
   the difference is legible in the corner of the eye rather than by scanning.
   ORDER IS THE CASCADE: a superadmin is always a branch too, so its rule is
   written after `.is-branch` and wins on the rows that carry both. `.is-me`
   comes first for the same reason in reverse — it must NOT beat the blue on
   the superadmin who is usually the one logged in. */
.admin-tree tbody tr.is-me:not(.o-grp) td {
  background: color-mix(in srgb, var(--primary) 7%, transparent);
}
.admin-tree tbody tr.is-branch:not(.o-grp) td {
  background: color-mix(in srgb, var(--fg) 7%, transparent);
}
.admin-tree tbody tr.is-super:not(.o-grp) td {
  background: color-mix(in srgb, var(--primary) 11%, transparent);
}
.admin-tree tbody tr:not(.o-grp):hover td,
.admin-tree tbody tr.is-me:not(.o-grp):hover td { background: var(--bg-hover); }
.admin-tree tbody tr[hidden] { display: none; }

/* The Admin cell: lines, caret, name. The only cell that differs.
   The guides are an OVERLAY, not inline boxes. A percentage height does not
   resolve against a table cell, so an inline rail collapses to nothing and
   only the elbow's little dash survives — which is exactly what it did. As an
   absolutely-positioned layer it spans the cell's full height, consecutive
   rows' guides meet, and one family reads as a single unbroken line. */
/* `.table td` sets padding with the SHORTHAND, which outranks a bare
   `.admin-tree__cell` rule — so the indent padding never applied and the
   guide lines ran straight through the names. Matched on the table too, to
   win the cascade honestly rather than with !important. */
.admin-tree__cell { white-space: nowrap; position: relative; }
.admin-tree tbody td.admin-tree__cell {
  padding-inline-start: calc(14px + var(--ind, 0px));
}
.admin-tree__cell > *:not(.admin-tree__indent) { vertical-align: middle; }
.admin-tree__indent {
  position: absolute; inset-block: 0; inset-inline-start: 14px;
  display: flex; align-items: stretch; pointer-events: none;
}
.admin-tree__g, .admin-tree__elbow { position: relative; flex: 0 0 20px; }
/* ── HOW DEPTH IS DRAWN — one variant class, so it can be swapped ────────
   The operator is trying these one at a time. Each design is a single class
   on the table; nothing else in the page changes between them.

   A · `--lines`  one continuous rule per family, no ticks
   B · `--bands`  no rules at all; a tinted gutter whose WIDTH is the depth */

/* A ── lines. Only the child's own parent draws, the full height of every one
   of its children including the last. The full tree convention — a rule per
   ancestor plus a tick into each child — drew four marks to say one thing.  */
.admin-tree--lines .admin-tree__elbow::before {
  content: ""; position: absolute; inset-inline-start: 9px;
  top: 0; bottom: 0; width: 1px; background: var(--border);
}

/* B ── bands. No line anywhere. The indent itself is tinted, so depth is read
   as WIDTH rather than as a count of rules, and the edge where the tint stops
   is the only mark on the row. Stacked rows make it one continuous column
   with nothing to align or break. */
.admin-tree--bands .admin-tree__indent {
  background: color-mix(in srgb, var(--primary) 5%, transparent);
  border-inline-end: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
}

/* C ── one rail for the whole nested block, the way a quote is marked.
   A and B both scale their mark WITH the depth — a rule per level, or a
   gutter that widens. This one does not: every descendant, at any depth,
   shares ONE 2px rail at the same x, and depth is carried by the indentation
   alone. So the page has exactly one vertical mark no matter how deep the
   organisation goes, and what it says is "everything against this rail
   belongs to the branch above it". */
.admin-tree--edge .admin-tree__indent { border: 0; }
.admin-tree--edge .admin-tree__row[data-level="0"] .admin-tree__indent { display: none; }
.admin-tree--edge .admin-tree__indent::before {
  content: ""; position: absolute; inset-block: 0; inset-inline-start: 3px;
  width: 2px; border-radius: 1px;
  background: color-mix(in srgb, var(--primary) 28%, transparent);
}

/* Fold. A branch with ten clerks under it is a wall otherwise, and the caret
   is also the only thing on the row that says there is more underneath. */
.admin-tree__fold {
  width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px;
  background: none; color: var(--muted); cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.admin-tree__fold svg {
  width: 13px; height: 13px; transform: rotate(90deg);
  transition: transform .12s ease;
}
.admin-tree__fold[aria-expanded="false"] svg { transform: none; }
/* Collapsed, the caret points the way the text runs. */
[dir="rtl"] .admin-tree__fold[aria-expanded="false"] svg { transform: scaleX(-1); }
.admin-tree__fold:hover { background: var(--surface-2); color: var(--text); }
.admin-tree__fold.is-empty { pointer-events: none; display: inline-block; }

/* The handle. Drag with no visible grip is a feature nobody finds — but it is
   an accelerator, never the only way: every row menu carries "Move under…",
   which is what works on a phone and with a keyboard. */
.admin-tree__grip {
  color: var(--muted); cursor: grab; opacity: 0;
  display: inline-flex; align-items: center; margin-inline-start: .3rem;
}
.admin-tree__grip svg { width: 13px; height: 13px; }
.admin-tree tbody tr:hover .admin-tree__grip,
.admin-tree tbody tr:focus-within .admin-tree__grip { opacity: .75; }
/* A press-and-move on a row must START A DRAG, not select the text under
   the cursor. Without this the browser wins the gesture on a real mouse and
   the drag never begins — which looks exactly like "drag and drop not
   working" while a synthetic test still passes. */
.admin-tree tbody tr[draggable] { user-select: none; -webkit-user-select: none; }
.admin-tree tbody tr.is-dragging { opacity: .45; }
.admin-tree tbody tr.is-drop-target td {
  background: color-mix(in srgb, var(--primary) 18%, transparent);
  box-shadow: inset 0 1px 0 var(--primary), inset 0 -1px 0 var(--primary);
}

@media (max-width: 760px) {
  .admin-tree__grip { display: none; }
  .admin-tree__g, .admin-tree__elbow { flex-basis: 13px; width: 13px; }
  .admin-tree__g.is-line::before, .admin-tree__elbow::before,
  .admin-tree__elbow::after { inset-inline-start: 6px; }
}
}
  /* Separators drawn, not typed, so nothing new needs translating. */
  .admin-tree__row > :nth-child(4)::before,
  .admin-tree__row > :nth-child(7)::before,
  .admin-tree__row > :nth-child(8)::before { content: "·"; padding-inline: .4rem; }
  .admin-tree__when { padding-inline-start: 0; }
  .admin-tree__grip { display: none; }
  .admin-tree__g, .admin-tree__elbow { flex-basis: 13px; }
  .admin-tree__g.is-line::before, .admin-tree__elbow::before,
  .admin-tree__elbow::after { inset-inline-start: 6px; }
}
}
}

/* A device hidden from the diagram stays listed on Map Elements, dimmed. */
.is-hidden-row > td:not([data-col="select"]):not([data-col="act"]) { opacity: .55; }

/* A settings switch names its own state: "On" beside a checked switch, "Off" beside an
   unchecked one (settings.html renders both words). */
.toggle-row .toggle-row__off { display: none; }
.toggle-row:has(input[type="checkbox"]:not(:checked)) .toggle-row__on { display: none; }
.toggle-row:has(input[type="checkbox"]:not(:checked)) .toggle-row__off { display: inline; }

/* Manager → Actions (bulk router jobs, 2026-10-06) */
.mgr-bulk { position: relative; }
.mgr-bulk > summary { list-style: none; }
.mgr-bulk > summary::-webkit-details-marker { display: none; }
.mgr-bulk .cp__item { width: 100%; border: 0; background: none; justify-content: flex-start; text-align: start; font: inherit; }
.mgr-bulk .cp__item svg { width: 14px; height: 14px; color: var(--fg-muted); flex-shrink: 0; }

.mgr-bulk .cp__item--danger, .mgr-bulk .cp__item--danger svg { color: var(--danger); }
.mgr-bulk .cp__item--warn, .mgr-bulk .cp__item--warn svg { color: var(--warn, #d97706); }

/* VPN client menu: Install Link is the action used most — blue so it reads first (2026-10-06). */
.ctx-menu__item--primary { background: var(--primary); color: #fff; border-radius: 6px; }
.ctx-menu__item--primary svg { color: #fff; }
.ctx-menu__item--primary:hover { background: var(--primary); filter: brightness(1.08); }

/* ── Grouped app picker, copied from SOVA's policy editor (5.3.4) ─────────── */
.app-logo { width: 18px; height: 18px; border-radius: 4px; object-fit: contain; flex-shrink: 0; }
.app-pick { margin-top: 8px; max-height: 320px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; padding: 4px; }
.app-pick__item { display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 8px; cursor: pointer; min-width: 0; }
.app-pick__item:hover { background: var(--bg-hover); }
.app-pick__item:has(input:checked) { background: color-mix(in srgb, var(--primary) 12%, transparent); }
.app-pick__name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-pick__item[hidden] { display: none; }
.app-pick--grouped { display: block; }
.app-pick__group + .app-pick__group { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; }
.app-pick__ghead { display: flex; align-items: center; gap: 8px; padding: 6px 9px; font-weight: 600; font-size: 13px; cursor: pointer; }
.app-pick__gbody { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 2px; }
.app-pick__group[hidden] { display: none; }
.switch--sm { width: 30px; height: 17px; }
.switch--sm .switch__track::before { width: 13px; height: 13px; }
.switch--sm input:checked + .switch__track::before { transform: translateX(13px); }

/* ── Dashboard: skeletons while the blocks load after first paint ─────────────
   The page is only a frame; each KPI tile and block fills in from its own small
   request. A skeleton keeps the grid the same size it will be, so nothing jumps
   when the data lands. */
.skel { display: block; border-radius: 6px; background: var(--surface-2);
  position: relative; overflow: hidden; }
.skel::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--bg-hover), transparent);
  animation: skel-sweep 1.4s ease-in-out infinite; }
@keyframes skel-sweep { to { transform: translateX(100%); } }
.skel--num { display: inline-block; width: 3.2ch; height: 1em; vertical-align: -0.1em; }
.skel--line { height: 12px; margin: 0 0 12px; }
.skel--short { width: 55%; }
.dash-skel .card__body { padding-top: 6px; }
.dash-retry { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  color: var(--fg-dim); font-size: 13px; padding: 14px 16px; }
@media (prefers-reduced-motion: reduce) { .skel::after { animation: none; } }

/* Config delivery progress bar (same as SOVA's) */
.dl-bar { display: inline-block; vertical-align: middle; width: 90px; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
.dl-bar > span { display: block; height: 100%; background: var(--accent, #16a34a); }

/* Dashboard: KPI tiles and blocks fade up the moment their values arrive. */
.dash-fill-in { animation: fade-up .35s ease-out both; }
@media (prefers-reduced-motion: reduce) { .dash-fill-in { animation: none; } }

/* The exact text an admin must type to confirm (rename, bulk delete): bold,
   boxed, spaces kept, so it is never missed or misread. */
.confirm-token{display:inline-block;font-family:var(--font-mono,monospace);font-weight:700;
  color:var(--fg);background:var(--bg-2);border:1px solid var(--border);border-radius:5px;
  padding:0 6px;margin:0 2px;white-space:pre;}

/* Wizard push result (in-page and /wizard/apply page): long RouterOS command lines wrap instead of running off
   the screen, and on a phone each command is one stacked block: status, command, detail. */
.wiz-result-tbl .wiz-result__st { width: 90px; white-space: nowrap; }
.wiz-result-tbl .wiz-result__cmd { font-size: 12px; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
.wiz-result-tbl .wiz-result__detail { white-space: normal; overflow-wrap: anywhere; }
.wiz-result-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
@media (max-width: 640px) {
  .wiz-result-tbl thead { display: none; }
  .wiz-result-tbl, .wiz-result-tbl tbody, .wiz-result-tbl tr, .wiz-result-tbl td { display: block; width: 100%; }
  .wiz-result-tbl tr { padding: 10px 0; border-bottom: 1px solid var(--border); }
  .wiz-result-tbl td { border: 0; padding: 2px 0; background: transparent !important; }
  .wiz-result-tbl .wiz-result__st { width: auto; }
  .wiz-result-tbl .wiz-result__detail:empty { display: none; }
  .wiz-result-actions .btn { flex: 1 1 auto; justify-content: center; }
}
