/* =============================================================
   TBPanel admin UI — sidebar layout + content
   ============================================================= */

* { box-sizing: border-box; }

/* The UA's [hidden] rule loses to any class that sets `display`, and the
   shared components here set it constantly (.form-field is a flex column,
   .check-cards a grid). JS toggles visibility via el.hidden, so make the
   attribute win. */
[hidden] { display: none !important; }

/* -------------------------------------------------------------
   Design tokens.

   Every surface/ink/line colour in this file goes through a token so
   the dark theme is a single override block instead of a second copy
   of the stylesheet. Rule of thumb when adding CSS:
     --panel / --panel-2 / --panel-3  surfaces, lightest → most filled
     --ink / --ink-body / --ink-soft  text, strongest → most muted
     --tint-*-bg / --tint-*-ink       status chips (badges, banners)
   Never hardcode a hex for those; only fully-saturated colours that
   read the same on both themes (white text on a solid accent button)
   stay literal.
   ------------------------------------------------------------- */
:root {
  color-scheme: light;

  --bg:        #f5f7fb;
  --panel:     #ffffff;
  --panel-2:   #f8fafc;
  --panel-3:   #f1f5f9;
  --ink:       #0f172a;
  --ink-body:  #334155;
  --ink-soft:  #64748b;
  --line:      #e5e9f0;
  --line-strong: #cbd5e1;
  --accent:    #3b6df0;
  --accent-h:  #2a55d4;
  --accent-soft: #eef2ff;
  --danger:    #dc2626;
  --danger-h:  #b91c1c;
  --danger-soft: #fef2f2;
  --danger-line: #fecaca;
  /* Solid "inverted" button (login submit) — near-black on light,
     near-white on dark, so it stays the loudest control either way. */
  --invert-bg:  #111827;
  --invert-bg-h:#2a2a2a;
  --invert-ink: #ffffff;

  --shadow-sm: 0 1px 3px rgba(0,0,0,.05);
  --shadow-card: 0 1px 4px rgba(0,0,0,.08);
  --shadow-menu: 0 10px 25px -10px rgba(15,23,42,.25), 0 2px 4px rgba(15,23,42,.04);
  --shadow-dialog: 0 20px 45px -15px rgba(15,23,42,.35);

  /* Status tints — badges, banners, chips. */
  --tint-info-bg:    #dbeafe;  --tint-info-ink:    #1e40af;
  --tint-info-soft:  #eff6ff;  --tint-info-line:   #bfdbfe;
  --tint-ok-bg:      #d1fae5;  --tint-ok-ink:      #065f46;
  --tint-warn-bg:    #fef3c7;  --tint-warn-ink:    #92400e;
  --tint-warn-soft:  #fffbeb;  --tint-warn-line:   #fcd34d;
  --tint-err-bg:     #fee2e2;  --tint-err-ink:     #991b1b;
  --tint-neutral-bg: #e5e7eb;  --tint-neutral-ink: #4b5563;
  --tint-violet-bg:  #ede9fe;  --tint-violet-ink:  #5b21b6;
  --tint-cyan-bg:    #cffafe;  --tint-cyan-ink:    #155e75;

  /* Sidebar — light in day mode for both the workspace and /admin
     shells; the admin variant only shifts its surface a notch and
     keeps its own badge for context. */
  --sidebar-bg:          #ffffff;
  --sidebar-ink:         #0f172a;
  --sidebar-line:        var(--line);
  --sidebar-item:        #475569;
  --sidebar-item-hover:  #f1f5f9;
  --sidebar-item-hover-ink: var(--ink);
  --sidebar-active-bg:   var(--accent-soft);
  --sidebar-active-ink:  var(--accent);
  --sidebar-label:       var(--ink-soft);
  --sidebar-chip-bg:     #f8fafc;
  --sidebar-admin-bg:    #f8fafc;

  --sidebar-w: 232px;
  --topbar-h:  60px;
}

/* -------------------------------------------------------------
   Dark theme.

   Scoped to the app shell (`body.layout-app`) on purpose: the public
   login/setup surfaces ship their own self-contained light styling,
   so flipping their tokens would leave half-dark pages. The toggle
   only appears inside the shell for the same reason.
   ------------------------------------------------------------- */
[data-theme="dark"] body.layout-app {
  color-scheme: dark;

  --bg:        #0a1020;
  --panel:     #131c2e;
  --panel-2:   #19233a;
  --panel-3:   #1f2b46;
  --ink:       #e6edf7;
  --ink-body:  #cbd5e1;
  --ink-soft:  #94a3b8;
  --line:      #243049;
  --line-strong: #34425f;
  /* Same accent as light mode keeps the brand mark / avatar
     legible on white text in both themes (~4.5:1). The hover shade
     stays a touch darker for the same reason. */
  --accent:    #3b6df0;
  --accent-h:  #2a55d4;
  --accent-soft: rgba(59,109,240,.18);
  --danger:    #ef4444;
  --danger-h:  #dc2626;
  --danger-soft: rgba(248,113,113,.14);
  --danger-line: rgba(248,113,113,.4);
  --invert-bg:  #e2e8f0;
  --invert-bg-h:#f1f5f9;
  --invert-ink: #0f172a;

  --shadow-sm: 0 1px 3px rgba(0,0,0,.4);
  --shadow-card: 0 1px 4px rgba(0,0,0,.5);
  --shadow-menu: 0 10px 25px -10px rgba(0,0,0,.7), 0 2px 4px rgba(0,0,0,.4);
  --shadow-dialog: 0 20px 45px -15px rgba(0,0,0,.75);

  --tint-info-bg:    rgba(59,130,246,.18);  --tint-info-ink:    #bfdbfe;
  --tint-info-soft:  rgba(59,130,246,.12);  --tint-info-line:   rgba(59,130,246,.45);
  --tint-ok-bg:      rgba(16,185,129,.18);  --tint-ok-ink:      #6ee7b7;
  --tint-warn-bg:    rgba(245,158,11,.18);  --tint-warn-ink:    #fcd34d;
  --tint-warn-soft:  rgba(245,158,11,.12);  --tint-warn-line:   rgba(245,158,11,.45);
  --tint-err-bg:     rgba(239,68,68,.18);   --tint-err-ink:     #fca5a5;
  --tint-neutral-bg: rgba(148,163,184,.18); --tint-neutral-ink: #cbd5e1;
  --tint-violet-bg:  rgba(139,92,246,.2);   --tint-violet-ink:  #ddd6fe;
  --tint-cyan-bg:    rgba(34,211,238,.18);  --tint-cyan-ink:    #a5f3fc;

  --sidebar-bg:          #111a2b;
  --sidebar-ink:         #f1f5f9;
  --sidebar-line:        #26324a;
  --sidebar-item:        #cbd5e1;
  --sidebar-item-hover:  #1e293b;
  --sidebar-item-hover-ink: #f1f5f9;
  --sidebar-active-bg:   #1d4ed8;
  --sidebar-active-ink:  #f1f5f9;
  --sidebar-label:       #64748b;
  --sidebar-chip-bg:     #1b2434;
  --sidebar-admin-bg:    #0e1729;
}

html { min-height: 100%; }
body { min-height: 100vh; }
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
  background: var(--bg);
  color: var(--ink);
  font-size: 14px;
  line-height: 1.5;
}

/* ---------- Public layout (login / signup) ---------- */
body.layout-public {
  display: block;
  background: var(--bg);
}
/* Reset the shared `main.content` dashboard chrome (1.5rem padding +
   1280px max-width) so the marketing/login surfaces can paint edge-to-edge.
   Without this the 1280px ceiling squeezes the login card into the middle
   of a wide viewport and leaves a ~200px gutter on either side that the
   .login-shell radial-gradient then has to bleed across, which reads as
   the page "floating" instead of being framed. */
.layout-public .content-public {
  display: block;
  max-width: none;
  padding: 0;
  width: auto;
}

/* ---------- Login card + tabs (shared by /login and /signup) ---------- */
.login-shell {
  min-height: 100vh;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  padding: 2rem 1rem;
}
.login-card {
  max-width: 26rem; width: 100%;
  padding: 2rem 2rem 1.5rem;
  background: var(--panel);
  border-radius: 10px;
  box-shadow: var(--shadow-card);
}
.login-header { display: flex; align-items: center; gap: .65rem; margin-bottom: .75rem; }
.login-title { margin: 0; font-size: 1.35rem; font-weight: 700; color: var(--ink); }
.login-tabs {
  display: flex; gap: .25rem; margin: 0 0 1rem; padding: .25rem;
  background: var(--bg);
  border-radius: 8px;
}
.login-tab {
  flex: 1 1 0; text-align: center; padding: .5rem .25rem;
  border-radius: 6px; color: var(--ink-soft); text-decoration: none;
  font-size: .9em; font-weight: 600;
}
.login-tab.is-active {
  background: var(--panel); color: var(--ink);
  box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.login-tab:hover { color: var(--ink); }
.login-card form { display: flex; flex-direction: column; gap: .5rem; }
.login-card form label { margin-top: .25rem; font-size: .85em; }
.login-card form input {
  padding: .6rem .8rem; border: 1px solid var(--line-strong);
  border-radius: 6px; font: inherit; font-size: .95rem;
}
.login-card form button.primary {
  margin-top: .75rem; padding: .65rem 1rem; border: 0; border-radius: 6px;
  background: var(--invert-bg); color: var(--invert-ink); font: inherit; font-weight: 600; cursor: pointer;
}
.login-card form button.primary:hover { background: var(--invert-bg-h); }
.login-card .err {
  background: #fdecea; color: #8a1f17; border: 1px solid #f5c2c0;
  padding: .6rem .8rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9em;
}
.login-lang { margin-top: 1rem; font-size: .85rem; }
.login-lang a { color: var(--ink-soft); text-decoration: none; padding: .25rem .5rem; border-radius: 4px; }
.login-lang a:hover { background: var(--line); color: var(--ink); }
/* Slider-captcha styles live inline in login.html so the captcha
   component ships as a self-contained block — keeping the rules next
   to the markup makes the modal easier to evolve without touching the
   shared global stylesheet. */

/* ---------- App shell (sidebar + topbar + content) ---------- */

body.layout-app {
  display: flex;
  min-height: 100vh;
  align-items: stretch;
}

aside.sidebar {
  background: var(--sidebar-bg);
  color: var(--sidebar-ink);
  display: flex;
  flex-direction: column;
  padding: 0;
  /* Pin the sidebar to the viewport so navigation stays reachable
     while the user scrolls through long content. The shell is a
     flex row (no longer grid) so the sidebar's containing block —
     <body> — grows with the main column's content instead of being
     capped at 100vh. That extra height is what gives position:
     sticky room to engage; without it, sidebar fills the body box
     and scrolls with the document on any page taller than the
     viewport (tunnels, probes matrix, billing history, …).

     width: var(--sidebar-w) locks the column width; align-self:
     flex-start prevents the flex cross-axis stretch from making
     the sidebar grow with the row's tallest cell. overflow-y:
     auto lets the nav scroll internally on the rare viewport
     where the menu is taller than the screen. The narrow-screen
     rule below resets this back to a static horizontal bar. */
  border-right: 1px solid var(--sidebar-line);
  flex: 0 0 var(--sidebar-w);
  width: var(--sidebar-w);
  position: sticky;
  top: 0;
  height: 100vh;
  align-self: flex-start;
  overflow-y: auto;
}

aside.sidebar .brand {
  display: flex;
  align-items: center;
  gap: .65rem;
  padding: 1rem 1.1rem;
  color: var(--sidebar-ink);
  text-decoration: none;
  font-weight: 700;
  font-size: 1.05rem;
  border-bottom: 1px solid var(--sidebar-line);
}
.brand-mark {
  width: 28px; height: 28px;
  background: var(--accent);
  color: #fff;
  border-radius: 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: -.5px;
}
/* Image variant used when the operator configured a custom logo URL
   (see _helpers._site_brand and the brand block in base.html /
   _admin_sidebar.html). The blue square background is dropped so
   the logo's own background shows through; the rounded rectangle
   + dark-mode friendly filter stay so a flat PNG reads correctly
   on both themes. object-fit keeps aspect ratio without distorting
   non-square uploads. */
.brand-mark-img {
  background: transparent;
  object-fit: contain;
  padding: 2px;
}
.brand-text { letter-spacing: -.2px; }

nav.sidenav {
  display: flex;
  flex-direction: column;
  padding: .65rem .65rem;
  gap: 2px;
  flex: 1 1 auto;
  overflow-y: auto;
}
nav.sidenav a {
  position: relative;
  display: block;
  padding: .55rem .85rem .55rem 1rem;
  color: var(--sidebar-item);
  text-decoration: none;
  border-radius: 6px;
  font-size: .92rem;
  transition: background .15s ease, color .15s ease;
}
/* Active state: a 3px accent stripe on the left edge plus the soft
   background tint, so the current page reads even when the eye is
   scanning a long menu. The stripe sits inside the border-radius so
   the active item still looks like a chip, not a tab. */
nav.sidenav a::before {
  content: '';
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 0;
  border-radius: 0 3px 3px 0;
  background: var(--accent);
  transition: height .15s ease;
}
nav.sidenav a:hover { background: var(--sidebar-item-hover); color: var(--sidebar-item-hover-ink); }
nav.sidenav a:hover::before { height: 55%; background: var(--line-strong); }
nav.sidenav a.active {
  background: var(--sidebar-active-bg);
  color: var(--sidebar-active-ink);
  font-weight: 600;
}
nav.sidenav a.active::before { height: 60%; background: var(--accent); }

/* Grouped sections inside the sidebar — keeps related items close
   without forcing a separate top-level entry per area. */
nav.sidenav .nav-group { display: flex; flex-direction: column; gap: 2px; }
nav.sidenav .nav-group + .nav-group { margin-top: .75rem; }
nav.sidenav .nav-group-label {
  padding: .55rem .85rem .25rem;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--sidebar-label);
}
nav.sidenav .nav-group-admin { padding-top: .5rem; border-top: 1px solid var(--sidebar-line); }

