:root {
	--bg: #131722;
	--bg-elevated: #1a1f2e;
	--surface: #1e2436;
	--surface-alt: #262d43;
	--surface-hover: #2c3450;
	--border: #3a4460;
	--border-light: #4a5578;
	--text: #f1f3fa;
	--text-muted: #aab2cc;
	--text-faint: #757fa0;

	--accent: #6366f1;
	--accent-2: #818cf8;
	--accent-3: #22d3ee;
	--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #22d3ee 130%);
	--accent-glow: 0 0 0 1px rgba(99, 102, 241, 0.4), 0 4px 20px rgba(99, 102, 241, 0.25);

	/* Sidebar gradient stops -- broken out as variables (previously
	   hardcoded directly in `.sidebar`'s background, see below) purely so
	   the theme system's [data-theme="..."] rules further down can swap
	   them per theme. These 3 defaults are identical to what `.sidebar`
	   used to hardcode, so leaving the data-theme attribute absent (or
	   setting it to "constellation") stays pixel-identical. */
	--side-1: #171c2c;
	--side-2: #1b2136;
	--side-3: #201c38;

	--danger: #fb7185;
	--danger-strong: #f43f5e;
	--danger-bg: rgba(244, 63, 94, 0.14);
	--danger-text: #fda4af;
	--success: #34d399;
	--success-strong: #10b981;
	--success-bg: rgba(16, 185, 129, 0.14);
	--success-text: #6ee7b7;
	--warning: #fbbf24;
	--warning-bg: rgba(251, 191, 36, 0.14);
	--warning-text: #fcd34d;

	--radius: 10px;
	--radius-lg: 16px;
	--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.28);
	--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.55);
}

/* ---------------------------------------------------------------------
   Theme palettes -- driven by a `data-theme="<id>"` attribute set on
   <html> in base.html/login.html (see app_settings.py's active_theme()
   Jinja global). Only the accent/brand palette and sidebar gradient
   change per theme -- functional/status colors (--danger/--success/
   --warning, status pills, chart colors) are deliberately untouched by
   every one of these blocks.

   "constellation" is spelled out explicitly (rather than just relying on
   the attribute being absent) so an admin who's explicitly pinned it in
   Settings gets byte-identical values to the :root defaults above -- a
   true no-op either way, per the approved design.

   Values are the exact 6 palettes approved in the theme preview; each
   theme's accent-glow rgba is that theme's own `accent` hex translated to
   rgb(), same construction as the default glow above (#6366f1 -> 99,102,241).

   Each theme ALSO now tints its own --bg/--surface*/--border* neutrals
   toward its accent hue (previously only the accent/sidebar-gradient
   varied and every theme shared the exact same navy chrome underneath --
   which is why 6 "distinct" themes read as one theme with 6 different
   link colors). Still deliberately dark across the board (this is a
   working admin UI, not a showcase) and --text/--text-muted/--text-faint
   and every status color (--danger/--success/--warning) stay constant per
   this file's own long-standing rule above -- only the neutral backdrop
   shifts, so contrast/legibility is unaffected, just its hue. */
