/* =============================================
   TYPE / SPACING / RADIUS SCALES -- theme-independent (unlike the
   color tokens below, these don't change with light/dark/sunny), so
   they live in a plain :root rather than per [data-theme]. Consolidated
   from ~60 raw one-off rem values that had organically drifted across
   different authoring passes (e.g. six different "small secondary
   text" sizes within 0.1rem of each other, used interchangeably across
   unrelated components) -- each tier below is anchored on whichever
   raw value was already most common in that range, so the vast
   majority of call sites render at the exact same size as before;
   only the minority sitting a fraction of a rem off actually move,
   snapping onto the nearest real tier instead of their own arbitrary
   number. Two categories of value are deliberately left as plain
   literals, not part of either scale: --brand-sub's own two sizes
   (0.55rem in the nav header, 0.6rem in the larger auth-screen lockup)
   are a proportional pair scaled to their own differently-sized
   brand-mark, not general-purpose text; and .toast-message's 0.05rem
   top padding and the xray-link-input's 2.6rem right padding are each
   a one-off optical/layout nudge, not a reusable rhythm value either.
   ============================================= */
:root {
  --text-xs: 0.72rem;
  --text-sm: 0.78rem;
  --text-base: 0.85rem;
  --text-md: 0.95rem;
  --text-lg: 1.1rem;
  --text-xl: 1.3rem;
  --text-2xl: 1.5rem;

  --space-1: 0.2rem;
  --space-2: 0.3rem;
  --space-3: 0.4rem;
  --space-4: 0.5rem;
  --space-5: 0.65rem;
  --space-6: 0.85rem;
  --space-7: 1.1rem;
  --space-8: 1.75rem;
  --space-9: 2rem;
  --space-10: 3rem;

  --radius-xs: 0.35rem;
  --radius-sm: 0.4rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.65rem;
  --radius-xl: 0.75rem;
  --radius-2xl: 1rem;

  /* Same consolidation as the type/space/radius scales above, for
     motion instead of size -- ~30 call sites had organically settled
     on 0.15s for nearly every hover/interactive transition already
     (good instinct, just never named), while two continuous spinners
     had quietly drifted to different rotation speeds (0.7s vs 0.8s)
     for no real reason. Four tiers, each tied to what kind of change
     it's covering rather than to any one component:
       fast  -- hover/interactive state changes (color, background,
                border, small transforms on buttons/icons/carets).
       base  -- bigger UI movement settling into place (modal/toast
                enter-exit) and the default "fade content in" tier.
       slow  -- a value visibly changing (chart lines, the gauge arc).
       spin  -- one full rotation for a continuous spinner.
     pulse/loading-pulse are their own named durations rather than
     folded into the scale above -- each is tied to one specific,
     already-distinctively-named animation (status-dot-pulse,
     status-dot-loading-pulse), not a general-purpose tier other
     components would ever reach for. */
  --duration-fast: 0.15s;
  --duration-base: 0.2s;
  --duration-slow: 0.3s;
  --duration-spin: 0.7s;
  --duration-pulse: 1.4s;
  --duration-loading-pulse: 1.2s;

  --ease-standard: ease;
  --ease-out: ease-out;
  --ease-in-out: ease-in-out;
  --ease-linear: linear;
}

/* =============================================
   DESIGN TOKENS -- three lightness modes, one
   brand hue (orange) throughout.
   ============================================= */
[data-theme="dark"] {
  --bg-primary: #0b0c16;
  --bg-secondary: #121424;
  --text-primary: #f5f3ff;
  --text-secondary: #9496b8;
  --accent: #8b5cf6;
  --accent-hover: #7c3aed;
  --accent-rgb: 139, 92, 246;
  --accent-subtle: rgba(139, 92, 246, 0.16);
  --border: rgba(139, 92, 246, 0.12);
  --surface-overlay: rgba(255, 255, 255, 0.05);
  --surface-overlay-strong: rgba(139, 92, 246, 0.15);
  --glass-bg: rgba(18, 20, 36, 0.78);
  --danger: #ef4444;
  --danger-subtle: rgba(239, 68, 68, 0.14);
  --success: #10b981;
  --success-subtle: rgba(16, 185, 129, 0.14);
  --warn: #f59e0b;
  --warn-subtle: rgba(245, 158, 11, 0.14);
  --json-key: #a78bfa;
  --json-string: #6ee7b7;
  --json-number: #fde047;
  --json-keyword: #c4b5fd;
}

[data-theme="black"] {
  --bg-primary: #000000;
  --bg-secondary: #0a0a12;
  --text-primary: #ffffff;
  --text-secondary: #8e90a6;
  --accent: #a78bfa;
  --accent-hover: #8b5cf6;
  --accent-rgb: 167, 139, 250;
  --accent-subtle: rgba(167, 139, 250, 0.18);
  --border: rgba(167, 139, 250, 0.16);
  --surface-overlay: rgba(255, 255, 255, 0.08);
  --surface-overlay-strong: rgba(167, 139, 250, 0.2);
  --glass-bg: rgba(10, 10, 18, 0.82);
  --danger: #f87171;
  --danger-subtle: rgba(248, 113, 113, 0.14);
  --success: #34d399;
  --success-subtle: rgba(52, 211, 153, 0.14);
  --warn: #fbbf24;
  --warn-subtle: rgba(251, 191, 36, 0.14);
  --json-key: #c4b5fd;
  --json-string: #a7f3d0;
  --json-number: #fef08a;
  --json-keyword: #ddd6fe;
}

[data-theme="sunny"] {
  --bg-primary: #faf8ff;
  --bg-secondary: #f3effe;
  --text-primary: #2e1065;
  --text-secondary: #6b5b95;
  --accent: #6230d1;
  --accent-hover: #5224b8;
  --accent-rgb: 98, 48, 209;
  --accent-subtle: rgba(98, 48, 209, 0.12);
  --border: rgba(98, 48, 209, 0.15);
  --surface-overlay: rgba(98, 48, 209, 0.05);
  --surface-overlay-strong: rgba(98, 48, 209, 0.12);
  --glass-bg: rgba(250, 248, 255, 0.88);
  --danger: #dc2626;
  --danger-subtle: rgba(220, 38, 38, 0.12);
  --success: #059669;
  --success-subtle: rgba(5, 150, 105, 0.12);
  --warn: #d97706;
  --warn-subtle: rgba(217, 119, 6, 0.12);
  --json-key: #6230d1;
  --json-string: #047857;
  --json-number: #b45309;
  --json-keyword: #7c3aed;
}
  /* Darker, more saturated than the two dark themes' own tokens -- a
     light background needs real contrast, not the pastel tones that
     read fine on near-black. */
  --json-key: #0369a1;
  --json-string: #15803d;
  --json-number: #b45309;
  --json-keyword: #6d28d9;
}

.theme-btn:hover, .theme-btn.active,
.lang-btn:hover, .lang-btn.active {
  border-color: var(--accent) !important;
  box-shadow: 0 0 0 0.0625rem var(--accent);
}

/* Persian needs its own font stack -- the Latin system-ui stack has
   no real Farsi glyphs to fall back on. :lang(fa) (not [dir="rtl"])
   ties this to the language it's actually for, since that's also
   what i18n.js's loadPersianFont checks before ever fetching the
   font -- see its own comment for why the font itself is registered
   from JS (FontFace + document.fonts.add) instead of a plain
   @font-face here, which would let a browser decide to prefetch it
   for every visitor regardless of language. Tahoma/IRANSans/system-ui
   are the fallback while Vazirmatn is still loading (or if it fails
   to), not the primary choice -- previously they came first, back
   when nothing here actually bundled Vazirmatn to load. */
:lang(fa) {
  font-family: "Vazirmatn", Tahoma, "IRANSans", system-ui, sans-serif;
}

/* =============================================
   RESET & BASE
   ============================================= */
*, *::before, *::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Every scrollbar in the app (.content, .modal, .dropdown-menu-wide,
   .table-wrap, .checkbox-list, ...) stays hidden -- scrolling itself
   is untouched, only the visible track/thumb chrome a browser draws
   for it. scrollbar-width covers Firefox, the pseudo-element covers
   Chrome/Safari/newer Edge, -ms-overflow-style covers old Edge/IE. */
* {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
*::-webkit-scrollbar {
  display: none;
}

/* One consistent keyboard-focus ring, in the brand's own accent color,
   for every custom-styled interactive element (buttons, tabs, custom
   selects, checkboxes, nav links, ...) -- previously the only state in
   this whole hand-styled system that fell back to the browser's plain
   native outline instead. :focus-visible (not :focus) so a mouse click
   doesn't trigger it, only actual keyboard navigation. .form-input is
   the one exception: it already gets its own clear accent-colored
   border-color change on focus (see FORMS below), so this would just
   double up on the same signal there. */
:focus-visible {
  outline: 0.125rem solid var(--accent);
  outline-offset: 0.125rem;
}
.form-input:focus-visible {
  outline: none;
}

html { color-scheme: light dark; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background-color: var(--bg-primary);
  color: var(--text-primary);
  min-height: 100vh;
  min-height: 100dvh; /* see .page's own comment on why dvh, not vh */
}
/* Setting overflow-x alone (with overflow-y left unset) makes the
   *other* axis compute to auto, not visible -- so this was quietly
   making body its own scroll container instead of the real viewport,
   which is what made position: sticky/fixed children (.nav,
   .bottom-nav) glitch and lag during scroll instead of staying put.
   The horizontal-overflow clipping this provided is no longer needed
   now that the actual sources of horizontal overflow (long node/probe
   ids -- see .mono/.id-row above) wrap instead of overflowing. */
body.modal-open { overflow: hidden; }
/* .content, not body, is the app shell's actual scrolling element
   (see .page's own comment) -- locking body alone while a modal is
   open would lock an element that was never scrolling in the first
   place and leave the real scrollable region (.content) still able to
   move behind the modal's backdrop. */
body.modal-open .content { overflow: hidden; }

button, input, select, textarea {
  font-family: inherit;
}

a { color: inherit; }

code, .mono {
  font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
  font-variant-numeric: tabular-nums;
  /* A node/probe id is one long unbroken token -- without this, it
     never wraps and just pushes its flex row wider than the viewport
     on mobile instead of breaking onto a second line. */
  overflow-wrap: anywhere;
}

/* =============================================
   PAGE WRAPPER
   ============================================= */
/* An app shell, not a scrolling document: .page is locked to exactly
   the viewport's height (100vh as an older-browser fallback, 100dvh --
   the real, currently-visible viewport -- as the one that actually
   tracks the mobile browser's address bar animating in/out) and clips
   anything that would make it taller. .nav and .bottom-nav are then
   just plain flex children that never move, and only .content (below)
   scrolls internally -- no position: sticky/fixed juggling needed, and
   since the document itself never scrolls, a mobile browser's own
   "hide the address bar on scroll" heuristic (which watches the
   document/body scroll, not an inner div's) never triggers either. */
.page { display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; }

/* =============================================
   UNFUNDED BANNER (persistent, account-wide notice)
   ============================================= */
.unfunded-banner {
  flex-shrink: 0;
  background: linear-gradient(90deg, var(--danger), var(--accent));
  color: #fff;
  text-shadow: 0 0.0625rem 0.15rem rgba(0, 0, 0, 0.55);
  padding: var(--space-5) var(--space-7);
  font-size: var(--text-base);
  text-align: center;
  line-height: 1.4;
}
.unfunded-banner strong { font-weight: 700; }
.unfunded-banner.banner-warn { background: linear-gradient(90deg, var(--warn), var(--accent)); }
.unfunded-banner.banner-danger { background: var(--danger); }

/* =============================================
   NAV
   ============================================= */
.nav {
  flex-shrink: 0;
  z-index: 1000;
  background: var(--bg-primary);
  border-bottom: 0.0625rem solid var(--border);
}

.nav-container {
  max-width: 75rem;
  margin: 0 auto;
  padding: var(--space-6) var(--space-7);
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--space-7);
}