/* ---------- Admin sidebar (separate layout for /admin/*) ---------- */

/* The admin sidebar uses the same shell — and the same light palette in
   day mode — as the workspace one; only the surface shifts a notch and
   the badge under the brand makes the platform-admin context explicit.
   It used to be hardcoded slate-900, which read as "always dark" even
   in day mode and fought the rest of the page. Everything below rides
   the --sidebar-* tokens, so the dark theme comes for free. */
body.layout-admin aside.sidebar { background: var(--sidebar-admin-bg); }
body.layout-admin aside.sidebar .brand .brand-mark { background: var(--accent-h); color: #fff; }
/* Image variant on admin pages: the operator's logo must show through
   the brand frame, so the high-specificity admin override above
   needs an explicit transparent partner with equal specificity. Without
   this, the accent-h background wins again and the brand square
   stays blue behind the uploaded logo. */
body.layout-admin aside.sidebar .brand .brand-mark-img { background: transparent; color: inherit; }
body.layout-admin .admin-sidebar-badge {
  margin: .85rem .85rem 0;
  display: inline-flex;
}
body.layout-admin .admin-sidebar-badge .badge {
  background: var(--accent-h);
  color: #f1f5f9;
  font-size: .65rem;
  letter-spacing: .08em;
  text-transform: uppercase;
}
body.layout-admin .nav-group-settings { margin-top: .5rem; padding-top: .85rem; border-top: 1px solid var(--sidebar-line); }
body.layout-admin .nav-group-back {
  margin-top: auto;
  padding-top: 1rem;
  border-top: 1px solid var(--sidebar-line);
}
body.layout-admin .nav-group-back a { color: var(--sidebar-label); font-size: .82rem; }
body.layout-admin .nav-group-back a:hover { background: var(--sidebar-item-hover); color: var(--sidebar-item-hover-ink); }

/* Tenant plan pill — used by the /orgs list (`.tenant-plan`).
   The dashboard / sidebar / topbar do NOT show a workspace plan
   chip; the dashboard's "Current plan" card is the single source of
   truth for plan state. */
.tenant-plan {
  font-size: .65rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  padding: .1rem .45rem;
  border-radius: 999px;
  background: var(--tint-neutral-bg);
  color: var(--tint-neutral-ink);
}
.tenant-plan.plan-pro         { background: var(--tint-info-bg); color: var(--tint-info-ink); }
.tenant-plan.plan-enterprise  { background: var(--tint-warn-bg); color: var(--tint-warn-ink); }
.tenant-plan.plan-free        { background: var(--tint-neutral-bg); color: var(--tint-neutral-ink); }

/* ---------- Topbar ---------- */

.app-main {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-width: 0;
}

header.topbar {
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  /* A barely-there drop shadow separates the sticky topbar from the
     content that scrolls beneath it. Without this the border reads as
     part of the content panel and the topbar loses its "floating"
     feel. */
  box-shadow: 0 1px 0 var(--line), 0 4px 12px -8px rgba(15,23,42,.06);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 1.5rem;
  position: sticky;
  top: 0;
  z-index: 10;
  height: var(--topbar-h);
  flex: 0 0 auto;
}
header.topbar .page-title {
  margin: 0;
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--ink);
  letter-spacing: -.01em;
}
.topbar-right {
  display: flex;
  align-items: center;
  gap: .75rem;
  margin-left: auto;
}
.lang-switch {
  font-size: .82rem;
  color: var(--ink-soft);
  text-decoration: none;
  padding: .3rem .65rem;
  border: 1px solid var(--line);
  border-radius: 6px;
}
.lang-switch:hover { background: var(--panel-3); color: var(--ink); }

/* Theme toggle — same pill as .lang-switch, but a <button> because it
   flips a client-side preference instead of navigating. Both icons ship
   in the markup and CSS picks the one for the *target* theme, so the
   control never flashes the wrong glyph before JS runs. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--ink-soft);
  cursor: pointer;
  line-height: 1;
  font-size: .95rem;
}
.theme-toggle:hover { background: var(--panel-3); color: var(--ink); }
.theme-toggle:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.theme-toggle .theme-icon-dark { display: inline; }
.theme-toggle .theme-icon-light { display: none; }
[data-theme="dark"] .theme-toggle .theme-icon-dark { display: none; }
[data-theme="dark"] .theme-toggle .theme-icon-light { display: inline; }

/* (Workspace chip / badge styles removed — the dashboard's
   "Current plan" card is the single source of truth for plan state,
   so the sidebar chip and topbar badge are no longer rendered.) */
/* ---------- User dropdown (top-right) ---------- */

.user-menu { position: relative; }
.user-menu-trigger {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: .25rem .75rem .25rem .3rem;
  cursor: pointer;
  font: inherit;
  color: var(--ink);
  transition: border-color .15s, box-shadow .15s;
}
.user-menu-trigger:hover { border-color: var(--line-strong); }
.user-menu-trigger:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.user-avatar {
  width: 28px; height: 28px;
  background: var(--accent);
  color: #fff;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: .85rem;
}
.user-menu-trigger .user-name {
  font-size: .88rem;
  font-weight: 600;
  color: var(--ink);
  display: inline-flex;
  align-items: baseline;
  gap: .15rem;
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.user-menu-trigger .user-name-id {
  font-weight: 600;
  color: var(--ink);
}
.user-menu-trigger .user-name-org {
  font-size: .78rem;
  font-weight: 500;
  color: var(--ink-soft);
}
.user-menu-trigger .user-role {
  font-size: .68rem;
  background: var(--accent-soft);
  color: var(--accent);
  padding: .05rem .4rem;
  border-radius: 999px;
  font-weight: 600;
}
.user-caret {
  font-size: .75rem;
  color: var(--ink-soft);
  margin-left: .15rem;
}
.user-menu-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  min-width: 200px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  box-shadow: var(--shadow-menu);
  padding: .35rem;
  z-index: 20;
}
.user-menu-head {
  padding: .5rem .65rem .6rem;
  border-bottom: 1px solid var(--line);
  margin-bottom: .25rem;
}
.user-menu-name {
  font-weight: 600;
  color: var(--ink);
  font-size: .9rem;
  display: inline-flex;
  align-items: baseline;
  gap: .15rem;
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.user-menu-name .user-name-id { font-weight: 600; color: var(--ink); }
.user-menu-name .user-name-org {
  font-size: .8rem;
  font-weight: 500;
  color: var(--ink-soft);
}
.user-menu-role {
  font-size: .72rem;
  color: var(--accent);
  font-weight: 600;
  margin-top: .1rem;
}
.user-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: 0;
  color: var(--ink);
  padding: .45rem .65rem;
  border-radius: 6px;
  font: inherit;
  font-size: .88rem;
  cursor: pointer;
  text-decoration: none;
}
.user-menu-item:hover { background: var(--panel-3); }
.user-menu-item:focus-visible {
  outline: none;
  background: var(--accent-soft);
  color: var(--accent);
}
.user-menu-logout { color: var(--danger); }
.user-menu-logout:hover { background: var(--danger-soft); }
.user-menu-form { margin: 0; }

main.content {
  padding: 1.5rem 1.75rem 2.25rem;
  flex: 1;
  max-width: 1280px;
  width: 100%;
}

/* ---------- Cards / tables ---------- */

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 1rem;
  margin-bottom: 1.5rem;
}
.card {
  background: var(--panel);
  padding: 1rem 1.1rem;
  border-radius: .5rem;
  box-shadow: var(--shadow-sm);
}
.card .num { font-size: 1.5rem; font-weight: 700; color: var(--ink); }
.card .lbl { color: var(--ink-soft); font-size: .85rem; margin-top: .25rem; }

table {
  width: 100%;
  border-collapse: collapse;
  background: var(--panel);
  border-radius: .5rem;
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}
th, td {
  padding: .6rem .9rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
  vertical-align: middle;
}
th { background: var(--panel-3); font-weight: 600; font-size: .85rem; color: var(--ink-body); }
tr:last-child td { border-bottom: 0; }
tr:hover td { background: var(--panel-2); }

.badge {
  display: inline-block;
  padding: .18rem .55rem;
  border-radius: 999px;
  font-size: .73rem;
  font-weight: 600;
  background: var(--tint-neutral-bg);
  color: var(--tint-neutral-ink);
  letter-spacing: .01em;
  line-height: 1.4;
}
.badge.type-in     { background: var(--tint-info-bg); color: var(--tint-info-ink); }
.badge.type-out    { background: var(--tint-warn-bg); color: var(--tint-warn-ink); }
.badge.status-active   { background: var(--tint-ok-bg); color: var(--tint-ok-ink); }
.badge.status-inactive { background: var(--tint-err-bg); color: var(--tint-err-ink); }
.badge.status-suspended{ background: var(--tint-warn-bg); color: var(--tint-warn-ink); }
.badge.status-disabled { background: var(--tint-neutral-bg); color: var(--tint-neutral-ink); }
.badge.status-pending  { background: var(--tint-info-bg); color: var(--tint-info-ink); }
.badge.status-paid     { background: var(--tint-ok-bg); color: var(--tint-ok-ink); }
.badge.status-error    { background: var(--tint-err-bg); color: var(--tint-err-ink); }
.badge.proto-tcp   { background: var(--tint-violet-bg); color: var(--tint-violet-ink); }
.badge.proto-icmp  { background: var(--tint-cyan-bg); color: var(--tint-cyan-ink); }

/* Generic <button> baseline — paints every button with the
   bordered-neutral look so an unclassed button (e.g. an inline
   <button> in a table cell that hasn't been promoted to a class)
   still reads as part of the design system. Loud controls should
   opt into .btn-primary / .btn-invert; destructive ones into
   .btn-danger / .btn-danger-ghost. */
button {
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  padding: .5rem 1rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: .9rem;
  font-family: inherit;
}
button:hover { background: var(--panel-2); }
button.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
button.danger:hover { background: var(--danger-h); border-color: var(--danger-h); }

input, select {
  padding: .4rem .6rem;
  border: 1px solid var(--line);
  border-radius: .25rem;
  font-size: .9rem;
  background: var(--panel);
  color: var(--ink);
  font-family: inherit;
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Labels render inline by default so the required-field `<span class="req">*</span>`
   stays on the same line as the label text. The few places that DO want
   stacked children (e.g. filter-bar inputs with a caption above the
   control) opt back in to flex-column below. */
label {
  display: block;
  font-size: .85rem;
  color: var(--ink-body);
}
/* Filter-bar labels stack a caption `<span>` above the `<input>`/`<select>`;
   that's the only place where the legacy column-flex is still wanted. */
.filter-bar label {
  display: flex;
  flex-direction: column;
  gap: .25rem;
}

.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; }
.row label { flex: 1 1 160px; }

details.create-form {
  background: var(--panel);
  padding: .75rem 1rem;
  border-radius: .5rem;
  box-shadow: var(--shadow-sm);
  margin-bottom: 1rem;
}
details.create-form summary { cursor: pointer; font-weight: 600; }
details.create-form[open] summary { margin-bottom: .75rem; }

