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

* { 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 {
	width: 252px;
	flex: 0 0 252px;
	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: 10px;
	padding: 4px 10px 26px;
	text-decoration: none;
}
.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;
	width: 32px;
	height: 32px;
	padding: 6px;
	border-radius: 10px;
	background: var(--accent-gradient);
	box-shadow: var(--accent-glow);
}
.sidebar .brand-icon svg { display: block; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.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: 1rem; width: 18px; text-align: center; opacity: 0.8; }
.sidebar nav a:hover { text-decoration: none; color: var(--text); background: var(--surface); }
.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);
}
/* --- 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); }
.notif-item-time { color: var(--text-muted); font-size: 0.75rem; margin-top: 4px; }

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

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

.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); }
tr.row-dim td { opacity: 0.55; }
tr.row-online td:first-child { box-shadow: inset 3px 0 0 var(--success-strong); }
/* VPN Clients' single-row-select toolbar redesign -- the selected row gets
   a persistent highlight distinct from the transient :hover above, so it
   stays visually obvious which client the toolbar buttons above the table
   will act on even after the mouse moves away. */
tr.row-selected td { background: var(--surface-alt); }
tr.row-selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); }

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

/* 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;
	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); }
.btn-sm { padding: 5px 12px; font-size: 0.78rem; }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; }

input[type=text], input[type=password], input[type=date], input[type=email], input[type=tel], 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; }

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

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

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

.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 6 `.login-bg` variants below,
   server-selected, plus a `data-theme-bg="<id>"` attribute on
   `.login-page` that these rules key off. Each variant's colors/shapes
   are ported verbatim from the approved theme preview. --------------- */
.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-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; }
}

.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;
	background: var(--accent-gradient);
	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; }
.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); }

/* OpenVPN Install page (see openvpn_install.html) -- reuses .error-banner's
   token palette for its "danger zone" banner rather than inventing a new
   one, since it's the same semantic (destructive-action warning). */
.ovi-danger-banner { background: var(--danger-bg); color: var(--danger-text); border: 1px solid rgba(244, 63, 94, 0.3); }
.ovi-gate-card { max-width: 480px; }
.ovi-section-heading { font-size: 0.92rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 22px 0 10px; }
.ovi-section-heading:first-of-type { margin-top: 0; }
.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%; }
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; }

/* --- Team rows (teams.html) -- a plain table row per team, 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 team
   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 team-specific, despite the class name. */
tr.team-row { cursor: pointer; }
tr.team-row:hover td { background: var(--surface-alt); }
tr.team-row:focus-visible { outline: 2px solid var(--accent-2); outline-offset: -2px; }
.team-row-count { display: flex; align-items: center; gap: 10px; }
.team-row-bar { position: relative; display: inline-block; width: 110px; height: 6px; border-radius: 999px; background: var(--surface-alt); overflow: hidden; flex: none; }
.team-row-bar-fill { position: absolute; inset: 0; width: 0; border-radius: inherit; background: var(--accent-gradient); }
.team-row-bar-fill.muted-fill { background: var(--text-faint); opacity: 0.5; }
.team-row-view { color: var(--text-faint); font-size: 0.82rem; font-weight: 600; opacity: 0; transition: opacity 0.15s ease; }
tr.team-row:hover .team-row-view, tr.team-row:focus-visible .team-row-view { opacity: 1; }

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

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