.nav-brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
  font-weight: 700;
  font-size: var(--text-lg);
  letter-spacing: -0.02em;
  white-space: nowrap;
  justify-self: start;
}

.nav-brand .brand-dot {
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0.5rem var(--accent);
  flex-shrink: 0;
}

.brand-text { display: flex; flex-direction: column; line-height: 1.15; text-align: start; }
.brand-mark { font-weight: 700; letter-spacing: -0.02em; }
.brand-mark .brand-accent { color: var(--accent); }
.brand-sub {
  font-size: 0.55rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.nav-center {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  justify-self: center;
}

.nav-center a {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-6);
  border-radius: var(--radius-lg);
  text-decoration: none;
  color: var(--text-secondary);
  font-size: var(--text-base);
  font-weight: 500;
  transition: background var(--duration-fast), color var(--duration-fast);
  white-space: nowrap;
}

.nav-center a .icon { width: 1.05rem; height: 1.05rem; flex-shrink: 0; }
.nav-center a:hover { background: var(--accent-subtle); color: var(--text-primary); }
.nav-center a.active { background: var(--accent-subtle); color: var(--accent); font-weight: 600; }

.nav-right { display: flex; align-items: center; gap: var(--space-5); justify-self: end; }

/* =============================================
   MAIN CONTENT
   ============================================= */
.content {
  flex: 1;
  /* The one scrolling region in the app shell (see .page's own
     comment) -- everything that used to make the *document* tall
     (an overfilled Overview page for a root account with many nodes/
     probes especially) now only grows this box's internal scroll,
     never the page itself, so .nav/.bottom-nav (siblings, not
     ancestors of this) never have anything to react to. */
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  min-height: 0; /* a flex item's default min-height:auto would otherwise refuse to shrink below its content's height, defeating overflow-y here */
  width: 100%;
  max-width: 75rem;
  margin: 0 auto;
  padding: var(--space-6) var(--space-8) var(--space-8);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.content-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-7);
  flex-wrap: wrap;
}

#page-actions { display: flex; align-items: center; gap: var(--space-5); }

#main-view { display: flex; flex-direction: column; gap: var(--space-8); }

/* =============================================
   SPACING UTILITIES (small, one-off overrides only --
   prefer a real component class when a pattern repeats)
   ============================================= */
.mt-sm { margin-top: var(--space-6); }
.mt-md { margin-top: var(--space-8); }
.mb-xs { margin-bottom: var(--space-4); }
.mb-sm { margin-bottom: var(--space-6); }
.mb-md { margin-bottom: var(--space-7); }
.text-center { text-align: center; }

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
  gap: var(--space-8);
  align-items: start;
}
/* Opt-in modifier: lets cards in the same row match each other's
   height (e.g. Account's Credit/Credentials pair) -- still just one
   column on a narrow viewport since auto-fit already collapses the
   grid there, so this never forces two cards side by side on mobile;
   it only changes how same-row cards size once there IS a row. */
.card-grid-stretch { align-items: stretch; }
/* Opt-in modifier: caps the grid at 2 columns even on very wide
   screens, where auto-fit's plain minmax(20rem, 1fr) would otherwise
   happily pack in a 3rd (e.g. Account's now-4-card grid) -- 3 cards
   per row reads as cramped even on a large monitor. Each column's own
   minimum is whichever is larger of 20rem (mobile-collapse floor,
   unchanged from the base .card-grid) or half the row's width minus
   half a gap -- on a wide viewport that's always > 20rem, so a 3rd
   column can never fit; a narrow viewport falls back to the same
   20rem-driven single-column collapse .card-grid already had. */
.card-grid-cap-2 { grid-template-columns: repeat(auto-fit, minmax(max(20rem, calc(50% - var(--space-8) / 2)), 1fr)); }
.btn-block { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: var(--space-4); }
.loading-card { display: flex; justify-content: center; padding: var(--space-10); }

h1 { font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em; }
/* A badge dropped inline into an h1 (e.g. Account's status pill next
   to its own heading) sits on the *text* baseline by default, which
   reads as floating too low against a heading's much taller line box
   than it would next to body text -- middle-align it to the heading's
   own line instead. */
h1 .badge { vertical-align: middle; }
h2 { font-size: var(--text-lg); font-weight: 700; }
p.subtitle { color: var(--text-secondary); font-size: var(--text-md); }

/* =============================================
   GLASS CARD / SURFACES
   ============================================= */
.glass-card {
  background: var(--bg-secondary);
  border: 0.0625rem solid var(--border);
  border-radius: var(--radius-2xl);
  padding: var(--space-8);
  box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
}

/* Every icon is now <svg class="icon"><use href="/icons.svg#name">
   (see icons.svg) instead of its own inline path data -- a use
   element's shadow content inherits CSS from the tree it's used in,
   so this rule still styles every icon exactly as it did when the
   path data lived inline here. */
.icon {
  width: 1em; height: 1em;
  display: inline-block;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vertical-align: -0.125em;
}

/* =============================================
   MONITOR CARDS (Overview)
   ============================================= */
.monitor-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  gap: var(--space-7);
}
.clickable { cursor: pointer; }
.monitor-title { font-size: var(--text-md); font-weight: 600; display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }

/* Cards roomy enough for a real per-node chart. */
.monitor-grid-rich { grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); }
.monitor-card-rich { padding: var(--space-7) var(--space-7); cursor: pointer; transition: border-color var(--duration-fast), box-shadow var(--duration-fast); }
/* box-shadow, not transform -- a `transform` on this card creates a
   new containing block for its own chart tooltip (position: fixed,
   see .chart-tooltip), which then positions itself relative to this
   card instead of the real viewport the moment it's hovered -- the
   exact "tooltip shows up in the wrong place in grid view" bug this
   replaced. box-shadow gets the same "lifted" affordance with none of
   that. */
.monitor-card-rich:hover { border-color: var(--accent); box-shadow: 0 0.4rem 1.2rem rgba(0, 0, 0, 0.25); }

/* Overview list view -- one dense row per probe: header line, an
   optional per-node color legend, and a small multi-line latency
   chart (see .overview-chart / renderProbeSparkChart) instead of one
   big per-row card, so scanning dozens of targets at once doesn't
   need scrolling past oversized cards. */
.overview-row { padding: var(--space-5) var(--space-7); }

/* The opt-in "Compact" density mode (see openOverviewCustomizeModal) --
   one class on the row/card container itself (renderProbes, same "one
   class high up" pattern list-vs-grid layout already uses), never
   redefining the shared --space-N/--text-N tokens those rules pull
   from -- those are used app-wide (tabs, buttons, forms), not just
   here, so a blanket redefinition would tighten spacing everywhere,
   not just the Overview page. Scoped descendant overrides instead,
   touching only the row/card selectors above. */
.density-compact .overview-row { padding: var(--space-3) var(--space-7); }
.density-compact .monitor-card-rich { padding: var(--space-5) var(--space-6); }
.density-compact .list-item-info h3 { margin-bottom: 0; }
.density-compact .probe-row-history-slot,
.density-compact .probe-row-sparkline-slot { margin-block: var(--space-2); }
/* display: inline-block is load-bearing, not decorative -- a plain
   <span> (this element's actual tag everywhere it's used) defaults to
   display: inline, where width/height are simply ignored. This used
   to work anyway wherever .status-dot sat as a *direct* flex child
   (flex auto-blockifies its immediate children), but nesting it one
   level deeper inside .status-dot-updating-ring's own wrapper span
   (see that class's own comment) silently collapsed it to zero size
   there, since that wrapper is a plain inline span, not a flex
   container. Explicit here means it renders correctly regardless of
   whatever happens to contain it next. */
/* background is transitioned here (not just set inline via
   statusDotStyle) so swapping from status-dot-loading's gray to a
   probe/node's real health color -- or later, one real color to
   another, e.g. healthy to degraded on a live-refresh tick -- eases
   in instead of snapping the instant the inline style changes. */
.status-dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; flex-shrink: 0; transition: background var(--duration-base) var(--ease-standard); }
/* The Nodes/Probes list rows' own dedicated square for the status dot
   -- a fixed-size slot (matching .icon-btn's own footprint, for visual
   balance against the actions column) that centers the dot both ways,
   independent of however many lines the title/subtitle stack next to
   it grows to. Also just deliberately empty space around the dot on
   every side for a future "just polled the API, here's a fresh
   result" glow/pulse (box-shadow around .status-dot) to bloom into
   without disturbing the row's layout. */
.status-dot-slot {
  width: 2rem;
  height: 2rem;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* A spinning ring around the dot itself, in place of a separate
   "updating…" text line next to the version -- see nodes.js's own
   isUpdating. Sized against the dot's own real rendered size (not a
   fixed guess) via padding, so it always reads as "around the dot"
   regardless of the dot's current size rules. */
.status-dot-updating-ring { position: relative; display: inline-flex; padding: var(--space-2); }
.status-dot-updating-ring::before {
  content: ""; position: absolute; inset: 0;
  border-radius: 50%; border: 0.1rem solid transparent; border-top-color: var(--warn);
  animation: spin var(--duration-spin) var(--ease-linear) infinite;
}