details.install-cmd { display: inline-block; margin-right: .5rem; }
details.install-cmd summary {
  background: var(--accent);
  color: #fff;
  padding: .35rem .75rem;
  border-radius: .25rem;
  font-size: .85rem;
  cursor: pointer;
}
details.install-cmd[open] summary { margin-bottom: .4rem; }
details.install-cmd textarea {
  width: 320px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .8rem;
  margin-bottom: .35rem;
}
/* Token / copy button — small bordered button matching the
   .btn-secondary family. Sits next to a <code class="copyable">
   cell, so it must read as the same control type as the rest of the
   panel just at a smaller scale. */
.copy-btn {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .3rem .65rem;
  margin-left: .35rem;
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  font-size: .82rem;
  font-weight: 500;
  cursor: pointer;
  text-decoration: none;
  transition: background .15s, border-color .15s;
}
.copy-btn:hover { background: var(--panel-2); border-color: var(--line-strong); }
.copy-btn:active { background: var(--panel-3); }
.copy-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.copied { margin-left: .35rem; color: var(--tint-ok-ink); font-size: .8rem; }

.row-actions { white-space: nowrap; }

.route-loading {
  position: fixed;
  top: .75rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  padding: .45rem .75rem;
  color: var(--tint-info-ink);
  background: var(--tint-info-soft);
  border: 1px solid var(--tint-info-line);
  border-radius: 999px;
  box-shadow: 0 4px 12px rgba(15, 23, 42, .12);
  font-size: .82rem;
  font-weight: 600;
}
.route-loading[hidden] { display: none; }
.route-loading-spinner {
  width: 12px;
  height: 12px;
  border: 2px solid var(--tint-info-line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: route-spin .7s linear infinite;
}
@keyframes route-spin { to { transform: rotate(360deg); } }

.err { background: var(--tint-err-bg); color: var(--tint-err-ink); padding: .6rem 1rem; border-radius: .25rem; margin-bottom: 1rem; }
.ok  { background: var(--tint-ok-bg); color: var(--tint-ok-ink); padding: .6rem 1rem; border-radius: .25rem; margin-bottom: 1rem; }
.muted { color: var(--ink-soft); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; }

/* ---------- Admin section tab nav ---------- */

nav.admin-tabs {
  display: flex;
  gap: .25rem;
  border-bottom: 1px solid var(--line);
  margin: -.25rem 0 1.25rem;
}
nav.admin-tabs a {
  display: inline-block;
  padding: .55rem .9rem;
  color: var(--ink-soft);
  text-decoration: none;
  font-size: .9rem;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}
nav.admin-tabs a:hover { color: var(--ink); }
nav.admin-tabs a.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
  font-weight: 600;
}

.btn-link {
  color: var(--ink-soft);
  text-decoration: none;
  padding: .5rem .75rem;
  font-size: .88rem;
}
.btn-link:hover { color: var(--ink); }
.audit-summary { margin-top: .75rem; font-size: .85rem; }

details.inline-details { display: inline-block; vertical-align: middle; margin-right: .35rem; }
details.inline-details summary {
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  padding: .35rem .75rem;
  border-radius: 6px;
  font-size: .85rem;
  font-weight: 500;
  cursor: pointer;
  display: inline-block;
}
details.inline-details[open] summary { margin-bottom: .4rem; }
details.inline-details .inline-form { display: flex; flex-wrap: wrap; gap: .35rem; }
.inline-form { display: inline-flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.inline-form input, .inline-form select { font-size: .85rem; padding: .3rem .5rem; }
.inline-form button.danger { font-size: .85rem; padding: .35rem .7rem; }
.tenant-slug { background: var(--panel-2); padding: .1rem .35rem; border-radius: 4px; }

/* Legacy solid-accent link button — superseded by .btn-secondary. Kept
   as an alias so a stray reference still renders as the bordered style
   instead of breaking the design system. */
.button-link {
  display: inline-flex;
  align-items: center;
  vertical-align: middle;
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  padding: .35rem .75rem;
  border-radius: 6px;
  font-size: .85rem;
  font-weight: 500;
  text-decoration: none;
  margin-right: .35rem;
  transition: background .15s, border-color .15s;
}
.button-link:hover { background: var(--panel-2); border-color: var(--line-strong); }

.impersonation-indicator {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  color: var(--tint-warn-ink);
  background: var(--tint-warn-soft);
  border: 1px solid var(--tint-warn-line);
  border-radius: 6px;
  padding: .25rem .35rem .25rem .65rem;
  font-size: .78rem;
  font-weight: 600;
}
.impersonation-indicator form { margin: 0; }
.impersonation-indicator button {
  padding: .3rem .55rem;
  background: #92400e;
  color: #fff;
  font-size: .78rem;
}
.impersonation-indicator button:hover { background: #78350f; }

.detail-heading,
.section-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.detail-heading { margin-bottom: 1rem; }
.detail-heading h2 { margin: .25rem 0 0; }
.detail-heading p { margin: .15rem 0 0; }
.detail-back { padding-left: 0; }
.detail-actions { display: flex; align-items: center; }
.detail-actions form { margin: 0; }
.tenant-detail-cards { margin-bottom: 1rem; }
.detail-panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .5rem;
  padding: 1rem;
  margin-bottom: 1rem;
  overflow-x: auto;
}
.detail-panel h3 { margin: 0 0 .8rem; font-size: 1rem; }
.section-heading h3 { margin-bottom: .8rem; }
.detail-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: .75rem;
  margin: 0;
}
.detail-list div {
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: .65rem .75rem;
}
.detail-list dt { color: var(--ink-soft); font-size: .78rem; }
.detail-list dd { margin: .2rem 0 0; font-weight: 600; }
.audit-detail { max-width: 320px; overflow-wrap: anywhere; }

/* ---------- Dashboard ---------- */

/* Quick actions leading the page. Unlike .page-actions (which trails a
   page title and is right-aligned), these open the page, so they sit at
   the left edge under the title. */
.dash-actions {
  display: flex;
  align-items: center;
  gap: .75rem;
  margin: 0 0 1.25rem;
}

/* Inline stroke icons from the icon() macro in _helpers.html. Sized here
   once so every call site is just `{{ icon('name') }}`. */
.icon {
  width: 1.05em;
  height: 1.05em;
  flex: none;
  display: block;
}

/* KPI row. auto-fit rather than a fixed 4 columns so the row degrades to
   2×2 and then 1 column without its own breakpoints. */
.dash-kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: 1rem;
  margin-bottom: 1rem;
}
.kpi-card {
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .5rem;
  padding: 1rem 1.1rem 1.1rem;
  text-decoration: none;
  color: inherit;
  transition: border-color .15s, box-shadow .15s, transform .15s;
}
a.kpi-card:hover {
  border-color: var(--accent);
  box-shadow: 0 4px 12px -4px rgba(37, 99, 235, .15);
  transform: translateY(-1px);
}
a.kpi-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.kpi-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .75rem;
  margin-bottom: .9rem;
}
.kpi-label { font-size: .88rem; color: var(--ink-body); font-weight: 500; }
.kpi-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  background: var(--panel-3);
  color: var(--ink-soft);
}
.kpi-icon .icon { width: 17px; height: 17px; }
a.kpi-card:hover .kpi-icon { background: var(--accent-soft); color: var(--accent); }
.kpi-value {
  font-size: 2rem;
  font-weight: 700;
  line-height: 1.15;
  color: var(--ink);
}
/* The plan card puts a name where the others put a number — cap it so a
   long plan name wraps to two lines instead of blowing out the row. */
.kpi-value-text {
  font-size: 1.35rem;
  overflow-wrap: anywhere;
}
.kpi-meta {
  margin-top: .3rem;
  font-size: .82rem;
  color: var(--ink-soft);
}

/* Two-column content row: a wide primary panel plus a narrow companion. */
.dash-split {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 1rem;
  align-items: start;
}
/* Panels inside a split are spaced by the grid's own gap; .detail-panel's
   bottom margin would double it. */
.dash-split > .dash-panel { margin-bottom: 0; }
.dash-split + .dash-split,
.dash-split + .detail-panel { margin-top: 1rem; }
.dash-panel .small { font-size: .8rem; }
.dash-empty {
  margin: .5rem 0 0;
  padding: 1.5rem .25rem;
  text-align: center;
  font-size: .88rem;
}

/* 7-day traffic area chart. Coordinates and both paths come precomputed
   from the backend; this only styles them. */
.chart-total {
  font-size: 1.9rem;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.2;
}
.chart-total-sm { font-size: 1rem; font-weight: 600; }
.chart-caption { font-size: .82rem; margin-bottom: .5rem; }
.area-chart {
  display: block;
  width: 100%;
  height: 150px;
  overflow: visible;
}
/* stop-color/stroke are set here rather than as SVG presentation
   attributes so `var()` resolves and the chart follows the theme. */