:root, [data-theme="constellation"] {
	--bg: #131722;
	--bg-elevated: #1a1f2e;
	--surface: #1e2436;
	--surface-alt: #262d43;
	--surface-hover: #2c3450;
	--border: #3a4460;
	--border-light: #4a5578;
	--accent: #6366f1;
	--accent-2: #818cf8;
	--accent-3: #22d3ee;
	--accent-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #22d3ee 130%);
	--accent-glow: 0 0 0 1px rgba(99, 102, 241, 0.4), 0 4px 20px rgba(99, 102, 241, 0.25);
	--side-1: #171c2c;
	--side-2: #1b2136;
	--side-3: #201c38;
}
[data-theme="contour"] {
	--bg: #0c1a1e;
	--bg-elevated: #112329;
	--surface: #142d33;
	--surface-alt: #1a373e;
	--surface-hover: #1f4149;
	--border: #2c4d54;
	--border-light: #3c636b;
	--accent: #0ea5e9;
	--accent-2: #14b8a6;
	--accent-3: #5eead4;
	--accent-gradient: linear-gradient(135deg, #0ea5e9 0%, #14b8a6 55%, #5eead4 130%);
	--accent-glow: 0 0 0 1px rgba(14, 165, 233, 0.4), 0 4px 20px rgba(14, 165, 233, 0.25);
	--side-1: #0c181d;
	--side-2: #0d2026;
	--side-3: #0b211d;
}
[data-theme="ingress"] {
	--bg: #04060d;
	--bg-elevated: #080c17;
	--surface: #0c1220;
	--surface-alt: #101a2c;
	--surface-hover: #142236;
	--border: #202e4a;
	--border-light: #2d3f61;
	--accent: #1d4ed8;
	--accent-2: #0ea5e9;
	--accent-3: #67e8f9;
	--accent-gradient: linear-gradient(135deg, #1d4ed8 0%, #0ea5e9 55%, #67e8f9 130%);
	--accent-glow: 0 0 0 1px rgba(29, 78, 216, 0.4), 0 4px 20px rgba(29, 78, 216, 0.25);
	--side-1: #060911;
	--side-2: #070d18;
	--side-3: #061021;
}
[data-theme="cipher"] {
	--bg: #08120e;
	--bg-elevated: #0d1a15;
	--surface: #12211b;
	--surface-alt: #182a22;
	--surface-hover: #1d3329;
	--border: #2a473c;
	--border-light: #395a4c;
	--accent: #059669;
	--accent-2: #10b981;
	--accent-3: #34d399;
	--accent-gradient: linear-gradient(135deg, #059669 0%, #10b981 55%, #34d399 130%);
	--accent-glow: 0 0 0 1px rgba(5, 150, 105, 0.4), 0 4px 20px rgba(5, 150, 105, 0.25);
	--side-1: #08130f;
	--side-2: #0a1a14;
	--side-3: #071a12;
}
[data-theme="perimeter"] {
	--bg: #180f06;
	--bg-elevated: #21160a;
	--surface: #2b1e0e;
	--surface-alt: #352713;
	--surface-hover: #403018;
	--border: #573f1c;
	--border-light: #6e5227;
	--accent: #d97706;
	--accent-2: #f59e0b;
	--accent-3: #fbbf24;
	--accent-gradient: linear-gradient(135deg, #d97706 0%, #f59e0b 55%, #fbbf24 130%);
	--accent-glow: 0 0 0 1px rgba(217, 119, 6, 0.4), 0 4px 20px rgba(217, 119, 6, 0.25);
	--side-1: #170f07;
	--side-2: #1d1407;
	--side-3: #180f05;
}
[data-theme="horizon"] {
	--bg: #140a1c;
	--bg-elevated: #1c1027;
	--surface: #251533;
	--surface-alt: #2e1a3f;
	--surface-hover: #38204c;
	--border: #4a2c62;
	--border-light: #5f3b7c;
	--accent: #7c3aed;
	--accent-2: #db2777;
	--accent-3: #f472b6;
	--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #db2777 55%, #f472b6 130%);
	--accent-glow: 0 0 0 1px rgba(124, 58, 237, 0.4), 0 4px 20px rgba(124, 58, 237, 0.25);
	--side-1: #130b1c;
	--side-2: #170d22;
	--side-3: #140a1e;
}
/* The 4 themes below were added 2026-08-19 to bring the total to 10 --
   same "tint every neutral toward the accent hue" rule as the original 6
   above, each deliberately picked a hue family none of those 6 already
   used (crimson / fuchsia-indigo / lime / slate-cyan). */
[data-theme="lattice"] {
	--bg: #1a0a0a;
	--bg-elevated: #240e0e;
	--surface: #2e1313;
	--surface-alt: #3a1717;
	--surface-hover: #461c1c;
	--border: #5c2323;
	--border-light: #74302f;
	--accent: #dc2626;
	--accent-2: #ef4444;
	--accent-3: #fca5a5;
	--accent-gradient: linear-gradient(135deg, #dc2626 0%, #ef4444 55%, #fca5a5 130%);
	--accent-glow: 0 0 0 1px rgba(220, 38, 38, 0.4), 0 4px 20px rgba(220, 38, 38, 0.25);
	--side-1: #190909;
	--side-2: #200c0c;
	--side-3: #1a0808;
}
[data-theme="flux"] {
	--bg: #150a1e;
	--bg-elevated: #1d0e29;
	--surface: #251334;
	--surface-alt: #2f1841;
	--surface-hover: #3a1e4f;
	--border: #4c2a66;
	--border-light: #61397f;
	--accent: #c026d3;
	--accent-2: #818cf8;
	--accent-3: #f0abfc;
	--accent-gradient: linear-gradient(135deg, #c026d3 0%, #a855f7 55%, #818cf8 130%);
	--accent-glow: 0 0 0 1px rgba(192, 38, 211, 0.4), 0 4px 20px rgba(192, 38, 211, 0.25);
	--side-1: #14091c;
	--side-2: #180b22;
	--side-3: #150a1e;
}
[data-theme="pulse"] {
	--bg: #0c1206;
	--bg-elevated: #121a09;
	--surface: #17220c;
	--surface-alt: #1e2b10;
	--surface-hover: #253514;
	--border: #34481b;
	--border-light: #445d24;
	--accent: #65a30d;
	--accent-2: #84cc16;
	--accent-3: #bef264;
	--accent-gradient: linear-gradient(135deg, #65a30d 0%, #84cc16 55%, #bef264 130%);
	--accent-glow: 0 0 0 1px rgba(101, 163, 13, 0.4), 0 4px 20px rgba(101, 163, 13, 0.25);
	--side-1: #0b1106;
	--side-2: #0e1608;
	--side-3: #0c1305;
}
[data-theme="relay"] {
	--bg: #10141a;
	--bg-elevated: #161c24;
	--surface: #1c232d;
	--surface-alt: #232c38;
	--surface-hover: #2b3543;
	--border: #3a4655;
	--border-light: #4b596b;
	--accent: #64748b;
	--accent-2: #94a3b8;
	--accent-3: #67e8f9;
	--accent-gradient: linear-gradient(135deg, #64748b 0%, #94a3b8 55%, #67e8f9 130%);
	--accent-glow: 0 0 0 1px rgba(100, 116, 139, 0.4), 0 4px 20px rgba(100, 116, 139, 0.25);
	--side-1: #0f131a;
	--side-2: #131a22;
	--side-3: #101519;
}

* { box-sizing: border-box; }

body {
	margin: 0;
	font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
	background: var(--bg);
	color: var(--text);
	line-height: 1.55;
	-webkit-font-smoothing: antialiased;
}

a { color: var(--accent-2); text-decoration: none; font-weight: 600; }
a:hover { text-decoration: underline; }

/* --- Loading indicator: a clockwise-spinning ring, centered on the ------
   viewport. Non-blocking (no backdrop, pointer-events off) since it also
   fires for background refreshes, not just full-page loads. */
#page-progress {
	position: fixed;
	top: 50%; left: 50%;
	transform: translate(-50%, -50%);
	width: 44px; height: 44px;
	z-index: 999;
	opacity: 0;
	pointer-events: none;
	transition: opacity 0.2s ease;
}
#page-progress.active { opacity: 1; }
#page-progress .ring {
	display: block;
	width: 100%; height: 100%;
	border-radius: 50%;
	border: 4px solid var(--border-light);
	border-top-color: var(--accent-2);
	border-right-color: var(--accent-3);
	box-shadow: var(--shadow-lg);
	animation: page-progress-spin 0.7s linear infinite;
}
@keyframes page-progress-spin {
	to { transform: rotate(360deg); }
}

/* --- Sidebar ------------------------------------------------------------ */
body.has-sidebar { display: flex; align-items: stretch; min-height: 100vh; }

.sidebar {
	/* Widened 252px -> 276px -- reported live: cramped now that .sidebar
	   nav can grow a scrollbar of its own (every section expanded) eating
	   into the already-tight available width for link text. Widened
	   again to 325px shortly after (reported live again: "a little more
	   widened"), then narrowed back down to 300px (reported live
	   2026-08-23). Still well under a small phone's own viewport width for
	   the off-canvas mobile drawer below, which inherits this same value
	   (no separate mobile override exists). */
	width: 300px;
	flex: 0 0 300px;
	display: flex;
	flex-direction: column;
	background: linear-gradient(190deg, var(--side-1) 0%, var(--side-2) 60%, var(--side-3) 140%);
	border-right: 1px solid var(--border);
	position: sticky;
	top: 0;
	height: 100vh;
	padding: 22px 16px;
}
.sidebar .brand {
	font-weight: 800;
	font-size: 1.08rem;
	letter-spacing: -0.01em;
	color: #fff;
	display: flex;
	align-items: center;
	gap: 12px;
	padding: 4px 10px 26px;
	text-decoration: none;
	/* Anchors .sidebar-release-indicator's top-right corner placement below
	   -- the badge is positioned relative to this whole logo section, not
	   just the version text next to it. */
	position: relative;
}
.sidebar .brand:hover { color: #fff; text-decoration: none; opacity: 0.88; }
.sidebar .brand-icon {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	flex: none;
	box-sizing: border-box;
	/* Grown from 32px/20px-svg -- a little more visibility for the one
	   brand mark that anchors the whole top-left corner (reported live
	   2026-08-21: "grow the logo size in a little bit more"). Padding
	   scaled proportionally so the ring of dark background around the
	   glyph stays the same relative thickness, not just a bigger glyph
	   crammed into the old tile. */
	width: 40px;
	height: 40px;
	padding: 7px;
	border-radius: 11px;
	/* Fixed, not var(--accent-gradient) -- the real brand mark's own teal/
	   amber (see base.html's own comment) needs a constant dark backdrop
	   to read correctly; a shifting per-theme tile is what made it look
	   washed out/invisible in some themes to begin with. The glow stays
	   theme-reactive -- only the tile itself is pinned. */
	background: #0b0f1a;
	box-shadow: var(--accent-glow);
}
.sidebar .brand-icon svg { display: block; }
/* Name + version stacked, to the icon's right -- version used to live in
   the content-header (top-right), competing for space with the notif
   bell/theme switch and reading as disconnected from the app it's the
   version OF. Directly under "Cyferio" instead reads as one unit at a
   glance, same idea as a desktop app's About panel pairing the two.
   Reported live 2026-08-21. */
.sidebar .brand-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.sidebar .brand-name { line-height: 1.15; }
.sidebar .brand-version {
	font-size: 0.72rem;
	font-weight: 500;
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	letter-spacing: 0;
	/* Same var(--text-muted) the nav-section-labels above already rely on
	   against this same dark sidebar gradient -- proven legible across
	   every theme already, no new sidebar-specific color needed. Slightly
	   higher opacity than those (0.6) since a version string is denser/
	   harder to parse at a glance than an all-caps section label. */
	color: var(--text-muted);
	opacity: 0.75;
}
/* Sidebar-specific skin for .release-indicator-toggle (style.css's shared
   button styling further down is meant for the light content-header
   background, wrong colors entirely against this dark gradient) -- same
   font treatment as .brand-version above it, just a real <button>
   underneath for the click/keyboard-focus behavior static/app.js's
   initReleaseIndicator() already wires up. */
.sidebar-release-indicator-toggle {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	background: none;
	border: none;
	padding: 0;
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	font-size: 0.72rem;
	font-weight: 500;
	color: var(--text-muted);
	opacity: 0.75;
	cursor: pointer;
}
.sidebar-release-indicator-toggle:hover { opacity: 1; }
.sidebar-release-indicator-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Pinned to the top-right corner of the whole .brand/logo section
   (reported live 2026-08-23), not stacked under the version line --
   reads as a badge on the app icon/name block rather than a third line
   of the name/version label. .sidebar .brand's own position: relative
   (above) is what this anchors against. */
.sidebar-release-indicator { position: absolute; top: 4px; right: 0; }
/* The shared .release-indicator-panel is anchored `right: 0` (correct for
   its old spot near the content-header's own right edge) -- from this
   narrow, left-of-screen sidebar position that would push most of the
   380px panel off the left edge of the viewport. Anchoring from the left
   instead opens it rightward, over the main content area, which actually
   stays fully on-screen. */
.sidebar-release-indicator .release-indicator-panel { left: 0; right: auto; }
/* Detail panel opens on hover (reported live), on top of the existing
   click/keyboard toggle (which adds/removes .open via static/app.js) --
   :focus-within covers keyboard activation (Tab + Enter/Space lands
   focus on the toggle button) for anyone who can't hover, e.g. touch or
   keyboard-only navigation. The plain .release-indicator-panel selector
   elsewhere still defaults to display:none; this only overrides it while
   one of these three conditions holds. */
.sidebar-release-indicator:hover .release-indicator-panel,
.sidebar-release-indicator:focus-within .release-indicator-panel,
.sidebar-release-indicator .release-indicator-panel.open {
	display: block;
}
/* min-height: 0 overrides flexbox's default "never shrink below content
   size" for a flex child -- without it, a nav with every section expanded
   grows taller than the sidebar's fixed 100vh and pushes its own bottom
   items out of view with no way to reach them (reported live: items at
   the bottom aren't visible once everything's expanded). overflow-y: auto
   then gives that overflow somewhere to go -- an internal scrollbar on
   the nav itself, not the whole sidebar (brand/logo above stays put). */
/* scrollbar-gutter: stable reserves the scrollbar's own width up front
   (supporting browsers) instead of only claiming it once the nav
   actually overflows -- without it, every link's text reflows a few
   pixels narrower the moment a scrollbar appears (e.g. expanding one
   more section pushes it past the viewport), which reads as content
   jumping around rather than a stable layout gaining a scrollbar. */
.sidebar nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
/* Groups the sidebar into labeled sections (My Account / VPN Management /
   Monitoring / Administration) -- see base.html's own comment on why the
   grouping exists, and its Expand/Collapse comment for why most roles get
   a per-section toggle (reported live 2026-08-21: "the left panel of the
   app is grown so much"). .nav-section-label is a real <button> for those
   roles (was always a plain non-interactive <div> before this feature) --
   reset to look identical to the old label, plus a caret, full-width and
   keyboard-focusable so the toggle is a real control, not a div with a
   click handler bolted on. Base rule covers BOTH the <button> and the
   <div> variant (the "User" self-service role, which never gets the
   toggle -- see base.html's nav_sections_collapsible) so the label reads
   identically either way; only the interactive affordances below
   (cursor/hover/focus) are button-only, via the element selector rather
   than a class, so the static <div> variant never looks clickable. */
.sidebar nav .nav-section-label {
	display: flex;
	align-items: center;
	justify-content: space-between;
	width: 100%;
	margin-top: 14px;
	/* Vertical padding (was 0 on the old plain <div>, relying only on the
	   0.7rem text's own line height) is what actually gives the <button>
	   variant a real button-sized tap target instead of a cramped-looking
	   sliver -- reported live 2026-08-21 ("fonts and size of collapse
	   section is not good"). Harmless on the static <div> variant too
	   (just a little breathing room, matches the button's spacing exactly
	   so the two variants are visually indistinguishable at rest). */
	padding: 8px 14px;
	background: none;
	border: none;
	font-family: inherit;
	font-size: 0.7rem;
	font-weight: 700;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--text-muted);
	opacity: 0.6;
	border-radius: 8px;
	transition: opacity 0.15s ease, background 0.15s ease;
}
.sidebar nav button.nav-section-label { cursor: pointer; }
.sidebar nav button.nav-section-label:hover { opacity: 0.9; background: var(--surface); }
.sidebar nav button.nav-section-label:focus-visible {
	outline: 2px solid var(--accent);
	outline-offset: -2px;
}
/* A pure-CSS triangle (border trick), not a Unicode glyph -- "▾" rendered
   inconsistently (too heavy/chunky, or the wrong size relative to the
   0.7rem label text next to it) depending on the system font actually
   backing that codepoint, different per OS/browser. A CSS-drawn triangle
   renders pixel-identical everywhere, and its size is a real, tunable
   number instead of "whatever that glyph happens to look like". */
.sidebar nav .nav-section-caret {
	flex: none;
	width: 0;
	height: 0;
	border-left: 3.5px solid transparent;
	border-right: 3.5px solid transparent;
	border-top: 4.5px solid currentColor;
	transition: transform 0.22s ease;
}
.nav-section.collapsed .nav-section-caret { transform: rotate(-90deg); }
/* The actual expand/collapse animation -- a CSS grid-rows trick (1fr <->
   0fr) rather than max-height or JS-measured scrollHeight: those either
   need an arbitrary max-height guess (breaks if a section's link count
   changes) or a resize-observer to stay correct, where this animates the
   TRUE content height with nothing hardcoded, using only two rules.
   .nav-section-body is the grid (one implicit row); .nav-section-links is
   that row's one grid item, so 0fr genuinely animates it to zero height
   (not just clipped) while `overflow:hidden` keeps the links invisible
   and untabbable mid-collapse rather than reflowing/wrapping oddly. */
.nav-section-body {
	display: grid;
	grid-template-rows: 1fr;
	transition: grid-template-rows 0.22s ease;
}
.nav-section-links { display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.nav-section.collapsed .nav-section-body { grid-template-rows: 0fr; }
.sidebar nav a {
	display: flex;
	align-items: center;
	gap: 12px;
	color: var(--text-muted);
	font-weight: 600;
	font-size: 0.92rem;
	padding: 10px 14px;
	border-radius: 9px;
	transition: background 0.15s ease, color 0.15s ease;
}
.sidebar nav a .nav-icon { font-size: 1.3rem; width: 22px; text-align: center; opacity: 0.92; }
.sidebar nav a:hover { text-decoration: none; color: var(--text); background: var(--surface); }
.sidebar nav a:hover .nav-icon { opacity: 1; }
.sidebar nav a.active {
	color: #fff;
	background: linear-gradient(90deg, rgba(99,102,241,0.22), rgba(99,102,241,0.04));
	box-shadow: inset 2px 0 0 var(--accent-2);
}
.sidebar nav a.active .nav-icon { opacity: 1; }

/* Hamburger toggle (base.html's #sidebar-toggle, .content-header-left) --
   hidden by default, only shown once the sidebar itself goes off-canvas
   below the --sidebar-breakpoint media query further down. Plain CSS
   transform, no icon font/SVG needed for 3 bars. */
.sidebar-toggle {
	display: none;
	flex-direction: column;
	justify-content: center;
	gap: 4px;
	width: 36px;
	height: 36px;
	padding: 0;
	border: 1px solid var(--border);
	border-radius: 8px;
	background: var(--surface);
	cursor: pointer;
}
.sidebar-toggle span { display: block; width: 16px; height: 2px; margin: 0 auto; background: var(--text); border-radius: 2px; }

/* Backdrop behind the off-canvas sidebar on mobile (base.html's
   #sidebar-backdrop) -- click-to-close target, also just visually
   separates the open drawer from the page content behind it. */
.sidebar-backdrop {
	display: none;
	position: fixed;
	inset: 0;
	z-index: 190;
	background: rgba(0, 0, 0, 0.5);
}
.sidebar-backdrop.open { display: block; }

/* Below this width there isn't room for a permanent 325px sidebar next to
   real content (tables/forms already fight for space on a phone) -- same
   breakpoint the marketing/docs sites collapse their own nav at, so the
   whole product switches to a mobile layout at one consistent width.
   The sidebar becomes a fixed, off-canvas drawer (translateX, not
   display:none, so the slide transition has something to animate) toggled
   by JS adding/removing .open (see app.js's initSidebarToggle()); because
   position:fixed removes it from body.has-sidebar's flex flow entirely,
   .content already reclaims the full width with no extra rule needed. */
@media (max-width: 860px) {
	/* Chromium still counts a translateX(-100%)'d fixed element toward the
	   document's horizontal scroll extent, even fully off-screen -- so the
	   closed drawer alone was adding ~46px of page-level horizontal
	   overflow with nothing visibly there to explain it. Has to be on
	   <html> specifically, not <body> -- position:fixed elements are
	   positioned against the viewport and escape a body-level overflow
	   clip entirely (that's the defining behavior of "fixed"), so only
	   clipping the true root scrolling element actually suppresses it.
	   Since nothing at this breakpoint is meant to scroll sideways on
	   purpose anyway, this costs nothing. */
	html { overflow-x: hidden; }
	.sidebar-toggle { display: flex; }
	.sidebar {
		position: fixed;
		left: 0;
		top: 0;
		z-index: 200;
		transform: translateX(-100%);
		transition: transform 0.22s ease;
		box-shadow: var(--shadow-lg);
	}
	.sidebar.open { transform: translateX(0); }
}

/* --- Content header ---------------------------------------------------
   Sits above every logged-in page's own content (see base.html) -- holds
   the admin-only quick theme switcher plus the "who"/logout controls that
   used to live in .sidebar-footer. Themed automatically: every color used
   below is one of the --accent* / --side-* custom properties the
   [data-theme="..."] rules above already override. -------------------- */
.content-header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 16px;
	padding-bottom: 18px;
	margin-bottom: 22px;
	border-bottom: 1px solid var(--border);
}
.content-header-right { display: flex; align-items: center; gap: 18px; }
.content-header .who { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-muted); }
.content-header a.who-name { font-weight: 700; color: var(--text); text-decoration: none; }
.content-header a.who-name:hover { color: var(--accent-2); text-decoration: underline; }
.content-header .link-button { color: var(--text-muted); font-size: 0.85rem; }
.content-header .link-button:hover { color: var(--text); }

/* Notification bell -- same anchored-panel shape as .theme-quick-switch
   below (relative wrapper, absolutely-positioned panel toggled via JS'
   plain display:block/none, closed on outside click), not a centered
   <dialog> -- a bell dropdown is anchored to its trigger, not modal. */
.notif-bell { position: relative; }
.notif-bell-toggle { position: relative; display: inline-flex; align-items: center; }
.notif-bell-badge {
	position: absolute; top: -6px; right: -6px;
	min-width: 17px; height: 17px; padding: 0 4px;
	border-radius: 999px;
	background: var(--danger-strong); color: #fff;
	font-size: 0.68rem; font-weight: 700; line-height: 17px; text-align: center;
}
.notif-bell-panel {
	display: none;
	position: absolute;
	top: calc(100% + 6px);
	right: 0;
	z-index: 50;
	width: 340px;
	max-height: 420px;
	overflow-y: auto;
	background: var(--surface);
	border: 1.5px solid var(--border-light);
	border-radius: 9px;
	box-shadow: var(--shadow-lg);
}
.notif-bell-panel-head {
	display: flex; align-items: center; justify-content: space-between;
	padding: 12px 14px;
	border-bottom: 1px solid var(--border-light);
	font-weight: 700; font-size: 0.85rem;
	position: sticky; top: 0; background: var(--surface);
}
.notif-item { padding: 12px 14px; border-bottom: 1px solid var(--border-light); font-size: 0.85rem; }
.notif-item:last-child { border-bottom: none; }
.notif-item.unread { background: var(--surface-alt); }
.notif-item-level { display: inline-block; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 2px 7px; border-radius: 5px; margin-bottom: 5px; }
.notif-item-level.warning { background: var(--warning-bg); color: var(--warning-text); }
.notif-item-level.critical { background: var(--danger-bg); color: var(--danger-text); }
/* Support Ticketing System notifications (routes/notifications.py's
   TicketNotification merge) -- neutral, not a warning/critical severity. */
.notif-item-level.info { background: var(--surface-alt); color: var(--text-muted); }
.notif-item-time { color: var(--text-muted); font-size: 0.75rem; margin-top: 4px; }

/* Release Availability Indicator/Popup -- same anchored-panel shape as
   .notif-bell above (relative wrapper, absolutely-positioned panel toggled
   via JS' plain display:block/none, closed on outside click). Deliberately
   its own set of classes, not reusing .notif-bell-* -- this isn't a list of
   items, it's a single status dot + one detail panel. */
.release-indicator { position: relative; }
.release-indicator-toggle { display: inline-flex; align-items: center; gap: 7px; }
.release-indicator-dot {
	width: 9px; height: 9px; border-radius: 50%; display: inline-block;
	background: var(--success-strong, #2e9e5b);
}
.release-indicator-dot.update_available { background: var(--warning-strong, #d69e2e); }
.release-indicator-dot.critical_update { background: var(--danger-strong, #d64545); }
.release-indicator-panel {
	display: none;
	position: absolute;
	top: calc(100% + 6px);
	right: 0;
	z-index: 50;
	width: 380px;
	max-height: 480px;
	overflow-y: auto;
	background: var(--surface);
	border: 1.5px solid var(--border-light);
	border-radius: 9px;
	box-shadow: var(--shadow-lg);
}
.release-indicator-panel-head {
	display: flex; align-items: center; justify-content: space-between;
	padding: 12px 14px;
	border-bottom: 1px solid var(--border-light);
	font-weight: 700; font-size: 0.85rem;
	position: sticky; top: 0; background: var(--surface);
}
.release-indicator-body { padding: 14px; font-size: 0.85rem; }
.release-indicator-body pre { white-space: pre-wrap; word-break: break-word; font-size: 0.8rem; background: var(--surface-alt); padding: 8px; border-radius: 6px; }
.release-indicator-body code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }

.theme-quick-switch { position: relative; }
#theme-quick-toggle { display: inline-flex; align-items: center; gap: 8px; }
/* A small colored dot -- on the toggle button itself this deliberately has
   NO theme-specific class, so it just inherits var(--accent) off the live
   <html data-theme="..."> the page is actually rendering in, and always
   matches at a glance with zero JS color logic. Inside the panel, each
   option gets its own hardcoded per-theme class below (the panel itself
   isn't under that theme's [data-theme] scope, so it can't inherit the
   real value) -- same hex pairs as style.css's own [data-theme="..."]
   blocks' --accent/--accent-2, so the swatch always matches what selecting
   it will actually look like. */
.theme-swatch-dot {
	display: inline-block;
	width: 10px; height: 10px;
	border-radius: 50%;
	flex: none;
	background: var(--accent);
	box-shadow: 0 0 0 2px var(--surface), 0 0 6px 1px rgba(0,0,0,0.25);
}
.theme-swatch-auto { background: conic-gradient(#6366f1, #0ea5e9, #059669, #d97706, #7c3aed, #dc2626, #c026d3, #65a30d, #64748b, #6366f1); }
.theme-swatch-constellation { background: linear-gradient(135deg, #6366f1, #22d3ee); }
.theme-swatch-contour { background: linear-gradient(135deg, #0ea5e9, #5eead4); }
.theme-swatch-ingress { background: linear-gradient(135deg, #1d4ed8, #67e8f9); }
.theme-swatch-cipher { background: linear-gradient(135deg, #059669, #34d399); }
.theme-swatch-perimeter { background: linear-gradient(135deg, #d97706, #fbbf24); }
.theme-swatch-horizon { background: linear-gradient(135deg, #7c3aed, #f472b6); }
.theme-swatch-lattice { background: linear-gradient(135deg, #dc2626, #fca5a5); }
.theme-swatch-flux { background: linear-gradient(135deg, #c026d3, #818cf8); }
.theme-swatch-pulse { background: linear-gradient(135deg, #65a30d, #bef264); }
.theme-swatch-relay { background: linear-gradient(135deg, #64748b, #67e8f9); }

.theme-quick-panel {
	display: none;
	position: absolute;
	top: calc(100% + 6px);
	right: 0;
	z-index: 50;
	min-width: 210px;
	background: var(--surface);
	border: 1.5px solid var(--border-light);
	border-radius: 9px;
	box-shadow: var(--shadow-lg);
	padding: 6px;
}
.theme-quick-option {
	display: flex;
	align-items: center;
	gap: 9px;
	width: 100%;
	text-align: left;
	background: none;
	border: none;
	box-shadow: none;
	color: var(--text);
	font-weight: 600;
	font-size: 0.85rem;
	padding: 8px 10px;
	border-radius: 7px;
	margin: 0;
}
.theme-quick-option:hover { background: var(--surface-hover); transform: none; }
.theme-quick-check { margin-left: auto; opacity: 0; color: var(--accent); font-weight: 700; }
/* .active = the currently-SAVED setting (not necessarily what's resolved
   on screen right now -- "auto" itself is marked active even though the
   theme actually showing is only one of the 6 it rotates through, see
   app.js's initThemeQuickSwitch docstring). */
.theme-quick-option.active { background: var(--surface-alt); }
.theme-quick-option.active .theme-quick-check { opacity: 1; }

/* --- Info tooltip -------------------------------------------------------
   Shared app-wide "(i)" affordance for explaining a label/setting/column
   that isn't self-evident, without permanently taking up space the way
   .field-hint's always-visible caption does. CSS-only show/hide via
   :hover/:focus-visible (the trigger is a real <button>, so it's tab-
   focusable and gets this for free -- no JS needed for desktop/keyboard
   use). app.js's initInfoTips() only handles the tap case, where CSS
   :hover doesn't fire reliably on touch devices. Positioned/styled to
   match the notif-bell-panel/theme-quick-panel "floating surface"
   convention above rather than inventing a new visual language. */
.info-tip {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 15px;
	height: 15px;
	border-radius: 50%;
	background: var(--surface-alt);
	color: var(--text-muted);
	font-size: 0.68rem;
	font-weight: 700;
	font-style: italic;
	font-family: Georgia, "Times New Roman", serif;
	border: 1px solid var(--border-light);
	cursor: help;
	position: relative;
	margin-left: 4px;
	padding: 0;
	line-height: 1;
	vertical-align: middle;
}
.info-tip:hover, .info-tip:focus-visible { color: var(--accent); border-color: var(--accent); }
.info-tip::after {
	content: attr(data-tip);
	display: none;
	position: absolute;
	bottom: calc(100% + 8px);
	left: 50%;
	transform: translateX(-50%);
	width: max-content;
	max-width: 260px;
	background: var(--surface);
	color: var(--text);
	border: 1.5px solid var(--border-light);
	border-radius: 9px;
	box-shadow: var(--shadow-lg);
	padding: 8px 10px;
	font-size: 0.78rem;
	font-weight: 400;
	font-style: normal;
	font-family: inherit;
	text-align: left;
	z-index: 60;
	white-space: normal;
}
.info-tip:hover::after, .info-tip:focus-visible::after, .info-tip.tip-open::after { display: block; }

/* --- Content footer ----------------------------------------------------
   Full-width within the content column, 3 zones (left/center/right) --
   center is deliberately left as a real, styled, empty slot (trademark
   text / profile links land there later), not just implied by spacing. */
.content-footer {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 16px;
	margin-top: 40px;
	padding-top: 18px;
	border-top: 1px solid var(--border);
	font-size: 0.78rem;
	color: var(--text-faint);
}
.content-footer-left, .content-footer-right { flex: 1; min-width: 0; }
.content-footer-right { text-align: right; }
.content-footer-center { flex: 2; text-align: center; }
.content-footer a { color: var(--text-muted); text-decoration: underline; text-underline-offset: 2px; }
.content-footer a:hover { color: var(--accent-2); }

.role-badge {
	font-size: 0.66rem;
	padding: 3px 9px;
	border-radius: 999px;
	text-transform: uppercase;
	font-weight: 800;
	letter-spacing: 0.04em;
}
/* Super Admin (the bootstrap admin's exclusive role, see permissions.py's
   _SYSTEM_ROLES) gets its own gold/amber gradient -- distinct from admin's
   indigo-purple-cyan so the one account that's permanently un-demotable/
   un-modifiable reads as visibly a tier above regular admins, not just a
   same-colored badge with different text. */
.role-super_admin { background: linear-gradient(90deg, #f59e0b, #eab308); color: #1a1305; }
.role-admin { background: var(--accent-gradient); color: #fff; }
/* Previously #22d3ee -> #6366f1 -- the exact same two hues as
   --accent-gradient (admin's badge), just reversed, so admin and editor
   badges read as near-identical at a glance despite being different
   permission tiers. Sky/teal instead: distinct from admin's indigo-purple-
   cyan, viewer's slate, and the status colors (green=online/success,
   red=danger/revoked, amber=warning/rejected). */
.role-editor { background: linear-gradient(90deg, #0ea5e9, #14b8a6); color: #fff; }
/* Previously background: var(--surface-alt) -- the same color used for
   table headers, .status-offline, and most card/panel backgrounds, so the
   badge had almost no contrast against the page around it and read as
   "missing" rather than "viewer". A distinct (still subdued, since viewer
   is intentionally the lowest-emphasis tier next to admin/editor's vivid
   gradients) slate gradient instead. */
.role-viewer { background: linear-gradient(90deg, #4b5573, #6b7494); color: #fff; }

.link-button {
	background: none;
	border: none;
	color: var(--accent-2);
	cursor: pointer;
	font: inherit;
	font-weight: 600;
	padding: 0;
}
.link-button:hover { text-decoration: underline; }

/* 1200px -> 1680px (via 1440px): VPN Clients now carries 5 action buttons,
   a dedicated Restrictions column, and per-chip badges per row -- 11
   columns total for an admin -- and was cramped/wrapping at the old width.
   Tables are width:100% within their .card (see `table` below), so the
   extra room is actually used rather than just adding empty margin either
   side. */
.content { flex: 1; min-width: 0; max-width: 1680px; margin: 0 auto; padding: 40px 32px 64px; }

h1 {
	font-size: 1.85rem;
	font-weight: 800;
	letter-spacing: -0.02em;
	margin: 0 0 6px;
	background: var(--accent-gradient);
	-webkit-background-clip: text;
	background-clip: text;
	color: transparent;
	display: inline-block;
}
h2 { font-size: 1.08rem; font-weight: 700; margin: 0 0 14px; color: var(--text); }
.page-subtitle { color: var(--text-muted); margin: 0 0 28px; font-size: 0.97rem; }

.card {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius-lg);
	padding: 24px;
	margin-bottom: 22px;
	box-shadow: var(--shadow);
}

/* Settings page: groups a run of related .card sections under one labeled
   heading (see settings.html) -- the page was previously ~17 flat, equally-
   weighted cards in a row with no structure above "top to bottom", which
   read as scattered rather than organized. Purely a visual/navigational
   grouping layer -- every .card inside keeps its own id/markup/JS wiring
   completely unchanged, this only wraps them. */
.settings-group { margin-bottom: 36px; scroll-margin-top: 84px; }
.settings-group:last-of-type { margin-bottom: 22px; }
.settings-group-title {
	display: flex; align-items: center; gap: 10px;
	font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
	color: var(--text-muted); margin: 0 0 12px; padding-bottom: 8px;
	border-bottom: 1px solid var(--border-light);
}
.settings-group-title .settings-group-icon {
	display: inline-flex; align-items: center; justify-content: center;
	width: 22px; height: 22px; border-radius: 7px;
	background: var(--accent-soft, rgba(35, 214, 196, 0.14)); color: var(--accent-2);
	font-size: 0.85rem; flex: none;
}
.settings-group .card:last-child { margin-bottom: 0; }

/* Sticky quick-jump bar -- one pill per group, scrolls the page to that
   group's anchor instead of making an admin hunt-and-scroll through the
   whole page to find e.g. Slack settings. */
.settings-quicknav {
	position: sticky; top: 0; z-index: 5;
	display: flex; flex-wrap: wrap; gap: 8px;
	background: var(--bg); padding: 10px 0 16px;
	margin-bottom: 8px;
	border-bottom: 1px solid var(--border-light);
}
.settings-quicknav a {
	font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
	background: var(--surface-alt); border: 1px solid var(--border-light);
	padding: 6px 13px; border-radius: 999px; text-decoration: none;
	transition: color 0.15s, border-color 0.15s;
}
.settings-quicknav a:hover { color: var(--text); border-color: var(--accent-2); }

/* Dashboard's admin-only "Manage Users" nudge -- the one deliberately
   prominent callout on this app's default landing page, distinct from a
   plain .card (accent top stripe + glow, same visual language as
   .stat-card's own accent stripe and every .btn-primary's glow) so it
   reads as "start here" rather than blending into the page. */
.welcome-banner {
	position: relative;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 20px;
	flex-wrap: wrap;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius-lg);
	padding: 20px 26px;
	margin-bottom: 24px;
	box-shadow: var(--accent-glow);
	overflow: hidden;
}
.welcome-banner::before {
	content: "";
	position: absolute;
	top: 0; left: 0; right: 0;
	height: 3px;
	background: var(--accent-gradient);
}
.welcome-banner-text h2 { margin: 0 0 4px; font-size: 1.1rem; display: flex; align-items: center; gap: 9px; }
.welcome-banner-text p { margin: 0; color: var(--text-muted); font-size: 0.88rem; }

/* "Something's here" pulse -- a small live-style dot (not a static icon)
   next to the banner's heading, the same visual shorthand as a
   notification/live badge elsewhere on the web. Pure CSS, no new asset. */
.pulse-dot {
	position: relative;
	display: inline-block;
	width: 9px;
	height: 9px;
	border-radius: 50%;
	background: #22c55e;
	flex: none;
	animation: pulse-dot-ring 2.2s ease-out infinite;
}
@keyframes pulse-dot-ring {
	0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55); }
	70% { box-shadow: 0 0 0 9px rgba(34, 197, 94, 0); }
	100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}

/* Dedicated CTA pill for the "Manage Users" nudge (Dashboard's banner and
   VPN Clients' toolbar) -- deliberately its own class, not .btn-primary,
   because .btn-primary's fill is var(--accent-gradient), which is
   per-theme and can be too close to the (also dark) page background to
   read as an obvious call-to-action in some of the 10 themes. This uses
   a fixed, always-vivid indigo/violet/cyan gradient + a real glow, so
   it stands out consistently no matter which theme is active -- the one
   button on the page whose whole job is to be impossible to miss. */
.btn-cta {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	flex: none;
	white-space: nowrap;
	padding: 10px 22px;
	border-radius: 999px;
	border: none;
	font: inherit;
	font-weight: 700;
	font-size: 0.92rem;
	color: #fff;
	background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #22d3ee 130%);
	box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.5), 0 6px 22px rgba(99, 102, 241, 0.45);
	text-decoration: none;
	cursor: pointer;
	transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-cta:hover {
	transform: translateY(-1px);
	box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.7), 0 10px 30px rgba(99, 102, 241, 0.55);
	text-decoration: none;
	color: #fff;
}

.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 18px; margin-bottom: 28px; }
a.stat-card, button.stat-card, .stat-card {
	position: relative;
	display: block;
	width: 100%;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius-lg);
	padding: 20px 20px 18px;
	box-shadow: var(--shadow);
	overflow: hidden;
	text-decoration: none;
	color: inherit;
	text-align: left;
	font: inherit;
	transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
a.stat-card, button.stat-card { cursor: pointer; }
a.stat-card:hover, button.stat-card:hover { transform: translateY(-2px); border-color: var(--border-light); box-shadow: var(--shadow), 0 0 0 1px rgba(99,102,241,0.25); text-decoration: none; }
button.stat-card.stat-card-active { border-color: var(--accent-2); box-shadow: var(--shadow), 0 0 0 2px rgba(99,102,241,0.45); }
.stat-card::before {
	content: "";
	position: absolute;
	top: 0; left: 0; right: 0;
	height: 3px;
	background: var(--accent-gradient);
}
.stat-card.accent-online::before { background: linear-gradient(90deg, #10b981, #34d399); }
.stat-card.accent-total::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.stat-card.accent-revoked::before { background: linear-gradient(90deg, #f43f5e, #fb7185); }
.stat-card.accent-rejected::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.stat-card .stat-value { font-size: 2.05rem; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
.stat-card .stat-label { color: var(--text-muted); font-size: 0.85rem; font-weight: 600; margin-top: 2px; }
.stat-card .stat-hint { position: absolute; top: 18px; right: 18px; color: var(--text-faint); font-size: 0.75rem; opacity: 0; transition: opacity 0.15s ease; }
a.stat-card:hover .stat-hint { opacity: 1; }

table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--border); }
th { color: var(--text-muted); font-weight: 700; font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.05em; background: var(--surface-alt); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface-hover); }
/* Excludes the actions column (text-right, e.g. Restore/Delete Permanently/
   Purge) -- these buttons are always fully functional on a dimmed row (the
   dimming only communicates "this row's own state is inactive/revoked/
   unassigned", not "these controls are disabled"), so dimming them too
   made active buttons read as greyed-out/disabled. */
tr.row-dim td:not(.text-right) { opacity: 0.55; }
tr.row-online td:first-child { box-shadow: inset 3px 0 0 var(--success-strong); }
/* Single-row-select highlight (System Audit's run history table) -- the
   selected row gets a persistent highlight distinct from the transient
   :hover above, so it stays visually obvious which run is being viewed
   even after the mouse moves away. (VPN Clients used this too before its
   per-row-action-buttons redesign removed row selection entirely.) */
tr.row-selected td { background: var(--surface-alt); }
tr.row-selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); }

/* Support Ticketing System's ticket list tables (support.html/
   support_center.html) -- a whole row navigates to the ticket detail
   page, same client-side-pagination pattern as users_activity.html. */
tr.clickable-row { cursor: pointer; }
tr.clickable-row:hover td { background: var(--surface-alt); }
.pagination { display: flex; align-items: center; justify-content: center; }

.status-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 11px; border-radius: 999px; font-size: 0.78rem; font-weight: 700; }
.status-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; flex: none; }
.status-online { background: var(--success-bg); color: var(--success-text); }
.status-online::before { background: var(--success-strong); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); }
.status-offline { background: var(--surface-alt); color: var(--text-muted); border: 1px solid var(--border-light); }
.status-offline::before { background: #64748b; }
.status-revoked { background: var(--danger-bg); color: var(--danger-text); }
.status-revoked::before { background: var(--danger-strong); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.2); }
/* VPN Device Availability Monitoring's "warning" bucket -- offline but
   still within its own grace threshold, not yet alert-worthy (see
   device_monitoring.widget_snapshot's own comment on why this is a
   derived display state, not a persisted one). Amber, distinct from both
   the neutral-grey "genuinely offline, past threshold" (.status-offline)
   and the red "revoked" -- a warning isn't a failure yet. */
.status-warning { background: var(--warning-bg, #fef3c7); color: var(--warning-text, #92400e); }
.status-warning::before { background: #d97706; box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.2); }

/* Ticket priority badge -- same pill shape as .status-pill (reused, not
   duplicated) but color keyed to PRIORITIES (low/medium/high/critical)
   instead of a connection/session state. low/medium stay neutral (a
   routine ticket shouldn't visually compete with an actually urgent one);
   high/critical use the danger palette so an upgrade-required or
   otherwise urgent ticket reads as red at a glance in both the ticket
   list and detail view, not just in its plain-text label. critical adds
   a stronger border on top of high's fill so the two are still
   distinguishable from each other, not just from low/medium. */
.priority-low, .priority-medium { background: var(--surface-alt); color: var(--text-muted); border: 1px solid var(--border-light); }
.priority-high { background: var(--danger-bg); color: var(--danger-text); }
.priority-critical { background: var(--danger-bg); color: var(--danger-text); border: 1px solid var(--danger-strong); }

/* System Audit's 6 severity tiers (system_audit/__init__.py's Finding) --
   own set of pills rather than reusing .priority-* above: the ticket
   scale deliberately groups low+medium as one neutral tier, but the
   audit spec explicitly wants all 6 levels (Critical/High/Medium/Low/
   Info/Passed) visually distinct from each other, since the whole point
   of the severity-count summary is scanning which buckets have numbers
   in them. */
.status-sev-critical { background: var(--danger-bg); color: var(--danger-text); border: 1px solid var(--danger-strong); }
.status-sev-critical::before { background: var(--danger-strong); box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.2); }
.status-sev-high { background: var(--danger-bg); color: var(--danger-text); }
.status-sev-high::before { background: var(--danger-strong); }
.status-sev-medium { background: var(--warning-bg, #fef3c7); color: var(--warning-text, #92400e); }
.status-sev-medium::before { background: #d97706; }
.status-sev-low { background: var(--surface-alt); color: var(--text); border: 1px solid var(--border-light); }
.status-sev-low::before { background: var(--text-muted); }
.status-sev-info { background: var(--surface-alt); color: var(--text-muted); border: 1px solid var(--border-light); }
.status-sev-info::before { background: var(--accent-2); }
.status-sev-passed { background: var(--success-bg); color: var(--success-text); }
.status-sev-passed::before { background: var(--success-strong); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); }

/* Security score -- a large single number, colored by how good it is
   (same danger/warning/success palette as the severity pills above, just
   applied to plain text at a big size rather than a pill) so the overall
   posture reads at a glance before anyone reads a single finding. */
.audit-score { font-size: 3.2rem; font-weight: 800; line-height: 1; }
.audit-score-good { color: var(--success-text); }
.audit-score-fair { color: var(--warning-text, #fcd34d); }
.audit-score-poor { color: var(--danger-text); }

/* Severity-count summary strip (spec's "Critical 2, High 5, ..." example)
   -- one clickable stat per severity, filters the findings table below
   when clicked (see system_audit.html's applyFindingsFilter()). */
.audit-severity-strip { display: flex; flex-wrap: wrap; gap: 10px; }
.audit-severity-stat {
	flex: 1 1 100px;
	min-width: 100px;
	padding: 12px 14px;
	border-radius: var(--radius);
	border: 1.5px solid var(--border-light);
	background: var(--surface-alt);
	cursor: pointer;
	text-align: center;
	transition: border-color 0.15s ease, transform 0.1s ease;
}
.audit-severity-stat:hover { transform: translateY(-1px); }
.audit-severity-stat.active { border-color: var(--accent-2); box-shadow: 0 0 0 1px var(--accent-2); }
.audit-severity-stat .count { display: block; font-size: 1.6rem; font-weight: 800; }
.audit-severity-stat .label { display: block; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; margin-top: 2px; }

.audit-finding-row { cursor: pointer; }
.audit-finding-row:hover { background: var(--surface-hover); }
.audit-finding-detail dt { color: var(--text-muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; margin-top: 12px; }
.audit-finding-detail dt:first-child { margin-top: 0; }
.audit-finding-detail dd { margin: 4px 0 0; white-space: pre-wrap; }
.audit-finding-detail dd.evidence-block { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; background: var(--surface-alt); padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border-light); }
/* System Audit's firewall-status line ("Show policy/table details") --
   same raw-evidence look as .evidence-block above, as a standalone class
   since it's rendered outside a <dl>/<dd> (see renderFirewallStatus()). */
.firewall-status-evidence { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; background: var(--surface-alt); padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border-light); white-space: pre-wrap; margin: 0; max-height: 260px; overflow-y: auto; }
.audit-badge-new { background: var(--accent-gradient); color: white; font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 999px; margin-left: 6px; }
.audit-badge-resolved { background: var(--success-bg); color: var(--success-text); font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 999px; margin-left: 6px; }

/* Dashboard Recent Activity table (see dashboard.html) -- a pill for the
   humanized action label (same visual weight/shape as .role-badge, just
   neutral-colored since there's no admin/viewer-style semantic to color
   by here), and de-emphasized timestamp/detail columns so "Who" and
   "Action" read as the primary columns at a glance. */
.activity-action {
	display: inline-block;
	font-size: 0.74rem;
	font-weight: 700;
	padding: 3px 10px;
	border-radius: 999px;
	background: var(--surface-alt);
	color: var(--text);
	border: 1px solid var(--border-light);
	white-space: nowrap;
}
.activity-when { color: var(--text-muted); font-size: 0.85rem; }
.activity-detail { color: var(--text-muted); font-size: 0.87rem; }

button, .btn {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	padding: 9px 18px;
	border-radius: 999px;
	border: 1px solid transparent;
	font: inherit;
	font-weight: 700;
	font-size: 0.87rem;
	cursor: pointer;
	/* A crowded toolbar (e.g. system_audit.html's export controls) can run
	   a flex row out of width -- without this, the browser wraps a
	   button's own label onto multiple lines instead of shrinking the row,
	   and the 999px pill radius turns that multi-line label into a
	   near-circular blob. flex-shrink:0 keeps a button from being squeezed
	   in the first place; its container should flex-wrap onto a new line
	   instead (see .toolbar, and system_audit.html's own control row). */
	white-space: nowrap;
	flex-shrink: 0;
	transition: transform 0.1s ease, box-shadow 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn-primary { background: var(--accent-gradient); color: white; box-shadow: var(--accent-glow); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 0 0 1px rgba(99,102,241,0.55), 0 8px 24px rgba(99,102,241,0.35); }
.btn-danger { background: linear-gradient(135deg, #e11d48, #f43f5e); color: white; box-shadow: 0 2px 14px rgba(244, 63, 94, 0.3); }
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 0 0 1px rgba(244,63,94,0.5), 0 8px 24px rgba(244,63,94,0.35); }
.btn-secondary { background: var(--surface-alt); color: var(--text); border-color: var(--border-light); }
.btn-secondary:hover { background: var(--surface-hover); border-color: var(--accent-2); }
/* Small capsule/chip toggle-button -- the "Select Desktop"/"Select
   Mobile" OS quick-select shortcuts (clients.html/users.html). First cut
   was plain link-style text (found live 2026-08-24: read as an
   unstyled/broken control -- no visible shape at all, easy to miss as a
   button). This instead reuses the exact indigo chip treatment
   .restriction-chip.chip-country already establishes elsewhere in this
   file (soft tinted fill, matching border, pill radius already inherited
   from the base `button` rule) -- so it reads as a real, tappable
   quick-select chip rather than inline text, without inventing a new
   color language. */
.btn-chip { background: rgba(99, 102, 241, 0.14); color: #a5b4fc; border-color: rgba(99, 102, 241, 0.4); box-shadow: none; font-weight: 700; }
.btn-chip:hover { background: rgba(99, 102, 241, 0.26); border-color: var(--accent-2); color: var(--text); transform: none; }
.btn-chip:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; }
.btn-sm { padding: 5px 12px; font-size: 0.78rem; }
.btn-chip.btn-sm { padding: 4px 12px; font-size: 0.74rem; }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; }

/* Small leading glyph on a button (Clients page's action toolbar) --
   same nav-icon idea the sidebar already uses, just sized down to sit
   inline in a pill-shaped btn-sm rather than a full nav row. Purely
   decorative (aria-hidden not needed -- the button's own text label is
   still the accessible name; this is additive, not a replacement). */
.btn-icon { margin-right: 6px; opacity: 0.85; }

/* Generic per-row "More" overflow menu -- originally built for the
   Clients page's per-row actions (Manage Monitoring/Email Profile/Reset
   Usage/Disconnect/Revoke/Details), same anchored-panel shape as
   base.html's #notif-bell/#theme-quick-switch (relative wrapper,
   absolutely-positioned panel, closed on outside click via each page's
   own initRowActions()-style delegated handler). Generalized to
   `.row-actions-*` (2026-08-25, "move edit/reset password/delete... up"
   on the Users page) so a second page could reuse the exact same
   pattern instead of inventing/duplicating it -- see clients.html's
   initClientRowActions() and users.html's initUserRowActions() for the
   two current call sites.
   Redesigned 2026-08-22 ("lets move ... buttons to each user['s row]") --
   every row now gets its own copy of this menu instead of one shared
   toolbar-level menu acting on whichever row was radio-selected. */
.row-actions-menu { position: relative; display: inline-block; }
.row-actions-toggle .dropdown-caret {
	display: inline-block;
	margin-left: 7px;
	width: 0;
	height: 0;
	border-left: 3.5px solid transparent;
	border-right: 3.5px solid transparent;
	border-top: 4.5px solid currentColor;
	vertical-align: middle;
	opacity: 0.7;
	transition: transform 0.18s ease;
}
/* Points up while the panel is open -- same rotate-on-expand idea as the
   sidebar's .nav-section-caret, driven off the button's own aria-expanded
   rather than a separate class, so it can never drift out of sync with
   the actual open/closed state. */
.row-actions-toggle[aria-expanded="true"] .dropdown-caret { transform: rotate(180deg); }
/* `position: fixed`, not `absolute` -- this panel used to be positioned
   relative to its `.row-actions-menu` wrapper (top/right below), which
   sits inside `.table-scroll { overflow-x: auto }` (style.css, further
   down). Setting overflow-x alone on an element implicitly computes
   overflow-y to `auto` too (CSS Overflow §3), not `visible` -- so
   .table-scroll was ALSO clipping this panel vertically and, for rows
   near the bottom of a long list, growing its own scrollbar to
   accommodate the clipped absolutely-positioned content. `position:
   fixed` escapes that ancestor's overflow/clipping box entirely (nothing
   between here and the viewport sets a transform/filter/will-change that
   would otherwise re-establish a containing block for it -- see each
   page's own positionRowActionsPanel()-equivalent for how the
   coordinates are computed and re-clamped to the viewport on open/
   scroll/resize). z-index matches dialog::backdrop's overlay layer
   (2147483000-ish isn't needed here -- a native <dialog>'s top layer
   already beats any z-index, so this only needs to clear ordinary
   in-page content) at a value comfortably above every other non-dialog
   overlay on this page (toast-container's 9999 included, since a toast
   firing while this menu is open shouldn't render underneath it). */
.row-actions-panel {
	position: fixed;
	z-index: 10000;
	min-width: 200px;
	padding: 6px;
	background: var(--surface);
	border: 1.5px solid var(--border-light);
	border-radius: 10px;
	box-shadow: var(--shadow-lg);
	text-align: left;
}
.row-actions-item {
	display: flex;
	align-items: center;
	width: 100%;
	padding: 8px 10px;
	background: none;
	border: none;
	border-radius: 7px;
	font: inherit;
	font-size: 0.85rem;
	font-weight: 600;
	color: var(--text);
	text-align: left;
	cursor: pointer;
	transition: background 0.12s ease;
}
.row-actions-item:hover { background: var(--surface-hover); }
.row-actions-item:disabled { opacity: 0.45; cursor: not-allowed; background: none; }
.row-actions-item .btn-icon { margin-right: 9px; }
/* The genuinely destructive action(s) in a given menu (Disconnect/
   Revoke on Clients, Delete/Delete Permanently on Users) get
   danger-tinted text (not a full red button background, which would
   look jarring stacked in a plain white/dark menu list) plus a divider
   above separating them from the lower-stakes actions that precede
   them. */
.row-actions-item.danger { color: var(--danger-strong, #e11d48); }
.row-actions-item.danger:hover { background: var(--danger-bg, rgba(225,29,72,0.08)); }
.row-actions-divider { height: 1px; margin: 5px 4px; background: var(--border-light); }

/* Per-row action buttons (Manage MACs/Manage Restrictions/View-Copy
   .ovpn/the "More" toggle/viewer-only Details) -- plain capsule buttons
   with text labels, same .btn-secondary/.btn-sm pill style as every other
   secondary action in this app, one set generated fresh per row by
   renderClients(). Deliberately NOT color-coded per action (an earlier
   pass gave each its own fixed hue) -- with a whole row of them per
   client, distinct colors read as noisy rather than informative; a
   consistent capsule with a legible label scans faster across many rows
   than memorizing which color means what. */
.row-actions { display: flex; gap: 6px; align-items: center; flex-wrap: nowrap; justify-content: flex-end; }

/* Key/value list for the drawer's field set (OS, Source IP, Connected
   Since, ...) -- label left, value right, one pair per row. Tried
   .form-grid first (label stacked above value, multi-column) but at the
   drawer's ~480px width its minmax(200px,1fr) columns collapse to one
   anyway, so every field ends up as label-then-value-on-the-next-line --
   twice the vertical space for no benefit once there's no second column
   to fill. A plain two-column row reads faster for this kind of flat
   "field: value" listing. */
.detail-kv { margin: 0; }
.detail-kv-row {
	display: flex;
	justify-content: space-between;
	align-items: baseline;
	gap: 14px;
	padding: 9px 0;
	border-bottom: 1px solid var(--border-light);
}
.detail-kv-row:last-child { border-bottom: none; }
.detail-kv-row dt {
	flex: none;
	color: var(--text-muted);
	font-size: 0.78rem;
	text-transform: uppercase;
	letter-spacing: 0.03em;
}
.detail-kv-row dd {
	margin: 0;
	text-align: right;
	font-weight: 600;
	overflow-wrap: anywhere;
}

input[type=text], input[type=password], input[type=date], input[type=email], input[type=tel], input[type=search], select, textarea {
	padding: 10px 14px;
	border-radius: 9px;
	border: 1.5px solid var(--border);
	font: inherit;
	width: 100%;
	background: var(--bg-elevated);
	color: var(--text);
	transition: border-color 0.15s ease, background 0.15s ease;
}
select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: calc(100% - 18px) center, calc(100% - 13px) center; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; }

/* Every theme in this app is a dark palette (see the [data-theme="..."]
   block below), but the browser's native date-picker calendar icon
   renders in a fixed dark color meant for light backgrounds -- it goes
   invisible against every one of our themes' dark field backgrounds.
   Inverting it to white keeps it visible regardless of which accent
   theme is active, since none of them ever lighten --bg-elevated. */
input[type=date]::-webkit-calendar-picker-indicator {
	filter: invert(1);
	cursor: pointer;
	opacity: 0.85;
}
input[type=date]::-webkit-calendar-picker-indicator:hover {
	opacity: 1;
}

/* File inputs (ticket attachments) -- otherwise render as the browser's
   bare OS-native picker, which reads as unstyled/broken against this
   app's dark theme. Wraps the whole control in the same pill shape as
   every other field, and re-skins the browser's own "Choose File" part
   as a .btn-secondary so it matches every other button on the page
   instead of clashing with it. */
input[type=file] {
	padding: 8px 14px;
	border-radius: 9px;
	border: 1.5px solid var(--border);
	background: var(--bg-elevated);
	color: var(--text-muted);
	font: inherit;
	font-size: 0.85rem;
	width: 100%;
}
input[type=file]::file-selector-button {
	margin-right: 12px;
	padding: 7px 14px;
	border-radius: 999px;
	border: 1px solid var(--border-light);
	background: var(--surface-alt);
	color: var(--text);
	font: inherit;
	font-weight: 600;
	font-size: 0.8rem;
	cursor: pointer;
	transition: background 0.15s ease, border-color 0.15s ease;
}
input[type=file]::file-selector-button:hover { background: var(--surface-hover); border-color: var(--accent-2); }

/* --- Geo-scoped restriction pickers (users.html's City/ASN Login
   Restrictions) -- a country <select> narrows what the multiselect next
   to it offers, so the two sit side by side as one control. */
.geo-picker-row { display: flex; gap: 8px; align-items: flex-start; }
.geo-picker-row select { flex: 0 0 auto; width: 200px; }
.geo-picker-row .ms-dropdown { flex: 1 1 auto; min-width: 0; }

/* Small screens: dialogs shrink to 90-94% of a narrow viewport (see the
   `dialog` rule above), which isn't enough room for a fixed 200px country
   <select> plus a multiselect side by side -- stack them instead of
   squeezing both into a sliver. Same breakpoint used for the top-of-form
   .form-row pairs, which already wrap via flex-wrap but benefit from a
   little extra top margin on the second field once stacked. */
@media (max-width: 560px) {
	.geo-picker-row { flex-direction: column; }
	.geo-picker-row select { width: 100%; }
	.form-row .field { min-width: 100%; }
}

/* --- Multiselect dropdown (createMultiselectDropdown in app.js) --------
   Closed-by-default team picker: looks/behaves like a normal <select>
   until clicked, then expands into a checkable options panel, instead of
   a native <select multiple> permanently-open listbox. */
.ms-dropdown { position: relative; }
.ms-toggle {
	width: 100%;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 10px;
	padding: 10px 14px;
	border-radius: 9px;
	border: 1.5px solid var(--border);
	background: var(--bg-elevated);
	color: var(--text);
	font: inherit;
	font-weight: 500;
	text-align: left;
	cursor: pointer;
	box-shadow: none;
}
.ms-toggle:hover { border-color: var(--accent-2); }
.ms-disabled .ms-toggle { opacity: 0.6; cursor: not-allowed; }
.ms-disabled .ms-toggle:hover { border-color: var(--border-light); }
.ms-toggle .ms-toggle-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-toggle .ms-toggle-label.muted { color: var(--text-faint); font-weight: 400; }
.ms-toggle .ms-toggle-caret { flex: none; color: var(--text-muted); font-size: 0.75rem; transition: transform 0.15s ease; }
.ms-toggle:has(+ .ms-panel.open) .ms-toggle-caret { transform: rotate(180deg); }
.ms-panel {
	display: none;
	position: absolute;
	top: calc(100% + 6px);
	left: 0;
	right: 0;
	z-index: 50;
	background: var(--surface);
	border: 1.5px solid var(--border-light);
	border-radius: 9px;
	box-shadow: var(--shadow-lg);
	padding: 6px;
}
.ms-panel.open { display: block; }
/* Searchable pickers (users.html's City/ASN) add a fixed search box +
   results-count hint around the options list -- only .ms-options itself
   scrolls, so those stay visible while browsing a long result set. */
.ms-panel .ms-search {
	width: 100%;
	margin-bottom: 6px;
}
.ms-panel .ms-hint {
	padding: 4px 10px 2px;
	font-size: 0.78rem;
}
.ms-panel .ms-options { max-height: 220px; overflow-y: auto; }
.ms-panel .ms-option {
	display: flex;
	align-items: center;
	gap: 10px;
	padding: 8px 10px;
	border-radius: 7px;
	font-weight: 500;
	font-size: 0.92rem;
	cursor: pointer;
	margin: 0;
}
.ms-panel .ms-option:hover { background: var(--surface-hover); }
.ms-panel .ms-option input { width: auto; flex: none; }
.ms-panel .ms-empty { padding: 10px; font-size: 0.88rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-2); background: var(--surface); box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.14); }
input::placeholder { color: var(--text-faint); }
input:disabled, select:disabled, textarea:disabled { opacity: 0.7; cursor: not-allowed; color: var(--text-muted); }
label { display: block; font-weight: 700; font-size: 0.82rem; margin-bottom: 6px; color: var(--text-muted); }
.field { margin-bottom: 18px; }
.form-row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
.form-row .field { flex: 1; min-width: 180px; margin-bottom: 0; }
/* The phone field (.phone-input, below) needs more room than an equal
   three-up split gives it in a 920px dialog -- the country-code <select>
   alone needs ~170px to avoid truncating a country name, leaving too
   little for the local number input otherwise. Widens just this one
   column; siblings (Email/Teams) keep their normal share. */
.field-wide-phone { flex: 1.8; min-width: 340px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
/* Vertically-stacked fields, each taking the FULL width of its container
   (users.html's Device & Access Policy / Location & Network Restrictions
   fieldsets) -- unlike .form-grid's multi-column layout, this is for
   fields whose controls need real horizontal room to not truncate (a
   country <select> plus a multiselect side by side, a MAC/bandwidth
   input, a multi-line IP textarea) rather than fields that are fine
   sharing a row. */
.field-stack { display: flex; flex-direction: column; gap: 18px; }
.field-stack .field { margin-bottom: 0; }
/* Same reasoning as the .field rule above, for a nested <fieldset> (e.g.
   users.html's Device Monitoring "Notifications"/"Alert Cooldown"
   sub-sections): the base `fieldset` rule below gives every fieldset its
   own margin-bottom: 18px, which stacked on top of this flex column's own
   18px gap doubled the visual space around a nested fieldset compared to
   a plain .field sitting right next to it. */
.field-stack fieldset { margin-bottom: 0; }
/* The Generate-password button sits in its own .field-shaped slot at the
   end of the New/Confirm Password row (users.html's Row 3) so it lines up
   with the two input baselines via .form-row's align-items:flex-end --
   the invisible label above it (see users.html's `&nbsp;` label) reserves
   the same vertical space a real label would. */
.password-row-generate { flex: 0 0 auto; min-width: 0; }
.password-row-generate button { white-space: nowrap; }
fieldset { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin: 0 0 18px; }
fieldset legend { padding: 0 8px; color: var(--text-muted); font-weight: 700; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }

/* --- Checkboxes -------------------------------------------------------
   Custom-drawn (appearance:none + a clip-path checkmark) rather than the
   browser default -- every OS/browser renders a native checkbox
   differently and none of them pick up this app's own accent color, so
   they previously looked visually disconnected from everything else on
   the page. Same box for every checkbox app-wide (Login Restrictions,
   Allowed Device OS, Manage MACs' bulk-select, the Roles permission
   matrix, ...) -- one definition, no per-page variants. */
input[type="checkbox"] {
	appearance: none;
	-webkit-appearance: none;
	width: 18px;
	height: 18px;
	flex: none;
	margin: 0;
	display: inline-grid;
	place-content: center;
	border: 1.5px solid var(--border-light);
	border-radius: 5px;
	background: var(--surface-alt);
	cursor: pointer;
	vertical-align: middle;
	transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
input[type="checkbox"]::before {
	content: "";
	width: 10px;
	height: 10px;
	transform: scale(0);
	transition: transform 0.12s cubic-bezier(0.2, 0.8, 0.3, 1.2);
	clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
	background: #fff;
}
input[type="checkbox"]:checked {
	background: var(--accent-gradient);
	border-color: transparent;
}
input[type="checkbox"]:checked::before { transform: scale(1); }
input[type="checkbox"]:hover:not(:disabled) { border-color: var(--accent-2); }
input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
input[type="checkbox"]:disabled { opacity: 0.5; cursor: not-allowed; }

/* Radio buttons -- same custom-drawn treatment as checkboxes above (round
   instead of square, a dot instead of a checkmark), first used by
   Settings' Bandwidth Quota Enforcement Policy choice. */
input[type="radio"] {
	appearance: none;
	-webkit-appearance: none;
	width: 18px;
	height: 18px;
	flex: none;
	margin: 0;
	display: inline-grid;
	place-content: center;
	border: 1.5px solid var(--border-light);
	border-radius: 50%;
	background: var(--surface-alt);
	cursor: pointer;
	vertical-align: middle;
	transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
input[type="radio"]::before {
	content: "";
	width: 8px;
	height: 8px;
	border-radius: 50%;
	transform: scale(0);
	transition: transform 0.12s cubic-bezier(0.2, 0.8, 0.3, 1.2);
	background: #fff;
}
input[type="radio"]:checked {
	background: var(--accent-gradient);
	border-color: transparent;
}
input[type="radio"]:checked::before { transform: scale(1); }
input[type="radio"]:hover:not(:disabled) { border-color: var(--accent-2); }
input[type="radio"]:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
input[type="radio"]:disabled { opacity: 0.5; cursor: not-allowed; }

/* Label wrapping a checkbox (the checkbox itself + its text, e.g. "Restrict
   by country") -- one class instead of each template hand-rolling the same
   `display:flex;align-items:center;gap:8px;cursor:pointer` inline style. */
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
/* A horizontal group of several checkbox-labels (e.g. the Windows/Linux/
   macOS "Allowed Device OS" set) -- wraps on narrow screens instead of
   overflowing. */
.checkbox-row { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 4px; }
/* Vertical variant, one checkbox-label per line -- for a long list of
   independent toggles (Settings -> Slack Integration's per-event-type
   checkboxes) where a wrapping horizontal row reads as a jumbled grid
   instead of a scannable list. Same gap as .checkbox-row, just stacked. */
.checkbox-col { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }

/* Slack Integration's per-event-type groups (Support Center/Security
   Events/System Events/Operational Events, each a .checkbox-col above) --
   stacked one-under-another was taking up most of the Settings page's
   vertical space (reported live 2026-08-21). A responsive grid instead:
   auto-fit naturally lands on 3-4 columns at a normal desktop width (each
   group needs ~220px to read comfortably) and gracefully collapses to
   fewer/1 column on a narrower viewport, rather than a fixed column count
   that would either overflow or leave awkward empty space. */
.notify-groups-grid {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
	gap: 18px 28px;
	align-items: start;
}

/* --- Phone input (dial-code <select> + local-number field), see app.js's
   createPhoneInput -- used anywhere a User.phone is entered/edited.
   Previous pass widened the OUTER .field wrapper but left
   .phone-local-input's min-width at 0 -- under real layout pressure
   flexbox honored the select's min-width floor first and shrank the
   number input toward that 0 floor instead, which is the actual bug (the
   number being typed wasn't fully visible). Fixed by giving the local
   input its own firm minimum (160px, comfortable for 10-14 digits) so it
   can never be squeezed away, and joining the two controls into one
   visually-grouped unit (shared border, no gap, radius only on the outer
   edges) rather than two separate fields with daylight between them. */
.phone-input { display: flex; max-width: 460px; }
.phone-input .phone-dial-select {
	flex: 0 1 190px; min-width: 150px;
	border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none;
}
.phone-input .phone-local-input {
	flex: 1 1 180px; min-width: 160px;
	border-top-left-radius: 0; border-bottom-left-radius: 0;
}
.phone-input .phone-dial-select:focus, .phone-input .phone-local-input:focus { z-index: 1; position: relative; }

/* --- Password field + its "Generate" button (Add/Edit User forms). */
.password-field-row { display: flex; gap: 8px; align-items: center; }
.password-field-row input { flex: 1; min-width: 0; }
.password-field-row button { flex: 0 0 auto; white-space: nowrap; }

/* --- Password show/hide toggle (app.js's initPasswordToggles) ----------
   Wraps every input[type=password] app-wide. `flex: 1` here (not on the
   input directly, per initPasswordToggles' own docstring) is what keeps
   `.password-field-row`'s input+"Generate"-button flex pairing intact once
   the input is one level deeper inside this wrapper -- harmless outside a
   flex parent (a `flex` value on a non-flex-item is simply ignored). Extra
   right padding on the input makes room for the button without it
   overlapping typed text; same border/background/radius language as every
   other text input in style.css, just scoped narrowly enough not to need
   redeclaring those here. */
.password-toggle-wrap { position: relative; flex: 1; min-width: 0; }
.password-toggle-wrap input[type="password"],
.password-toggle-wrap input[type="text"] { padding-right: 42px; }
.password-toggle-btn {
	position: absolute;
	top: 50%;
	right: 4px;
	transform: translateY(-50%);
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 30px;
	padding: 0;
	border: none;
	border-radius: 7px;
	background: transparent;
	color: var(--text-faint);
	box-shadow: none;
	cursor: pointer;
}
.password-toggle-btn:hover { color: var(--text-muted); background: var(--surface-alt); transform: translateY(-50%); }
.password-toggle-btn:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; color: var(--accent-2); }
.password-toggle-btn svg { display: block; }

/* Generic input+button row -- same flex layout as .password-field-row
   above, just not password-specific (e.g. the OpenVPN Install page's Port
   field + "Default" reset button). Kept as its own class rather than
   reusing .password-field-row so the name isn't misleading wherever it's
   applied. */
.input-with-action { display: flex; gap: 8px; align-items: center; }
.input-with-action input { flex: 1; min-width: 0; }
.input-with-action button { flex: 0 0 auto; white-space: nowrap; }

/* --- Inline field validation (email/phone), see app.js's
   attachInlineValidation -- this app's first use of this pattern, so it's
   reused as-is anywhere else inline validation is added later. */
input.input-invalid, select.input-invalid { border-color: var(--danger-strong); box-shadow: 0 0 0 3px var(--danger-bg); }
.field-error { color: var(--danger-text); font-size: 0.78rem; font-weight: 600; margin-top: 5px; min-height: 0; }
.field-error:empty { margin-top: 0; }

.empty-state { text-align: center; padding: 48px 20px; color: var(--text-muted); font-weight: 500; }

.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 12px; flex-wrap: wrap; }
.toolbar .toolbar-title { display: flex; align-items: baseline; gap: 10px; }
.count-badge { font-size: 0.85rem; font-weight: 700; color: var(--text-muted); background: var(--surface-alt); border: 1px solid var(--border-light); padding: 3px 12px; border-radius: 999px; }

.restrictions-summary { margin: 10px 0 0; font-size: 0.82rem; }
.restrictions-summary strong { color: var(--text); }

/* --- Per-client restriction badges (All Clients table) --------------- */
/* Deliberately NOT reusing .status-pill's green/red/amber palette -- those
   already mean online/offline/revoked elsewhere on this page. Restriction
   chips get their own indigo/amber/cyan hues (pulled from the same
   --accent-gradient stops used everywhere else in this app) so a client
   row with restrictions reads as "informational" rather than "warning".
   Only rendered when a client actually has that restriction set -- see
   renderClients() -- so the common case (no restrictions) shows nothing. */
.restriction-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.restriction-chip {
	display: inline-flex; align-items: center;
	padding: 1px 8px; border-radius: 999px;
	font-size: 0.7rem; font-weight: 700; letter-spacing: 0.01em;
	border: 1px solid transparent; white-space: nowrap;
}
.restriction-chip.chip-country { background: rgba(99, 102, 241, 0.14); color: #a5b4fc; border-color: rgba(99, 102, 241, 0.32); }
.restriction-chip.chip-os { background: rgba(251, 191, 36, 0.14); color: var(--warning-text); border-color: rgba(251, 191, 36, 0.32); }
.restriction-chip.chip-bw { background: rgba(34, 211, 238, 0.14); color: #67e8f9; border-color: rgba(34, 211, 238, 0.32); }
/* Users page login-restriction chips -- chip-country above is reused as-is
   (same restriction concept, just applied to logins instead of a VPN
   client); these three cover the login-only restriction types. */
.restriction-chip.chip-city { background: rgba(236, 72, 153, 0.14); color: #f9a8d4; border-color: rgba(236, 72, 153, 0.32); }
.restriction-chip.chip-asn { background: rgba(168, 85, 247, 0.14); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.32); }
.restriction-chip.chip-ip { background: rgba(52, 211, 153, 0.14); color: #6ee7b7; border-color: rgba(52, 211, 153, 0.32); }
/* Users table's combined Restrictions cell -- Portal Login Restrictions
   and VPN Access Restrictions stacked under their own small label so the
   two independent restriction sets never read as one combined restriction
   (see users.html's combinedRestrictionCellHtml). */
.restriction-row { display: flex; align-items: center; gap: 6px; }
.restriction-row + .restriction-row { margin-top: 4px; }
.restriction-row-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); flex-shrink: 0; }
.restriction-row .restriction-chips { margin-top: 0; }

.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
.filter-chip {
	background: var(--surface-alt);
	border: 1px solid var(--border-light);
	color: var(--text-muted);
	padding: 6px 14px;
	border-radius: 999px;
	font-size: 0.82rem;
	font-weight: 700;
	cursor: pointer;
}
.filter-chip.active { background: var(--accent-gradient); color: #fff; border-color: transparent; }

/* --- Login ------------------------------------------------------------
   Background is theme-driven (see app_settings.py's active_theme()):
   login.html renders exactly one of the 10 `.login-bg` variants below,
   server-selected, plus a `data-theme-bg="<id>"` attribute on
   `.login-page` that these rules key off. The original 6 variants' colors/
   shapes are ported verbatim from the approved theme preview; the 4 added
   2026-08-19 (lattice/flux/pulse/relay) are all canvas-based, following
   the same size()/init()/frame() shape as constellation/ingress/cipher/
   horizon below. --------------- */
.login-page {
	display: flex;
	align-items: center;
	justify-content: center;
	min-height: 100vh;
	position: relative;
	overflow: hidden;
	background: #0a0c16; /* constellation's base -- overridden below per theme */
}
.login-page[data-theme-bg="contour"] { background: #0c0f1a; }
.login-page[data-theme-bg="ingress"] { background: #05060c; }
.login-page[data-theme-bg="cipher"] { background: #0a0910; }
.login-page[data-theme-bg="perimeter"] {
	background:
		radial-gradient(900px 700px at 50% 42%, rgba(99, 102, 241, 0.10), transparent 65%),
		#0b0d18;
}
.login-page[data-theme-bg="horizon"] { background: #0a0c17; }
.login-page[data-theme-bg="lattice"] { background: #12060a; }
.login-page[data-theme-bg="flux"] { background: #0f0817; }
.login-page[data-theme-bg="pulse"] { background: #070c04; }
.login-page[data-theme-bg="relay"] { background: #0a0d12; }

.login-bg { position: absolute; inset: 0; overflow: hidden; z-index: 0; pointer-events: none; }
.login-bg canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* Signal Contour: faint oscilloscope-style waveform lines, drifting */
.login-bg svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.login-bg svg path { fill: none; stroke-width: 1.2; }
.login-bg .wave-1 { animation: theme-drift-x 60s linear infinite; }
.login-bg .wave-2 { animation: theme-drift-x 84s linear infinite reverse; }
.login-bg .wave-3 { animation: theme-drift-x 110s linear infinite; }
@keyframes theme-drift-x { from { transform: translateX(0); } to { transform: translateX(-400px); } }

/* Perimeter Grid: faint grid + slow radar-style sweep.
   Sized in vmax/% (not the fixed px the original preview used -- that
   preview's stage was a bounded ~1180px panel, but a real login page is
   the full, arbitrary-size viewport, so a fixed 900px mask only covered
   the center and left every larger screen's edges plain/uncovered). vmax
   scales with whichever of width/height is larger, guaranteeing the mask
   and sweep both extend past every edge on any screen size. */
.login-bg .grid {
	position: absolute; inset: -1px;
	background-image:
		linear-gradient(rgba(129, 140, 248, 0.10) 1px, transparent 1px),
		linear-gradient(90deg, rgba(129, 140, 248, 0.10) 1px, transparent 1px);
	background-size: 46px 46px;
	mask-image: radial-gradient(75vmax 60vmax at 50% 42%, #000 55%, transparent 92%);
}
.login-bg .sweep {
	position: absolute;
	left: 50%; top: 42%;
	width: 220vmax; height: 220vmax;
	margin: -110vmax 0 0 -110vmax;
	background: conic-gradient(from 0deg, rgba(34, 211, 238, 0.20), transparent 12%, transparent 100%);
	animation: theme-sweep-rotate 14s linear infinite;
	border-radius: 50%;
}
@keyframes theme-sweep-rotate { to { transform: rotate(360deg); } }

/* Data Horizon: soft glow band behind the drifting bars canvas */
.login-bg .glow {
	position: absolute; left: 0; right: 0; top: 46%;
	height: 220px;
	background: linear-gradient(180deg, transparent, rgba(139, 92, 246, 0.16), rgba(34, 211, 238, 0.10), transparent);
	filter: blur(20px);
}

@media (prefers-reduced-motion: reduce) {
	.login-bg svg path, .login-bg .sweep { animation: none !important; }
	.pulse-dot { animation: none !important; }
}

.login-box {
	position: relative;
	z-index: 1;
	background: rgba(30, 36, 54, 0.88);
	backdrop-filter: blur(20px);
	border: 1px solid rgba(255,255,255,0.08);
	border-radius: var(--radius-lg);
	box-shadow: var(--shadow-lg), 0 0 0 1px rgba(99,102,241,0.1);
	padding: 42px 38px;
	width: 100%;
	max-width: 380px;
}
.login-box .login-brand {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 14px;
	margin-bottom: 30px;
}
.login-box .login-logo {
	width: 52px; height: 52px;
	border-radius: 14px;
	/* Fixed, not var(--accent-gradient) -- see .sidebar .brand-icon's own
	   comment. Same fixed tile color for both, so the mark reads
	   identically whether the visitor is looking at the login page or
	   already inside the app. */
	background: #0b0f1a;
	display: flex; align-items: center; justify-content: center;
	font-size: 1.4rem;
	box-shadow: var(--accent-glow);
}
.login-box h1 { display: block; text-align: center; margin: 0; font-size: 1.4rem; }
.login-box button { width: 100%; justify-content: center; margin-top: 10px; padding: 11px; }
/* .login-box button's width:100% above (0,1,1 specificity) otherwise beats
   .password-toggle-btn's own 30px width (0,1,0), stretching the toggle
   button to cover the entire password field on login.html/reset_password.html
   and silently blocking every mouse click into the input -- keyboard/Tab
   focus still worked, so this was easy to miss without an actual click
   test. Re-declared here at (0,2,0) specificity to win back the small
   icon-button size inside .login-box specifically. */
.login-box .password-toggle-btn { width: 30px; margin-top: 0; padding: 0; }
.login-forgot { text-align: right; margin: -8px 0 4px; }
.login-forgot a { color: var(--text-muted); font-size: 0.84rem; text-decoration: none; }
.login-forgot a:hover { color: var(--accent-2); text-decoration: underline; }
.error-banner { background: var(--danger-bg); color: var(--danger-text); padding: 11px 14px; border-radius: 10px; margin-bottom: 18px; font-size: 0.9rem; font-weight: 600; border: 1px solid rgba(244, 63, 94, 0.3); }
.success-banner { background: rgba(16,185,129,0.12); color: #34d399; padding: 11px 14px; border-radius: 10px; margin-bottom: 18px; font-size: 0.9rem; font-weight: 600; border: 1px solid rgba(16,185,129,0.3); }

.field-hint { color: var(--text-muted); font-size: 0.8rem; margin: 5px 0 0; }

/* z-index alone can't beat a native <dialog>'s top-layer, which is why
   toast() re-parents into the currently-open dialog (if any) instead --
   see app.js. The z-index here still matters for the body-level container
   when no dialog is open. */
#toast-container { position: fixed; bottom: 24px; right: 24px; display: flex; flex-direction: column; gap: 10px; z-index: 9999; pointer-events: none; }
/* A toast container re-parented inside an open <dialog> renders inside that
   dialog's own top-layer stacking context, so it appears above the dialog's
   own content/backdrop without needing an even-higher z-index. */
dialog #toast-container { position: absolute; bottom: 16px; right: 16px; left: 16px; align-items: flex-end; }
.toast {
	background: var(--surface); border: 1px solid var(--border-light); color: var(--text);
	padding: 13px 42px 13px 20px; border-radius: 12px; box-shadow: var(--shadow-lg);
	font-size: 0.9rem; font-weight: 500; max-width: 380px; position: relative;
	pointer-events: auto; animation: toast-in 0.18s ease;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.toast.success { background: linear-gradient(135deg, #065f46, #10b981); border-color: transparent; color: #fff; }
.toast.error { background: linear-gradient(135deg, #9f1239, #f43f5e); border-color: transparent; color: #fff; }
.toast .toast-close {
	position: absolute; top: 8px; right: 8px; width: 22px; height: 22px;
	display: flex; align-items: center; justify-content: center;
	background: rgba(255,255,255,0.14); border: none; border-radius: 50%;
	color: inherit; font-size: 0.95rem; line-height: 1; padding: 0; cursor: pointer;
	box-shadow: none;
}
.toast .toast-close:hover { background: rgba(255,255,255,0.26); transform: none; }

.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.88em; }
.muted { color: var(--text-muted); }
.text-right { text-align: right; }

dialog { border: none; border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 0; max-width: 460px; width: 90%; background: var(--surface); color: var(--text); border: 1px solid var(--border-light); }
/* Edit User shows every section inline (Access, VPN Profile, Login
   Restrictions, Reset Password) with no collapsible panels -- much wider
   than the 460px every other dialog uses so fields get to expand into the
   available space rather than being squeezed into narrow columns. This was
   640px before; at that width the Login Restrictions .form-grid's City/ASN
   fields (a fixed-width 200px country <select> plus a multiselect, see
   .geo-picker-row) only had ~80-100px left for the multiselect itself --
   values truncated and felt "congested". Widening the dialog itself (not
   individual fields) is the fix; #edit-dialog .form-grid below then widens
   its own columns to actually use the extra room. */
#edit-dialog { max-width: 920px; width: 94%; }
#edit-dialog .form-grid { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
/* Add User has the same fieldsets/Location & Network Restrictions shape as
   Edit User (it's the create-side twin of that same form) -- same width
   override, same reasoning. */
#add-user-dialog { max-width: 920px; width: 94%; }
/* Manage Restrictions (clients.html) grew a Location & Network
   Restrictions section with the same City/ASN geo-picker-row fields as
   Edit User -- same widening rationale as #edit-dialog above. */
#policy-dialog { max-width: 780px; width: 94%; }
/* Duplicate Cleanup (support_center.html) -- was widened via
   `min-width:520px` on the inner .dialog-body instead of the <dialog>
   element itself, which stays capped at the base rule's 460px max-width
   regardless -- an inner min-width the outer box can never actually
   satisfy just overflows/clips inside the frame. Same widening rationale
   as #edit-dialog above (each cluster's own ticket list + action button
   need real room), applied to the right element this time. */
#sc-cluster-dialog { max-width: 640px; width: 94%; }
/* Per-cluster block inside that dialog -- was a full .card (page-level
   border + box-shadow + 24px padding), which nested inside an
   already-elevated dialog frame looked heavy and redundant (a shadowed
   box inside a shadowed box). Same lighter, borderless-shadow treatment
   as .ua-chart-card elsewhere in the app for "a labeled sub-block inside
   one card/dialog", not "a page-level section" in its own right. */
.sc-cluster-card { background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 12px; }
.sc-cluster-card:last-child { margin-bottom: 0; }
.sc-cluster-ticket-list { display: flex; flex-direction: column; gap: 6px; }
.sc-cluster-ticket-row { align-items: flex-start; gap: 8px; }
dialog::backdrop { background: rgba(8, 10, 16, 0.72); backdrop-filter: blur(2px); }
dialog .dialog-body { padding: 28px; }
dialog .dialog-body h2 { background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block; }
dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
dialog ul { padding-left: 20px; margin: 8px 0; }
dialog li { margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }

/* --- Group rows (groups.html) -- a plain table row per group, same list
   pattern as Clients/Revoked/Users, instead of a KPI-style card grid.
   Card grids size columns by auto-fit, which looks fine for short fixed
   numbers (dashboard stats) but produced uneven row widths and
   truncated/colliding text once the content was a variable-length group
   name -- a table keeps every row's columns aligned regardless of name
   length. Also reused as-is (clickable row + relative-size bar) by Users
   Activity's Most Active Users leaderboard (users_activity.html) -- the
   pattern is generic (a clickable named row with a proportional count
   bar), not actually group-specific, despite the class name.
   Renamed from .team-row* -- the Team->Group rename (2026-08-22) already
   updated every template's class names to .group-row*, but missed this
   CSS file, so these rows (on both groups.html and users_activity.html)
   had been rendering completely unstyled since that merge. */
tr.group-row { cursor: pointer; }
tr.group-row:hover td { background: var(--surface-alt); }
tr.group-row:focus-visible { outline: 2px solid var(--accent-2); outline-offset: -2px; }
.group-row-count { display: flex; align-items: center; gap: 10px; }
.group-row-bar { position: relative; display: inline-block; width: 110px; height: 6px; border-radius: 999px; background: var(--surface-alt); overflow: hidden; flex: none; }
.group-row-bar-fill { position: absolute; inset: 0; width: 0; border-radius: inherit; background: var(--accent-gradient); }
.group-row-bar-fill.muted-fill { background: var(--text-faint); opacity: 0.5; }
.group-row-view { color: var(--text-faint); font-size: 0.82rem; font-weight: 600; opacity: 0; transition: opacity 0.15s ease; }
tr.group-row:hover .group-row-view, tr.group-row:focus-visible .group-row-view { opacity: 1; }
/* The currently-open group's row, so switching between groups doesn't
   lose track of which one you're looking at once the list and the detail
   panel are side by side (see .groups-layout below) instead of stacked
   with the list scrolled out of view. */
tr.group-row.group-row-selected td { background: var(--surface-alt); box-shadow: inset 3px 0 0 var(--accent-2); }

/* --- Groups page (groups.html) -- master/detail layout: a narrower list
   column that stays fully visible next to a wider detail column, instead
   of the list and the selected group's members/roles stacking full-width
   one after another (which meant scrolling past a potentially long member
   list just to get back to the group list). Falls back to a single
   stacked column below 900px, where there isn't room for both side by
   side. The placeholder card (shown before any group is selected) and the
   real detail card occupy the same grid slot -- exactly one of the two is
   visible at a time, toggled in showGroup()/closeGroupDetail(). */
.groups-layout { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 20px; align-items: start; }
/* .groups-detail-col holds the placeholder and the real detail card --
   exactly one visible at a time -- so the grid only ever sees two items
   (list, detail-col) and the second one never wraps onto its own row. */
.groups-detail-col { min-width: 0; }
.groups-list-card, .groups-detail-placeholder, .groups-detail-card { margin-bottom: 0; min-width: 0; }
.groups-detail-placeholder .empty-state { margin: 0; }

/* Small "x" clear button inside a search input (Groups page's group
   search) -- generic enough (no page-specific class names beyond the
   wrapper) to reuse anywhere else in the app that wants the same. The
   button is absolutely positioned over the input's own right padding
   rather than sitting beside it, so the field's width doesn't change
   when the button appears/disappears as text is typed/cleared. */
.search-input-clearable { position: relative; }
.search-input-clearable input { padding-right: 28px; }
.search-clear-btn {
	position: absolute; top: 50%; right: 6px; transform: translateY(-50%);
	width: 20px; height: 20px; border: none; background: none; cursor: pointer;
	color: var(--text-faint); font-size: 1.1rem; line-height: 1; border-radius: 4px;
	display: flex; align-items: center; justify-content: center;
}
.search-clear-btn:hover { color: var(--text); background: var(--surface-alt); }
@media (max-width: 900px) { .groups-layout { grid-template-columns: 1fr; } }
/* Members and Assigned Roles as two visually distinct sub-sections within
   the detail card, rather than a border-top rule as the only separator --
   this is a real "page" of content now (a group can have several members
   and several roles), not a short appendix under the member table. */
.groups-detail-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.groups-detail-section:first-of-type { margin-top: 16px; }

/* --- Health page -- one card per health source (App/Database/Host
   Machine/Traefik), each a compact label/value list rather than a table
   (unlike the PKI-consistency/lint cards below them, which list a
   variable number of named issues and stay full-width table cards). */
.health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 18px; margin-bottom: 22px; }
.health-grid .card { margin-bottom: 0; min-width: 0; }

/* Pairs of short subsections inside the (now full-width) Traefik health
   card -- Entrypoints+Middlewares, Requests' By-service+By-entrypoint --
   side by side instead of stacked, since there's a full card's width to
   use. align-items:start, not the grid default of stretch -- exactly the
   "a shorter sibling gets stretched to match a taller one" issue
   health.html's own comment explains for why Traefik left .health-grid
   in the first place, just scoped here to two short tables instead of
   four whole cards. */
.traefik-subsection-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 4px 24px; align-items: start; }
/* Entrypoints/Middlewares and the Requests "by service"/"by entrypoint"
   pair both want a fixed, even 2-column split -- auto-fit above collapses
   a side to full width when its partner is short or empty (e.g. no
   middlewares reported), which is what threw the pair out of alignment.
   Falls back to a single column below 560px so neither table gets
   squeezed unreadably narrow on a small viewport. */
.traefik-subsection-grid-2col { grid-template-columns: 1fr 1fr; }
@media (max-width: 560px) { .traefik-subsection-grid-2col { grid-template-columns: 1fr; } }
/* Vertical rule between "By service" and "By entrypoint" -- requested
   specifically for this pair, not Entrypoints/Middlewares below it (same
   grid class, deliberately left untouched), so this lives on its own
   modifier rather than .traefik-subsection-grid-2col itself.
   .health-kv-value (the "N req" figure) is text-align:right against the
   full width of this column -- with no padding, the border sat flush
   against that text, reading as cut off/hidden behind the rule.
   padding-right pulls the line away from it. min-height guarantees the
   rule still reads as a real divider (not a token sliver) even when one
   or both sides only have a couple of entries. */
.traefik-metrics-grid > div:first-child { border-right: 1px solid var(--border-light); padding-right: 16px; min-height: 90px; }
.traefik-metrics-grid > div:last-child { padding-left: 16px; min-height: 90px; }
@media (max-width: 560px) {
	.traefik-metrics-grid > div:first-child { border-right: none; padding-right: 0; min-height: 0; }
	.traefik-metrics-grid > div:last-child { padding-left: 0; min-height: 0; }
}
/* Only this one pair overrides the grid's own align-items: start (see
   .traefik-subsection-grid's comment on why that's the default) --
   without it, the border-right above only runs as tall as "By service"'s
   own content, stopping short of a taller "By entrypoint" list instead of
   reading as a full-height rule between the two. */
.traefik-metrics-grid { align-items: stretch; }
/* Small label ("Entrypoints:", "Middlewares:", "By service:", "By
   entrypoint:") sitting directly above its own kv-list -- one consistent
   style for all four instead of two of them being full <h3> headings and
   the other two being ad-hoc inline-styled <p> tags. */
.traefik-subsection-label { margin: 0 0 6px; font-size: 0.85rem; font-weight: 700; color: var(--text-muted); }

/* Simple key(left)/value(right) row list -- Health page's Traefik
   Entrypoints/Middlewares and Requests subsections (see health.html's
   kvList()/traefikReqTotals()). A plain aligned list instead of a table
   for data that's really just name->value lookups: a table header row
   and grid of columns is more structure than a single value per row
   needs, and reads slower at a glance than key left/value right. Also
   fixes a real readability problem the table version had for the
   Requests rows specifically -- several independent values (count,
   per-status breakdown, latency) packed into one table row with no
   visual separation between them, which was unreadable once selected
   and copied as plain text. */
.health-kv-list { display: flex; flex-direction: column; }
.health-kv-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 6px 0; }
.health-kv-key { color: var(--text-muted); overflow-wrap: anywhere; }
.health-kv-value { color: var(--text); font-weight: 600; text-align: right; flex-shrink: 0; }
/* A plain Entrypoints/Middlewares row (no wrapper -- see kvList()) gets
   its own separator; a Requests entry wraps its key/value row + detail
   line in .traefik-req-row instead (see traefikReqTotals()) so the pair
   shares one separator below the detail line, not two. */
.health-kv-list > .health-kv-row { border-bottom: 1px solid var(--border-light); }
.health-kv-list > .health-kv-row:last-child { border-bottom: none; }
.traefik-req-row { border-bottom: 1px solid var(--border-light); padding-bottom: 6px; }
.traefik-req-row .health-kv-row { padding-bottom: 0; }
.traefik-req-row:last-child { border-bottom: none; }
/* Secondary detail line under a Requests row (status-code breakdown +
   avg latency) -- smaller and muted since the row's own key/value pair
   above (name -> request count) is the number anyone scans this for. */
.traefik-req-detail { padding-top: 2px; font-size: 0.82rem; }

/* --- Reports' Analytics section (see reports.html; moved here from the
   Dashboard) -- one tile per chart, same responsive-grid-of-cards shape as
   .health-grid above but nested inside a single outer .card rather than
   being the page's own top-level grid (this section is one card among
   several on the Reports page, not the whole page). */
.ua-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 18px; }
.ua-chart-card { background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: var(--radius-lg); padding: 16px 18px; min-width: 0; }
.ua-chart-card h3 { margin: 0 0 14px; font-size: 0.92rem; font-weight: 700; color: var(--text); }

/* Chart card header row: title (h3, above) on the left, an optional
   chart-type <select> (app.js's chartTypeSelector()) on the right. Not
   every chart card has one (Peak Usage Hours' heatmap and Session
   Duration's fixed histogram don't render a .ua-chart-type-mount at all
   in that case, so this row silently collapses to just the title). */
.ua-chart-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.ua-chart-card-head h3 { margin: 0; }
.chart-type-select { font-size: 0.8rem; padding: 4px 26px 4px 10px; height: auto; }

/* Fixed-height mount for a Chart.js canvas -- Chart.js's
   maintainAspectRatio:false (see buildChartJsConfig() in app.js) makes the
   canvas fill its parent's size exactly, so that parent needs an explicit
   height or the chart collapses to 0px. */
.chart-canvas-mount { position: relative; height: 260px; }

/* Summary stat row above the Analytics charts (total sessions/bandwidth/
   avg duration/peak hour in the selected range) -- same visual language
   as .stat-grid's cards elsewhere in the app, just smaller/denser since
   these sit inside a single card rather than being the page's own
   top-level stat row. */
.ua-summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 20px; }
.ua-summary-stat { background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: var(--radius-lg); padding: 12px 16px; }
.ua-summary-stat .ua-summary-value { font-size: 1.4rem; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
.ua-summary-stat .ua-summary-label { font-size: 0.78rem; color: var(--text-muted); margin-top: 2px; }

/* Peak Usage Hours heatmap (app.js's renderHeatmapGrid()) -- a CSS-grid
   layout with 4 regions: an empty corner, hour labels along the top, day
   labels down the left, and the cell matrix itself, so labels never
   scroll independently of the cells they annotate. */
/* .heatmap-row-labels and .heatmap-cells both use the same grid-auto-rows
   (22px) + gap (3px) so their rows line up exactly regardless of column
   count/width -- deliberately NOT aspect-ratio-based cells (which would
   size row height off the column width instead, and could drift out of
   sync with an independently-flexed label column). */
.heatmap-grid { display: grid; grid-template-columns: 46px 1fr; grid-template-rows: 20px auto; gap: 4px; }
.heatmap-corner { grid-column: 1; grid-row: 1; }
.heatmap-col-labels { grid-column: 2; grid-row: 1; display: grid; gap: 3px; }
.heatmap-col-labels span { font-size: 0.68rem; color: var(--text-muted); text-align: center; }
.heatmap-row-labels { grid-column: 1; grid-row: 2; display: grid; grid-auto-rows: 22px; gap: 3px; }
.heatmap-row-labels span { font-size: 0.72rem; color: var(--text-muted); display: flex; align-items: center; }
.heatmap-cells { grid-column: 2; grid-row: 2; display: grid; grid-auto-rows: 22px; gap: 3px; }
.heatmap-cell { border-radius: 3px; transition: transform 0.1s; }
.heatmap-cell:hover { transform: scale(1.15); }

/* Wide/unbreakable content (e.g. Traefik's router-rule table below) inside
   a narrow health-grid tile: a horizontal-scroll fallback for anything
   still too wide after wrapping, so it can never push the card itself
   wider than its grid track and overflow into a neighbor. min-width: 0 on
   .health-grid .card above is required for this to work at all -- a grid
   item's default min-width is auto (= its content's intrinsic width), which
   silently overrides any child overflow-x rule and defeats the whole point. */
/* A small bordered callout for a card's explanatory blurb -- distinct from
   a bare .muted paragraph (which reads as unstyled/orphaned floating text)
   without being as loud as a status pill or warning banner. Prefixed with
   "Note:" in markup, not via a ::before/content trick, so it stays
   selectable/copyable and reads correctly to a screen reader. */
.note { background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: 8px; padding: 10px 14px; margin: 0 0 16px; font-size: 0.85rem; color: var(--text-muted); }
.note strong { color: var(--text); }

/* Own Records Only vs Any Records legend on the Roles permission matrix
   (roles.html) -- explains the Scope column in plain language, next to the
   same wording used in the column's own tooltip and each <select>'s
   options. */
.scope-legend { display: flex; flex-direction: column; gap: 8px; background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: 8px; padding: 12px 14px; }
.scope-legend-item { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: 0.85rem; }
.scope-legend-item .status-pill { flex: none; }

.table-scroll { overflow-x: auto; }
.traefik-router-table { table-layout: fixed; }
.traefik-router-table .traefik-rule-cell { font-size: 0.82em; overflow-wrap: anywhere; }
.metric-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.metric-row:last-child { border-bottom: none; }
.metric-label { color: var(--text-muted); font-weight: 600; }
.metric-value { display: flex; align-items: center; gap: 8px; text-align: right; }
.metric-pct { font-weight: 700; font-variant-numeric: tabular-nums; min-width: 2.6em; text-align: right; }
.usage-bar { position: relative; display: inline-block; width: 90px; height: 6px; border-radius: 999px; background: var(--surface-alt); overflow: hidden; flex: none; }
.usage-bar-fill { position: absolute; inset: 0; width: 0; border-radius: inherit; }
.usage-bar-fill.usage-bar-ok { background: linear-gradient(90deg, #10b981, #34d399); }
.usage-bar-fill.usage-bar-warning { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.usage-bar-fill.usage-bar-critical { background: linear-gradient(90deg, #f43f5e, #fb7185); }

.mac-list { list-style: none; padding: 0; margin: 12px 0; display: flex; flex-direction: column; gap: 8px; }
.mac-list li { background: var(--surface-alt); border: 1px solid var(--border-light); border-radius: 8px; padding: 8px 12px; display: flex; align-items: center; justify-content: space-between; }
.mac-add-row { display: flex; gap: 8px; margin-top: 14px; }
.mac-add-row input { flex: 1; }

.profile-header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
.avatar-circle { width: 56px; height: 56px; border-radius: 16px; background: var(--accent-gradient); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 1.3rem; color: #fff; box-shadow: var(--accent-glow); flex: none; }

.tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.tab-btn { background: none; border: none; box-shadow: none; color: var(--text-muted); padding: 10px 4px; margin-right: 22px; border-radius: 0; border-bottom: 2px solid transparent; font-weight: 700; }
.tab-btn:hover { color: var(--text); background: none; }
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent-2); }

/* Dashboard "Activity Breakdown" donut chart + legend (see dashboard.html).
   Legend doubles as the WCAG table-view equivalent of the chart -- every
   value the donut encodes visually is also plain text here. */
.donut-layout { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 4px; }
.donut-chart { width: 84px; height: 84px; flex: none; }
/* Rotation is applied here in CSS, not as an SVG "transform" attribute on
   the <circle> -- mixing a presentation-attribute transform with a CSS
   `transition: transform` (as this previously did) is a known Chromium
   paint bug: the segment can silently fail to render on first paint after
   an innerHTML swap (which happens on every periodic dashboard refresh)
   until something forces a style recalc. Keeping the whole transform
   CSS-driven from the start avoids the attribute/CSS-transition conflict,
   and lets the hover scale compose with the base rotation instead of
   replacing it (a CSS `transform` overrides the attribute entirely, it
   doesn't combine with it). */
.donut-segment { transition: opacity 0.15s ease; transform: rotate(-90deg); transform-origin: 70px 70px; cursor: pointer; }
.donut-segment:hover { opacity: 0.82; transform: rotate(-90deg) scale(1.035); }
.donut-center-value { font-size: 1.1rem; font-weight: 800; fill: var(--text); font-family: inherit; }
.donut-center-label { font-size: 0.42rem; fill: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; font-family: inherit; }
.donut-legend { flex: 1; min-width: 170px; max-width: 320px; font-size: 0.83rem; }
.donut-legend th { background: none; padding: 3px 8px; font-size: 0.64rem; }
.donut-legend td { padding: 5px 8px; border-bottom: 1px solid var(--border); }
.donut-legend tr:last-child td { border-bottom: none; }
.donut-legend tr:hover td { background: var(--surface-hover); }

/* Shared renderDonutChart() (app.js) -- used by Diagnostics' rejection
   charts. Same hover treatment as the Dashboard's Activity Breakdown
   donut above, kept as a separate block since this one isn't built from
   the .donut-legend <table> markup. */
.donut-legend-row:hover { background: var(--surface-hover); }
.legend-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 9px; vertical-align: middle; }
.activity-breakdown #activity-stat-grid { margin-bottom: 24px; }

/* --- FAQ page (faq.html) -- plain <details>/<summary> accordion, no JS
   needed to open/close a single entry (only the search box's show/hide
   filtering is scripted). Deliberately not reusing .card per-question --
   a bordered box around every single Q&A reads as heavier/more
   "technical" than this page is meant to feel; one .card per category
   with borderless rows inside keeps it closer to a plain document. */
.faq-search { max-width: 420px; margin-bottom: 24px; }
.faq-category { margin-bottom: 10px; }
.faq-category:last-child { margin-bottom: 0; }
.faq-item { border-bottom: 1px solid var(--border); }
.faq-item:last-child { border-bottom: none; }
.faq-item[hidden] { display: none; }
.faq-item summary {
	list-style: none;
	cursor: pointer;
	padding: 14px 4px;
	font-weight: 600;
	color: var(--text);
	display: flex;
	align-items: center;
	gap: 10px;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::before {
	content: "+";
	flex: none;
	width: 20px;
	height: 20px;
	display: flex;
	align-items: center;
	justify-content: center;
	border-radius: 6px;
	background: var(--surface-alt);
	color: var(--accent-2);
	font-size: 0.95rem;
	font-weight: 700;
	transition: transform 0.15s ease;
}
.faq-item[open] summary::before { content: "\2212"; transform: rotate(180deg); }
.faq-item summary:hover { color: var(--accent-2); }
.faq-item summary:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; border-radius: 6px; }
.faq-item .faq-answer { padding: 0 4px 16px 34px; color: var(--text-muted); line-height: 1.6; }
.faq-item .faq-answer p { margin: 0 0 10px; }
.faq-item .faq-answer p:last-child { margin-bottom: 0; }
.faq-item .faq-answer code { background: var(--surface-alt); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 0.88em; }
.faq-no-results { text-align: center; padding: 32px 0; }
.faq-contact { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