/* One-shot glow the instant a node/probe's status dot reflects a real,
   just-happened change (see nodePulseClass/probePulseClass and
   statusDotStyle in app.js) -- ok (green) for a fresh heartbeat/check
   landing, miss (whatever "danger"/"warn" the dot's own color just
   became, via the --pulse-glow custom property statusDotStyle sets
   inline -- not a fixed color here, since "just went stale" should
   glow the same color the dot itself just turned, and that can be
   either depending on which surface it's on) for an active node/probe
   that just went quiet. Plays once on insertion and doesn't loop:
   every Nodes/Probes/Overview render fully replaces these rows, so a
   class present on a freshly-inserted element only ever plays this
   once, never needs manual cleanup, and simply isn't there at all on
   the next render unless another real change just happened again. */
@keyframes status-dot-pulse {
  0% { box-shadow: 0 0 0 0 var(--pulse-glow, transparent); }
  60% { box-shadow: 0 0 0.6rem 0.4rem var(--pulse-glow, transparent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.status-dot.pulse-ok, .status-dot.pulse-miss { animation: status-dot-pulse var(--duration-pulse) var(--ease-out); }
/* Shown between buildProbeRowShell's own immediate render (probe
   name/target/buttons/node legend, from the fast GET /v1/probes and
   /v1/nodes responses alone) and hydrateProbeRow filling in the real
   health color once the slower checks fetch actually resolves --
   looping rather than one-shot like pulse-ok/pulse-miss above, since
   "still finding out" is an ongoing state, not a momentary event.
   Replaced outright (className reset) the instant real data lands, so
   this never lingers past that. */
@keyframes status-dot-loading-pulse {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 1; }
}
.status-dot-loading { background: var(--text-secondary); animation: status-dot-loading-pulse var(--duration-loading-pulse) var(--ease-in-out) infinite; }

.node-legend-row { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: var(--space-3); }
.node-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
/* Filled = node currently reachable (recent heartbeat); outline-only
   (transparent background, colored border) = it's gone quiet. Fixed
   border width in both states via box-sizing so the circle doesn't
   change size when it toggles between the two. */
.node-legend-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex-shrink: 0; box-sizing: border-box; border: 0.09375rem solid transparent; }

.overview-chart { position: relative; width: 100%; height: 3.75rem; }
.overview-chart canvas { width: 100%; height: 100%; display: block; }
.overview-chart-empty { font-size: var(--text-xs); color: var(--text-secondary); margin: 0; }
/* buildProbeRowShell's placeholder, same height as the real chart it's
   replaced by (see hydrateProbeRow) so nothing jumps when it lands. */
.overview-chart-loading { display: flex; align-items: center; justify-content: center; }

/* A probe with one summary metric (most probers) keeps the original
   single full-width chart; one with several (e.g. system's cpu/mem/
   network) lays them out side by side instead, each its own small
   labeled block -- see probeMetricChartsHtml. */
.probe-metric-charts { margin-top: var(--space-4); }
/* .probe-charts-enter (not baked into .probe-metric-charts itself) is
   what keeps this to a true first-appearance animation -- the loading
   placeholder appearing, or hydrateProbeRow's first swap from that
   placeholder to a real chart -- instead of replaying on every single
   live-refresh tick's routine data update, which reused the same
   "animate on insert" element-replace approach and would otherwise
   look like the whole card were loading again every few seconds. See
   hydrateProbeRow's own isFirstHydrate check for where this gets
   added (or not). */
@keyframes probeChartsIn { from { opacity: 0; transform: translateY(0.25rem); } to { opacity: 1; transform: translateY(0); } }
.probe-charts-enter { animation: probeChartsIn var(--duration-base) var(--ease-out); }
.probe-metric-charts-multi {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: var(--space-6);
}
.probe-metric-charts-multi .probe-metric-chart-block {
  background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg); padding: var(--space-4) var(--space-5);
}
.probe-metric-charts-multi .overview-chart { height: 3rem; }

.view-toggle {
  display: inline-flex; gap: var(--space-1);
  background: var(--bg-primary); padding: var(--space-1); border-radius: var(--radius-lg);
  border: 0.0625rem solid var(--border);
}
.view-toggle-btn {
  width: 2rem; height: 2rem; padding: 0; border: none; background: transparent;
  color: var(--text-secondary); border-radius: var(--radius-md); cursor: pointer;
  display: flex; align-items: center; justify-content: center; transition: var(--duration-fast);
}
.view-toggle-btn:hover:not(.active) { background: var(--accent-subtle); color: var(--text-primary); }
.view-toggle-btn.active { background: var(--accent); color: #fff; }
/* Text-labeled variant (1h/6h/12h/24h/Custom) -- same toggle-group
   chrome as the icon buttons above, just auto-width with horizontal
   padding instead of a fixed square, since a label like "Custom" or
   "30d" doesn't fit a 2rem square the way a single icon does. */
.view-toggle-btn-text { width: auto; height: 1.9rem; padding: 0 var(--space-5); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; }

/* The Customize modal's own group-color tag picker (see
   openOverviewCustomizeModal) -- a "none" swatch plus one per preset
   key. Colors themselves are the [data-color=...] rules below, shared
   verbatim with .group-tab-color-dot (same attribute, same selector)
   so a group's tab can never visually disagree with what its own
   swatch picker shows as selected. */
.color-swatch-row { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.color-swatch {
  width: 1.6rem; height: 1.6rem; border-radius: 50%; padding: 0; cursor: pointer;
  border: 0.125rem solid transparent; background: var(--surface-overlay); box-shadow: inset 0 0 0 0.0625rem var(--border);
  transition: var(--duration-fast);
}
.color-swatch.active { border-color: var(--text-primary); }
.color-swatch-none { background: transparent; }
.color-swatch-none::after {
  content: ""; display: block; width: 100%; height: 0.0625rem; background: var(--text-secondary);
  transform: rotate(45deg); position: relative; top: 0.75rem;
}
/* One evenly-spaced hue per preset, same hsl(hue, 72%, 58%) formula
   node-status.js's own nodeAccentColor already uses for node identity
   colors -- proven to read well in both themes, so a group's color tag
   never needs its own separate light/dark handling. */
[data-color="red"] { background: hsl(0, 72%, 58%); }
[data-color="orange"] { background: hsl(30, 72%, 58%); }
[data-color="yellow"] { background: hsl(50, 72%, 58%); }
[data-color="green"] { background: hsl(140, 72%, 58%); }
[data-color="teal"] { background: hsl(175, 72%, 58%); }
[data-color="blue"] { background: hsl(210, 72%, 58%); }
[data-color="purple"] { background: hsl(265, 72%, 58%); }
[data-color="pink"] { background: hsl(320, 72%, 58%); }

/* The view-window picker (see overviewWindowToggleHtml) -- one
   compact trigger replacing what used to be a row of 1h/6h/12h/24h/
   Custom buttons, which ran wide enough to crowd the rest of the
   toolbar on a phone-width screen. Sized/colored like the view-toggle
   buttons above it, but built on the same .select-custom/.select-panel
   chrome as renderCustomSelect (see its own comment) so it gets the
   same open/close and modal-scroll-lock handling for free. */
.window-picker-trigger {
  display: inline-flex; align-items: center; gap: var(--space-2);
  height: 2rem; padding: 0 var(--space-5); border-radius: var(--radius-lg);
  border: 0.0625rem solid var(--border); background: var(--bg-primary);
  color: var(--text-primary); cursor: pointer;
  font-size: var(--text-sm); font-weight: 600; white-space: nowrap;
  transition: var(--duration-fast);
}
.window-picker-trigger:hover { background: var(--accent-subtle); }
.window-picker.active .window-picker-trigger { border-color: var(--accent); color: var(--accent); }
.window-picker-trigger .icon:first-child { width: 0.9rem; height: 0.9rem; }
.window-picker-trigger .select-caret { width: 0.85rem; height: 0.85rem; }
.window-picker .select-panel { min-width: 8rem; }

/* #page-actions is the one shared toolbar element every page (Probes,
   Nodes, Account, Checks) fills in -- a primary-btn has no explicit
   height of its own (just padding + line-height), which puts it a
   hair taller than the 2rem icon-btn/window-picker-trigger next to it
   once they sit directly adjacent in here, a real and visible
   mismatch (same issue on both the Probes and Nodes toolbars). Scoped
   to this one container rather than the global .primary-btn rule --
   its natural height is fine everywhere else a primary-btn appears
   with no icon-btn beside it to mismatch against. */
#page-actions .primary-btn { height: 2rem; }

/* Full time-proportional timeline + latency chart, used in the probe
   detail modal (see openJobDetailModal). */
.timeline-bar { display: flex; height: 1.25rem; border-radius: var(--radius-sm); overflow: hidden; background: var(--border); }
.timeline-bar .seg { flex: 0 0 auto; }
/* The opt-in Overview-row history bar (see openOverviewCustomizeModal)
   -- empty and collapses to nothing when the widget's off or a probe's
   own .timeline-bar has no segments yet, so it never reserves blank
   space up front. */
.probe-row-history-slot:empty { display: none; }
.probe-row-history-slot { margin-block: var(--space-4); }
/* The opt-in uptime-percentage badge -- sits inline right after the
   probe's own name/orphan-badge spans (see tpl-overview-row/-card). */
.uptime-badge { margin-inline-start: var(--space-2); font-size: var(--text-xs); }
/* The opt-in latency-sparkline widget -- same "collapses to nothing
   when off" convention as .probe-row-history-slot above. Fixed, small
   height (smaller than a real per-field chart's ~3.75rem -- this is a
   glance-only strip, not something meant to be read precisely). */
.probe-row-sparkline-slot:empty { display: none; }
.probe-row-sparkline-slot { margin-block: var(--space-4); }
.latency-sparkline { height: 2rem; }
.latency-sparkline canvas { width: 100%; height: 100%; display: block; }
.chart-wrap { position: relative; width: 100%; height: 14rem; }
.chart-wrap canvas { width: 100%; height: 100%; display: block; }
/* position: fixed, not absolute -- wireChartTooltip positions this in
   real viewport coordinates (see its own comment), not relative to
   its own chart container. That container can be tiny (the overview's
   own ~3.75rem-tall sparkline), and a tooltip listing several nodes
   is routinely taller than that -- position: absolute confined its
   own placement math to that same small local box, which for a probe
   with more than a handful of nodes (or one near the bottom of the
   page) could push it partly off-screen with no way to fully see it.
   The one ancestor in this app with its own transform (.modal-covered
   .modal, which would otherwise trap a fixed descendant inside its
   own bounds instead of the real viewport) also sets pointer-events:
   none, so hover can never reach a tooltip through it regardless. */
.chart-tooltip {
  display: none; position: fixed; background: var(--bg-secondary); border: 0.0625rem solid var(--border);
  border-radius: var(--radius-md); padding: var(--space-3) var(--space-5); font-size: var(--text-xs); color: var(--text-primary);
  pointer-events: none; z-index: 20; white-space: nowrap; box-shadow: 0 0.3rem 1rem rgba(0, 0, 0, 0.3);
}
.chart-tooltip-time { color: var(--text-secondary); margin-bottom: var(--space-1); }
.chart-tooltip-row { display: flex; align-items: center; gap: var(--space-2); line-height: 1.4; }
.chart-tooltip-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: 0 0 auto; }

/* Every other declared numeric field a prober's module reports, below
   the one headline chart -- smaller and grid-arranged, since there
   can be several (see openJobDetailModal). */
.metric-chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: var(--space-7); }
.metric-chart-block { background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-xl); padding: var(--space-6) var(--space-6); }
.metric-chart-label { font-size: var(--text-xs); color: var(--text-secondary); text-transform: capitalize; margin-bottom: var(--space-3); }
.chart-wrap-small { height: 6rem; }
/* A per-node value table (renderTableCard) inside one of the small
   metric-chart-grid cards above -- the global `table` rule's 40rem
   min-width (sized for the node-metrics table) would force an
   oversized horizontal scroll inside a ~16rem card, so this overrides
   it back down to just fitting the card. */