.chart-stop-top { stop-color: var(--accent); stop-opacity: .28; }
.chart-stop-bottom { stop-color: var(--accent); stop-opacity: 0; }
.chart-area { fill: url(#traffic-fill); }
.chart-line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart-dot { fill: var(--accent); }
.chart-hit { fill: transparent; }
.chart-axis {
  display: flex;
  justify-content: space-between;
  margin-top: .5rem;
  font-size: .75rem;
}

/* Compact list used by "recent tunnels": badge + name on the left,
   context + value right-aligned. Two rows per item, with the badge and
   the value spanning both — placement is explicit so auto-flow can't
   reorder the cells when a field is empty. */
.mini-list { list-style: none; margin: 0; padding: 0; }
.mini-item {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-rows: auto auto;
  align-items: center;
  column-gap: .6rem;
  padding: .55rem .15rem;
  border-bottom: 1px solid var(--line);
  font-size: .88rem;
}
.mini-item:last-child { border-bottom: 0; }
.mini-item .badge { grid-column: 1; grid-row: 1 / span 2; }
.mini-name {
  grid-column: 2;
  grid-row: 1;
  font-weight: 600;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.mini-meta {
  grid-column: 2;
  grid-row: 2;
  font-size: .78rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.mini-value {
  grid-column: 3;
  grid-row: 1 / span 2;
  text-align: right;
  color: var(--ink-body);
  white-space: nowrap;
}

/* Quota bar: a labelled horizontal progress strip per resource. */
.quota-grid { display: flex; flex-direction: column; gap: .6rem; }
.quota-row {
  display: grid;
  grid-template-columns: 6rem 1fr 7rem;
  align-items: center;
  gap: .85rem;
}
.quota-label { font-size: .85rem; color: var(--ink-soft); }
.quota-bar {
  height: 8px;
  background: var(--panel-3);
  border-radius: 999px;
  overflow: hidden;
}
.quota-fill {
  height: 100%;
  background: var(--accent);
  transition: width .25s ease;
}
.quota-fill-warn { background: var(--danger); }
.quota-value { text-align: right; font-size: .85rem; color: var(--ink); }

/* Inline sparkline — a single 280×60 SVG. */
.spark { display: block; width: 100%; height: 60px; }

/* Event list on the dashboard — compact, one row per audit event. */
.event-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.event-item {
  display: grid;
  grid-template-columns: 11rem 8rem 10rem 1fr;
  align-items: center;
  gap: .75rem;
  padding: .5rem .25rem;
  border-bottom: 1px solid var(--line);
  font-size: .88rem;
}
.event-item:last-child { border-bottom: 0; }
.event-time { font-size: .78rem; white-space: nowrap; }
.event-actor { font-weight: 600; color: var(--ink); }
.event-action {
  background: var(--accent-soft);
  color: var(--accent);
  padding: .1rem .5rem;
  border-radius: 4px;
  font-size: .78rem;
  font-weight: 600;
  justify-self: start;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}
.event-target { font-size: .82rem; }

/* The split stacks well before the sidebar does — a 1fr companion column
   stops being readable around here, so it drops to full width first. The
   audit list's four fixed columns stop fitting at the same point, and it
   sits full-width under the split, so it collapses in step. */
@media (max-width: 900px) {
  .dash-split { grid-template-columns: 1fr; }
  .event-item { grid-template-columns: 1fr; gap: .15rem; padding: .65rem 0; }
}
@media (max-width: 720px) {
  .quota-row { grid-template-columns: 1fr; }
  .quota-value { text-align: left; }
  .dash-actions { flex-direction: column; align-items: stretch; }
  .dash-actions > * { justify-content: center; }
}

/* Foldable JSON detail on the audit log. The table row stays compact;
   clicking "View JSON" reveals the full payload. */
.audit-detail-fold summary {
  cursor: pointer;
  font-size: .82rem;
  color: var(--accent);
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  padding: .15rem .35rem;
  border-radius: 4px;
}
.audit-detail-fold summary:hover { background: var(--accent-soft); }
.audit-json {
  margin: .4rem 0 0;
  padding: .65rem .75rem;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  font-size: .78rem;
  line-height: 1.45;
  max-width: 480px;
  max-height: 240px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
}

/* Inline row edit — used by members + tenants for quick updates
   without leaving the table. */
.inline-edit summary {
  display: inline-block;
  background: var(--accent);
  color: #fff;
  padding: .3rem .6rem;
  border-radius: 6px;
  font-size: .82rem;
  cursor: pointer;
  list-style: none;
  margin-right: .25rem;
}
.inline-edit summary::-webkit-details-marker { display: none; }
.inline-edit[open] summary { margin-bottom: .35rem; }
.inline-edit[open] summary { background: var(--accent-h); }
.inline-edit .inline-form {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem;
  padding: .4rem 0;
}

/* Plan edit — multi-field form that opens inline under the row's edit
   button. Each label stacks its <span> above the input, and `.inline-edit-wide`
   elements span the full form width so multi-selects/checkboxes can breathe. */
.inline-edit-form {
  display: flex;
  flex-wrap: wrap;
  gap: .55rem .85rem;
  padding: .5rem 0 .25rem;
  min-width: 280px;
  max-width: 360px;
}
.inline-edit-form label {
  display: flex;
  flex-direction: column;
  gap: .25rem;
  flex: 1 1 140px;
  min-width: 0;
}
.inline-edit-form label > span {
  font-size: .78rem;
  color: var(--ink-soft);
  font-weight: 600;
}
.inline-edit-form input,
.inline-edit-form select {
  font-size: .85rem;
  padding: .3rem .5rem;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--panel);
  color: var(--ink);
  width: 100%;
  min-width: 0;
}
.inline-edit-form .form-actions {
  flex: 1 1 100%;
  margin: .35rem 0 0;
}
.inline-edit-wide {
  flex: 1 1 100% !important;
}

/* ---------- Responsive ---------- */

@media (max-width: 720px) {
  body.layout-app {
    flex-direction: column;
  }
  aside.sidebar {
    flex: 0 0 auto;
    width: 100%;
    height: auto;
    position: static;
    flex-direction: row;
    align-items: center;
    padding: .5rem .75rem;
  }
  aside.sidebar .brand { padding: 0; border: 0; }
  nav.sidenav {
    flex-direction: row;
    overflow-x: auto;
    padding: 0 .5rem;
    gap: .25rem;
  }
  nav.sidenav a { white-space: nowrap; }
  nav.sidenav .nav-group { flex-direction: row; }
  nav.sidenav .nav-group + .nav-group { margin-top: 0; padding-left: .5rem; border-left: 1px solid var(--sidebar-line); }
  nav.sidenav .nav-group-label { display: none; }
  nav.sidenav .nav-group-admin { border-top: 0; padding-top: 0; }
  .topbar-right { gap: .5rem; }
  .user-menu-trigger .user-name,
  .user-menu-trigger .user-role { display: none; }
  main.content { padding: 1rem; }
}

/* =============================================================
   Page actions (primary "+ 新建..." button + friends)
   ============================================================= */

.page-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: .75rem;
  margin: 0 0 1rem;
  /* A hair more vertical air around the action button — separates it
     from the page title above and the filter bar below. */
  padding: .35rem 0 .15rem;
}

/* The primary action is solid ink, not accent blue — accent stays
   reserved for links, focus rings and the active sidebar item. This
   matches .btn-invert so the loudest control looks the same whether it
   sits in a page header or a dialog footer. */
.btn-primary {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  padding: .52rem 1rem;
  background: var(--invert-bg);
  color: var(--invert-ink);
  border: 1px solid var(--invert-bg);
  border-radius: 8px;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  box-shadow: 0 1px 2px rgba(15,23,42,.06);
  transition: background .15s, border-color .15s, box-shadow .15s, transform .05s;
}
.btn-primary:hover { background: var(--invert-bg-h); border-color: var(--invert-bg-h); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.btn-secondary {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  padding: .52rem 1rem;
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  font-size: .9rem;
  font-weight: 500;
  cursor: pointer;
  text-decoration: none;
  transition: background .15s, border-color .15s;
}
.btn-secondary:hover { background: var(--panel-2); border-color: var(--line-strong); }
.btn-secondary:active { background: var(--panel-3); }
/* Compact variant — same border + fill as .btn-secondary, sized for
   in-row use (e.g. the "复制" token button next to a code cell, or
   the "详情" link in a row-actions cell). The default button looks
   too heavy next to a copyable token. */
.btn-secondary.is-compact,
.btn-secondary.compact,
.btn-secondary-sm {
  padding: .32rem .7rem;
  font-size: .82rem;
  border-radius: 6px;
  gap: .35rem;
}

/* ---------- Create form (replaces the old <details> panel) ---------- */

.create-form {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.1rem 1.35rem 1.35rem;
  margin-bottom: 1rem;
  box-shadow: var(--shadow-card);
}
.create-form[hidden] { display: none; }
.create-form-head {
  margin: 0 0 .85rem;
  border-bottom: 1px solid var(--line);
  padding-bottom: .65rem;
}
.create-form-title {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink);
}
.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  /* Bigger vertical gap inside the wizard — three sections per panel
     read better when there's air between them. */
  gap: 1.4rem 1.5rem;
}
.form-field { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
.form-field-full,
.form-field-wide { grid-column: 1 / -1; }
/* Horizontal variant for checkbox fields: label pinned on the left, the
   options flow on the right. Used on the node-group edit wizard's
   protocol/tunnel-type steps where the label is short and the option
   list is wider than tall — stacking them vertically wastes a row. */
.form-field-row {
  flex-direction: row;
  align-items: flex-start;
  gap: 1.25rem;
}
.form-field-row > label {
  flex: 0 0 7rem;
  padding-top: .35rem;
  color: var(--ink-body);
}
.form-field-row-body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: .35rem;
}
.form-field label {
  font-size: .875rem;
  color: var(--ink);
  font-weight: 500;
}
.form-field input,
.form-field select,
.form-field textarea {
  padding: .55rem .7rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  font: inherit;
  background: var(--panel);
  color: var(--ink);
  width: 100%;
}
.form-field textarea { resize: vertical; min-height: 5.5rem; line-height: 1.5; }
.form-field input::placeholder,
.form-field textarea::placeholder { color: var(--ink-soft); }
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Read-only mirror of a value the form can't change (e.g. node_type on
   an existing group): looks like a field, reads as static text. */
.form-field .form-static {
  padding: .55rem .7rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
  color: var(--ink-soft);
  min-height: 2.4rem;
  display: flex;
  align-items: center;
}
/* The hint sits *under* the control, so order it last regardless of
   where the markup puts it. */
.form-field .form-field-hint { order: 9; margin: 0; }
/* Inline-hint variant: the hint sits next to the label instead of
   under the control — used on checkbox-heavy fields where the hint is
   really a label clarifier (e.g. "为空允许所有…") and stacking it under
   the row of checkboxes makes it look like a separate paragraph. */
.form-field-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .35rem .65rem;
}
.form-field-head > label { margin: 0; }
.form-field-head .form-field-hint { order: 0; font-weight: 400; }
/* Inside the horizontal row layout, pin the head (label + inline hint)
   to the same 7rem slot the bare <label> used to occupy. */
.form-field-row > .form-field-head {
  flex: 0 0 7rem;
  padding-top: .35rem;
}
.form-actions {
  display: flex;
  gap: .5rem;
  justify-content: flex-end;
  margin-top: 1rem;
  padding-top: .85rem;
  border-top: 1px solid var(--line);
}

/* ---------- Empty state ---------- */

.empty-card {
  background: var(--panel);
  border: 1px dashed var(--line-strong);
  border-radius: 12px;
  padding: 3rem 1.5rem;
  text-align: center;
  margin-bottom: 1rem;
  box-shadow: var(--shadow-sm);
}
.empty-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  margin: 0 auto 1rem;
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 1.8rem;
  border-radius: 50%;
}
.empty-title {
  margin: 0 0 .4rem;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--ink);
}
.empty-desc {
  margin: 0 auto 1.25rem;
  max-width: 36rem;
  color: var(--ink-soft);
  line-height: 1.55;
}
.empty-actions {
  display: inline-flex;
  flex-wrap: wrap;
  gap: .6rem;
  justify-content: center;
}

/* ---------- Filter bar (search + status) ---------- */

.filter-bar {
  display: flex;
  flex-wrap: wrap;
  gap: .65rem;
  align-items: center;
  background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: .55rem .8rem;
  margin-bottom: 1rem;
  box-shadow: var(--shadow-sm);
}
.filter-bar label { flex: 1 1 160px; margin: 0; }
/* A page action parked at the right end of the filter bar. margin-left
   is a no-op while .filter-search is growing, but keeps the button
   pinned right once the bar wraps on narrow screens. Padding is
   trimmed from .btn-primary's default so the button matches the search
   input's height instead of stretching the bar. */
.filter-bar-action {
  margin-left: auto;
  flex: none;
  padding: .4rem .85rem;
  font-size: .85rem;
}
.filter-search { flex: 1 1 220px; min-width: 180px; position: relative; }
/* The magnifier glyph sits inside the search field — gives the input a
   "this is search" affordance without pulling in an icon font. The
   input gets matching left-padding so the placeholder doesn't slide
   under the icon. */
.filter-search::before {
  content: '';
  position: absolute;
  left: .7rem;
  top: 50%;
  width: 14px;
  height: 14px;
  transform: translateY(-50%);
  background: var(--ink-soft);
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>") no-repeat center / contain;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>") no-repeat center / contain;
  pointer-events: none;
  opacity: .75;
}
.filter-search:focus-within::before { opacity: 1; background: var(--accent); }
.filter-search input {
  width: 100%;
  padding: .42rem .65rem .42rem 2.05rem;
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  font: inherit;
  background: var(--panel);
  color: var(--ink);
  transition: border-color .15s, box-shadow .15s;
}
.filter-search input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.filter-bar .filter-status { display: inline-flex; flex-direction: row; align-items: center; gap: .5rem; }
.filter-bar .filter-status span { font-size: .85rem; color: var(--ink-soft); }
.filter-bar .filter-status select {
  padding: .4rem .55rem;
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  cursor: pointer;
  transition: border-color .15s, box-shadow .15s;
}
.filter-bar .filter-status select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.filter-count {
  margin-left: auto;
  font-size: .85rem;
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}
.filter-empty {
  margin: .75rem 0 1rem;
  padding: 1.5rem 1rem;
  text-align: center;
  background: var(--panel);
  border: 1px dashed var(--line-strong);
  border-radius: 10px;
  color: var(--ink-soft);
}
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0,0,0,0);
  white-space: nowrap;
  border: 0;
}

/* ---------- Data table baseline ---------- */

.data-table {
  width: 100%;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  border-collapse: separate;
  border-spacing: 0;
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}
.data-table th,
.data-table td {
  padding: .7rem .9rem;
  text-align: left;
  font-size: .9rem;
  border-bottom: 1px solid var(--line);
}
.data-table thead th {
  background: linear-gradient(180deg, var(--panel-2) 0%, var(--panel-3) 100%);
  font-weight: 600;
  color: var(--ink-body);
  font-size: .78rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  border-bottom-color: var(--line-strong);
}
.data-table tbody tr:last-child td { border-bottom: 0; }
/* Row hover: a soft accent tint, not a flat gray, so the eye lands on
   the row that's about to be interacted with. The transition smooths
   the change so quick mouse passes don't strobe. */
.data-table tbody tr {
  transition: background .12s ease;
}
.data-table tbody tr:hover { background: var(--accent-soft); }
.data-table tbody tr:hover td { color: var(--ink); }

/* Sticky actions column — keeps the operation reachable on narrow screens
   when the table horizontally scrolls. */
.data-table .sticky-col {
  position: sticky;
  right: 0;
  background: var(--panel);
  box-shadow: -1px 0 0 var(--line);
  z-index: 1;
}
.data-table tbody tr:hover .sticky-col { background: var(--accent-soft); }

/* Horizontal scroll wrapper so wide tables don't clip on small screens */
.table-scroll {
  width: 100%;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  border-radius: 10px;
}
.table-scroll .data-table { min-width: 720px; }

/* Generic tunnel-type badge (mirrors the chain badges) */
.badge.type-tunnel { background: var(--tint-violet-bg); color: var(--tint-violet-ink); }