.metric-table-wrap table { width: 100%; min-width: 0; border-collapse: collapse; font-size: var(--text-base); }
.metric-table-wrap td { padding: var(--space-2) 0; }
/* Same override as .metric-table-wrap above, for runResultsTableHtml's
   own short (4-column, node-count-many-rows) table -- the generic
   `table` rule's 40rem min-width is tuned for the much wider node-
   metrics table and just forces an unnecessary horizontal scroll here. */
.run-results-table-wrap table { min-width: 0; }
.metric-table-wrap td:first-child { color: var(--text-secondary); }
.metric-table-wrap tr + tr td { border-top: 0.0625rem solid var(--border); }

/* Probe detail modal tabs -- only rendered at all once a module
   declares more than one field `group` (see openJobDetailModal); a
   simple prober with one or zero groups just gets the flat
   .metric-chart-grid above instead, no tab chrome. */
.detail-tabs { display: flex; gap: var(--space-3); margin-bottom: var(--space-6); flex-wrap: wrap; }
.detail-tab {
  padding: var(--space-3) var(--space-6); border: 0.0625rem solid var(--border); background: transparent;
  color: var(--text-secondary); border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 600;
  text-transform: capitalize; cursor: pointer; transition: var(--duration-fast);
}
.detail-tab:hover:not(.active) { background: var(--accent-subtle); color: var(--text-primary); }
.detail-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.metric-tab-panel.hidden { display: none; }

/* Per-probe field customization (see openFieldPickerModal) -- every
   candidate field, selected ones first in their saved order, with a
   checkbox to toggle and a pair of up/down buttons to reorder. */
.field-picker-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
.field-picker-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-6);
  padding: var(--space-4) var(--space-6); background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
}
.field-picker-label { display: flex; align-items: center; gap: var(--space-4); font-size: var(--text-base); text-transform: capitalize; cursor: pointer; }
.field-picker-move { display: flex; gap: var(--space-2); flex-shrink: 0; }
.field-picker-move .icon-btn { width: 1.6rem; height: 1.6rem; font-size: var(--text-base); }
/* Same row shape as .field-picker-label (see openOverviewCustomizeModal,
   which reuses .field-picker-list/-row/-move as-is), minus the
   capitalize -- fine for fixed English field names, wrong for
   arbitrary user-set probe names/targets. */
.probe-picker-label { display: flex; align-items: center; gap: var(--space-4); font-size: var(--text-base); cursor: pointer; }
/* A probe list can run to dozens of rows, unlike a probe's own (always
   small) field list -- caps it rather than growing the modal
   unbounded. */
.probe-picker-list { max-height: 20rem; overflow-y: auto; }

/* A module field declared display: gauge -- a plain SVG radial
   progress ring (no chart library), showing the latest sample as a
   fraction of the field's bound (or its own observed max if
   unbounded). */
.gauge-wrap { position: relative; width: 5.5rem; height: 5.5rem; margin: 0 auto; }
.gauge-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.gauge-track { fill: none; stroke: var(--border); stroke-width: 8; }
.gauge-arc { fill: none; stroke: var(--accent); stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset var(--duration-slow); }
.gauge-value { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: var(--text-base); }

/* A module field declared display: stat -- just the latest value, no
   graph (e.g. uptime, where a wiggly line isn't meaningful). */
.stat-card-value { font-size: var(--text-xl); font-weight: 700; text-align: center; padding: var(--space-6) 0; }

/* =============================================
   STAT TILES
   ============================================= */
.stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 1px;
  background: var(--border);
  border: 0.0625rem solid var(--border);
  border-radius: var(--radius-2xl);
  overflow: hidden;
}

.stat-tile { background: var(--bg-secondary); padding: var(--space-7) var(--space-7); }
.stat-tile .label { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); font-weight: 600; }
.stat-tile .value { font-size: var(--text-2xl); font-weight: 700; margin-top: var(--space-2); font-variant-numeric: tabular-nums; }
.stat-tile .value small { font-size: 0.65em; color: var(--text-secondary); font-weight: 500; }

/* A row-style stat -- label left, value right on the same line -- for
   a stack of related figures (e.g. Credit's balance/hourly/monthly)
   that reads better as rows than as tiles in a repeating grid. */
.lead-stat { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-7); }
.lead-stat .label { font-size: var(--text-base); color: var(--text-secondary); font-weight: 600; }
.lead-stat .value { font-size: var(--text-lg); font-weight: 700; font-variant-numeric: tabular-nums; }

/* A lead-stat's own total, further split into who it's actually coming
   from (e.g. Credit's own-nodes vs. public-pool cost) -- deliberately
   tighter/smaller than the lead-stat it sits under, so the split reads
   as "detail of the number above," not a second stat of equal weight. */
.lead-stat-breakdown { display: flex; justify-content: space-between; gap: var(--space-7); font-size: var(--text-sm); color: var(--text-secondary); margin-top: var(--space-2); }
.lead-stat-breakdown .value { font-variant-numeric: tabular-nums; }
.stat-tile .value-badge { font-size: var(--text-lg); }

/* =============================================
   BUTTONS
   ============================================= */
.primary-btn {
  background-color: var(--accent);
  color: #fff;
  border: none;
  padding: var(--space-5) var(--space-7);
  border-radius: var(--radius-lg);
  font-size: var(--text-base);
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  transition: background var(--duration-fast), transform var(--duration-fast), box-shadow var(--duration-fast);
  box-shadow: 0 0.2rem 0.6rem rgba(var(--accent-rgb), 0.3);
  white-space: nowrap;
}
.primary-btn:hover:not(:disabled) {
  background-color: var(--accent-hover);
  transform: translateY(-0.0625rem);
  box-shadow: 0 0.3rem 0.9rem rgba(var(--accent-rgb), 0.4);
}
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }

.ghost-btn {
  background: transparent;
  color: var(--text-primary);
  border: 0.0625rem solid var(--border);
  padding: var(--space-4) var(--space-7);
  border-radius: var(--radius-lg);
  font-size: var(--text-base);
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  transition: background var(--duration-fast), border-color var(--duration-fast);
}
.ghost-btn:hover { background: var(--accent-subtle); border-color: var(--accent); }
.ghost-btn:disabled { opacity: 0.5; cursor: not-allowed; }

.danger-btn { color: var(--danger); border-color: var(--danger); }
.danger-btn:hover { background: var(--danger-subtle); }

.icon-btn {
  width: 2rem; height: 2rem; flex-shrink: 0;
  background: var(--surface-overlay);
  border: 0.0625rem solid var(--border);
  border-radius: 50%;
  color: var(--text-secondary);
  font-size: var(--text-md);
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--duration-fast), color var(--duration-fast);
}
.icon-btn:hover { color: var(--text-primary); background: var(--surface-overlay-strong); }
.icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* Update actions specifically -- a node with a real update available
   (or the "update everything outdated" button above the list) gets a
   yellow-ish tint instead of the neutral icon-btn background, so it
   reads as "there's something to act on here" at a glance. */
.icon-btn-warn { background: var(--warn-subtle); color: var(--warn); border-color: var(--warn-subtle); }
.icon-btn-warn:hover { background: var(--warn-subtle); filter: brightness(1.15); color: var(--warn); }

/* The account menu's own trigger -- unlike every other .icon-btn (a
   fixed circular icon-only affordance), this one also carries the
   current account's own display name, so it needs to grow into a
   pill instead of staying a fixed-size circle. */
.dropdown-trigger {
  width: auto;
  border-radius: 999px;
  padding-block: var(--space-2);
  padding-inline: var(--space-2) var(--space-6);
  gap: var(--space-3);
}
.dropdown-trigger-name {
  font-size: var(--text-base);
  font-weight: 600;
  color: var(--text-primary);
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Empty until renderAccountChrome() fills it in (see app.js) --
   collapses back to the plain icon-only circle rather than leaving an
   awkward gap before boot() resolves or if it's ever left blank. */
.dropdown-trigger-name:empty { display: none; }
.dropdown-trigger:has(.dropdown-trigger-name:empty) {
  width: 2rem;
  padding: 0;
}

/* An icon-only affordance with no border/background of its own --
   for a copy button sitting directly beside plain text (e.g. Account
   ID) rather than one more chrome-heavy control in a row of real
   buttons. Only picks up a background on hover, as the hint that it's
   interactive. */
.icon-btn-plain {
  width: 1.75rem; height: 1.75rem; flex-shrink: 0;
  background: none; border: none; border-radius: var(--radius-sm);
  color: var(--text-secondary);
  font-size: var(--text-md);
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--duration-fast), color var(--duration-fast);
}
.icon-btn-plain:hover { color: var(--text-primary); background: var(--surface-overlay-strong); }

.id-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
/* Flex items default to a min-width equal to their own content's
   intrinsic width, which overrides any wrapping/overflow-wrap on a
   long id/token value -- without this, that content still forces the
   row (and the page, on mobile) wider instead of actually wrapping. */
.id-row > span:last-child, .id-row-value { min-width: 0; }
.id-row > span:last-child { text-align: end; }
/* The value and its copy button are one unit on the far right of the
   row -- without this, a 3-child space-between (label, value, button)
   would spread the value out into the middle of the row instead of
   sitting flush against the button that acts on it. */
.id-row-value { display: flex; align-items: center; gap: var(--space-2); }
/* A breakdown of the .id-row directly above it (e.g. the account
   storage card's raw/compacted split under one combined total) --
   indented so it reads as a sub-item, not a sibling of equal weight. */
.id-row-nested { padding-inline-start: var(--space-7); }

.divider { border: none; border-top: 0.0625rem solid var(--border); margin: var(--space-6) 0; }

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

/* =============================================
   BADGES (status pills)
   ============================================= */
.badge {
  display: inline-flex; align-items: center; gap: var(--space-2);
  background: var(--surface-overlay);
  padding: var(--space-1) var(--space-4);
  border-radius: var(--radius-xs);
  font-size: var(--text-xs);
  font-weight: 600;
  white-space: nowrap;
}
.badge::before {
  content: ""; width: 0.4rem; height: 0.4rem; border-radius: 50%;
  background: currentColor; flex-shrink: 0;
}
.badge-active, .badge-ok { background: var(--success-subtle); color: var(--success); }
.badge-fail, .badge-suspended { background: var(--danger-subtle); color: var(--danger); }
.badge-paused, .badge-pending { background: var(--warn-subtle); color: var(--warn); }
/* No background override -- .badge's own default (--surface-overlay) is already the neutral look. */
.badge-neutral { color: var(--text-secondary); }
.badge-accent { background: var(--accent-subtle); color: var(--accent); }
/* A node update actually in flight -- the spinner replaces the usual
   static status dot so this reads as "happening right now", not just
   another static state like the other badges. */
.badge-updating::before { display: none; }
.badge-updating .spinner-xs { border-color: currentColor; border-top-color: transparent; }

/* =============================================
   LIST / TABLE
   ============================================= */
.list-container {
  display: flex; flex-direction: column;
  background: var(--bg-secondary);
  border-radius: var(--radius-xl);
  border: 0.0625rem solid var(--border);
  overflow: hidden;
}

.list-item {
  border-bottom: 0.0625rem solid var(--border);
  padding: var(--space-6) var(--space-7);
  transition: background var(--duration-fast);
}
.list-item:last-child { border-bottom: none; }
.list-item:hover { background: var(--glass-bg); }
.list-item-warn { background: var(--warn-subtle); }
.list-item-warn:hover { background: var(--warn-subtle); filter: brightness(1.1); }

.list-item-row { display: flex; align-items: center; gap: var(--space-6); width: 100%; }
.list-item-info { flex: 1; min-width: 0; }
.list-item-info h3 {
  font-size: var(--text-md); font-weight: 600; margin-bottom: var(--space-1);
  display: flex; align-items: center; gap: var(--space-3);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.list-item-info small {
  color: var(--text-secondary); font-size: var(--text-sm);
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
}
/* Plain text, not a badge -- sits right after the version string, only
   for a node's own owner with a real update pending/available (see
   renderNodes's updateHint). */
/* Installed bundled-module versions, tacked on after the node's own
   version -- plain reference text like the version itself, not a
   call-to-action, so it gets no special color of its own. */
.node-module-versions::before { content: "\00b7"; margin-inline-end: var(--space-3); opacity: 0.6; }
.update-hint { color: var(--warn); font-weight: 600; }
.list-item-actions { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }

.table-wrap { overflow-x: auto; border-radius: var(--radius-xl); border: 0.0625rem solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: var(--text-base); min-width: 40rem; }
thead th {
  text-align: start; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--text-secondary); font-weight: 600;
  padding: var(--space-5) var(--space-6); border-bottom: 0.0625rem solid var(--border);
  background: var(--bg-secondary); white-space: nowrap;
}
thead th.num, td.num { text-align: end; }
tbody td { padding: var(--space-5) var(--space-6); border-bottom: 0.0625rem solid var(--border); vertical-align: top; }
tbody td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--glass-bg); }

.node-metrics-name-cell { display: flex; align-items: center; gap: var(--space-3); white-space: nowrap; }

/* Checks page -- the summary/expand table (renderChecks) and the
   per-node breakdown it expands to (runResultsTableHtml, shared with
   openRunResultsModal's own modal). Target used to be given no width
   hint at all and just soaked up whatever space Nodes/Time didn't
   need, leaving both cramped together with a large gap before them;
   capping it here is what actually fixes that, not just adding gap
   rules that would've fought the browser's own auto layout. */
.checks-table .checks-target-cell { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.checks-table .checks-nodes-cell { white-space: nowrap; }
.checks-table .checks-caret-cell { width: 2rem; }
.checks-node-dot { display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-inline-end: var(--space-1); }
.checks-row-caret { transition: transform var(--duration-fast); }
.checks-row-caret.active { transform: rotate(180deg); }
/* The expanded per-node table needs to read as *belonging to* the
   summary row above it, not as a second, equally-weighted table --
   its own border/radius (from .table-wrap, shared with the standalone
   modal use) is dropped in favor of a plain, slightly recessed panel
   instead. */
.checks-detail-cell { padding: var(--space-5) !important; background: var(--bg-secondary); }
.checks-detail-cell .run-results-table-wrap { border: none; border-radius: 0; }

.log-message { font-size: var(--text-sm); max-width: 20rem; overflow-wrap: anywhere; }

.empty-state {
  padding: var(--space-10) var(--space-8); text-align: center; color: var(--text-secondary);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
}
.empty-state .icon { width: 2.25rem; height: 2.25rem; opacity: 0.5; }

.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-3); padding-top: var(--space-8); flex-wrap: wrap; }
.pagination span { font-size: var(--text-base); color: var(--text-secondary); }
/* Numbered page buttons + the prev/next arrows share the same round
   2rem footprint (see paginationControls in app.js) so the whole row
   reads as one consistent set of controls -- current page is a
   .primary-btn (filled, disabled -- it's not a link to itself), every
   other page is a plain .ghost-btn, both just overridden down from
   their normal text-button padding to fit a single number/glyph. */
.pagination-page, .pagination-arrow {
  width: 2rem;
  height: 2rem;
  min-width: 2rem;
  padding: 0;
  justify-content: center;
  border-radius: 50%;
  font-size: var(--text-base);
}
.pagination-arrow .icon { width: 1rem; height: 1rem; }
/* chevron-left/chevron-right (see tpl-pagination-prev-btn/-next-btn)
   point toward the reading-start/-end direction, not a fixed screen
   side -- prev/next's own functional binding (page - 1 / page + 1)
   never changes, only which way each arrow visually points, so a
   mirror is all either icon needs, not a different glyph. */
[dir="rtl"] .pagination-arrow .icon { transform: scaleX(-1); }
.pagination-ellipsis {
  width: 2rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
  font-size: var(--text-base);
}

/* =============================================
   FORMS
   ============================================= */
.form-group { margin-bottom: var(--space-7); }
.form-group:last-child { margin-bottom: 0; }
.form-group label, .form-group .form-group-label {
  display: block; font-size: var(--text-base); font-weight: 600;
  color: var(--text-secondary); margin-bottom: var(--space-3);
}
.form-input, select.form-input {
  width: 100%;
  background: var(--bg-primary);
  border: 0.0625rem solid var(--border);
  color: var(--text-primary);
  padding: var(--space-5) var(--space-6);
  border-radius: var(--radius-lg);
  font-size: var(--text-base);
  transition: border-color var(--duration-fast);
}
.form-input:focus { outline: none; border-color: var(--accent); }
.form-hint { font-size: var(--text-sm); color: var(--text-secondary); margin-top: var(--space-7); }
.form-row { display: flex; gap: var(--space-6); }
.form-row .form-group { flex: 1; }
.checkbox-row { display: flex; align-items: center; gap: var(--space-4); font-size: var(--text-base); }

/* Custom-styled <select> replacement (see app.js's renderCustomSelect)
   -- a real button + a floating option list, reusing the same open/
   close visual language as .custom-dropdown/.dropdown-menu above, so
   an option can show more than a plain <option> text node allows --
   first use: which node(s) a prober variant is actually available on,
   in the probe creation form's own prober picker. */
.select-custom { position: relative; }
.select-trigger {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
  cursor: pointer;
}
.select-trigger:disabled { cursor: not-allowed; opacity: 0.6; }
.select-trigger-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select-trigger-placeholder { color: var(--text-secondary); }
.select-caret { width: 1.1rem; height: 1.1rem; flex-shrink: 0; color: var(--text-secondary); transition: transform var(--duration-fast); }
.select-custom.active .select-caret { transform: rotate(180deg); }
.select-panel {
  /* position: fixed, not absolute -- same reasoning as .info-popover's
     own comment: .modal is overflow-y: auto with a capped max-height,
     which clips (not just fails to scroll into view, actually cuts
     off) any absolutely-positioned descendant that would extend past
     its edge. top/left/width are computed in JS at open time (see
     positionFloatingPanel), not expressed statically here, since a
     trigger's real on-screen position can't be known in CSS. */
  position: fixed;
  background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
  box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.3);
  opacity: 0; visibility: hidden; transform: translateY(-0.5rem);
  transition: opacity var(--duration-fast), transform var(--duration-fast); z-index: 2100;
  max-height: 16rem; overflow-y: auto; padding: var(--space-2);
  /* Without this, scrolling past this panel's own top/bottom edge
     (e.g. the node multi-select, which can hold more entries than fit)
     "chains" into whatever's underneath -- here, always .modal's own
     scrolling body -- so the whole form visibly scrolls out from under
     an open dropdown the instant your scroll gesture runs out of room
     inside the panel itself. */
  overscroll-behavior: contain;
}
.select-custom.active .select-panel { opacity: 1; visibility: visible; transform: translateY(0); }
.select-option {
  display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-4) var(--space-5); border-radius: var(--radius-md);
  cursor: pointer; font-size: var(--text-base);
}
.select-option:hover { background: var(--glass-bg); }
.select-option.selected { }
.select-option-checkbox { flex-direction: row; align-items: center; gap: var(--space-4); }
.select-option-checkbox input[type="checkbox"] { margin: 0; flex-shrink: 0; }
/* The synthetic "all nodes" row in renderNodeMultiSelect -- visually
   set apart (bolder, its own bottom border) from the real per-node
   rows below it, since it isn't one of them, it's a control over all
   of them. */