/* Required-field marker + form hints */
.req { color: var(--danger); font-weight: 700; }
.create-form-hint {
  margin: .3rem 0 0;
  font-size: .78rem;
  color: var(--ink-soft);
}
.form-field-hint {
  font-size: .75rem;
  color: var(--ink-soft);
  line-height: 1.35;
}

/* Checkbox label inside a form-field — keeps the box + label on one
   line and uses a muted hint underneath for explanations. */
.checkbox-row {
  display: inline-flex;          /* override the column-flex base rule */
  flex-direction: row-reverse;
  align-items: center;
  gap: .4rem;
  font-weight: 500;
  cursor: pointer;
}
.checkbox-row input[type="checkbox"] { margin: 0; flex: none; width: auto; }

/* Solid danger button (used inside the confirm dialog) */
.btn-danger {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  padding: .5rem .9rem;
  background: var(--danger);
  color: #fff;
  border: 1px solid var(--danger);
  border-radius: 6px;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
}
.btn-danger:hover { background: var(--danger-h); border-color: var(--danger-h); }
.btn-danger:focus-visible { outline: 2px solid var(--danger); outline-offset: 2px; }

/* Neutral in-row affordance — the "Edit" partner to .btn-danger-ghost
   below, so the two controls in a row-actions cell match in size. */
.btn-secondary-ghost {
  background: transparent;
  color: var(--ink-body);
  border: 1px solid transparent;
  padding: .3rem .6rem;
  border-radius: 6px;
  font-size: .85rem;
  font-weight: 500;
  cursor: pointer;
}
.btn-secondary-ghost:hover { background: var(--panel-3); border-color: var(--line); color: var(--ink); }
.btn-secondary-ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Low-key in-row delete affordance — full red is reserved for the dialog */
.btn-danger-ghost {
  background: transparent;
  color: var(--danger);
  border: 1px solid transparent;
  padding: .3rem .6rem;
  border-radius: 6px;
  font-size: .85rem;
  font-weight: 500;
  cursor: pointer;
}
.btn-danger-ghost:hover { background: var(--danger-soft); border-color: var(--danger-line); }
.btn-danger-ghost:focus-visible { outline: 2px solid var(--danger); outline-offset: 1px; }
.row-delete-form { margin: 0; }

/* Confirmation dialog */
.confirm-dialog {
  border: 1px solid var(--line);
  border-radius: .6rem;
  padding: 0;
  width: min(90vw, 380px);
  box-shadow: var(--shadow-dialog);
  background: var(--panel);
  color: var(--ink);
}
.confirm-dialog::backdrop { background: rgba(15,23,42,.4); }
.confirm-dialog-body { margin: 0; padding: 1.25rem; }
.confirm-dialog-title { margin: 0 0 .5rem; font-size: 1.05rem; font-weight: 600; }
.confirm-dialog-msg { margin: 0 0 1.25rem; color: var(--ink-soft); line-height: 1.5; }
.confirm-dialog-actions { display: flex; justify-content: flex-end; gap: .5rem; }

/* =============================================================
   Modal form dialog  (node-group edit wizard, plan editor, pickers)
   -------------------------------------------------------------
   Structure the markup is expected to use:

     <dialog class="app-dialog">
       <form>
         <header class="dialog-header">  title + .dialog-close
         <ol class="wizard-steps">       optional step pills
         <div class="dialog-body">       scrolls; holds .wizard-step-panel
         <footer class="dialog-footer">  .dialog-footer-start | actions

   The dialog itself is a flex column so the body is the only thing
   that scrolls — header, stepper and footer stay put however long the
   form gets. `display:flex` is scoped to [open] because the UA rule
   `dialog:not([open]){display:none}` is what keeps a closed dialog
   hidden, and an unscoped display would defeat it.
   ============================================================= */

.app-dialog,
.plan-picker-dialog {
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel);
  color: var(--ink);
  box-shadow: var(--shadow-dialog);
  width: min(94vw, 780px);
  max-height: min(88vh, 920px);
  overflow: hidden;
}
.app-dialog[open],
.plan-picker-dialog[open] { display: flex; flex-direction: column; }
.app-dialog::backdrop,
.plan-picker-dialog::backdrop { background: rgba(15,23,42,.45); }
.app-dialog > form {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1;
  margin: 0;
}

.dialog-header {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  padding: 1.35rem 1.5rem .25rem;
}
.dialog-title {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.3;
}
.dialog-close {
  margin-left: auto;
  flex: none;
  width: 1.9rem;
  height: 1.9rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-soft);
  font-size: 1.15rem;
  line-height: 1;
  cursor: pointer;
}
.dialog-close:hover { background: var(--panel-3); color: var(--ink); }
.dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.dialog-body {
  padding: 1rem 1.5rem 1.35rem;
  overflow-y: auto;
  flex: 1;
  min-height: 0;
}

.dialog-footer {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: 1rem 1.5rem 1.35rem;
  border-top: 1px solid var(--line);
}
/* "Back" sits alone on the left; everything after it is pushed right. */
.dialog-footer-start { margin-right: auto; }

/* ---------- Node-group picker (plan editor) ---------------------- */

.plan-picker-list {
  display: flex;
  flex-direction: column;
  gap: .25rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.plan-picker-item > label {
  /* Name on the left, checkbox pinned to the right edge of the row —
     row-reverse + space-between so the box lands on the right without
     reordering the markup. */
  display: flex;
  flex-direction: row-reverse;
  justify-content: space-between;
  align-items: center;
  gap: .75rem;
  padding: .5rem .65rem;
  border: 1px solid transparent;
  border-radius: 6px;
  font-size: .875rem;
  color: var(--ink-body);
  cursor: pointer;
}
.plan-picker-item > label:hover { background: var(--panel-2); color: var(--ink); }
.plan-picker-item > label:has(input:checked) {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--ink);
}
.plan-picker-item input[type="checkbox"] { margin: 0; flex: none; width: auto; }
.plan-picker-empty {
  padding: 1.25rem;
  text-align: center;
  color: var(--ink-soft);
  font-size: .875rem;
}
.plan-picker-toolbar { justify-content: flex-end; }

.dialog-footer .dialog-footer-end {
  margin-left: auto;
  display: flex;
  gap: .5rem;
  align-items: center;
}

/* ---------- Step pills ---------- */

.wizard-steps {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .35rem .5rem;
  list-style: none;
  margin: 0;
  padding: .35rem 1.5rem 0;
}
.wizard-step { display: flex; align-items: center; gap: .5rem; min-width: 0; }
/* Connector between pills — decorative, so it lives in CSS only. */
.wizard-step + .wizard-step::before {
  content: '';
  width: 1.75rem;
  height: 1px;
  background: var(--line-strong);
  flex: none;
}
.wizard-step-pill {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  padding: .32rem .8rem;
  border-radius: 999px;
  background: var(--panel-3);
  color: var(--ink-soft);
  font-size: .8rem;
  white-space: nowrap;
}
.wizard-step-mark { font-variant-numeric: tabular-nums; }
.wizard-step.is-done .wizard-step-pill { color: var(--ink-body); }
.wizard-step.is-active .wizard-step-pill {
  background: var(--invert-bg);
  color: var(--invert-ink);
  font-weight: 600;
}
.wizard-step-panel[hidden] { display: none; }

/* ---------- Inline checkbox row (allowed tunnel types, protocols) ----
   Replaces <select multiple>: same submitted name, no ctrl-click, no
   nested scroll area. Short enum lists only — long lists still want a
   picker dialog. ------------------------------------------------------ */

.check-row {
  display: flex;
  flex-wrap: wrap;
  /* Left-align so the first option lines up under its label and the
     whole row reads as one logical group. Items flow left-to-right,
     then wrap to the next line — which keeps a long list (e.g. 9
     tunnel types) compact instead of stretching a single row across
     the entire dialog and leaving wide gaps between boxes. */
  justify-content: flex-start;
  column-gap: 1.25rem;
  row-gap: .6rem;
  margin: .15rem 0 0;
  padding: 0;
  list-style: none;
}
/* Tighter gaps on very narrow bodies (e.g. stacked form-field at
   <640px) so the wrapped options still read as a grid. The base rule
   is `display:flex`, so this tunes the gaps rather than the columns. */
@media (max-width: 640px) {
  .check-row { column-gap: .9rem; row-gap: .5rem; }
}
.check-option {
  display: inline-flex;          /* override the column-flex base rule */
  /* row-reverse puts the box on the right of its label without
     reordering the markup, so the wrapping <label> keeps its natural
     reading order for screen readers. */
  flex-direction: row-reverse;
  align-items: center;
  gap: .5rem;
  font-size: .875rem;
  font-weight: 400;
  color: var(--ink-body);
  cursor: pointer;
  /* Keep "TCP（多路复用）" on one line — breaking inside the parentheses
     leaves an orphan on a second line and looks broken. */
  white-space: nowrap;
}
/* Custom checkbox — strip the OS default (which clashes with the dark
   theme and ignores our accent colour), draw a 16×16 rounded box, and
   paint a checkmark when :checked. Position-relative on the label keeps
   the absolutely-positioned mark from leaking out of the box. */
.check-option input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  flex: none;
  width: 16px;
  height: 16px;
  border: 1.5px solid var(--line-strong);
  border-radius: 4px;
  background: var(--panel);
  cursor: pointer;
  position: relative;
  transition: background-color .12s ease, border-color .12s ease, box-shadow .12s ease;
}
/* Hover lifts the border toward the accent so the option reads as
   interactive. Color-only is too subtle on the dark surface, so the
   border also thickens via a faint ring. */