.select-option-all { font-weight: 600; border-bottom: 0.0625rem solid var(--border); border-radius: 0; margin-bottom: var(--space-2); padding-bottom: var(--space-5); }
/* A disabled row in renderCheckboxMultiSelect (the module picker,
   while a previous Save's module-actions batch is still pending) --
   dimmed and inert, same visual language .icon-btn:disabled already
   uses elsewhere, not just relying on the checkbox's own native
   disabled look to carry it. */
.select-option-disabled { opacity: 0.5; cursor: not-allowed; }
.select-option-disabled:hover { background: none; }
.select-option-icon { margin-inline-end: var(--space-3); color: var(--text-secondary); }
.select-option.selected .select-option-icon { color: var(--accent); }
.select-option-hint { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 500; }
.select-option.selected .select-option-hint { color: var(--accent); opacity: 0.85; }

.info-icon-wrap { position: relative; display: inline-flex; vertical-align: middle; margin-inline-start: var(--space-2); }
.info-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 1.1rem; height: 1.1rem; padding: 0; border: none; background: none; color: var(--text-secondary); cursor: pointer; }
.info-icon-btn:hover { color: var(--accent); }
.info-icon-btn .icon { width: 1.1rem; height: 1.1rem; }
.info-popover {
  /* position: fixed, not absolute -- .modal itself is overflow-y: auto
     with a capped max-height (see its own rule), which would otherwise
     clip this against the modal's own edge for any field below the
     fold. Fixed positioning escapes that clipping since it's relative
     to the viewport, not the scrolling ancestor; top/left are computed
     in JS at open time (see the global click handler) rather than in
     CSS, since where the button actually sits on screen can't be known
     statically. */
  position: fixed; z-index: 2100;
  width: max(14rem, min(20rem, 60vw)); padding: var(--space-5) var(--space-6); border-radius: var(--radius-lg);
  background: var(--bg-secondary); border: 0.0625rem solid var(--border);
  box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.3);
  font-size: var(--text-sm); line-height: 1.4; color: var(--text-secondary); font-weight: 400;
}

.advanced-toggle-btn { display: flex; align-items: center; gap: var(--space-3); width: 100%; justify-content: center; margin: var(--space-6) 0; padding: var(--space-4); background: none; border: 0.0625rem dashed var(--border); border-radius: var(--radius-md); color: var(--text-secondary); cursor: pointer; font-size: var(--text-base); }
.advanced-toggle-btn:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.advanced-toggle-caret { width: 1rem; height: 1rem; transition: transform var(--duration-fast); }
.advanced-toggle-btn.active .advanced-toggle-caret { transform: rotate(180deg); }
.advanced-options-group { display: flex; flex-direction: column; gap: 0; }

.switch-row { display: flex; align-items: center; gap: var(--space-5); font-size: var(--text-base); cursor: pointer; }
/* Label text leads, the switch itself sits at the far end of the row
   -- for a row whose label is the more important thing to read first
   (see advancedProbeFieldsHtml's expiration toggle), instead of the
   switch always being the first thing shown regardless of content. */
.switch-row-reverse { justify-content: space-between; }
.switch { position: relative; width: 2.5rem; height: 1.4rem; flex-shrink: 0; }
.switch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
/* pointer-events: none is load-bearing, not decorative -- .switch-track
   sits on top of the real (invisible, opacity: 0) checkbox in the same
   position:absolute/inset:0 box, later in DOM order, so it paints above
   it. Without this, a direct click anywhere on the visible track/knob
   never actually reaches the real input underneath at all -- every
   switch in the app only ever worked because it happened to sit inside
   a <label> that forwarded the click to the labeled control instead.
   That's an accident this depended on, not something to keep relying
   on: a switch that isn't (or can no longer safely be, see
   switch-row-reverse's own info-icon-button-in-a-label bug) wrapped in
   a <label> needs to work standalone. */
.switch-track { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: background var(--duration-fast); pointer-events: none; }
.switch-track::before {
  content: ""; position: absolute; width: 1.1rem; height: 1.1rem;
  inset-inline-start: 0.15rem; top: 0.15rem;
  background: #fff; border-radius: 50%; transition: transform var(--duration-fast);
}
.switch input:checked + .switch-track { background: var(--accent); }
.switch input:checked + .switch-track::before { transform: translateX(1.1rem); }
[dir="rtl"] .switch input:checked + .switch-track::before { transform: translateX(-1.1rem); }

.secret-field { display: flex; gap: var(--space-4); align-items: center; }
.secret-field .secret-input { flex: 1; min-width: 0; }
.secret-field .icon-btn { border-radius: var(--radius-lg); width: auto; height: auto; padding: var(--space-4) var(--space-5); }

/* copyableCodeBlockHtml -- a long install command (node_id + secret +
   proxy + module flags easily runs past what a single-line .secret-
   field can show without horizontal scrolling) wraps across as many
   lines as it needs instead, so the whole thing is visible at once. */
.code-block { position: relative; }
.code-block-pre {
  background: var(--bg-primary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
  padding-block: var(--space-5); padding-inline: var(--space-6) 2.6rem; font-size: var(--text-base); line-height: 1.5;
  white-space: pre-wrap; overflow-wrap: anywhere; margin: 0;
}
.code-block-copy { position: absolute; top: 0.4rem; inset-inline-end: 0.4rem; }

/* codeBlockHeaderEl's own title bar (api-docs.js) -- a labeled strip
   ("cURL", "JSON Response") above the code, with the same
   .code-block-copy button moved in as a normal flex child instead of
   floating over the code. Only present on the numbered blocks that
   call it; every other .code-block-pre (secrets, install commands)
   keeps its own top corners and the bare floating icon button above,
   untouched. */
.code-block-header {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-5);
  background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-bottom: none;
  border-start-start-radius: var(--radius-lg); border-start-end-radius: var(--radius-lg);
  padding: var(--space-3) var(--space-5);
}
.code-block-header-label { font-size: var(--text-sm); font-weight: 700; color: var(--accent); }
.code-block-header + .code-block-pre { border-start-start-radius: 0; border-start-end-radius: 0; }
.code-block-copy-labeled {
  position: static; display: inline-flex; align-items: center; gap: var(--space-2);
  width: auto; height: auto; padding: var(--space-2) var(--space-4);
  font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary);
}

/* Line-numbered variant (see api-docs.js's renderNumberedLines) -- one
   grid row per source line, two columns (number, wrapped text), NOT a
   fixed-height gutter column running alongside a separately-wrapping
   text block. A CSS grid row always sizes to its tallest cell, so a
   long wrapped line's own number stays aligned with the TOP of that
   line's own (possibly multi-row) text automatically, with no JS
   measurement needed -- unlike a plain two-column layout, where a
   wrapped line has nothing forcing its number to grow along with it.
   .code-block-copy's own "copy the pre's textContent" handler would
   run every number and line together with no separators at all once
   content lives in these per-line child spans -- see main.js's own
   comment on the pre.dataset.copyText override that avoids that. */
.code-block-numbered .code-block-pre {
  display: grid; grid-template-columns: auto 1fr; column-gap: var(--space-5); row-gap: 0;
}
.code-block-line-num {
  grid-column: 1; text-align: end; user-select: none; color: var(--text-secondary); opacity: 0.55;
  padding-inline-end: var(--space-4); border-inline-end: 0.0625rem solid var(--border);
}
.code-block-line-text { grid-column: 2; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }

/* The account page's API-documentation card -- a native <details>
   (collapsed by default, no attribute needed for that), styled to
   still read as a .glass-card either way. Spans both columns of the
   account grid (see account.js's own comment) -- code samples need
   real width. Visually modeled on bgp.mehrnet.com/api's own reference
   page (eyebrow labels, a base-URL banner, an endpoint index grid, one
   global language switcher, side-by-side request/response panels) --
   see api-docs.js's own header comment for the fuller reasoning. */
.api-docs-card { grid-column: 1 / -1; }
.api-docs-card > summary { cursor: pointer; list-style: none; }
.api-docs-card > summary::-webkit-details-marker { display: none; }
.api-docs-summary-title {
  display: flex; align-items: center; gap: var(--space-3); margin: 0;
}
/* The expand/collapse chevron -- CSS-only, driven by the parent
   <details>'s own [open] state rather than JS, same "native semantics,
   no extra state to wire up" reasoning as using <details> at all. */
.api-docs-summary-title::after {
  content: ""; width: 0.6rem; height: 0.6rem; margin-inline-start: auto;
  border-inline-end: 0.125rem solid var(--text-secondary); border-block-end: 0.125rem solid var(--text-secondary);
  transform: rotate(45deg); transition: transform var(--duration-fast);
}
.api-docs-card[open] .api-docs-summary-title::after { transform: rotate(-135deg); }
.api-docs-body { margin-top: var(--space-9); }

.api-docs-eyebrow {
  display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-3);
  font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent);
}
.api-docs-eyebrow .icon { width: 0.9rem; height: 0.9rem; }
.api-docs-hero-title { font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em; margin: 0 0 var(--space-5); }
.api-docs-hero-subtitle { color: var(--text-secondary); max-width: 44rem; margin: 0 0 var(--space-9); }

.api-docs-base-url {
  display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap;
  background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
  padding: var(--space-7); margin-bottom: var(--space-10);
}
.api-docs-base-url .icon { width: 1.3rem; height: 1.3rem; color: var(--accent); flex-shrink: 0; }
.api-docs-base-url-main { flex: 1; min-width: 16rem; }
.api-docs-base-url-label {
  font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-secondary); margin: 0 0 var(--space-2);
}
.api-docs-base-url-value { font-size: var(--text-md); font-weight: 700; margin: 0 0 var(--space-2); }
.api-docs-base-url-hint { color: var(--text-secondary); margin: 0; }
.api-docs-openapi-link { flex-shrink: 0; text-decoration: none; align-self: flex-start; }

.api-docs-section-heading { font-size: var(--text-xl); font-weight: 700; margin: 0 0 var(--space-3); }
/* Groups the (now 27-endpoint) body into the same resource-oriented
   sections as the index list below -- see api-docs.js's own SECTIONS. */
.api-docs-section-group-heading { margin-top: var(--space-10); padding-bottom: var(--space-4); border-bottom: 0.0625rem solid var(--border); }
.api-docs-section-group-heading + .api-docs-endpoint { margin-top: 0; padding-top: 0; border-top: none; }

/* A plain list, not a grid -- a grid column count (2? 3?) always looks
   arbitrary against a row count that isn't a clean multiple of it; a
   single-column list has no such constraint, and reads fine whether
   there are 5 endpoints or 50. Each row links down to its own full
   section below rather than duplicating content. */
.api-docs-index-list {
  display: flex; flex-direction: column; gap: 0.0625rem;
  background: var(--border); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
  overflow: hidden; margin-bottom: var(--space-9);
}
/* One per SECTIONS entry -- own background (matching the rows, not the
   1px hairline "gap" color) so it doesn't read as a stray divider. */
.api-docs-index-group-heading {
  margin: 0; background: var(--bg-primary); padding: var(--space-3) var(--space-7);
  font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-secondary);
}
.api-docs-index-row {
  display: flex; align-items: baseline; gap: var(--space-6); background: var(--bg-secondary);
  padding: var(--space-5) var(--space-7); text-decoration: none; color: inherit; transition: var(--duration-fast);
}
.api-docs-index-row:hover { background: var(--surface-overlay); }
.api-docs-index-row-path { display: flex; align-items: center; gap: var(--space-4); flex-shrink: 0; }
.api-docs-index-row-summary { color: var(--text-secondary); font-size: var(--text-base); }

.api-docs-lang-switcher-row {
  display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; margin-bottom: var(--space-9);
}
.api-docs-lang-switcher-label {
  font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary);
}
.api-docs-lang-switcher { display: flex; gap: var(--space-3); flex-wrap: wrap; }
.api-docs-lang-btn {
  padding: var(--space-3) var(--space-6); border-radius: var(--radius-md); border: 0.0625rem solid var(--border);
  cursor: pointer; background: var(--bg-secondary); color: var(--text-primary); font-size: var(--text-sm); font-weight: 600;
  transition: var(--duration-fast);
}
.api-docs-lang-btn:hover { border-color: var(--accent); }
.api-docs-lang-btn.active { border-color: var(--accent); background: var(--accent-subtle); color: var(--accent); }

.api-docs-endpoint { margin-top: var(--space-10); padding-top: var(--space-9); border-top: 0.0625rem solid var(--border); scroll-margin-top: 1.5rem; }
.api-docs-endpoint:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
/* Briefly marks the endpoint a deep link (e.g. /<accountId>/account/api/nodes) landed on -- see api-docs.js's own handleApiDocsDeepLink. */
.api-docs-section-highlight { animation: api-docs-section-flash 2s ease-out; }
@keyframes api-docs-section-flash {
  0% { background: var(--accent-subtle); box-shadow: 0 0 0 0.0625rem var(--accent); }
  100% { background: transparent; box-shadow: 0 0 0 0 transparent; }
}
.api-docs-endpoint-title {
  display: flex; align-items: center; gap: var(--space-4); margin: 0 0 var(--space-3); font-size: var(--text-lg);
}
.api-docs-endpoint-title .icon { width: 1.1rem; height: 1.1rem; color: var(--accent); flex-shrink: 0; }
.api-docs-endpoint-summary { margin: 0 0 var(--space-3); }
.api-docs-endpoint-description { color: var(--text-secondary); margin: 0 0 var(--space-7); }
.api-docs-endpoint-route {
  display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-7);
  padding-bottom: var(--space-7); border-bottom: 0.0625rem solid var(--border);
}
.api-docs-method {
  display: inline-flex; align-items: center; padding: var(--space-1) var(--space-4);
  border-radius: var(--radius-xs); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.04em; flex-shrink: 0;
}
.api-docs-method-get { background: var(--accent-subtle); color: var(--accent); }
.api-docs-method-post { background: var(--success-subtle); color: var(--success); }
.api-docs-params-label {
  font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-secondary); margin: 0 0 var(--space-4);
}
.api-docs-params-label:not(:first-child) { margin-top: var(--space-8); }

.api-docs-panels { display: flex; gap: var(--space-7); flex-wrap: wrap; margin-top: var(--space-8); }
.api-docs-panel { flex: 1; min-width: 20rem; }
.api-docs-panel-label {
  display: flex; align-items: center; gap: var(--space-3); font-size: var(--text-xs); font-weight: 700;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: var(--space-3);
}
.api-docs-panel-label-lang { color: var(--accent); }
.api-docs-panel-label-status { color: var(--success); }

/* Hand-rolled JSON syntax coloring (see api-docs.js's highlightJson)
   -- only the response panel gets this, not the request code samples
   (curl/JS/PHP/Python already read fine as plain monospace, and
   syntax rules differ per language; JSON is the one shape repeated
   often enough, and simple enough, to be worth highlighting). */
.json-key { color: var(--json-key); }
.json-string { color: var(--json-string); }
.json-number { color: var(--json-number); }
.json-keyword { color: var(--json-keyword); }

fieldset { border: none; padding: 0; margin: 0; }

/* =============================================
   MODAL
   ============================================= */
.modal-overlay {
  position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55);
  z-index: 2000; display: flex; justify-content: center; align-items: center;
  padding: var(--space-7);
  opacity: 0; visibility: hidden; transition: opacity var(--duration-base);
}
.modal-overlay.active { opacity: 1; visibility: visible; }
/* A layer with something stacked on top of it (see openModal's own
   stack) -- still fully live underneath, just receded and unable to
   receive clicks. Its own backdrop goes transparent since the layer
   above it already darkens the page; only the box itself needs to
   visibly recede for the "there's more behind this" depth cue. */
.modal-overlay.modal-covered { background: transparent; pointer-events: none; }
/* Both a scale-down and an upward shift -- most nested dialogs open at
   roughly the same centered size/position as whatever's underneath
   them, so scale alone (which shrinks evenly toward the center) barely
   peeks out from behind an equally-sized layer on top; shifting up too
   is what actually makes the receded edge visible along the top. */
.modal-overlay.modal-covered .modal { transform: scale(0.92) translateY(-0.9rem); opacity: 0.5; }
.modal { width: 100%; max-width: 28rem; max-height: 85vh; overflow-y: auto; transition: transform var(--duration-base), opacity var(--duration-base); }
/* While any .select-custom dropdown is open (see syncModalScrollLock)
   -- its panel is position: fixed and computed against the trigger's
   current on-screen position, so scrolling the modal underneath it
   would drag the still-open panel out of alignment with its own
   field instead of moving together with it. */
.modal.scroll-locked { overflow: hidden; }
/* Used by the probe detail modal once it has real per-metric charts to
   stack (see openJobDetailModal) -- the default width is fine for a
   single chart but cramped for several side by side. */
.modal-wide { max-width: 48rem; }
.modal-header {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: var(--space-7);
}
.modal-header h2 { font-size: var(--text-lg); font-weight: 700; }
/* modal-header-extra is populated per-modal (see openJobDetailModal's
   fieldPickerButtonHtml) -- empty and invisible for every other
   modal, which never touches it. */
.modal-header-actions { display: flex; align-items: center; gap: var(--space-4); flex-shrink: 0; }
/* The probe detail modal's window-toggle row (see openProbeDetailModal) --
   its own full-width row, right-aligned, instead of squeezed into the
   same row as the stat-grid tiles (the previous layout, which crowded
   both once the tiles needed to wrap). */
.modal-toolbar { display: flex; justify-content: flex-end; }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--space-5); margin-top: var(--space-7); }
/* A destructive action (e.g. probe edit's own Delete) pushed to the
   opposite side from Cancel/Save -- distance, not just color, is what
   keeps a misclick unlikely between a destructive button and the
   default-focused Save right next to it. */
.modal-actions-split { justify-content: space-between; }

/* =============================================
   TOP LOADING BAR
   ============================================= */
/* Indeterminate progress indicator shown while a fetch is in flight
   (background live-refresh polls, tab-refocus refetches, any api()
   call) -- see startTopLoadingBar/finishTopLoadingBar in api.js. A
   single steady-pace segment loops for as long as the request is
   actually in flight; reaching the right edge means nothing, it's
   mid-loop regardless of how close the fetch is to done, so it never
   implies false progress. Speed itself is the only completion signal:
   when the request resolves, exactly one pass plays faster than every
   loop before it -- but instead of exiting off-screen like a normal
   lap, it settles filling the whole bar, fully visible, holds there
   briefly, then dissolves. A fast pass that just zips off-screen has
   nothing left on screen to fade -- settling filled first is what
   gives the fade something real to dissolve *from*.
   left/width (not transform) throughout, deliberately -- the loop and
   the finish pass interpolate through the same positioning system, so
   a new fetch starting while the previous finish/fade is still
   playing never has to reconcile two different ways of expressing
   "where is the segment right now". */
#top-loading-bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 0.15625rem;
  z-index: 9999;
  overflow: hidden;
  background: transparent;
  opacity: 0;
  pointer-events: none;
  /* Long enough that dissolving reads as its own deliberate moment,
     not just a quick blink-and-it's-gone. Reversible mid-transition
     for free: if .running gets re-added while this is still animating
     toward 0 (a new fetch starting mid-fade), the browser just
     retargets from whatever opacity it's currently at back up to 1
     along this same curve -- no jump, no restart, no separate code
     path needed for "cancel the fade". */
  transition: opacity 0.5s ease-out;
}
/* Two independent concerns, two independent classes -- "is the
   segment pinned at full width right now" (.filled) is not the same
   question as "should the bar be opaque right now" (.running /
   .finishing / .holding). Conflating them made a fade fight a position
   change on the same frame. .filled stays applied continuously from
   the instant the finish-pass completes all the way through the hold
   *and* the fade -- the segment's position never changes again until
   the fade has fully finished and the bar resets for next time. */
#top-loading-bar.running, #top-loading-bar.finishing, #top-loading-bar.holding { opacity: 1; }
#top-loading-bar .seg {
  position: absolute;
  top: 0; bottom: 0;
  left: -40%;
  width: 40%;
  background: linear-gradient(90deg, var(--accent), var(--accent-hover));
  box-shadow: 0 0 0.5rem 0 rgba(249, 115, 22, 0.6);
}
#top-loading-bar.running .seg {
  animation: top-loading-bar-loop 1.7s linear infinite;
}
/* Sweeps in and grows to fill the entire bar, rather than the loop's
   own "same-size segment exits off the right" shape -- ending full
   and visible is the whole point here, see the block comment above.
   forwards holds that filled state for the one frame between the
   animation completing and JS handing off to .filled below. */
#top-loading-bar.finishing .seg {
  animation: top-loading-bar-finish 0.35s cubic-bezier(0.3, 0, 0.2, 1) forwards;
}
/* The static (no animation) hold of that same full-width position --
   takes over the instant .finishing's own animation ends, painting
   the identical box, so there's no discontinuity at the handoff.
   Stays applied through the hold and the entire fade; only cleared
   once the fade is fully done. */
#top-loading-bar.filled .seg {
  left: 0;
  width: 100%;
}
@keyframes top-loading-bar-loop {
  0%   { left: -40%; width: 40%; }
  100% { left: 100%; width: 40%; }
}
@keyframes top-loading-bar-finish {
  0%   { left: -40%; width: 40%; }
  100% { left: 0%; width: 100%; }
}