.check-option input[type="checkbox"]:hover {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Filled state — accent fill, white checkmark drawn with a clip-path so
   we don't need an SVG asset. The mark's footprint matches the icon set
   in the dashboard so the panel reads as one design. */
.check-option input[type="checkbox"]:checked {
  background: var(--accent);
  border-color: var(--accent);
}
.check-option input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--invert-ink);
  /* Two strokes: a short up-stroke then a long down-stroke, meeting in
     a corner. clip-path keeps it a single solid shape (no antialias
     seams between two borders). The shape is inset 2px so the mark
     sits comfortably inside the 16px box. */
  clip-path: polygon(14% 50%, 0 64%, 38% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.check-option input[type="checkbox"]:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.check-option:has(input:checked) { color: var(--ink); }
.check-option:hover { color: var(--ink); }

/* ---------- Checkbox card (the boolean switches on the last step) --- */

.check-cards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .85rem 1.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.check-card {
  display: flex;
  /* Box on the right of the text block; the text takes the slack so the
     box is pinned to the card's right edge. */
  flex-direction: row-reverse;
  align-items: flex-start;
  gap: .7rem;
  padding: .85rem 1rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
  font-weight: 400;
}
.check-card:hover { background: var(--panel-2); }
.check-card:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
/* Same custom box as .check-option, scaled to 18px so it reads at the
   same optical weight as the card's body text. The mark scales with
   the box so the proportions stay correct. */
.check-card input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  margin: .15rem 0 0;
  flex: none;
  width: 18px;
  height: 18px;
  border: 1.5px solid var(--line-strong);
  border-radius: 5px;
  background: var(--panel);
  cursor: pointer;
  position: relative;
  transition: background-color .12s ease, border-color .12s ease, box-shadow .12s ease;
}
.check-card input[type="checkbox"]:hover {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.check-card input[type="checkbox"]:checked {
  background: var(--accent);
  border-color: var(--accent);
}
.check-card input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--invert-ink);
  clip-path: polygon(14% 50%, 0 64%, 38% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.check-card input[type="checkbox"]:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.check-card-text { display: flex; flex-direction: column; gap: .2rem; min-width: 0; flex: 1; }
.check-card-label { font-size: .875rem; color: var(--ink); }
.check-card-hint { font-size: .78rem; color: var(--ink-soft); line-height: 1.4; }
.check-card-full { grid-column: 1 / -1; }

/* ---------- Sliding switch (admin features page) ----------

   Visual-only iOS-style toggle. The form-control is a real
   <input type="checkbox"> so the wire value behaves like every
   other checkbox; the pill track + thumb are decorative
   pseudo-shapes built from <span>s so we don't depend on any
   JS to render. A wrapping <form data-switch-autosubmit>
   makes a click submit immediately (see static/app.js). */
.switch {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  user-select: none;
  /* Click target stays clickable even when the visible track is
     smaller than the spec — keeps the affordance honest. */
  padding: .15rem 0;
}
.switch input[type="checkbox"] {
  /* Hide the native box but keep it focusable + announced by
     assistive tech. Absolute positioning keeps the checkbox on
     top of the track so keyboard focus rings aren't clipped. */
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}
.switch .switch-track {
  position: relative;
  display: inline-block;
  width: 38px;
  height: 22px;
  border-radius: 999px;
  background: var(--panel-3);
  border: 1px solid var(--line);
  transition: background-color .18s ease, border-color .18s ease;
  vertical-align: middle;
  flex: 0 0 auto;
}
.switch .switch-thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--line);
  box-shadow: 0 1px 2px rgba(15, 23, 42, .18);
  transition: transform .18s ease, background-color .18s ease, border-color .18s ease;
}
.switch input[type="checkbox"]:checked + .switch-track {
  background: var(--accent);
  border-color: var(--accent);
}
.switch input[type="checkbox"]:checked + .switch-track .switch-thumb {
  transform: translateX(16px);
  background: #fff;
  border-color: var(--accent);
}
.switch input[type="checkbox"]:focus-visible + .switch-track {
  outline: none;
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Optional ON / OFF text inside the track. The off label sits on
   the right and is visible when the switch is off; the on label
   sits on the left and shows when on. Hidden by default to keep
   the chrome lean for toggles that don't need them. */
.switch .switch-on,
.switch .switch-off {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-size: .65rem;
  font-weight: 600;
  color: var(--ink-soft);
  pointer-events: none;
  text-transform: uppercase;
  letter-spacing: .02em;
  opacity: 0;
  transition: opacity .12s ease, color .12s ease;
}
.switch .switch-on { left: 6px; color: #fff; }
.switch .switch-off { right: 6px; }
.switch input[type="checkbox"]:checked + .switch-track .switch-on { opacity: 1; }
.switch input[type="checkbox"]:not(:checked) + .switch-track .switch-off { opacity: 1; }
/* The native checkbox is hidden but still in the layout; remove
   its line-height slot so the label doesn't add phantom height. */
.switch > input[type="checkbox"] {
  line-height: 0;
}
/* Disabled state — the operator can read but not toggle. */
.switch.is-disabled { cursor: not-allowed; opacity: .55; }
.switch.is-disabled .switch-track { background: var(--panel-2); }

/* Solid "inverted" action button — the loudest control in a dialog
   footer, matching the login submit. Also used as an <a> on the
   dashboard, hence the explicit text-decoration reset. */
.btn-invert {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .4rem;
  padding: .55rem 1.35rem;
  background: var(--invert-bg);
  color: var(--invert-ink);
  border: 1px solid var(--invert-bg);
  border-radius: 8px;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
}
.btn-invert:hover { background: var(--invert-bg-h); border-color: var(--invert-bg-h); }
.btn-invert:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Checkbox + label on one line (used outside the dialog wizard too —
   the plain `label` rule stacks them into a column otherwise). */
.toggle {
  display: inline-flex;          /* override the column-flex base rule */
  flex-direction: row-reverse;
  /* The box goes on the right of the label, and the label takes the
     slack so a full-width .toggle pins the box to the right edge. */
  justify-content: flex-end;
  align-items: center;
  gap: .45rem;
  font-size: .875rem;
  color: var(--ink-body);
  cursor: pointer;
}
.toggle > span { flex: 1 1 auto; min-width: 0; }
/* Same custom box as .check-option / .check-card so every checkbox in
   the panel shares one visual language. */
.toggle input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  flex: none;
  width: 16px;
  height: 16px;
  border: 1.5px solid var(--line-strong);
  border-radius: 4px;
  background: var(--panel);
  cursor: pointer;
  position: relative;
  transition: background-color .12s ease, border-color .12s ease, box-shadow .12s ease;
}
.toggle input[type="checkbox"]:hover {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.toggle input[type="checkbox"]:checked {
  background: var(--accent);
  border-color: var(--accent);
}
.toggle input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--invert-ink);
  clip-path: polygon(14% 50%, 0 64%, 38% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.toggle input[type="checkbox"]:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.toggle:has(input:checked) { color: var(--ink); }
.toggle:hover { color: var(--ink); }

/* Agent install line — long single-line command, so let it scroll
   sideways instead of blowing out the table cell it lives in. */
.install-snippet {
  margin: .4rem 0 .75rem;
  padding: .6rem .75rem;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow-x: auto;
  font-size: .8rem;
  line-height: 1.5;
}
.install-snippet code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }

/* /node-groups — card layout modelled on the node-status console.
   Each group is a header strip + always-visible node status table. */
.group-card-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.group-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 12px;
  overflow: hidden;
  position: relative;
}
.group-card[hidden] { display: none !important; }
/* Single-row header: name, badges and the identity chips all sit on one
   line, with the row actions pinned right. */
.group-card-head {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: .5rem .75rem;
  padding: .75rem 1rem;
  background: var(--panel-2);
  border-bottom: 1px solid var(--line);
}
.group-card-title-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .45rem .55rem;
  min-width: 0;
}
.group-card-name {
  margin: 0;
  font-size: 1rem;
  font-weight: 650;
  color: var(--ink);
  line-height: 1.3;
}
.group-card-actions {
  display: flex;
  align-items: center;
  gap: .35rem;
}
.group-chip {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .18rem .55rem;
  border-radius: 999px;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: .78rem;
  line-height: 1.3;
  white-space: nowrap;
}
/* The traffic multiplier is a useful number, not the headline. It used
   to be a solid black pill, which outweighed the group name sitting
   right next to it; keep it in the chip family and lean on weight
   alone to separate it. */
.group-chip-rate {
  color: var(--ink);
  font-weight: 600;
}
.group-card-body { padding: 0; }
.group-nodes-empty {
  margin: 0;
  padding: .85rem 1rem;
  font-size: .85rem;
}
.node-status-table {
  margin: 0;
  border: 0;
  border-radius: 0;
  width: 100%;
}
.node-status-table th,
.node-status-table td {
  font-size: .84rem;
  vertical-align: middle;
}
.node-status-table thead th {
  background: var(--panel);
  color: var(--ink-soft);
  font-weight: 600;
  border-bottom: 1px solid var(--line);
}
/* Pin the fixed-shape columns so the node-name column (the only one with
   variable-length content) absorbs the remaining width instead of the
   metric columns stretching. `width:100%` on the flexible column is what
   actually forces that — a plain `width` on the others is only a hint
   and browsers still share out slack proportionally. */