/* =============================================
   TOAST
   ============================================= */
#toast-container {
  position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); z-index: 3000;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-4);
  max-width: 22rem; width: calc(100% - 2rem);
}
.toast {
  position: relative;
  overflow: hidden;
  display: flex; align-items: flex-start; gap: var(--space-4);
  background: var(--bg-secondary);
  border: 0.0625rem solid var(--border);
  border-inline-start: 0.2rem solid var(--accent);
  border-radius: var(--radius-lg);
  padding-block: var(--space-6);
  padding-inline: var(--space-7) var(--space-4);
  box-shadow: 0 0.5rem 1.5rem rgba(0,0,0,0.3);
  font-size: var(--text-base);
  animation: toastIn var(--duration-base) var(--ease-out);
  width: 100%;
}
.toast.error { border-inline-start-color: var(--danger); }
.toast.success { border-inline-start-color: var(--success); }
.toast-message { flex: 1; padding-top: 0.05rem; }
.toast-close {
  flex-shrink: 0; background: none; border: none; cursor: pointer;
  padding: var(--space-1); color: var(--text-secondary); display: flex;
}
.toast-close:hover { color: var(--text-primary); }
.toast-close .icon { width: 0.8rem; height: 0.8rem; }
/* The countdown itself, not just its visual bar -- see toast() in
   app.js. animation-play-state is what actually freezes this in
   place on hover/hold, in lockstep with the real setTimeout that
   dismisses the toast, so the bar never drifts out of sync with when
   it'll actually disappear. */
.toast-progress {
  position: absolute; bottom: 0; inset-inline-start: 0; height: 0.15rem;
  background: var(--accent);
  animation: toastProgress var(--ease-linear) forwards;
}
.toast.error .toast-progress { background: var(--danger); }
.toast.success .toast-progress { background: var(--success); }
@keyframes toastIn { from { opacity: 0; transform: translateY(-0.6rem); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastProgress { from { width: 100%; } to { width: 0%; } }

/* =============================================
   SPINNER / SKELETON
   ============================================= */
.spinner {
  display: inline-block;
  width: 2rem; height: 2rem; border-radius: 50%;
  border: 0.2rem solid var(--border); border-top-color: var(--accent);
  animation: spin var(--duration-spin) var(--ease-linear) infinite;
}
.spinner-sm { width: 1rem; height: 1rem; border-width: 0.15rem; }
.spinner-xs { width: 0.6rem; height: 0.6rem; border-width: 0.1rem; }
@keyframes spin { to { transform: rotate(360deg); } }

/* =============================================
   LOGIN / SIGNUP
   ============================================= */
#boot-spinner {
  position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
  background: var(--bg-primary); z-index: 9998; transition: opacity var(--duration-base);
}
#boot-spinner.hidden { opacity: 0; pointer-events: none; }

#auth-screen {
  min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: var(--space-8);
}
.auth-card { width: 100%; max-width: 25rem; text-align: center; }
.auth-card h1 {
  display: flex; align-items: center; justify-content: center; gap: var(--space-4);
  margin-bottom: var(--space-3);
}
.auth-card h1 .brand-text { align-items: center; }
.auth-card h1 .brand-mark { font-size: var(--text-2xl); }
.auth-card h1 .brand-sub { font-size: 0.6rem; }
.auth-card .subtitle { margin-bottom: var(--space-8); }
/* Generic segmented tab switcher -- originally just the login/signup
   auth-screen switcher (.auth-tabs/.auth-tab), generalized once a
   second, unrelated use (the reinstall section's "This machine"/"New
   machine" choice) came along. No margin of its own -- unlike the
   auth-only version this replaced, a shared component can't assume
   how much breathing room every future caller wants, so each call
   site brings its own margin utility instead. */
.tabs { display: flex; gap: var(--space-3); background: var(--bg-primary); padding: var(--space-1); border-radius: var(--radius-lg); }
.tab {
  flex: 1; padding: var(--space-4); border: none; background: transparent; color: var(--text-secondary);
  border-radius: var(--radius-md); font-weight: 600; font-size: var(--text-base); cursor: pointer; transition: var(--duration-fast);
}
.tab.active { background: var(--accent); color: #fff; }

/* Probes page's group switcher -- a pill row, not .tabs above: that
   component is a fixed 2/3-way equal-width segmented control (login/
   signup, reinstall situation), while this one holds a variable,
   possibly-long list of an account's own groups plus trailing add/
   manage buttons, so items need to size to their own label and the
   row itself needs to scroll rather than squeeze. */
.group-tabs { display: flex; align-items: center; gap: var(--space-4); }
.group-tabs-list { display: flex; gap: var(--space-3); overflow-x: auto; flex: 1; min-width: 0; }
.group-tab {
  display: inline-flex; align-items: center;
  flex: none; padding: var(--space-3) var(--space-6); border: 0.0625rem solid var(--border); background: transparent;
  color: var(--text-secondary); border-radius: 999px; font-weight: 600; font-size: var(--text-sm);
  cursor: pointer; white-space: nowrap; transition: var(--duration-fast);
}
.group-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.group-tab-manage, .group-tab-add { flex: none; }
/* The optional per-group color tag (see openOverviewCustomizeModal's
   own color swatch row) -- removed outright from the DOM when unset
   (see renderGroupTabs), so this only ever needs to size/space the dot
   when one's actually present, never hide an empty one. Color values
   themselves live on .color-swatch below, shared by both. */
.group-tab-color-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-inline-end: var(--space-3); flex-shrink: 0; }
#auth-error, .form-error {
  color: var(--danger); font-size: var(--text-base); margin-bottom: var(--space-7); min-height: 1rem;
}
.pow-progress {
  margin-top: var(--space-7); font-size: var(--text-base); color: var(--text-secondary);
  display: flex; align-items: center; justify-content: center; gap: var(--space-4);
}
.picker-row { display: flex; gap: var(--space-8); margin-top: var(--space-8); }
.picker-col { flex: 1; text-align: start; }
.picker-label { font-size: var(--text-base); color: var(--text-secondary); margin-bottom: var(--space-4); display: block; }

.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
.theme-btn {
  padding: var(--space-4); border-radius: var(--radius-md); border: 0.0625rem solid var(--border);
  cursor: pointer; display: flex; align-items: center; justify-content: center; transition: var(--duration-fast);
  background: var(--bg-primary); color: var(--text-primary);
}
.lang-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4); }
.lang-btn {
  padding: var(--space-4) var(--space-5); border-radius: var(--radius-md); border: 0.0625rem solid var(--border);
  cursor: pointer; display: flex; align-items: center; justify-content: center; gap: var(--space-3);
  background: var(--bg-primary); color: var(--text-primary); font-size: var(--text-base); font-weight: 600; transition: var(--duration-fast);
}

/* Settings dropdown (main app nav) -- theme + language pickers,
   reused from the auth screen's inline layout instead of duplicating
   markup differently. inset-inline-end (not right) so it stays
   anchored to the trailing edge in both LTR and RTL. */
.custom-dropdown { position: relative; display: inline-flex; }
.dropdown-menu {
  position: absolute; top: calc(100% + 0.5rem); inset-inline-end: 0;
  background: var(--bg-secondary); border: 0.0625rem solid var(--border); border-radius: var(--radius-lg);
  padding: var(--space-6); min-width: 14rem; box-shadow: 0 0.5rem 1.5rem rgba(0,0,0,0.3);
  opacity: 0; visibility: hidden; transform: translateY(-0.5rem);
  transition: opacity var(--duration-fast), transform var(--duration-fast); z-index: 1500;
}
.custom-dropdown.active .dropdown-menu { opacity: 1; visibility: visible; transform: translateY(0); }
.dropdown-menu-wide { min-width: 18rem; max-width: 22rem; max-height: 80vh; overflow-y: auto; }
.dropdown-divider { border: none; border-top: 0.0625rem solid var(--border); margin: var(--space-6) 0; }
.full-width { width: 100%; justify-content: center; }

.account-row {
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-4) var(--space-5); border-radius: var(--radius-md);
  border: 0.0625rem solid var(--border); margin-bottom: var(--space-3);
}
.account-row:last-of-type { margin-bottom: 0; }
.account-row.clickable:hover { background: var(--glass-bg); }
.account-row-current { border-color: var(--accent); background: var(--accent-subtle); }
.account-row-icon { width: 1.1rem; height: 1.1rem; flex-shrink: 0; color: var(--text-secondary); }
.account-row-current .account-row-icon { color: var(--accent); }
.account-row-info { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.account-row-name { font-size: var(--text-base); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-row-info .account-row-id { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-row-balance { font-size: var(--text-base); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.account-row-edit { flex-shrink: 0; }

/* =============================================
   MISC
   ============================================= */
.hidden { display: none !important; }
.text-secondary { color: var(--text-secondary); }
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: var(--space-6); flex-wrap: wrap; }

/* =============================================
   BOTTOM NAV (mobile only -- see RESPONSIVE below)
   ============================================= */
.bottom-nav {
  display: none;
  flex-shrink: 0;
  z-index: 1000;
  background: var(--bg-primary);
  border-top: 0.0625rem solid var(--border);
  padding: var(--space-2) var(--space-1) calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
}
.bottom-nav a {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-2) var(--space-1);
  border-radius: var(--radius-lg);
  text-decoration: none;
  color: var(--text-secondary);
  font-size: var(--text-xs);
  font-weight: 500;
  transition: background var(--duration-fast), color var(--duration-fast);
}
.bottom-nav a .icon { width: 1.25rem; height: 1.25rem; }
.bottom-nav a:hover, .bottom-nav a.active { color: var(--accent); }
.bottom-nav a.active { background: var(--accent-subtle); }

/* =============================================
   RESPONSIVE
   ============================================= */
@media (max-width: 52rem) {
  .nav-container { display: flex; justify-content: space-between; }
  .nav-center { display: none; }
  .bottom-nav { display: flex; }
  /* No longer needs the old calc(4.5rem + safe-area) reserved at the
     bottom -- .bottom-nav is a normal flex sibling of .content now
     (see .page's own comment), not a fixed overlay floating on top of
     it, so nothing here needs to leave room to avoid being hidden
     behind it. */
  .content { padding: var(--space-7) var(--space-7); }
  .stat-grid { grid-template-columns: repeat(2, 1fr); }
  .api-docs-index-row { flex-direction: column; align-items: flex-start; gap: var(--space-2); }
}