.node-status-table th:first-child,
.node-status-table .node-id-cell { width: 100%; }
.node-status-table .col-metric { width: 10.5rem; }
.node-status-table .col-last-seen { width: 11rem; }
.node-id-cell { min-width: 14rem; }
.node-id-line {
  display: flex;
  align-items: center;
  gap: .45rem;
  min-width: 0;
}
.node-status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--tint-ok-ink, #1a7f37);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tint-ok-ink, #1a7f37) 18%, transparent);
}
.node-status-dot.status-inactive,
.node-status-dot.status-error {
  background: var(--tint-err-ink, #c0392b);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tint-err-ink, #c0392b) 18%, transparent);
}
.node-status-dot.status-suspended,
.node-status-dot.status-disabled {
  background: var(--tint-warn-ink, #a15c00);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tint-warn-ink, #a15c00) 18%, transparent);
}
.node-id {
  font-size: .82rem;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 22rem;
}
.node-ip-line {
  margin-top: .2rem;
  margin-left: 1.05rem;
  font-size: .78rem;
  color: var(--ink-soft);
}
.node-tag-line {
  display: flex;
  flex-wrap: wrap;
  gap: .3rem;
  margin-top: .3rem;
  margin-left: 1.05rem;
}
.node-tag {
  display: inline-flex;
  align-items: center;
  padding: .08rem .4rem;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--panel-2);
  font-size: .72rem;
  color: var(--ink-soft);
}
.node-tag-warn {
  background: var(--tint-warn-bg, #fff4d6);
  border-color: transparent;
  color: var(--tint-warn-ink, #7a4f00);
}
/* Fixed track width. A `1fr` track stretched to fill the (wide) metric
   column, pushing the percentage far away from the bar it labels. */
.usage-meter {
  display: grid;
  grid-template-columns: 6rem auto;
  align-items: center;
  gap: .5rem;
}
/* No telemetry yet: drop the reserved track column so the placeholder
   lines up with where the bars start instead of floating mid-cell. */
.usage-meter.is-empty { grid-template-columns: auto; }
.usage-meter.is-empty .usage-val {
  text-align: left;
  min-width: 0;
}
.usage-track {
  height: 8px;
  border-radius: 999px;
  background: var(--panel-3, #e8ebf0);
  overflow: hidden;
}
.usage-fill {
  height: 100%;
  border-radius: inherit;
  background: #3b82f6;
  /* Low single-digit values still have to read as a bar, not a dot. */
  min-width: 4px;
  transition: width .2s ease;
}
.usage-fill.usage-cpu { background: #3b82f6; }
.usage-fill.usage-ram { background: #f59e0b; }
.usage-fill.usage-disk { background: #2563eb; }
/* Shared warn/crit steps so CPU escalates like RAM and disk already do. */
.usage-fill.is-warn { background: #f59e0b; }
.usage-fill.is-crit { background: #ef4444; }
.usage-val {
  font-size: .8rem;
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  min-width: 3.2rem;
  text-align: right;
}
.node-last-seen {
  white-space: nowrap;
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}
.node-last-seen.is-offline {
  color: var(--tint-err-ink, #c0392b);
  font-weight: 600;
}
/* Offline rows recede: previously only the status dot and the heartbeat
   timestamp changed, which is easy to miss when scanning a long table. */
.node-status-row.status-inactive {
  background: color-mix(in srgb, var(--panel-2) 55%, transparent);
}
.node-status-row.status-inactive .node-id,
.node-status-row.status-inactive .node-ip-line,
.node-status-row.status-inactive .usage-val { opacity: .6; }
.node-status-row.status-inactive .usage-fill { filter: saturate(.25); }
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel);
  color: var(--ink-soft);
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.icon-btn:hover {
  background: var(--panel-2);
  color: var(--ink);
  border-color: var(--accent);
}
.icon-btn-danger:hover {
  color: var(--tint-err-ink, #c0392b);
  border-color: var(--tint-err-ink, #c0392b);
  background: var(--tint-err-bg, #fde8e8);
}
@media (max-width: 900px) {
  .usage-meter { grid-template-columns: 1fr; gap: .25rem; }
  .usage-val { text-align: left; }
  .node-id { max-width: 14rem; }
  .node-status-table .col-metric,
  .node-status-table .col-last-seen { width: auto; }
}

/* Live-feed connection state (see static/node_groups_live.js). Sits in
   the filter bar so the operator can tell "the numbers stopped moving"
   apart from "the socket dropped". */
.live-status {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  font-size: .78rem;
  color: var(--ink-soft);
  white-space: nowrap;
}
.live-status-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--ink-soft);
  transition: background .2s ease, box-shadow .2s ease;
}
.live-status.is-live .live-status-dot {
  background: var(--tint-ok-ink, #1a7f37);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tint-ok-ink, #1a7f37) 18%, transparent);
}
.live-status.is-down .live-status-dot {
  background: var(--tint-warn-ink, #a15c00);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tint-warn-ink, #a15c00) 18%, transparent);
}
/* Live metric cells update in place with no flash — a tint on every
   incoming sample turned the whole card into a strobe on a busy page,
   and the number changing is signal enough. */
@media (prefers-reduced-motion: reduce) {
  .usage-fill { transition: none; }
}

/* Legacy expandable registered-nodes panel (kept for safety if any
   markup still emits the old table sub-row). */
tr.group-nodes-row[hidden] {
  display: none !important;
}
.group-nodes-row > td {
  padding: .45rem .75rem .55rem;
  background: var(--panel-2);
  border-bottom: 1px solid var(--line);
}
.group-nodes-panel {
  padding: 0;
}
.group-nodes-panel .nested-table {
  margin: 0;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow: hidden;
}
.group-nodes-panel .nested-table th,
.group-nodes-panel .nested-table td {
  font-size: .82rem;
}

/* Per-group "Registered nodes" footer — the "Clear offline" form
   sits here when offline nodes exist. */
.nodes-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: .55rem;
  padding-top: .25rem;
  border-top: 1px dashed var(--line);
}
.nodes-actions .inline-form { margin: 0; }

/* "N offline" pill shown next to the total count in the nodes
   summary; keeps the destructive affordance visible even when the
   <details> body is collapsed. Uses the same warning tint as the
   status-inactive badge so operators link the two at a glance. */
.badge-offline-count {
  display: inline-flex;
  align-items: center;
  margin-left: .4rem;
  padding: .12rem .5rem;
  font-size: .72rem;
  font-weight: 600;
  border-radius: 999px;
  background: var(--tint-warn-bg, #fff4d6);
  color: var(--tint-warn-ink, #7a4f00);
  vertical-align: middle;
}

/* ---------- API documentation section ---------- */

.section-divider {
  border: none;
  border-top: 1px solid var(--line-strong);
  margin: 2rem 0;
}

.api-doc {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.5rem;
  margin-top: 1rem;
}

.api-doc h2 {
  font-size: 1.1rem;
  margin: 0 0 .8rem;
  color: var(--ink);
}

.api-doc h3 {
  font-size: .95rem;
  margin: 1.2rem 0 .5rem;
  color: var(--ink-soft);
}

.api-doc p {
  font-size: .9rem;
  line-height: 1.5;
  color: var(--ink-body);
  margin: 0 0 1rem;
}

.api-doc pre {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: .75rem 1rem;
  overflow-x: auto;
  font-size: .8rem;
  line-height: 1.5;
  margin: 0 0 1rem;
}

.api-doc code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.api-doc .endpoint-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.api-doc .endpoint-list li {
  padding: .4rem 0;
  font-size: .85rem;
  color: var(--ink-body);
  border-bottom: 1px solid var(--line);
}

.api-doc .endpoint-list li:last-child {
  border-bottom: none;
}

.api-doc .endpoint-list li code {
  color: var(--accent);
  font-weight: 600;
}

/* ---------- Responsive grid breakpoints ---------- */

@media (max-width: 900px) {
  .form-grid { grid-template-columns: 1fr; }
  .check-cards { grid-template-columns: 1fr; }
  .filter-search { flex-basis: 100%; }
  .filter-count { margin-left: 0; }
}
@media (max-width: 640px) {
  .page-actions { flex-direction: column; align-items: stretch; }
  .form-actions { flex-direction: column-reverse; }
  .form-actions button { width: 100%; justify-content: center; }
  .confirm-dialog-actions { flex-direction: column-reverse; }
  .confirm-dialog-actions button { width: 100%; justify-content: center; }
  .app-dialog { width: 100vw; max-width: 100vw; max-height: 100vh; border-radius: 0; }
  .dialog-header, .wizard-steps, .dialog-body, .dialog-footer { padding-left: 1rem; padding-right: 1rem; }
  /* Keep "Back" on the left and the primary action reachable by thumb. */
  .dialog-footer { flex-wrap: wrap; }
  .dialog-footer-end { width: 100%; }
  .dialog-footer-end button { flex: 1; }
}

/* ---------- Tunnels page (toolbar + table redesign) ---------- */

/* Two-row toolbar: search + actions on top, chip filters below */
.tunnels-toolbar {
  display: flex;
  flex-direction: column;
  gap: .55rem;
  margin-bottom: 1rem;
}
.tunnels-toolbar-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .65rem;
}
.tunnels-toolbar-search .filter-search { flex: 1 1 280px; min-width: 220px; }
.tunnels-toolbar-actions { display: inline-flex; gap: .5rem; margin-left: auto; }

/* The table stays dense; navigation sits below it and only appears when
   filtering or the row count creates more than one page. */
.table-pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .75rem;
  margin: 1rem 0 .25rem;
}
.pagination-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  min-width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  font-size: 1.35rem;
  line-height: 1;
}
.pagination-button:disabled { opacity: .45; cursor: not-allowed; }
.pagination-status {
  min-width: 7rem;
  text-align: center;
  color: var(--ink-soft);
  font-size: .85rem;
  font-variant-numeric: tabular-nums;
}

/* Filter chip: a compact pill with label + native control inside */
.filter-chip {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  padding: .25rem .55rem .25rem .75rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
  color: var(--ink-body);
  font-size: .85rem;
  line-height: 1.2;
  cursor: pointer;
  margin: 0;
  min-height: 2rem;
  transition: border-color .15s, background .15s, box-shadow .15s;
}
.filter-chip:hover { border-color: var(--line-strong); }
.filter-chip:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.filter-chip-label {
  color: var(--ink-soft);
  font-weight: 500;
  font-size: .82rem;
}
.filter-chip-label::before {
  /* "+" lead icon mirrors the reference image */
  content: '+';
  display: inline-block;
  margin-right: .35rem;
  color: var(--ink-soft);
  font-weight: 600;
}
.filter-chip select {
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  color: var(--ink);
  font: inherit;
  font-weight: 600;
  padding: 0 1.05rem 0 .15rem;
  margin: 0;
  cursor: pointer;
  max-width: 12rem;
  text-overflow: ellipsis;
}
.filter-chip select:focus { outline: none; }
.filter-chip select::-ms-expand { display: none; }
.filter-chip::after {
  /* right-side chevron rendered as a CSS border-triangle */
  content: '';
  width: 6px;
  height: 6px;
  margin-right: .25rem;
  border-right: 2px solid var(--ink-soft);
  border-bottom: 2px solid var(--ink-soft);
  transform: translateY(-2px) rotate(45deg);
  pointer-events: none;
}
.filter-chip.is-active { border-color: var(--accent); background: var(--accent-soft); }
.filter-chip.is-active .filter-chip-label,
.filter-chip.is-active .filter-chip-label::before { color: var(--accent); }

/* Filter chip hosting a text input (listen-port filter) */
.filter-chip.filter-chip-text { padding-right: .85rem; }
.filter-chip-text input {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-weight: 600;
  padding: 0 .25rem;
  width: 6rem;
}
.filter-chip-text input:focus { outline: none; }
.filter-chip-text input::placeholder { color: var(--ink-soft); font-weight: 400; }
.filter-chip-text::after { display: none; }

/* Inline form list reset for kebab action menu items */
.kebab-menu form,
.kebab-menu li { margin: 0; padding: 0; }
.kebab-menu li { list-style: none; }

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

/* Tunnel selection column */
.col-select {
  width: 44px;
  padding: .55rem .65rem !important;
  text-align: center;
  vertical-align: middle;
}
.col-select input[type="checkbox"] {
  width: 1rem;
  height: 1rem;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Kept for other tables that still expose a drag affordance. */
.col-handle {
  width: 28px;
  padding: .7rem .35rem !important;
  color: var(--ink-soft);
  cursor: grab;
  user-select: none;
}
.col-handle:active { cursor: grabbing; }
.col-handle .drag-handle {
  display: inline-block;
  line-height: 1;
  letter-spacing: -.05em;
  font-size: 1.1rem;
}

.bulk-edit-form {
  margin: 0 0 1rem;
  padding: 1rem;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  background: var(--panel);
}
.bulk-edit-head {
  display: flex;
  align-items: baseline;
  gap: .65rem;
  margin-bottom: .8rem;
}
.bulk-edit-title {
  margin: 0;
  font-size: 1rem;
}
.bulk-edit-selected {
  color: var(--ink-soft);
  font-size: .85rem;
}
.bulk-edit-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: .75rem;
}
.bulk-edit-grid .form-field { margin: 0; }
@media (max-width: 900px) {
  .bulk-edit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 520px) {
  .bulk-edit-grid { grid-template-columns: 1fr; }
}

/* Reorder drag handle in the first column of /node-groups. The handle
   is a 6-dot "grip" icon — the same affordance you see in macOS list
   views and Trello — sized to match the row's vertical rhythm. It is
   the only draggable element on the card; the rest of the row stays
   clickable for buttons, kebab menus, and text selection. */
.reorder-cell {
  width: 44px;
  padding: .45rem .35rem !important;
  white-space: nowrap;
  /* Vertically centre the handle inside the row regardless of how
     tall the row gets (the registered-nodes sub-row can stretch
     sibling rows, but each main row's own height stays modest). */
  vertical-align: middle;
}
.reorder-handle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
  color: var(--ink-mute, var(--ink));
  cursor: grab;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.reorder-handle:hover {
  background: var(--panel-2);
  border-color: var(--accent);
  color: var(--accent);
}
.reorder-handle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.reorder-handle:active {
  cursor: grabbing;
}

/* Drag-and-drop states. The dragged row goes half-transparent so the
   user sees where it came from, the rest of the body, and the row
   we're hovering over gets a coloured insertion line on the side
   closest to the cursor (upper half → line above, lower half →
   line below). The line is drawn via a box-shadow so it doesn't
   shift the row's height when it appears. */
.group-main-row.is-dragging {
  opacity: .45;
}
.group-main-row.drag-over-before {
  position: relative;
}
.group-main-row.drag-over-before::before,
.group-main-row.drag-over-after::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--accent);
  border-radius: 1px;
  pointer-events: none;
}
.group-main-row.drag-over-before::before {
  top: -1px;
}
.group-main-row.drag-over-after::after {
  bottom: -1px;
}
.col-check {
  width: 32px;
  padding: .7rem .35rem !important;
}
.col-check input[type="checkbox"] { margin: 0; cursor: pointer; }
.col-check-header input[type="checkbox"] { margin: 0; cursor: pointer; }

/* Group cell: name on top, mono IP + +N badge below */
.group-cell {
  display: flex;
  flex-direction: column;
  gap: .15rem;
  min-width: 0;
  max-width: 17rem;
}
.group-cell-name {
  font-weight: 600;
  color: var(--ink);
  line-height: 1.25;
  display: flex;
  /* baseline, not center: when a long name wraps, centring re-anchors
     the rate chip against the whole wrapped block and it drifts out of
     line with the IP pill below. */
  align-items: baseline;
  gap: .35rem;
  flex-wrap: wrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.group-cell-name .rate {
  color: var(--ink-soft);
  font-weight: 500;
  font-size: .78rem;
}
/* "+N nodes" overflow hint — the third line of the cell. Deliberately
   not .group-cell-name: at 600/var(--ink) it read as a second title. */
.group-cell-more {
  display: inline-flex;
  align-items: baseline;
  line-height: 1.25;
}
.group-cell-more .rate {
  color: var(--ink-soft);
  font-weight: 400;
  font-size: .78rem;
}
.group-cell-meta {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  width: fit-content;
  max-width: 100%;
  padding: .1rem .45rem .1rem .55rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .78rem;
  color: var(--ink-body);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  overflow: hidden;
}
.group-cell-meta .mono-ip {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.group-cell-meta .copy-ip {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-soft);
  padding: 0 .15rem;
  cursor: pointer;
  font-size: .85rem;
  line-height: 1;
  border-radius: 4px;
  flex: 0 0 auto;
}
.group-cell-meta .copy-ip:hover {
  color: var(--accent);
  background: var(--accent-soft);
}
.group-cell-meta .copy-ip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.group-cell-missing {
  color: var(--ink-soft);
  font-style: italic;
  font-size: .85rem;
}

/* Server IP:Port pill rendered under the tunnel name. Mirrors the visual
   weight of the old in-group IP pill so the column doesn't go empty, but
   keeps the listen port visible since the dedicated column was removed. */
.tunnel-server {
  margin-top: .25rem;
  display: inline-flex;
  align-items: center;
  gap: .15rem;
  padding: .1rem .35rem;
  font-size: .78rem;
  color: var(--ink-body);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  max-width: 100%;
}
.tunnel-server-addr {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tunnel-server .copy-ip {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-soft);
  padding: 0 .15rem;
  cursor: pointer;
  font-size: .85rem;
  line-height: 1;
  border-radius: 4px;
  flex: 0 0 auto;
}
.tunnel-server .copy-ip:hover {
  color: var(--accent);
  background: var(--accent-soft);
}
.tunnel-server .copy-ip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* IP:PORT (or forward target) under the in/out group name on /tunnels.
   Single-address variant: one pill under the name. The copy button
   uses the same ⧉ affordance as the listen IP next to the tunnel
   name. Multi-address variant (forward targets only): each address is
   its own pill, stacked. The shared list sits flush with the cell so
   long multi-target rows don't push the rate chip out of alignment. */
.group-cell-endpoint {
  margin-top: .15rem;
  display: inline-flex;
  align-items: center;
  gap: .15rem;
  padding: .1rem .35rem;
  font-size: .78rem;
  color: var(--ink-body);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  max-width: 100%;
}
.group-cell-endpoint-addr {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.group-cell-endpoint .copy-ip {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-soft);
  padding: 0 .15rem;
  cursor: pointer;
  font-size: .85rem;
  line-height: 1;
  border-radius: 4px;
  flex: 0 0 auto;
}
.group-cell-endpoint .copy-ip:hover {
  color: var(--accent);
  background: var(--accent-soft);
}
.group-cell-endpoint .copy-ip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.group-cell-endpoint-list {
  list-style: none;
  margin: .15rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: .15rem;
  min-width: 0;
}

/* /tunnels row — IP family on the left, tunnel transport badge on
   the right. Inline-flex with a small gap so a long IPv6 label and
   a "TCP+UDP" badge sit on one line at the row's natural height; the
   flex-wrap guards against an over-long IPv6 literal breaking the
   table layout. */
.ip-type-cell {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  flex-wrap: wrap;
}

/* Kebab overflow action menu */
.row-actions-kebab {
  position: relative;
  display: inline-flex;
  align-items: center;
}
.kebab-btn {
  appearance: none;
  background: transparent;
  border: 1px solid transparent;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--ink-body);
  cursor: pointer;
  transition: background .15s, border-color .15s, color .15s;
}
.kebab-btn:hover {
  background: var(--panel-3);
  border-color: var(--line);
}
.kebab-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.kebab-btn[aria-expanded="true"] {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--accent);
}
.kebab-menu {
  /* Fixed + high z-index so the menu escapes .data-table's
     overflow:hidden (rounded corners) and the sticky actions column.
     Hidden until JS adds .is-ready after setting top/left — otherwise
     details[open] paints an unpositioned fixed box at the button's
     static position (right edge) for a frame before reparenting. */
  position: fixed;
  z-index: 1000;
  min-width: 11rem;
  margin: 0;
  padding: .3rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  box-shadow: var(--shadow-dialog);
  display: none;
  flex-direction: column;
  gap: .1rem;
}
/* Only show after positionKebabMenu finishes (may live under <body>). */
.kebab-menu.is-ready {
  display: flex;
}
.kebab-menu[hidden] { display: none; }
.kebab-menu button {
  appearance: none;
  background: transparent;
  border: 0;
  color: var(--ink);
  font: inherit;
  font-size: .85rem;
  text-align: left;
  width: 100%;
  padding: .42rem .65rem;
  border-radius: 6px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: .5rem;
  white-space: nowrap;
}
.kebab-menu button:hover { background: var(--panel-2); }
.kebab-menu button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.kebab-menu button.danger { color: var(--danger); }
.kebab-menu button.danger:hover { background: var(--danger-soft); }

/* On-demand tunnel TCPING result modal (tunnels page "探测").
   Mirrors the .probe-popup pattern: a full-screen backdrop mask plus a
   centered dialog, instead of a fixed-position corner widget. */
.tunnel-test-mask {
  position: fixed;
  inset: 0;
  background: rgba(15, 23, 42, .35);
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tunnel-test-popup {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 16px 48px rgba(0, 0, 0, .22);
  width: min(560px, 92vw);
  max-height: 86vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.tunnel-test-head {
  padding: .8rem 1rem;
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
  background: var(--panel-2);
}
.tunnel-test-head .title {
  font-weight: 600;
  font-size: .95rem;
  color: var(--ink);
}
.tunnel-test-close {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--ink-soft);
  border-radius: 4px;
  padding: .2rem .55rem;
  cursor: pointer;
  font: inherit;
  line-height: 1.2;
}
.tunnel-test-close:hover {
  color: var(--ink);
  border-color: var(--line-strong);
}
.tunnel-test-body {
  padding: .75rem 1rem 1rem;
  overflow: auto;
}
.tunnel-test-row {
  display: grid;
  grid-template-columns: 7.5rem minmax(0, 1fr) auto;
  gap: .5rem .75rem;
  align-items: center;
  padding: .45rem 0;
  border-top: 1px dashed var(--line);
  font-size: .88rem;
}
.tunnel-test-row:first-child { border-top: none; }
.tunnel-test-kind { color: var(--ink-soft); font-weight: 600; }
.tunnel-test-target {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: .8rem;
}
.tunnel-test-status { white-space: nowrap; }
.tunnel-test-row.is-pending .tunnel-test-status,
.tunnel-test-row.is-running .tunnel-test-status { color: var(--ink-soft); }
.tunnel-test-row.is-ok .tunnel-test-status { color: var(--ok, #15803d); font-weight: 600; }
.tunnel-test-row.is-fail .tunnel-test-status { color: var(--danger); font-weight: 600; }

@media (max-width: 720px) {
  .tunnel-test-row {
    grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr);
  }
  .tunnel-test-status {
    grid-column: 2;
    white-space: normal;
  }
}

/* Compact live metrics cell */
.metric-cell-compact {
  /* Stack the metric rows explicitly. These were inline-flex spans in an
     inline parent, so their vertical order depended on the column being
     narrow enough to wrap — which .col-status's nowrap prevented. */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: .1rem;
  color: var(--ink-soft);
  font-size: .8rem;
  line-height: 1.45;
  font-variant-numeric: tabular-nums;
}
.metric-cell-compact[hidden] { display: none; }
.metric-cell-compact .metric-row {
  display: inline-flex;
  align-items: baseline;
  gap: .3rem;
  margin-right: 0;
}
.metric-cell-compact .metric-label {
  color: var(--ink-soft);
  font-size: .72rem;
}
.metric-cell-compact .metric-value {
  color: var(--ink-body);
  font-weight: 600;
}
.metric-cell-compact .stale {
  color: var(--tint-warn-ink);
  font-size: .72rem;
  margin-left: .35rem;
}

/* Traffic stat tile (实际 / 计费) */
.traffic-cell {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-end;
  line-height: 1.1;
  gap: .1rem;
}
.traffic-cell .traffic-value {
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.traffic-cell .traffic-unit {
  color: var(--ink-soft);
  font-size: .72rem;
}
.traffic-cell.is-empty .traffic-value { color: var(--ink-soft); font-weight: 500; }

/* Status column should keep its badge crisp */
.col-status { white-space: nowrap; }
.col-status .badge { white-space: nowrap; }

/* Stack toolbar responsively for narrow viewports */
@media (max-width: 720px) {
  .tunnels-toolbar-search .filter-search { flex: 1 1 100%; }
  .tunnels-toolbar-actions { width: 100%; justify-content: flex-end; }
  .filter-chip { flex: 1 1 calc(50% - .65rem); min-width: 0; }
  .filter-chip select { max-width: none; }
}

/* =============================================================
   /probes — fixed-combination probe list + popup
   ============================================================= */

.probe-list,
.probe-matrix {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  table-layout: fixed;
}
.probe-list th,
.probe-list td,
.probe-matrix th,
.probe-matrix td {
  padding: .55rem .65rem;
  border-bottom: 1px solid var(--line);
  vertical-align: middle;
  font-size: .85rem;
}
.probe-list thead th,
.probe-matrix thead th {
  background: var(--panel-2);
  font-weight: 600;
  color: var(--ink-soft);
  position: sticky;
  top: 0;
  z-index: 1;
}
.probe-list tbody tr:hover,
.probe-matrix tbody tr:hover {
  background: var(--panel-2);
}
.probe-list .probe-combination-name,
.probe-matrix .from-name {
  font-weight: 600;
  color: var(--ink);
}
.probe-list .cell-row,
.probe-matrix .cell-row {
  display: flex;
  align-items: center;
  gap: .35rem;
}
.probe-list .cell-row .latency,
.probe-matrix .cell-row .latency {
  flex: 1;
  font-variant-numeric: tabular-nums;
}
.probe-list .probe-open,
.probe-matrix .probe-open {
  flex: 0 0 auto;
  background: transparent;
  border: 1px solid var(--line);
  color: var(--ink-soft);
  border-radius: 4px;
  padding: .15rem .45rem;
  font-size: .7rem;
  cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
}
.probe-list .probe-open:hover,
.probe-list .probe-open:focus-visible,
.probe-matrix .probe-open:hover,
.probe-matrix .probe-open:focus-visible {
  background: var(--accent-soft);
  color: var(--accent);
  border-color: var(--accent);
  outline: none;
}
.probe-list .probe-open[disabled],
.probe-matrix .probe-open[disabled] {
  opacity: .45;
  cursor: not-allowed;
}
.probe-list .cell-empty,
.probe-matrix .cell-empty {
  color: var(--ink-soft);
  font-style: italic;
}
/* Fixed-combination rows carry the popup data attributes on the <tr>
   itself; give the cursor a hint that the whole row is clickable. */
.probe-list tbody tr[data-probe-cell] {
  cursor: pointer;
}

.probe-popup-mask {
  position: fixed;
  inset: 0;
  background: rgba(15, 23, 42, .35);
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
}
.probe-popup {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 16px 48px rgba(0, 0, 0, .22);
  width: min(880px, 92vw);
  max-height: 86vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.probe-popup-head {
  padding: .8rem 1rem;
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
  background: var(--panel-2);
}
.probe-popup-head .title {
  font-weight: 600;
  font-size: .95rem;
}
.probe-popup-head .meta {
  font-size: .75rem;
  color: var(--ink-soft);
  margin-top: .15rem;
}
.probe-popup-close {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--ink-soft);
  border-radius: 4px;
  padding: .2rem .55rem;
  cursor: pointer;
}
.probe-popup-close:hover {
  color: var(--ink);
  border-color: var(--line-strong);
}
.probe-popup-body {
  display: grid;
  grid-template-columns: 160px 1fr;
  min-height: 320px;
  flex: 1;
  overflow: hidden;
}
.probe-popup-tabs {
  border-right: 1px solid var(--line);
  background: var(--panel-2);
  padding: .4rem .25rem;
  display: flex;
  flex-direction: column;
  gap: .15rem;
}
.probe-popup-tab {
  text-align: left;
  background: transparent;
  border: none;
  padding: .5rem .65rem;
  border-radius: 6px;
  cursor: pointer;
  color: var(--ink-soft);
  font-size: .85rem;
  display: flex;
  align-items: center;
  gap: .45rem;
}
.probe-popup-tab:hover {
  background: var(--panel);
  color: var(--ink);
}
.probe-popup-tab.is-active {
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 600;
}
.probe-popup-tab .dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
  opacity: .5;
}
.probe-popup-content {
  padding: 1rem;
  overflow: auto;
  position: relative;
}
.probe-popup-toolbar {
  display: flex;
  gap: .5rem;
  align-items: center;
  margin-bottom: .65rem;
  flex-wrap: wrap;
}
.probe-popup-toolbar select,
.probe-popup-toolbar button {
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--ink);
  border-radius: 6px;
  padding: .3rem .55rem;
  font-size: .8rem;
  cursor: pointer;
}
.probe-popup-toolbar button:hover {
  border-color: var(--line-strong);
}

.probe-chart {
  width: 100%;
  height: 240px;
  display: block;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
}
.probe-chart .gridline {
  stroke: var(--line);
  stroke-width: 1;
  stroke-dasharray: 2 4;
}
.probe-chart .axis-label {
  fill: var(--ink-soft);
  font-size: 10px;
  font-family: var(--font-mono, ui-monospace, monospace);
}
.probe-chart .line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 1.6;
}
.probe-chart .area {
  fill: var(--accent-soft);
}
.probe-chart .loss-bar {
  fill: var(--tint-warn-line);
  opacity: .7;
}
.probe-chart .empty {
  fill: var(--ink-soft);
  font-size: 13px;
  text-anchor: middle;
}
.probe-chart .hover-line {
  stroke: var(--accent);
  stroke-width: 1;
  stroke-dasharray: 2 2;
  pointer-events: none;
}

.probe-summary {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: .55rem;
  margin-bottom: .65rem;
}
.probe-summary .tile {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: .5rem .65rem;
}
.probe-summary .tile .lbl {
  font-size: .7rem;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: .04em;
}
.probe-summary .tile .val {
  font-size: 1.05rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  margin-top: .15rem;
}
.probe-summary .tile .val.is-empty {
  color: var(--ink-soft);
  font-weight: 500;
}

.mtr-table {
  width: 100%;
  border-collapse: collapse;
  font-size: .82rem;
  font-variant-numeric: tabular-nums;
}
.mtr-table th,
.mtr-table td {
  padding: .35rem .55rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
}
.mtr-table thead th {
  background: var(--panel-2);
  font-weight: 600;
  color: var(--ink-soft);
}
.mtr-table .loss-bad {
  color: var(--tint-err-ink);
  font-weight: 600;
}
.mtr-table .loss-warn {
  color: var(--tint-warn-ink);
  font-weight: 600;
}
.mtr-run-head {
  display: flex;
  align-items: baseline;
  gap: .8rem;
  margin: 1rem 0 .35rem;
}
.mtr-run-head:first-child {
  margin-top: 0;
}
.mtr-run-head .when {
  font-size: .72rem;
  color: var(--ink-soft);
}

@media (max-width: 720px) {
  .probe-popup-body {
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr;
  }
  .probe-popup-tabs {
    flex-direction: row;
    border-right: none;
    border-bottom: 1px solid var(--line);
    overflow-x: auto;
  }
  .probe-summary {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
