/* ====================================================================
   Home One — flat "liquid glass" UI
   ====================================================================

   The design language is the one Trackr uses, ported to this app's markup:
   a near-black page with two ambient colour orbs behind it, and every raised
   thing a translucent pane that blurs what is behind it, edged with a single
   hairline border and a faint shine along its top.

   Surfaces are built from four tokens rather than from colours:

     --layer-1   the faintest fill — inputs, hover states, insets
     --surface   a card / panel
     --layer-3   something raised out of a card, or a selected row
     --glass-border + --glass-shine   the hairline edge and its highlight

   They are all *alpha over the page*, never opaque, which is what makes the
   blur read as glass instead of as flat grey. Two things must stay opaque —
   anything that floats over arbitrary content (menus, dropdowns, popovers)
   uses --bg-elev so its text never has page content showing through it.

   This file used to implement neumorphism: every raised element carried a
   pair of opposed shadows, a dark one down-right and a light one up-left,
   driven by --shadow-dark / --shadow-light. Both variables are gone. Raised
   things take --elev-card (one soft drop, straight down); recessed ones take
   a fill and a hairline. Do not reintroduce a light shadow — the raised bump
   it draws is the whole tell, and one left in a corner of the app is enough
   to make everything else look like a mistake.
   ==================================================================== */
:root {
  --bg: #0b0e15;
  /* Opaque surface for anything floating over unknown content. */
  --bg-elev: #151b28;
  --bg-light: #1a2130;

  --surface: rgba(255, 255, 255, 0.055);
  --layer-1: rgba(255, 255, 255, 0.035);
  --layer-3: rgba(255, 255, 255, 0.115);
  --glass-border: rgba(255, 255, 255, 0.12);
  --glass-shine: rgba(255, 255, 255, 0.06);
  --hairline: rgba(255, 255, 255, 0.07);

  --text: #c7ccd6;
  --text-dim: #868c99;
  /* Alias: the radio-site tablet view at the end of this sheet was written
     against `--muted`, which nothing ever defined — every rule reading it fell
     back to inheriting its parent's colour instead of going dim. */
  --muted: var(--text-dim);
  --heading: #e8ebf2;
  --accent: #5b8cff;
  --accent-dim: #3f63b8;
  --accent-glow: rgba(91, 140, 255, 0.35);
  --accent-soft: rgba(91, 140, 255, 0.14);
  --green: #46d39a;
  --red: #ff5f6d;
  --amber: #ffb454;

  /* Ambient orbs the glass refracts (see body::before / ::after). */
  --orb-1: rgba(91, 140, 255, 0.30);
  --orb-2: rgba(70, 211, 154, 0.12);

  --radius: 18px;
  --radius-sm: 12px;
  --radius-xs: 8px;
  --blur-glass: blur(18px) saturate(1.4);
  --blur-heavy: blur(30px) saturate(1.5);

  /* Elevation. A single soft drop, straight down — never a directional pair. */
  --elev-card: 0 8px 32px rgba(0, 0, 0, 0.45);
  --elev-pop: 0 14px 38px rgba(0, 0, 0, 0.6);

}

/* ====================================================================
   Themes — selected via <html data-theme="…"> (Settings ▸ Appearance).
   The default :root above is the "dark" theme; the blocks below override
   the palette for the other themes. The same variables are reused by the
   theme-swatch previews so each swatch shows its own palette regardless
   of the page's active theme.

   A theme sets its page colour, its accent and — for the light one — the
   direction the glass layers tint in. Everything else is inherited from the
   block above, so a new theme is a dozen lines and not a second stylesheet.
   ==================================================================== */

/* The one theme where the glass tints *dark over light* rather than the other
   way round: white-alpha layers vanish against a pale page, so every layer,
   border and shine flips. */
:root[data-theme="light"],
.theme-swatch[data-theme="light"] {
  --bg: #eef1f6;
  --bg-elev: #ffffff;
  --bg-light: #ffffff;
  --surface: rgba(255, 255, 255, 0.72);
  --layer-1: rgba(15, 23, 42, 0.04);
  --layer-3: rgba(15, 23, 42, 0.11);
  --glass-border: rgba(15, 23, 42, 0.12);
  --glass-shine: rgba(255, 255, 255, 0.75);
  --hairline: rgba(15, 23, 42, 0.09);
  --text: #38404e;
  --text-dim: #6b7280;
  --heading: #1f2733;
  --accent: #3b6ef0;
  --accent-dim: #93aef5;
  --accent-glow: rgba(59, 110, 240, 0.28);
  --accent-soft: rgba(59, 110, 240, 0.12);
  --green: #1aa06a;
  --red: #e23b4a;
  --amber: #d4881a;
  --orb-1: rgba(59, 110, 240, 0.18);
  --orb-2: rgba(26, 160, 106, 0.10);
  /* A heavy black drop reads as grime over a pale page. */
  --elev-card: 0 8px 28px rgba(15, 23, 42, 0.12);
  --elev-pop: 0 14px 34px rgba(15, 23, 42, 0.18);
}

/* Light theme, surfaces that sit over a scrim. The scrim and the near-white
   glass are tuned against a dark page; over a light one the dark scrim shows
   straight through a 72%-white pane and every modal reads grey. Darken less,
   and make the pane itself nearly opaque. */
:root[data-theme="light"] .modal-overlay { background: rgba(15, 23, 42, .28); }
:root[data-theme="light"] .modal { background: rgba(255, 255, 255, .93); }
:root[data-theme="light"] .app-sidebar { background: rgba(255, 255, 255, .72); }
:root[data-theme="light"] .bottom-nav { background: rgba(255, 255, 255, .8); }
:root[data-theme="light"] .sheet-backdrop,
:root[data-theme="light"] .site-backdrop { background: rgba(15, 23, 42, .3); }
/* Green and amber fills are light enough that white text disappears on them. */
:root[data-theme="light"] .btn.green { color: #06281c; }

:root[data-theme="midnight"],
.theme-swatch[data-theme="midnight"] {
  --bg: #05070d;
  --bg-elev: #0e1523;
  --bg-light: #131c2c;
  --surface: rgba(255, 255, 255, 0.05);
  --layer-3: rgba(255, 255, 255, 0.12);
  --text: #c3cad8;
  --text-dim: #7b8597;
  --heading: #e8ecf5;
  --accent: #5b8cff;
  --accent-dim: #38528f;
  --accent-glow: rgba(91, 140, 255, 0.4);
  --accent-soft: rgba(91, 140, 255, 0.15);
  --orb-1: rgba(91, 140, 255, 0.28);
  --orb-2: rgba(56, 82, 143, 0.16);
  --elev-card: 0 8px 34px rgba(0, 0, 0, 0.6);
}

/* The warm one. Every other theme here is a blue-grey page with a blue accent;
   this is Gruvbox's retro brown-and-orange, and it is the only palette in the
   set where the glass tints warm — hence a bone-coloured hairline rather than
   the plain white-alpha the dark themes use, which over #282828 goes visibly
   cold and makes the panes look like they belong to a different sheet. */
:root[data-theme="gruvbox"],
.theme-swatch[data-theme="gruvbox"] {
  --bg: #282828;
  --bg-elev: #32302f;
  --bg-light: #3c3836;
  --surface: rgba(251, 241, 199, 0.055);
  --layer-1: rgba(251, 241, 199, 0.035);
  --layer-3: rgba(251, 241, 199, 0.12);
  --glass-border: rgba(235, 219, 178, 0.14);
  --glass-shine: rgba(251, 241, 199, 0.06);
  --hairline: rgba(235, 219, 178, 0.08);
  --text: #ebdbb2;
  --text-dim: #a89984;
  --heading: #fbf1c7;
  --accent: #fe8019;
  --accent-dim: #d65d0e;
  --accent-glow: rgba(254, 128, 25, 0.30);
  --accent-soft: rgba(254, 128, 25, 0.14);
  --green: #b8bb26;
  --red: #fb4934;
  --amber: #fabd2f;
  --orb-1: rgba(254, 128, 25, 0.20);
  --orb-2: rgba(142, 192, 124, 0.14);
}

/* The accent is bright enough that white text on it is hard to read — the same
   problem the light theme has with its green and amber buttons. */
:root[data-theme="gruvbox"] .btn.accent { color: #282828; }

/* ─── Theme picker (Settings ▸ Appearance) ──────────────── */
.theme-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
  gap: 12px;
}
.theme-option {
  display: flex; align-items: center; gap: 12px;
  padding: 12px; border-radius: var(--radius-sm); cursor: pointer;
  background: var(--layer-1);
  border: 1px solid var(--glass-border);
  transition: background .15s ease, border-color .15s ease;
}
.theme-option:hover { background: var(--layer-3); }
.theme-option input { accent-color: var(--accent); }
.theme-option:has(input:checked) {
  background: var(--accent-soft);
  border-color: var(--accent);
}
.theme-swatch { display: inline-flex; gap: 3px; padding: 4px; border-radius: 8px; background: var(--bg); }
.theme-swatch .sw { width: 14px; height: 24px; border-radius: 4px; }
/* The swatch previews a theme's *page*, so it paints the layer over the page
   colour rather than borrowing the current theme's card. */
.theme-swatch .sw-bg { background: var(--surface); border: 1px solid var(--glass-border); }
.theme-swatch .sw-accent { background: var(--accent); }
.theme-swatch .sw-text { background: var(--text); }
.theme-meta { display: flex; flex-direction: column; line-height: 1.3; }
.theme-meta .dim { font-size: 12px; }

* { box-sizing: border-box; margin: 0; padding: 0; }

/* The page colour lives on <html>, not on <body>. See the orbs below: they are
   drawn as <html>'s pseudo-elements and body must stay transparent for them to
   show through. */
html { background: var(--bg); }

body {
  font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
  background: transparent;
  color: var(--text);
  min-height: 100vh;
  font-size: 14px;
  line-height: 1.5;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}

/* ─── Ambient orbs ────────────────────────────────────────
   Two fixed, heavily blurred colour washes behind everything. They are what
   the glass panes have to refract: without something coloured under them a
   translucent surface is just a slightly lighter grey, and the whole effect
   reads as flat. Fixed rather than scrolled, so the light stays put and the
   panes move across it.

   They hang off <html> rather than <body> so that nothing has to be lifted
   above them. Hanging them off <body> means giving the app a z-index to sit
   on top, and that traps every z-index in the page inside one stacking
   context — which is how the phone drawer ended up *underneath* its own
   backdrop, blurred by the very overlay it was supposed to sit above.
   As <html>'s children they paint before <body> does, and the ordinary
   z-index of the chrome (backdrop 110 < drawer 120) means what it says. */
html::before,
html::after {
  content: "";
  position: fixed;
  pointer-events: none;
  filter: blur(40px);
}
html::before {
  top: -220px; right: -160px; width: 720px; height: 720px;
  background: radial-gradient(circle at center, var(--orb-1), var(--orb-2) 42%, transparent 70%);
  opacity: .6;
}
html::after {
  bottom: -260px; left: -160px; width: 620px; height: 620px;
  background: radial-gradient(circle at center, var(--orb-2), transparent 70%);
  opacity: .5;
}

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

/* Scrollbars, so they read as part of the glass rather than as OS chrome. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--layer-3); border-radius: 6px;
  border: 2px solid transparent; background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; }

/* ─── Glass primitives ───────────────────────────────────
   `.neu` is the universal pane in this app's markup — cards, panels, the
   sidebar, popovers and most widgets all carry it — so giving it the glass
   treatment here is what carries the look across every page at once. The
   class name is left alone deliberately: renaming it would touch every
   template for no visual gain. */
.neu {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius);
  backdrop-filter: var(--blur-glass);
  -webkit-backdrop-filter: var(--blur-glass);
  box-shadow: var(--elev-card);
}
/* The single-pixel shine along the top edge. Inset from the corners so it
   fades out before the radius bends it. */
.neu::before {
  content: "";
  position: absolute;
  top: 0; left: 12px; right: 12px; height: 1px;
  background: linear-gradient(90deg, transparent, var(--glass-shine),
              rgba(255, 255, 255, .18), var(--glass-shine), transparent);
  pointer-events: none;
  border-radius: 1px;
}
:root[data-theme="light"] .neu::before {
  background: linear-gradient(90deg, transparent, var(--glass-shine), #fff, var(--glass-shine), transparent);
}
.neu-inset {
  background: var(--layer-1);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-sm);
}
.neu-pressable {
  transition: background .15s ease, border-color .15s ease, transform .08s ease;
  cursor: pointer;
}
.neu-pressable:hover { border-color: var(--text-dim); }
.neu-pressable:active { transform: scale(.985); }

/* ─── Layout ──────────────────────────────────
   The shell is Trackr's, ported to this app's markup: a fixed rail down the
   left at desktop widths, and on a phone a tab bar along the bottom instead.
   There is no state in between. The 64px icon-only rail that used to sit
   between the two is gone — it kept the rail's cost (a sixth of a small laptop
   screen) while throwing away the labels, which are the half that makes a
   navigation usable by anyone who has not memorised the icons.

   The rail is `position: fixed` rather than a sticky flex item because a flex
   item shrinks: a page with something wide in it (the flow builder's canvas, a
   long host table) squeezed the rail and wrapped every label onto two lines.
   .app-main reserves the width with a margin instead, which nothing can
   negotiate away.
   ═════════════════════════════════════════════════════════════════════ */
:root {
  --sidebar-width: 240px;
  --bottomnav-height: 64px;
}

.app-shell { display: flex; min-height: 100vh; min-height: 100dvh; }

/* The rail is glass in its own right, but a *panel* rather than a card: heavier
   blur, no drop shadow, and a single hairline down its right edge. */
.app-sidebar {
  position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
  width: var(--sidebar-width);
  display: flex; flex-direction: column;
  padding: 20px 14px;
  background: var(--layer-1);
  backdrop-filter: var(--blur-heavy);
  -webkit-backdrop-filter: var(--blur-heavy);
  border-right: 1px solid var(--glass-border);
}

/* `min-width: 0` lets the column shrink below its content's natural width, so
   the scroll containers inside it (.table-wrap, .flow-canvas) actually engage
   instead of pushing the whole page sideways. */
.app-main {
  flex: 1; min-width: 0;
  margin-left: var(--sidebar-width);
  width: calc(100% - var(--sidebar-width));
  max-width: 1400px;
  padding: 28px 32px 60px;
}

.brand {
  display: flex; align-items: center; gap: 10px;
  padding: 4px 8px 18px;
  font-weight: 700; font-size: 17px; color: var(--heading);
}
.brand:hover { color: var(--heading); text-decoration: none; }
/* The brand mark is the one place the accent is used as a filled shape, which
   is what makes it read as a logo tile rather than another nav row. */
.brand-mark {
  width: 34px; height: 34px; flex: none;
  border-radius: var(--radius-sm);
  display: grid; place-items: center;
  background: linear-gradient(135deg, var(--accent), var(--accent-dim));
  box-shadow: 0 4px 16px var(--accent-glow);
  color: #fff; font-size: 18px;
}
.brand-accent { color: var(--accent); }

/* `overflow-y: auto` forces the x axis to `auto` as well (CSS Overflow §3: a
   non-visible value on one axis computes `visible` on the other to `auto`), so
   any horizontal movement inside a row pops a scrollbar along the bottom of the
   nav. The gutter gives that movement somewhere to land, and the matching
   negative margin keeps every row drawn exactly where it was. */
.nav-list {
  display: flex; flex-direction: column; gap: 4px;
  flex: 1; overflow-y: auto;
  padding-right: 4px; margin-right: -4px;
}

/* One row shape for all three renderings of the nav: the rail, a section's
   fly-out, and the phone's More sheet. It is used on both <a> and <button>, so
   it carries the button reset as well — otherwise the sheet's buttons come out
   as native grey chrome. */
.nav-item {
  position: relative;
  display: flex; align-items: center; gap: 12px;
  width: 100%; min-height: 44px;
  padding: 11px 14px;
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  font: inherit; font-size: 14px; font-weight: 500; text-align: left;
  background: transparent; border: none; cursor: pointer;
  transition: background .15s ease, color .15s ease;
}
.nav-item .ic { width: 20px; flex: none; text-align: center; font-size: 16px; }
.nav-item .nav-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-item:hover { background: var(--layer-1); color: var(--text); text-decoration: none; }
/* Active is a filled row plus the accent bar drawn by .nav-item::before in the
   Motion section — colouring the label accent as well made the whole row shout. */
.nav-item.active { background: var(--layer-3); color: var(--heading); }
.nav-item.danger { color: var(--red); }

/* ─── Sidebar sections (cascade menus) ────────────────────────
   A section row looks like any other nav row; the caret is the only thing that
   says it opens sideways. The panel itself is moved to <body> by app.js and
   positioned from the row it belongs to, so it carries no position of its own
   beyond `fixed`. */
.nav-group-caret {
  flex: none; opacity: .45; font-size: 17px; line-height: 1;
  transition: transform .15s ease, opacity .15s ease;
}
.nav-group-row:hover .nav-group-caret,
.nav-group-row.is-open .nav-group-caret { opacity: .9; }
.nav-group-row.is-open { background: var(--layer-1); color: var(--text); }
.nav-group-row.is-open .nav-group-caret { transform: translateX(2px); }

.nav-flyout {
  position: fixed; z-index: 400;
  display: flex; flex-direction: column; gap: 2px;
  width: 216px; padding: 8px;
  /* OPAQUE: it floats over arbitrary page content. */
  background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-sm);
  box-shadow: var(--elev-pop);
}
.nav-flyout[hidden] { display: none; }
.nav-flyout-label {
  font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
  color: var(--text-dim); padding: 2px 12px 6px;
}
.nav-flyout-item { min-height: 40px; padding: 9px 12px; font-size: 13.5px; }

/* ─── Who you are, at the foot of the rail ──────────────────── */
.sidebar-user {
  display: flex; align-items: center; gap: 10px;
  margin-top: 8px; padding: 12px 8px 8px;
  border-top: 1px solid var(--glass-border);
}
.sidebar-user-id { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.sidebar-user-id:hover { text-decoration: none; }
.sidebar-user-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
.sidebar-user-name {
  font-size: 13px; font-weight: 600; color: var(--heading);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sidebar-user-role {
  font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim);
}
.sidebar-version { padding: 2px 10px 0; font-size: 10px; color: var(--text-dim); }

/* A borderless square for a single glyph — log out, close, dismiss. */
.icon-btn {
  width: 32px; height: 32px; flex: none;
  display: grid; place-items: center;
  border-radius: var(--radius-xs);
  color: var(--text-dim); font-size: 15px;
  background: transparent; border: none; cursor: pointer;
}
.icon-btn:hover { background: var(--layer-1); color: var(--text); text-decoration: none; }

/* ─── Top bar · global search ───────────────────────────── */
.app-topbar { display: flex; justify-content: flex-end; margin-bottom: 20px; }
.global-search { position: relative; width: 100%; max-width: 460px; }
.search-bar { position: relative; display: flex; align-items: center; }
/* No icon in front of the box. The placeholder already says what it searches,
   and a glyph nobody can click is 34px of the field spent on decoration. */
.search-bar input { padding-left: 14px; padding-right: 38px; }
/* WebKit/Blink draw their own furniture inside a type=search: a magnifying
   glass on the left and a clear button on the right (visible on Safari and on
   iOS). The field is styled from scratch here, so none of that native chrome is
   wanted anywhere. */
input[type=search] { appearance: none; -webkit-appearance: none; }
input[type=search]::-webkit-search-decoration,
input[type=search]::-webkit-search-cancel-button,
input[type=search]::-webkit-search-results-button,
input[type=search]::-webkit-search-results-decoration {
  appearance: none; -webkit-appearance: none; display: none;
}
.search-kbd {
  position: absolute; right: 10px; pointer-events: none;
  font: inherit; font-size: 11px; color: var(--text-dim);
  padding: 1px 6px; border-radius: 5px;
  background: var(--layer-1); border: 1px solid var(--glass-border);
}
.global-search-panel {
  position: absolute; top: 100%; left: 0; right: 0; z-index: 70;
  margin-top: 6px; padding: 8px;
  max-height: 70vh; overflow-y: auto;
  background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-sm);
  box-shadow: var(--elev-pop);
}
.global-search-panel[hidden] { display: none; }
.gs-group + .gs-group { margin-top: 8px; }
.gs-group-label {
  font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
  color: var(--text-dim); padding: 4px 12px;
}
.gs-item {
  display: flex; align-items: center; gap: 9px;
  width: 100%; min-height: 40px; padding: 8px 12px;
  border-radius: var(--radius-xs); color: var(--text);
}
/* `.cursor` is the keyboard's position in the list; hover is the pointer's.
   They look the same on purpose — there is only ever one of each. */
.gs-item:hover, .gs-item.cursor { background: var(--layer-1); text-decoration: none; }
.gs-item .ic { flex: none; font-size: 14px; }
.gs-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.gs-title { font-size: 13px; color: var(--heading); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs-sub { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs-empty { padding: 12px; font-size: 13px; color: var(--text-dim); }

/* ─── Phone chrome, declared here and switched on in the Phones block ──── */
.bottom-nav { display: none; }
.sheet { display: none; }
.sheet-backdrop { display: none; }

.page-head {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 26px; flex-wrap: wrap; gap: 14px;
}
.page-head h1 { font-size: 24px; font-weight: 700; color: var(--heading); }
.page-head .sub { color: var(--text-dim); font-size: 13px; margin-top: 3px; }

/* ─── Profile pictures ───────────────────────────────────────
   One component at three sizes: the sidebar rail, a table row, and the account
   page. Always a circle, always cropped to fill it (object-fit, so a portrait
   or a wide banner both come out as a face rather than as letterboxing), and
   always with the hairline every other surface has. Where there is no picture
   the same circle holds the initial, so a list of people is a column of evenly
   sized marks either way — a missing picture must not change the layout. */
.avatar {
  width: 26px; height: 26px; border-radius: 50%;
  object-fit: cover; flex: none;
  background: var(--layer-1);
  border: 1px solid var(--glass-border);
}
.avatar-fallback {
  display: inline-grid; place-items: center;
  font-size: .78em; font-weight: 700; color: var(--heading);
  text-transform: uppercase; line-height: 1;
  background: linear-gradient(135deg, var(--accent), var(--accent-dim));
  border-color: transparent;
}
.avatar.lg { width: 96px; height: 96px; }
.avatar.lg.avatar-fallback { font-size: 34px; }
.avatar.sm { width: 22px; height: 22px; }
/* The rail's footer is picture + name + role; the picture stands where a nav
   row's icon would, so it needs that alignment rather than the text baseline. */
.sidebar-user .avatar { vertical-align: middle; }
.avatar-row { display: flex; align-items: center; gap: 10px; }

/* ─── Cards / grid ───────────────────────────────────────── */
.grid { display: grid; gap: 22px; }
.cols-4 { grid-template-columns: repeat(4, 1fr); }
.cols-3 { grid-template-columns: repeat(3, 1fr); }
.cols-2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 1100px) { .cols-4 { grid-template-columns: repeat(2,1fr);} .cols-3,.cols-2{grid-template-columns:1fr;} }

.card { padding: 20px 22px; }
.card h2, .card h3 { color: var(--heading); margin-bottom: 16px; font-size: 16px; }

.stat { padding: 20px 22px; }
.stat .label { color: var(--text-dim); font-size: 12px; text-transform: uppercase; letter-spacing: .6px; }
.stat .value { font-size: 34px; font-weight: 700; color: var(--heading); margin-top: 6px; }
.stat .value.green { color: var(--green); }
.stat .value.red { color: var(--red); }
.stat .value.amber { color: var(--amber); }
.stat .meta { color: var(--text-dim); font-size: 12px; margin-top: 4px; }

/* ─── Buttons ──────────────────────────────────────────────
   Default is the outlined "secondary" button — a hairline box that only fills
   in on hover. The modifier classes used across the templates (.accent,
   .danger, .green) become *filled* buttons rather than merely tinted text:
   with everything else on the page reduced to hairlines, a solid fill is what
   marks the one action a page is really offering. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 10px 18px; border-radius: var(--radius-sm);
  border: 1px solid var(--glass-border);
  background: transparent; color: var(--text); font-weight: 600; font-size: 13px;
  cursor: pointer;
  transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
  font-family: inherit;
  white-space: nowrap;
}
/* `display` here is an author rule and beats the UA sheet's [hidden], so a
   button hidden by attribute — the builder's Test button on an unsaved flow —
   has to be told to go away. */
.btn[hidden] { display: none; }
.btn:hover { background: var(--layer-1); border-color: var(--text-dim); color: var(--heading); }
.btn:active { transform: scale(.98); }
.btn.accent {
  background: var(--accent); border-color: transparent; color: #fff;
  box-shadow: 0 2px 12px var(--accent-glow);
}
.btn.accent:hover { background: var(--accent); color: #fff; box-shadow: 0 4px 20px var(--accent-glow); }
.btn.danger {
  background: var(--red); border-color: transparent; color: #fff;
}
.btn.danger:hover { background: var(--red); color: #fff; box-shadow: 0 4px 20px rgba(255, 95, 109, .35); }
.btn.green {
  background: var(--green); border-color: transparent; color: #0b1a14;
}
.btn.green:hover { background: var(--green); color: #0b1a14; box-shadow: 0 4px 20px rgba(70, 211, 154, .35); }
/* A quiet button for rows of secondary controls — no edge at all until hover. */
.btn.ghost { border-color: transparent; color: var(--text-dim); }
.btn.ghost:hover { background: var(--layer-1); color: var(--text); border-color: transparent; }
.btn.sm { padding: 6px 12px; font-size: 12px; border-radius: var(--radius-xs); }

/* A small coloured button is a *row* action — one per line of a table, a dozen
   on screen at once. Filling those is how a host list ends up as a column of
   red rectangles that reads more urgently than anything in the data. So at
   .sm the colour drops back to the text and the edge, and the fill is kept for
   the one full-size button that is the point of the page. */
.btn.sm.accent, .btn.sm.danger, .btn.sm.green {
  background: transparent;
  border-color: color-mix(in srgb, currentColor 38%, transparent);
  box-shadow: none;
}
.btn.sm.accent { color: var(--accent); }
.btn.sm.danger { color: var(--red); }
.btn.sm.green { color: var(--green); }
.btn.sm.accent:hover, .btn.sm.danger:hover, .btn.sm.green:hover {
  color: currentColor;
  border-color: currentColor;
  background: color-mix(in srgb, currentColor 14%, transparent);
  box-shadow: none;
}
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }

/* ─── Forms ──────────────────────────────────────────────── */
label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 7px; font-weight: 600; }
input[type=text], input[type=password], input[type=number], input[type=email], input[type=search], input[type=time], select, textarea {
  width: 100%; padding: 10px 13px; border-radius: var(--radius-sm);
  border: 1px solid var(--glass-border);
  background: var(--layer-1); color: var(--text); font-size: 14px; font-family: inherit;
  outline: none;
  transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
}
input::placeholder, textarea::placeholder { color: var(--text-dim); }
input:hover, select:hover, textarea:hover { border-color: var(--text-dim); }
input:focus, select:focus, textarea:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-glow);
}
input[disabled], select[disabled], textarea[disabled] { opacity: .55; cursor: not-allowed; }
/* A settled value the page is showing rather than asking for — the account on
   the 2FA step, the otpauth:// setup key. Not `disabled`: a disabled field is
   dropped from the form and skipped by password managers, and on the 2FA page
   that field exists precisely so a password manager can read it. So it stays a
   live, focusable, copyable input and only *looks* inert. */
input[readonly], textarea[readonly] {
  color: var(--text-dim);
  background: transparent;
  cursor: default;
}
input[readonly]:hover, textarea[readonly]:hover { border-color: var(--glass-border); }
input[readonly]:focus, textarea[readonly]:focus {
  border-color: var(--glass-border);
  box-shadow: none;
}
/* Dropdowns get an OPAQUE surface: the popup list is drawn by the OS over
   whatever happens to be behind it, and a translucent one shows the page
   through the options. Same reason Trackr's --color-input-solid exists. */
select {
  appearance: none; cursor: pointer;
  background-color: var(--bg-elev);
  background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
                    linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
  background-position: calc(100% - 17px) center, calc(100% - 12px) center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 32px;
}
select option { background: var(--bg-elev); color: var(--text); }
textarea { resize: vertical; min-height: 70px; }
.field { margin-bottom: 18px; }

/* Assigned MIBs: a picker row, then one block per attached MIB. The blocks are
   the answer to "what is this device using" — they wrap, so ten of them grow
   the field downwards instead of hiding six behind a scrollbar. */
.mib-picker-row { display: flex; gap: 10px; align-items: center; }
.mib-picker-row .mib-picker { flex: 1; min-width: 0; }
.mib-blocks { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.mib-blocks:empty { margin-top: 0; }
.mib-block {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 7px 8px 7px 12px; border-radius: var(--radius-xs);
  background: var(--layer-1); border: 1px solid var(--glass-border);
  font-size: 13px; color: var(--text);
}
.mib-block-name { line-height: 1.2; }
.mib-block-x {
  border: none; background: transparent; cursor: pointer; padding: 0 4px;
  color: var(--text-dim); font-size: 15px; line-height: 1; border-radius: 4px;
}
.mib-block-x:hover { color: var(--red); background: color-mix(in srgb, var(--red) 14%, transparent); }
.mib-blocks-empty { margin-top: 10px; font-size: 12px; }
.mib-blocks-empty[hidden] { display: none; }

/* Side-by-side cards that should read as one row. A grid item already
   stretches to the tallest in its row, so the box heights match on their own —
   what did not match was what was *inside* them: one card's table stopped
   halfway up while the other ran to the bottom edge. Making the tile a column
   and letting its table take the slack lines the two up properly. */
.card.tile { display: flex; flex-direction: column; }
.card.tile > .table-wrap { flex: 1 1 auto; }
/* The empty state grows to fill the tile so the two boxes still line up, but it
   must NOT become a flex container itself: a link inside the message would then
   be a flex item of its own, and the sentence around it breaks into separately
   centred fragments. `align-content` centres the block's own lines without
   touching how its children lay out. */
.card.tile > .empty { flex: 1 1 auto; align-content: center; }

/* Grouped form sections (used by the add/edit host form). */
.form-section { margin-bottom: 18px; padding-bottom: 4px; }
.form-section + .form-section { border-top: 1px solid var(--hairline); padding-top: 16px; }
.form-section-title {
  font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
  color: var(--accent); font-weight: 700; margin-bottom: 12px;
}
.field-row { display: grid; grid-template-columns: repeat(2,1fr); gap: 18px; }
.field-row-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 18px; }
@media (max-width: 800px){ .field-row, .field-row-3 { grid-template-columns: 1fr; } }

/* toggle */
.switch { display: inline-flex; align-items: center; gap: 11px; cursor: pointer; user-select: none; }
.switch input { display: none; }
.switch .track {
  width: 44px; height: 26px; border-radius: 14px; position: relative;
  background: var(--layer-1);
  border: 1px solid var(--glass-border);
  transition: background .2s ease, border-color .2s ease;
}
.switch .track::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 20px; height: 20px; border-radius: 50%;
  background: var(--text-dim);
  transition: transform .2s, background .2s;
}
/* On, the track fills with the accent and the thumb goes white — the state is
   readable at a glance from the track colour alone, not just the position. */
.switch input:checked + .track { background: var(--accent); border-color: transparent; }
.switch input:checked + .track::after { transform: translateX(18px); background: #fff; }

/* ─── Tables ─────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--radius); padding: 2px; }
table { width: 100%; border-collapse: collapse; }
th {
  text-align: left; padding: 12px 14px; font-size: 11px; text-transform: uppercase;
  letter-spacing: .5px; color: var(--text-dim); font-weight: 700;
  border-bottom: 1px solid var(--glass-border);
  white-space: nowrap;
}
td { padding: 12px 14px; border-bottom: 1px solid var(--hairline); font-size: 13px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--layer-1); }
.mono { font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; font-size: 12px; }

/* ─── Badges ──────────────────────────────────────────────
   A badge is a tinted pill: the status colour at low alpha behind the same
   colour as text. `color` is set by the state class below and the background
   is derived from it with color-mix, so a badge only ever needs one colour
   declared and can never end up tinted one way and lettered another. */
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 700;
  color: var(--text-dim);
  background: var(--layer-3);   /* fallback where color-mix is unsupported */
  background: color-mix(in srgb, currentColor 15%, transparent);
  line-height: 1.5;
}
.sev-critical { color: var(--red); }
.sev-warning { color: var(--amber); }
.sev-info { color: var(--accent); }
.badge.up, .pill.up { color: var(--green); }
.badge.down, .pill.down { color: var(--red); }
.pill { display:inline-flex; align-items:center; gap:7px; font-weight:700; font-size:12px; }
.dot { width:9px; height:9px; border-radius:50%; display:inline-block; }
.dot.up { background: var(--green); box-shadow: 0 0 8px var(--green); }
.dot.down { background: var(--red); box-shadow: 0 0 8px var(--red); }
.dot.unknown { background: var(--text-dim); }
.dot.off { background: transparent; border: 1px solid var(--text-dim); }
.dot.unaddressed { background: var(--amber); }

/* status text helpers */
.ok { color: var(--green); }
.err { color: var(--red); }
.dim { color: var(--text-dim); }

/* ─── Bar chart (dashboard sparkline) ───────────────────── */
.spark { display: flex; align-items: flex-end; gap: 4px; height: 90px; padding: 8px; }
.spark .bar {
  flex: 1; border-radius: 4px 4px 2px 2px; min-height: 3px;
  background: linear-gradient(var(--accent), var(--accent-dim));
  box-shadow: 0 0 6px rgba(91,140,255,.3);
}

/* ─── Login ──────────────────────────────────────────────── */
.login-wrap { display: grid; place-items: center; min-height: 100vh; padding: 20px; }
.login-card { width: 100%; max-width: 380px; padding: 40px 34px; text-align: center; }
.login-card .logo-lg {
  width: 64px; height: 64px; border-radius: 20px; margin: 0 auto 18px;
  display: grid; place-items: center; font-size: 30px;
  background: linear-gradient(135deg, var(--accent), var(--accent-dim));
  color: #fff;
  box-shadow: 0 8px 28px var(--accent-glow);
  border: none;
}
.login-card .logo-lg::before { display: none; }
.login-card h1 { font-size: 21px; color: var(--heading); margin-bottom: 6px; }
.login-card p.sub { color: var(--text-dim); margin-bottom: 26px; font-size: 13px; }
.login-card .field { text-align: left; }

/* "or" between the password form and the identity-provider button: a hairline
   across the card with the word sitting in a gap in it. */
.or-divider {
  display: flex; align-items: center; gap: 12px;
  margin: 18px 0; color: var(--text-dim); font-size: 12px;
}
.or-divider::before, .or-divider::after {
  content: ""; flex: 1; height: 1px; background: var(--glass-border);
}

/* Sign in with <provider>. Full width like the submit button above it, but an
   outline rather than a fill — the password form is the primary action, and two
   filled buttons stacked would be a card with no answer to "which one". */
.btn.sso { width: 100%; justify-content: center; text-decoration: none; }
.btn.sso:hover { text-decoration: none; }
.trackr-mark { flex: none; }

/* Inline banners. Same tinted-pill idea as .badge, at block scale: the state
   colour at low alpha, a hairline of the same colour, and the text in it. */
.alert-error, .alert-notice {
  padding: 11px 14px; border-radius: var(--radius-sm);
  margin-bottom: 18px; font-size: 13px; text-align: left;
  background: color-mix(in srgb, currentColor 12%, transparent);
  border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
}
.alert-error { color: var(--red); }
.alert-ok { color: var(--green); }
/* Informational counterpart to .alert-error — a session that the server ended on
   policy (idle/absolute timeout) is not an error the user made. */
.alert-notice { color: var(--amber); }

/* misc */
.flex { display: flex; }
.between { justify-content: space-between; }
.items-center { align-items: center; }
.gap { gap: 14px; }
.mt { margin-top: 20px; }
.mb { margin-bottom: 20px; }
.toast {
  position: fixed; bottom: 26px; right: 26px; padding: 13px 18px; border-radius: var(--radius-sm);
  background: var(--bg-elev); border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
  font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .25s; z-index: 50; pointer-events:none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.empty { text-align: center; padding: 40px; color: var(--text-dim); }
.tag { font-size: 11px; padding: 3px 9px; border-radius: var(--radius-xs); color: var(--text-dim);
  background: var(--layer-1); border: 1px solid var(--glass-border); }
details.inline-form { margin-top: 6px; }
details.inline-form > summary { list-style: none; cursor: pointer; }
details.inline-form > summary::-webkit-details-marker { display: none; }

.linkbtn { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; padding: 0; text-align: left; }
.linkbtn:hover { text-decoration: underline; }

/* ─── Section headings (detail/settings pages) ───────────── */
.section-head {
  margin: 26px 2px 12px; padding-bottom: 8px; font-size: 15px; color: var(--accent);
  border-bottom: 1px solid var(--glass-border); letter-spacing: .3px;
}
.section-head:first-of-type { margin-top: 6px; }
.settings-tabs { margin-bottom: 16px; }
.settings-panel[hidden] { display: none; }

/* Floating "unsaved changes" save prompt (MIB object editor). */
.save-float {
  position: fixed; right: 26px; bottom: 26px; z-index: 80;
  display: flex; align-items: center; gap: 14px; padding: 12px 16px;
  border-radius: var(--radius-sm); background: var(--bg-elev);
  border: 1px solid var(--accent);
  box-shadow: var(--elev-pop);
  animation: savePulse 1.8s ease-in-out infinite;
}
.save-float[hidden] { display: none; }
.save-float .save-hint { color: var(--amber); font-weight: 700; font-size: 13px; }

/* ─── Heavy-load banner ──────────────────────────────────── */
.load-banner {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 16px; padding: 11px 16px; border-radius: var(--radius-sm);
  color: var(--amber); font-size: 13px; font-weight: 600;
  background: color-mix(in srgb, currentColor 12%, transparent);
  border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
}
.load-banner[hidden] { display: none; }
.load-banner .spin { display: inline-block; animation: spin 1.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ─── Flow builder (alerts) ──────────────────────────────── */
.flow-palette { display: flex; gap: 10px; flex-wrap: wrap; }
/* Height is set inline by flow.js (remembered per browser, ~72vh by default and
   drag-resizable); this is only the floor a stylesheet-only page would get. */
.flow-canvas {
  position: relative; height: 72vh; min-height: 320px; overflow: auto;
  border-radius: var(--radius); padding: 0; margin-top: 6px;
  background-image: radial-gradient(rgba(255,255,255,.04) 1px, transparent 1px);
  background-size: 22px 22px;
}
.flow-canvas-wrap { position: relative; }
/* Zoomable content layer (CSS zoom keeps scroll + coordinates consistent).
   Wide enough that a long escalation chain — trigger, condition, wait, action,
   clear, ack, end at 260px each — runs left to right without being folded. */
.flow-content { position: relative; width: 4800px; height: 3000px; transform-origin: 0 0; }

/* Drag-to-resize grip under the canvas. */
.flow-resize {
  height: 12px; margin-top: 2px; border-radius: 6px; cursor: ns-resize;
  background: var(--layer-1); border: 1px solid var(--glass-border);
  transition: background .15s ease, border-color .15s ease;
}
.flow-resize::after {
  content: ''; display: block; width: 42px; height: 2px; margin: 4px auto 0;
  border-radius: 2px; background: var(--text-dim); opacity: .5;
}
.flow-resize:hover { background: var(--layer-3); border-color: var(--text-dim); }
/* While dragging, the pointer must not select text or slip onto a node and
   start dragging that instead. */
body.flow-resizing { user-select: none; cursor: ns-resize; }
body.flow-resizing .flow-node { pointer-events: none; }

/* Full-screen builder. Same idea as the kiosk dashboard: hide the chrome and
   give the canvas the window. The card is promoted to a fixed layer rather than
   the canvas alone, so the name box, the add-node menu and Save stay reachable
   — a full-screen editor you cannot save from is a preview. */
.flow-fullscreen .app-sidebar,
.flow-fullscreen .app-topbar,
.flow-fullscreen .bottom-nav,
.flow-fullscreen .page-head,
.flow-fullscreen .alerts-tabs,
.flow-fullscreen #load-banner { display: none !important; }
.flow-fullscreen .app-main { padding: 0; max-width: none; margin-left: 0; width: 100%; }
.flow-fullscreen #builder-card {
  position: fixed; inset: 0; z-index: 900; margin: 0; border-radius: 0;
  display: flex; flex-direction: column; overflow: auto;
}
.flow-fullscreen #builder-card .flow-canvas-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.flow-fullscreen #builder-card .flow-canvas { flex: 1; height: auto !important; min-height: 0; }
.flow-fullscreen .flow-resize { display: none; }

/* Tabs on the alerts page. The builder is the reason this page is tabbed, so
   the panes are plain containers — the tab strip reuses .dash-tab. */
.alerts-pane[hidden] { display: none; }
.alerts-tabs .dash-tab .tag { margin-left: 6px; }
.flow-zoom {
  position: absolute; right: 14px; bottom: 14px; z-index: 40;
  display: flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 22px;
}
.flow-zoom button { background: none; border: none; color: var(--text); cursor: pointer; font-size: 17px; width: 26px; line-height: 1; }
.flow-zoom button:hover { color: var(--accent); }
.flow-zoom span { font-size: 12px; min-width: 42px; text-align: center; color: var(--text-dim); }
.flow-edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.flow-edge {
  fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: .75;
  pointer-events: stroke; cursor: pointer;
}
.flow-edge:hover { stroke: var(--red); opacity: 1; }
/* A node floats over the dotted canvas, so it needs an opaque body: a
   translucent one would show the grid through its own form fields. */
/* Width must match NODE_W in flow.js, which draws the edges from it. */
.flow-node {
  position: absolute; width: 260px; z-index: 2; border-radius: var(--radius-sm);
  background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  box-shadow: var(--elev-card);
}
.flow-node.trigger .flow-node-head { color: var(--accent); }
.flow-node.condition .flow-node-head { color: var(--amber); }
.flow-node.severity .flow-node-head { color: var(--red, #e06c75); }
.flow-node.wait .flow-node-head { color: var(--cyan, #56b6c2); }
.flow-node.ack .flow-node-head { color: var(--purple, #c678dd); }
.flow-node.action .flow-node-head { color: var(--green); }
/* The one node that acts on the physical world, so it reads as a warning
   colour rather than as another shade of "notify". */
.flow-node.relay .flow-node-head { color: var(--amber); }
.flow-node.clear .flow-node-head { color: var(--cyan, #56b6c2); }
.flow-node.end .flow-node-head { color: var(--muted, #7f848e); }

/* Dashboard tabs + stat meters. */
.dash-tabs { display: flex; gap: 8px; flex-wrap: wrap; }
/* Tabs read as one segmented control: a hairline pill each, with the current
   one filled rather than outlined. */
.dash-tab {
  padding: 7px 15px; border-radius: var(--radius-sm); font-size: 13px; color: var(--text-dim);
  background: var(--layer-1); border: 1px solid var(--glass-border);
  transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.dash-tab:hover { color: var(--text); background: var(--layer-3); }
.dash-tab.active { color: var(--heading); background: var(--layer-3); border-color: var(--text-dim); }
.dash-tab { cursor: pointer; }

/* ─── Full-screen (kiosk) dashboard mode ─────────────────────
   Triggered by JS adding `dash-fullscreen` to <body>. Hides the chrome
   (sidebar nav, page-head controls, tab bar, load banner) and lets the grid
   fill the viewport, leaving only a floating Exit button. */
.dash-fs-exit { position: fixed; top: 14px; right: 18px; z-index: 1200; display: none; }
.dash-fullscreen .dash-fs-exit { display: inline-flex; }
.dash-fullscreen .app-sidebar,
.dash-fullscreen .app-topbar,
.dash-fullscreen .bottom-nav,
.dash-fullscreen .page-head .btn-row,
.dash-fullscreen .dash-tabs,
.dash-fullscreen #load-banner { display: none !important; }
.dash-fullscreen .app-main { padding: 18px 24px; max-width: none; margin-left: 0; width: 100%; }
.dash-fullscreen .page-head { margin-bottom: 16px; }

/* "Dashboards" switcher dropdown (page-head). */
.dash-switch-menu { position: relative; }
.dash-switch-list {
  display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
  flex-direction: column; min-width: 200px; max-height: 60vh; overflow: auto; padding: 6px;
  border-radius: var(--radius-sm); background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
}
.dash-switch-list.open { display: flex; }
.dash-switch-item {
  padding: 8px 12px; border-radius: var(--radius-xs); font-size: 13px; color: var(--text);
  text-decoration: none; white-space: nowrap; cursor: pointer;
}
.dash-switch-item:hover { background: var(--layer-3); text-decoration: none; }
.dash-switch-item.active { color: var(--accent); font-weight: 700; }
.dash-switch-cfg, .dash-switch-new { width: 100%; text-align: left; background: none; border: none;
  font: inherit; cursor: pointer; }
.dash-switch-cfg { color: var(--text-dim); border-top: 1px solid var(--glass-border);
  margin-top: 4px; padding-top: 10px; }
.dash-switch-new { color: var(--accent); font-weight: 600; }

/* Switching dashboards/tabs.
   The dim is now only a *slow-fetch* hint — dashboard.js adds the class after a
   200 ms grace period, so an ordinary switch never dips at all. The swap itself
   cross-fades through a view transition (see "Page transitions" at the end of
   this sheet), which is what stops it reading as a page refresh. */
#dash-page { transition: opacity .15s ease; }
#dash-page.dash-switching { opacity: .55; }
.meter { height: 8px; border-radius: 6px; margin-top: 6px; background: var(--layer-1);
  border: 1px solid var(--glass-border); overflow: hidden; }
.meter-fill { height: 100%; border-radius: 6px; background: var(--accent); transition: width .4s ease; }
.meter-fill.red { background: var(--red, #e06c75); }

/* Empty-dashboard prompt (shown when a dashboard has no widgets yet). */
.dash-empty {
  text-align: center; padding: 52px 30px; margin-bottom: 14px;
  display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.dash-empty[hidden] { display: none; }
.dash-empty .dash-empty-ic {
  width: 64px; height: 64px; border-radius: 20px; display: grid; place-items: center;
  font-size: 30px; color: var(--accent);
  background: var(--accent-soft);
}
.dash-empty h2 { color: var(--heading); margin: 0; }
.dash-empty p { max-width: 460px; margin: 0 0 6px; }

/* ─── Dashboard drag-and-drop grid ───────────────────────── */
.dash-grid { display: grid; gap: 14px; grid-auto-rows: 92px; }
.dash-node { position: relative; overflow: auto; }
.dash-node-head { display: none; }
/* Visible grid + accent frame while editing. */
.dash-grid.editing {
  background-image:
    linear-gradient(90deg, rgba(91,140,255,.12) 1px, transparent 1px),
    linear-gradient(rgba(91,140,255,.12) 1px, transparent 1px);
  /* A pre-JS fallback only: applyGridGuides() replaces this with the exact
     pitch for the board's real column count, which is configurable and whose
     tracks are a gap wider than 100%/N. */
  background-size: calc(100% / 12) 106px, 100% 106px;
  border-radius: var(--radius);
  outline: 2px dashed var(--accent-dim); outline-offset: 6px;
  /* Keep a droppable area even when the dashboard is empty (new tabs). */
  min-height: 320px;
}
.dash-grid.drop-target { outline-color: var(--accent); background-color: rgba(91,140,255,.05); }
.dash-grid.editing .dash-node-head {
  display: flex; align-items: center; gap: 8px; justify-content: space-between;
  position: relative;
  margin: -4px -4px 8px; padding: 5px 8px; border-radius: var(--radius-xs); cursor: grab;
  background: var(--layer-1);
}
.dash-grid.editing .dash-node { outline: 1px dashed var(--accent-dim); cursor: move; user-select: none; overflow: visible;
  display: flex; flex-direction: column; }
.dash-grid.editing .dash-node-body { pointer-events: none; }   /* clicks fall through to drag */
.dash-grid.editing .dash-node-head, .dash-grid.editing .dn-config,
.dash-grid.editing .dn-config * , .dash-grid.editing .dn-remove,
.dash-grid.editing .dn-edit, .dash-grid.editing .dn-resize { pointer-events: auto; }
.dnh-title { font-weight: 700; font-size: 12px; color: var(--accent); white-space: nowrap; flex: 1; }
.dnh-title::before { content: "⠿ "; color: var(--text-dim); }
/* Keep the ✎/✕ tools above the corner resize handles so they stay clickable
   even where a top-corner grip overlaps them. */
.dnh-tools { display: flex; align-items: center; gap: 6px; position: relative; z-index: 9; }
.dn-edit, .dn-remove { display: none; background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 15px; line-height: 1; }
.dash-grid.editing .dn-edit, .dash-grid.editing .dn-remove { display: inline; }
.dn-edit:hover { color: var(--accent); }
.dn-remove:hover { color: var(--red); }
/* Config popover opened by the pencil. */
.dn-config {
  position: absolute; top: calc(100% + 4px); right: 0; z-index: 25; display: none;
  flex-direction: column; gap: 6px; padding: 10px; min-width: 210px; max-width: 280px;
  max-height: 70vh; overflow: auto;
  border-radius: var(--radius-sm); background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
}
.dn-config.open { display: flex; }
.dn-config select { width: 100%; padding: 6px 8px; font-size: 12px; }
/* Static placeholder shown in place of live widget data while editing. */
.edit-ph {
  display: grid; place-items: center; min-height: 64px; padding: 16px; text-align: center;
  color: var(--text-dim); font-size: 13px; font-weight: 600;
  border: 1px dashed var(--accent-dim); border-radius: 10px;
}
/* Resize grab points — one on each of the four corners. Drag any corner to
   resize; the opposite corner stays anchored (see onMove in dashboard.js). */
.dn-resize {
  display: none; position: absolute; width: 15px; height: 15px; z-index: 6;
  background: var(--accent); border-radius: 50%; opacity: .6;
  box-shadow: 0 0 0 2px var(--surface);
  transition: opacity .12s ease, transform .12s ease;
}
.dash-grid.editing .dn-resize { display: block; }
.dn-resize:hover { opacity: 1; transform: scale(1.15); }
.dn-resize.nw { left: -7px; top: -7px; cursor: nwse-resize; }
.dn-resize.ne { right: -7px; top: -7px; cursor: nesw-resize; }
.dn-resize.sw { left: -7px; bottom: -7px; cursor: nesw-resize; }
.dn-resize.se { right: -7px; bottom: -7px; cursor: nwse-resize; }
.dash-node.dragging { opacity: .92; z-index: 10; box-shadow: 0 0 0 2px var(--accent); }
/* Overlapping widgets are allowed while editing but flagged red and can't be saved. */
.dash-grid.editing .dash-node.overlapping { outline: 2px solid var(--red); outline-offset: 2px; z-index: 6;
  box-shadow: 0 0 0 2px var(--red), var(--elev-card); }
@keyframes dash-shake {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-6px); }
  30% { transform: translateX(6px); }
  45% { transform: translateX(-5px); }
  60% { transform: translateX(5px); }
  75% { transform: translateX(-3px); }
  90% { transform: translateX(3px); }
}
.dash-node.shake { animation: dash-shake .45s ease; }
.dash-edit-bar {
  display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 14px;
  position: sticky; top: 0; z-index: 60;
  border-color: var(--accent);
  box-shadow: var(--elev-card);
}
.dash-edit-bar[hidden] { display: none; }

/* ─── Board height (edit mode) ────────────────────────────────
   The board used to be exactly as tall as its tallest widget, so the only way
   to reach a new row was to already have something on it. The grip below the
   grid drags that height directly; the ＋/− pair in the edit bar does the same
   thing for anyone not using a pointer. */
.dash-canvas-grip {
  display: flex; align-items: center; justify-content: center;
  height: 22px; margin-top: 6px; cursor: ns-resize;
  touch-action: none;            /* the drag is ours, not the scroller's */
}
.dash-canvas-grip[hidden] { display: none; }
.dash-canvas-grip .dcg-bar {
  width: 132px; height: 6px; border-radius: 3px;
  background: var(--accent-dim);
  transition: background .12s ease, width .12s ease, transform .12s ease;
}
.dash-canvas-grip:hover .dcg-bar { background: var(--accent); width: 168px; }
.dash-canvas-grip.dragging .dcg-bar { background: var(--accent); width: 200px; transform: scaleY(1.4); }
.dash-canvas-grip:focus-visible { outline: 2px solid var(--accent); border-radius: 6px; }

/* Row counter + steppers in the edit bar. */
.dash-rows-ctl { display: flex; align-items: center; gap: 6px; }
.dash-rows-ctl .btn.sm { min-width: 30px; padding-inline: 8px; }
.dash-rows-count {
  min-width: 62px; text-align: center; font-size: 12px; font-weight: 600;
  color: var(--heading); font-variant-numeric: tabular-nums;
}

/* Host-history chart widget. */
.host-hist { width: 100%; }
.hh-svg { width: 100%; height: 92px; display: block; }
.hh-line { fill: none; stroke: var(--accent); stroke-width: 1.4; vector-effect: non-scaling-stroke; }
.hh-area { fill: var(--accent); opacity: .16; }
.hh-area.solid { opacity: .42; }
.hh-bar { fill: var(--accent); }
.hh-bar.down, .hh-downpt { fill: var(--red); }

/* Gauge (needle) widget — scales to fill the node; value sits below the dial. */
.gauge-svg { width: 100%; height: 100%; max-height: none; display: block; flex: 1 1 auto; min-height: 90px; }
.g-bg { fill: none; stroke: var(--layer-3); stroke-width: 8; stroke-linecap: round; }
.g-val { fill: none; stroke: var(--accent); stroke-width: 8; stroke-linecap: round; transition: stroke .3s ease; }
.g-needle { stroke: var(--text); stroke-width: 2; stroke-linecap: round; }
.g-hub { fill: var(--text); }
.g-val-text { fill: var(--heading); font-size: 12px; font-weight: 700; }
.g-tick { fill: var(--text-dim); font-size: 6px; }

/* Host-group status tiles — auto-fitting cards, one per host. A status-coloured
   dot + left accent carries the state; full name, RTT and UP/DOWN stay legible
   from 1 host up to 30+ (the grid just reflows). */
.stile-summary { display: flex; gap: 8px; flex: 0 0 auto; margin-bottom: 8px; font-size: 11px; font-weight: 700; }
.stile-summary .ss { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px;
  background: color-mix(in srgb, currentColor 15%, transparent); }
.stile-summary .ss.up { color: var(--green); }
.stile-summary .ss.down { color: var(--red); }
.stile-summary .ss.ev { color: var(--amber); }

.status-tiles {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 10px; padding: 2px; align-content: start; overflow: auto; min-height: 0;
}
.status-tiles.ph { grid-template-columns: repeat(2, 1fr); gap: 6px; }
.stile {
  display: flex; flex-direction: column; gap: 6px; padding: 10px 12px;
  border-radius: var(--radius-sm); border-left: 4px solid var(--text-dim);
  background: var(--layer-1); color: var(--text);
  border-top: 1px solid var(--glass-border);
  border-right: 1px solid var(--glass-border);
  border-bottom: 1px solid var(--glass-border);
  transition: transform .12s ease, background .15s ease; text-decoration: none;
}
.stile:hover { transform: translateY(-2px); background: var(--layer-3); text-decoration: none; }
.stile-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
.stile-top b { font-size: 13px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stile-top .sev { margin-left: auto; font-style: normal; font-size: 13px; flex: 0 0 auto; }
.stile .sdot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: var(--text-dim); box-shadow: 0 0 0 3px rgba(255,255,255,.04); }
.stile-bot { display: flex; align-items: baseline; justify-content: space-between; font-size: 11px; }
.stile .srtt { color: var(--text-dim); font-variant-numeric: tabular-nums; }
.stile .sstate { font-weight: 700; letter-spacing: .04em; }

/* Online = green · offline = red · unknown = grey. */
.stile.up { border-left-color: var(--green); }
.stile.up .sdot { background: var(--green); box-shadow: 0 0 8px var(--green); }
.stile.up .sstate { color: var(--green); }
.stile.down { border-left-color: var(--red); }
.stile.down .sdot { background: var(--red); box-shadow: 0 0 8px var(--red); }
.stile.down .sstate { color: var(--red); }
.stile.unknown { opacity: .85; }
.stile.unknown .sstate { color: var(--text-dim); }
/* Online but with an SNMP event: accent shifts to the event severity. */
.stile.ev-warning { border-left-color: var(--amber); }
.stile.ev-warning .sev { color: var(--amber); }
.stile.ev-critical { border-left-color: var(--red); animation: stilePulse 1.8s ease-in-out infinite; }
.stile.ev-critical .sev { color: var(--red); }
@keyframes stilePulse { 50% { box-shadow: 0 0 0 1px var(--red); } }

/* ─── Dashboard blocks: content that scales to the node ─────────────────── */
.dash-node-body { display: flex; flex-direction: column; min-height: 0; }
.dash-grid:not(.editing) .dash-node-body { height: 100%; }
.dash-node-body > h2, .dash-node-body > h3, .dash-node-body > .flex,
.dash-node-body > .btn-row { flex: 0 0 auto; }
/* Charts / gauge / hex fill the remaining height and centre their content. */
.dash-node-body .host-hist,
.dash-node-body .group-hex,
.dash-node-body .gauge { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; min-height: 0; }
.dash-node-body .table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
.dash-node-body .spark { flex: 0 0 auto; }
/* Smooth in-place content swaps (no white flash on refresh). */
.dash-node-body { transition: opacity .18s ease; }
.dash-node-body.swapping { opacity: .35; }

/* KPI / summary stats inside a dashboard node: inset neumorphic + scalable. */
.dash-node .grid.cols-4, .dash-node .grid.cols-2 { height: 100%; }
.dash-node .stat {
  border-radius: var(--radius-sm); padding: 14px 16px;
  box-shadow: inset 0 0 0 1px var(--glass-border);
  display: flex; flex-direction: column; justify-content: center;
}
.dash-node .stat:hover { transform: none; }
.dash-node .stat .value { font-size: clamp(20px, 3.4vw, 34px); }

/* ─── Dashboard · network map ────────────────────────────────
   The colours are the widget. Everything else here exists to keep a device
   under the pointer readable while the rest of the estate stays in frame. */
.dash-node-body .netmap { flex: 1 1 auto; display: flex; min-height: 0; }
.net-wrap { position: relative; flex: 1 1 auto; min-height: 0; display: flex; }
.net-svg {
  flex: 1 1 auto; width: 100%; height: 100%; min-height: 0;
  overflow: visible; cursor: crosshair; touch-action: none;
  /* Every word on the map is furniture, not content. Without this a drag across
     it — which is how the lens is driven — selects the labels it passes over,
     and on a touch screen that hands the name to the browser's "search for
     this" bar instead of magnifying the device it belongs to. */
  user-select: none; -webkit-user-select: none;
}
/* The camera. Long enough that a fly-to reads as travel across the map rather
   than as a cut, which is what makes it obvious *where* the new alarm is. */
.net-svg { transition: none; }

.net-link {
  fill: none; stroke: var(--glass-border); stroke-width: 1.6;
  transition: stroke .4s ease, stroke-width .4s ease, opacity .4s ease;
}
/* A radio registration, which only the hierarchy layout draws — on the site map
   every line means "if that goes, this goes", and a second line saying something
   else about the same pair of devices makes that sentence unreadable. Told apart
   from an ICMP path by weight and tint rather than by a dash: a dashed line on a
   map is read as a *provisional* one — something inferred, or not quite there —
   and a registration is neither. It is as real as the cable beside it. */
.net-link.radio { stroke: var(--accent-dim); stroke-width: 1.3; opacity: .8; }
/* A limb going dark. The line carrying a fault is coloured as well as the node,
   because "these six are behind that one" is the sentence the map is for. */
.net-link.bad { stroke: var(--red); stroke-width: 2.4; opacity: .95; }

.net-node {
  cursor: pointer;
  /* Only the scale is animated — the position comes from the same attribute and
     must not lag, or a node would drift away from the lines that reach it. */
  transition: transform .18s cubic-bezier(.2, .9, .3, 1.2);
}
.net-dot {
  fill: var(--layer-1); stroke: var(--green); stroke-width: 2.5;
  transition: fill .45s ease, stroke .45s ease;
}
.net-node.unknown .net-dot { stroke: var(--text-dim); }
.net-node.info    .net-dot { stroke: var(--accent); }
.net-node.warning .net-dot { stroke: var(--amber); fill: color-mix(in srgb, var(--amber) 18%, var(--layer-1)); }
.net-node.critical .net-dot,
.net-node.down    .net-dot { stroke: var(--red); fill: color-mix(in srgb, var(--red) 22%, var(--layer-1)); }

/* A site: the region its devices are laid out in, drawn behind them. It is not
   a node and has no lines to it — where a box *is* is not a claim about what it
   is wired to, and on this map every line is read as one. Tinted by the worst
   thing inside it, so a site in trouble is legible before any one dot is. */
.net-site rect {
  fill: var(--layer-1); stroke: var(--glass-border); stroke-width: 1;
  transition: fill .45s ease, stroke .45s ease;
}
.net-site.bad rect { stroke: var(--amber); fill: color-mix(in srgb, var(--amber) 7%, transparent); }
.net-site.down rect { stroke: var(--red); fill: color-mix(in srgb, var(--red) 8%, transparent); }
.net-site-name {
  fill: var(--text-dim); font-size: 12px; font-weight: 700;
  letter-spacing: .02em; pointer-events: none;
}
.net-site.bad .net-site-name { fill: var(--amber); }
.net-site.down .net-site-name { fill: var(--red); }
.net-site-count { font-size: 10px; font-weight: 600; opacity: .65; }

/* A generation of the Kairos hierarchy: masters, then what registers to them.
   Quieter than a site region on purpose — a site is a place you can drive to
   and a tier is a way of counting, so the band is a faint stripe with a word on
   it rather than a box around something. Full-width and unbordered for the same
   reason: it says "this row is the masters", not "these devices are together".
   Alternating so two adjacent tiers are separable without a rule between them. */
.net-tier rect { fill: var(--layer-1); opacity: .5; }
.net-tier:nth-child(even) rect { opacity: 0; }
/* Nobody has filled in a master for these. Warmer than a tier, because unlike
   the tiers it is a gap in the records rather than a fact about the radio. */
.net-tier.loose rect { fill: color-mix(in srgb, var(--amber) 6%, transparent); opacity: 1; }
.net-tier-name {
  fill: var(--text-dim); font-size: 11px; font-weight: 700;
  letter-spacing: .07em; text-transform: uppercase; pointer-events: none;
}
.net-tier.loose .net-tier-name { fill: var(--amber); }
.net-tier-count { font-size: 10px; font-weight: 600; opacity: .65; letter-spacing: 0; }

/* The ring that says "this one, now". Slow enough to read across a room.
   `transform-box: fill-box` is not optional: an SVG transform-origin resolves
   against the *view box* by default, so "center" means the middle of the whole
   map and the ring expands around that instead of around the node it belongs
   to — a pulse drawn in empty space, pointing at nothing. */
.net-pulse {
  fill: none; stroke: var(--red); stroke-width: 2; opacity: .5;
  transform-box: fill-box; transform-origin: center;
}
.net-node.warning .net-pulse { stroke: var(--amber); }
@keyframes net-pulse { from { transform: scale(1); opacity: .5; } to { transform: scale(2.6); opacity: 0; } }
.net-pulse { animation: net-pulse 2.2s ease-out infinite; }
/* The node itself is positioned by its transform attribute, so it must keep the
   view-box box — only the pulse inside it scales about itself. */
.net-node { transform-box: view-box; }
/* The one that arrived while nobody was looking gets a second, faster ring —
   an alarm from an hour ago and an alarm from ten seconds ago should not look
   the same on a wall board. */
.net-node.arrived .net-pulse { animation-duration: 1.1s; stroke-width: 3; }

.net-label, .net-sub {
  text-anchor: middle; fill: var(--text-dim); font-size: 11px; font-weight: 600;
  paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round;
  pointer-events: none; transition: opacity .18s ease, fill .3s ease;
}
.net-sub { font-size: 9.5px; opacity: 0; font-weight: 500; }
/* Under the lens: the name brightens and the reading appears. Detail arrives
   where it is being looked for, and nowhere else. */
.net-node.near .net-label { fill: var(--heading); }
.net-node.near .net-sub { opacity: 1; }
.net-node.down .net-label, .net-node.critical .net-label { fill: var(--red); }
.net-node.warning .net-label { fill: var(--amber); }

.net-legend {
  position: absolute; left: 0; bottom: 0; display: flex; gap: 10px;
  font-size: 11px; font-weight: 600; pointer-events: none;
}
.net-legend .nl { color: var(--text-dim); }
.net-legend .nl.bad { color: var(--amber); }
.net-legend .nl.down { color: var(--red); }

/* Library thumbnail. */
.ph-net { width: 100%; height: 100%; }
.ph-net line { stroke: var(--glass-border); stroke-width: 1.5; }
.ph-net circle { fill: var(--layer-1); stroke: var(--green); stroke-width: 2; }
.ph-net circle.down { stroke: var(--red); }
.ph-net circle.warn { stroke: var(--amber); }

@media (prefers-reduced-motion: reduce) {
  .net-pulse { animation: none; opacity: .35; }
  .net-node { transition: none; }
}

/* Host-history y-axis (units reference down the left side of the chart). */
.hh-wrap { display: flex; align-items: stretch; gap: 8px; flex: 1 1 auto; min-height: 60px; }
.hh-yaxis {
  display: flex; flex-direction: column; justify-content: space-between;
  text-align: right; font-size: 10px; color: var(--text-dim); font-weight: 600;
  padding: 2px 0; min-width: 30px; white-space: nowrap;
}
.hh-svg { flex: 1 1 auto; width: auto; height: 100%; min-height: 60px; }
.hh-foot { flex: 0 0 auto; }
.hh-legend { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; font-size: 11px; }
.hh-legend .lg { display: inline-flex; align-items: center; gap: 5px; }
.hh-legend .lg i { width: 11px; height: 3px; border-radius: 2px; display: inline-block; }

/* ─── Widget library (left rail during edit mode) ────────────────────────── */
.widget-library {
  position: fixed; top: 0; left: 0; width: 240px; height: 100vh; z-index: 90;
  padding: 20px 14px; display: none; flex-direction: column; gap: 10px;
  border-radius: 0; overflow-y: auto;
  background: var(--bg-elev);
  border: none; border-right: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
  animation: slideInLeft .25s ease both;
}
.widget-library::before { display: none; }
.widget-library.open { display: flex; }
.widget-library h3 { color: var(--heading); font-size: 15px; margin: 4px 4px 2px; }
.widget-library .wl-hint { color: var(--text-dim); font-size: 11px; margin: 0 4px 6px; line-height: 1.4; }
.wl-item {
  border-radius: var(--radius-sm); padding: 10px; cursor: grab;
  background: var(--layer-1); border: 1px solid var(--glass-border);
  transition: background .15s ease, border-color .15s ease, transform .1s ease;
}
.wl-item:hover { transform: translateY(-2px); background: var(--layer-3); border-color: var(--text-dim); }
.wl-item:active { cursor: grabbing; }
.wl-item.dragging { opacity: .5; box-shadow: 0 0 0 2px var(--accent); }
.wl-item .wl-label { font-size: 12px; font-weight: 700; color: var(--accent); margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.wl-preview {
  height: 56px; border-radius: var(--radius-xs); overflow: hidden; padding: 6px;
  display: flex; align-items: center; justify-content: center;
  background: var(--layer-1);
}
.wl-preview > * { max-width: 100%; max-height: 100%; }
.wl-preview .ph-kpis b { font-size: 16px; }
.wl-preview .status-tiles { gap: 4px; padding: 0; grid-template-columns: repeat(2, 1fr); }
.wl-preview .stile { padding: 4px 6px; gap: 2px; }
.wl-preview .stile-top b { font-size: 9px; }
.wl-preview .stile-bot { display: none; }
/* Drop hint on the grid while dragging a library item over it. */
.dash-grid.drop-target { outline: 2px dashed var(--accent); outline-offset: 6px; border-radius: var(--radius); }
@keyframes slideInLeft { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }

/* Edit-mode: clip bodies (placeholders only) + config rows + sample graphics.
   The node is a flex column while editing, so the body fills the height left
   after the header — giving flex children like the gauge SVG a bounded height so
   they can't balloon past the card on wide-but-short widgets; overflow:hidden
   clips anything that still doesn't fit. */
.dash-grid.editing .dash-node-body { flex: 1 1 auto; min-height: 0; overflow: hidden; }
.cfg-row { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--text-dim); }
.cfg-row > span { font-size: 10px; }
.cfg-row input, .cfg-row select { width: 100%; padding: 5px 7px; font-size: 12px; }
.chk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
.ph-title { font-weight: 700; font-size: 13px; color: var(--accent); margin-bottom: 8px; }
.ph-sub { font-size: 11px; color: var(--text-dim); margin-bottom: 6px; }
.ph-kpis { display: flex; gap: 16px; }
.ph-kpis b { font-size: 22px; color: var(--text-dim); opacity: .5; }
.ph-spark { width: 100%; height: 58px; opacity: .55; }
.ph-spark polyline { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.ph-spark rect { fill: var(--accent); opacity: .6; }
.ph-rows { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.ph-row { height: 12px; border-radius: 6px; opacity: .7; background: var(--layer-3); }
.ph-embed { font-size: 12px; color: var(--text-dim); display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 4px; flex: 1 1 auto; text-align: center; word-break: break-all; }

/* "Add tab" button styled like a dashboard tab. */
.dash-tab-add { cursor: pointer; border: none; font-weight: 600; }
.cfg-row.col textarea { width: 100%; padding: 5px 7px; font-size: 12px; font-family: var(--mono, monospace);
  resize: vertical; min-height: 80px; }

/* HTML block + embedded website/iframe widgets. */
.html-widget { flex: 1 1 auto; overflow: auto; min-height: 0; font-size: 13px; }
.html-widget img, .html-widget iframe { max-width: 100%; }
.embed-widget { flex: 1 1 auto; width: 100%; min-height: 0; border-radius: var(--radius-sm);
  border: 1px solid var(--glass-border); background: #fff; }
/* Always-present fallback link so a frame that refuses to load (X-Frame-Options /
   CSP) is still reachable instead of leaving the user with a broken-frame error. */
.embed-wrap { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; gap: 4px; }
.embed-fallback { flex: 0 0 auto; font-size: 11px; color: var(--text-dim); text-decoration: none; text-align: center; }
.embed-fallback:hover { color: var(--accent); }

/* ─── Multi-host gauge grid ───────────────────────────────────────────────── */
/* Single gauge: the lone row stretches so the dial fills the card. Multi: a
   scrolling auto-fit grid of fixed-ish cells. */
.gauge-grid { display: grid; grid-template-columns: 1fr; grid-auto-rows: 1fr; gap: 6px;
  flex: 1 1 auto; min-height: 0; }
.gauge-grid.multi { grid-auto-rows: auto; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  align-content: start; overflow: auto; }
.gauge-cell { display: flex; flex-direction: column; align-items: center; justify-content: center;
  min-width: 0; min-height: 0; }
.gauge-grid.multi .gauge-svg { min-height: 70px; }
.gauge-cap { font-size: 11px; color: var(--text-dim); max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ─── Temperatures (multi-host) widget ───────────────────────────────────── */
.multi-temp { flex: 1 1 auto; min-height: 0; overflow: auto; }
.temp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; align-content: start; padding: 2px; }
.temp-tile { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px;
  border-radius: var(--radius-sm); border: 1px solid var(--glass-border);
  border-left: 4px solid var(--text-dim); background: var(--layer-1); }
.temp-val { font-size: 24px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.temp-unit { font-size: 12px; font-weight: 600; color: var(--text-dim); margin-left: 2px; }
.temp-name { font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.temp-sub { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.temp-tile.ok { border-left-color: var(--green); } .temp-tile.ok .temp-val { color: var(--green); }
.temp-tile.warn { border-left-color: var(--amber); } .temp-tile.warn .temp-val { color: var(--amber); }
.temp-tile.crit { border-left-color: var(--red); } .temp-tile.crit .temp-val { color: var(--red); }
.temp-tile.unknown { opacity: .8; }

/* ─── Sources editor (multi host+item picker in config popovers) ──────────── */
.cfg-sources { display: flex; flex-direction: column; gap: 6px; }
.cfg-source { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 8px 7px 6px;
  border-radius: var(--radius-xs); background: var(--layer-1); border: 1px solid var(--glass-border); }
.cfg-source .src-host, .cfg-source .src-oid, .cfg-source .src-label { grid-column: 1 / -1; }
.cfg-source select, .cfg-source input { width: 100%; padding: 5px 7px; font-size: 12px; }
.cfg-source .src-del { position: absolute; top: 2px; right: 3px; width: auto; padding: 2px;
  background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 13px; line-height: 1; }
.cfg-source .src-del:hover { color: var(--red); }
.cfg-thresh { flex-direction: row; gap: 8px; }
.cfg-thresh label { display: flex; flex-direction: column; gap: 3px; flex: 1; }
.btn.xs { font-size: 11px; padding: 5px 10px; align-self: flex-start; }

/* ─── Resize/move size badge ─────────────────────────────────────────────── */
.dn-sizetag { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  z-index: 30; display: none; pointer-events: none;
  padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700;
  background: var(--accent); color: #fff; box-shadow: 0 2px 10px rgba(0,0,0,.45); }
/* Bigger, friendlier resize grips (all four corners). */
.dn-resize { width: 18px; height: 18px; }
.dn-resize.nw, .dn-resize.ne { top: -9px; }
.dn-resize.sw, .dn-resize.se { bottom: -9px; }
.dn-resize.nw, .dn-resize.sw { left: -9px; }
.dn-resize.ne, .dn-resize.se { right: -9px; }

/* ─── Library preview clamping (keep every preview inside its 56px box) ───── */
.wl-preview .gauge-svg { min-height: 0; height: 100%; }
.wl-preview .gauge-grid { width: 100%; }
.wl-preview .temp-grid { gap: 4px; grid-template-columns: repeat(2, 1fr); }
.wl-preview .temp-tile { padding: 3px 5px; box-shadow: none; gap: 0; }
.wl-preview .temp-val { font-size: 11px; }
.wl-preview .temp-unit, .wl-preview .temp-sub { display: none; }
.wl-preview .temp-name { font-size: 8px; }
.wl-preview .ph-spark { height: 100%; }
.wl-preview .ph-rows { width: 100%; }
/* Placeholders inside a live node while editing also stay clipped. */
.dash-node-body .temp-grid, .dash-node-body .gauge-grid { max-height: 100%; }

/* Compact "Add node" dropdown menu (flow builder). */
.node-menu { position: relative; display: inline-block; }
.node-menu-pop {
  position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; min-width: 220px;
  padding: 8px; border-radius: var(--radius-sm); display: none; flex-direction: column; gap: 2px;
  background: var(--bg-elev); border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
}
.node-menu-pop::before { display: none; }
.node-menu-pop.open { display: flex; }
.node-menu-pop button {
  display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
  background: transparent; border: none; color: var(--text); cursor: pointer;
  padding: 9px 11px; border-radius: var(--radius-xs); font-size: 13px; font-family: inherit;
}
.node-menu-pop button:hover { background: var(--layer-3); }
.nm-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; background: var(--accent); }
.nm-dot.condition { background: var(--amber); }
.nm-dot.severity { background: var(--red, #e06c75); }
.nm-dot.wait { background: var(--cyan, #56b6c2); }
.nm-dot.ack { background: var(--purple, #c678dd); }
.nm-dot.action { background: var(--green); }
.nm-dot.clear { background: var(--cyan, #56b6c2); }
.nm-dot.end { background: var(--muted, #7f848e); }
.flow-node-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 9px 12px; cursor: grab; font-weight: 700; font-size: 13px;
  border-bottom: 1px solid var(--glass-border); border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.flow-node-head:active { cursor: grabbing; }
.flow-del { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 17px; line-height: 1; }
.flow-del:hover { color: var(--red); }
/* Roomier now the cards are wider: the fields inside a node were sized for a
   210px column and read as a cramped form rather than a step of a flow. */
/* No max-height/overflow here on purpose: the object-search dropdowns are
   absolutely positioned inside a node, and a scrolling body would clip them. A
   tall node is fine — the canvas is what scrolls. */
.flow-node-body { padding: 12px 14px; }
.flow-node-desc { font-size: 11px; line-height: 1.4; margin-bottom: 10px; opacity: .8; }
.flow-check { display: flex; align-items: center; gap: 7px; font-size: 12px; cursor: pointer; }
.flow-check input { width: auto; }
.flow-hint { font-size: 11px; line-height: 1.45; margin-top: -2px; }
/* A hint about a configuration that will misbehave rather than merely one worth
   reading — e.g. a Trackr action that would open a ticket per firing event. */
.flow-hint.warn { color: var(--amber); opacity: 1; }
/* Not an explanation but a *readback*: what this node will actually put in a
   subject line, with any {vb:…} token shown as the object it quotes. It stays on
   the card rather than folding away with the prose, because a label holding a
   token reads as a bare OID in its own box and this is the line that says what
   it means. */
.flow-hint.preview {
  color: var(--text-dim); margin-top: 4px; padding: 5px 8px;
  border-radius: var(--radius-sm); background: var(--layer-1);
  box-shadow: inset 0 0 0 1px var(--glass-border);
}
.flow-hint.preview strong { color: var(--heading); font-weight: 600; }
/* The folded explanations (see foldHints in flow.js). Closed, a note is one
   muted line; open, it is the paragraphs that were always there. Sized and
   coloured like the hints it holds so opening one does not shift the card. */
.flow-note { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--border); }
.flow-note > summary {
  font-size: 11px; line-height: 1.45; color: var(--text-dim);
  cursor: pointer; user-select: none;
}
.flow-note > summary:hover { color: var(--text); }
.flow-note .flow-hint { margin-top: 4px; }
/* Which field a folded paragraph was written under. Moving the prose to the
   foot of the card is what makes the card readable; this is what stops that
   costing the reader the thing the prose is about. */
.flow-note-for {
  font-size: 10px; letter-spacing: .04em; text-transform: uppercase;
  color: var(--text-dim); opacity: .75; margin-top: 10px;
}
.flow-note-for:first-of-type { margin-top: 6px; }

/* Right-click "add node" context menu reuses the add-node popup styling. */
.flow-ctx-menu { position: absolute; }
.flow-field { margin-bottom: 11px; }
.flow-field:last-child { margin-bottom: 0; }
.flow-field label { font-size: 11px; margin-bottom: 5px; }
.flow-field select, .flow-field input { padding: 8px 11px; font-size: 13px; }
.flow-port {
  position: absolute; top: 14px; width: 14px; height: 14px; border-radius: 50%;
  background: var(--bg-elev); border: 1px solid var(--text-dim); cursor: pointer; z-index: 3;
  transition: border-color .12s ease, box-shadow .12s ease;
}
.flow-port.in { left: -8px; }
.flow-port.out { right: -8px; }
.flow-port:hover { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.flow-port.active { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }

/* Searchable object autofill (object-state trigger). */
.flow-ac { position: relative; }
.flow-ac-list {
  position: absolute; z-index: 40; top: calc(100% + 3px); left: 0; right: 0;
  max-height: 190px; overflow-y: auto; padding: 4px;
  border-radius: var(--radius-sm); background: var(--bg-elev);
  border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
}
.flow-ac-item {
  display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
  padding: 6px 8px; border-radius: 7px; cursor: pointer; font-size: 12px;
}
.flow-ac-item:hover { background: var(--accent-soft); color: var(--heading); }
.flow-ac-item .mono { font-size: 10px; }
/* A reading quoted inside a state label. Shown as the object it names rather
   than as the OID that is stored, because a preview with a raw OID in it is the
   one thing this preview exists to prevent. */
.flow-vb-token {
  font-style: normal; padding: 0 3px; border-radius: 4px;
  background: var(--accent-soft); color: var(--accent);
}
.flow-ac-more { padding: 6px 8px; font-size: 10px; }

/* Picking a trap this server has actually received, on a flow node and in the
   wizard (see recentTrapPicker in flow.js and wizRecent in flow-wizard.js).
   Folded away like the explanations are, because it is a way *in* to the fields
   above it rather than a field of its own — a card that led with it would read
   as though the trap list were the thing being configured. */
.flow-recent > summary, .wiz-recent > summary { color: var(--accent); opacity: .85; }
.flow-recent > summary:hover, .wiz-recent > summary:hover { opacity: 1; }
.flow-recent .flow-ac, .wiz-recent .flow-ac { margin-top: 8px; }
/* The field under the picker is a field of its own, not the last line of it. */
.flow-recent { margin-bottom: 14px; padding-bottom: 2px; }
/* Which arrival is being read: its name, where it came from, and what it said. */
.flow-recent-chosen {
  margin-top: 8px; padding: 7px 9px; font-size: 11px; line-height: 1.5;
  border-radius: var(--radius-sm); background: var(--bg-elev);
  border: 1px solid var(--border);
}
.flow-recent-chosen .dim { font-size: 10px; }
/* The readings it carried, each one a click away from becoming the test. Laid
   out as chips rather than rows: a trap with a dozen varbinds is common, and a
   dozen full-width rows buries the fields the click is filling in. */
.flow-recent-vbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.flow-recent-vb {
  /* Grows to fill the row rather than hugging its text: a reading is named by
     its object where the MIB is imported and by a bare OID where it is not, and
     the second is wider than the card. Left to hug, the short ones sit two to a
     row and the long ones overflow; allowed to grow, both read as one list with
     the value at the right. */
  display: inline-flex; flex: 1 1 auto; justify-content: space-between;
  align-items: baseline; gap: 10px;
  padding: 5px 8px; font-size: 11px; line-height: 1.3; cursor: pointer;
  border-radius: 7px; border: 1px solid var(--border);
  background: var(--bg-elev); color: var(--text);
  transition: border-color .12s ease, background .12s ease, color .12s ease;
}
.flow-recent-vb:hover { border-color: var(--accent); color: var(--heading); }
.flow-recent-vb .mono { font-size: 10px; color: var(--text-dim); }
.flow-recent-vb.on {
  border-color: var(--accent); background: var(--accent-soft); color: var(--heading);
}
.flow-recent-vb.on .mono { color: var(--heading); }

/* Dependency gate node. */
.flow-node.dependency .flow-node-head { color: var(--cyan, #c678dd); }
.nm-dot.dependency { background: var(--cyan, #c678dd); }

/* ─── Alarms in progress ─────────────────────────────────── */
/* Stage summary: a count per stage, each one a filter on the table below. */
.stage-summary { display: flex; gap: 10px; flex-wrap: wrap; }
.stage-tile {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  min-width: 96px; padding: 10px 14px; cursor: pointer; text-align: left;
  border-radius: var(--radius-sm); background: var(--layer-1);
  border: 1px solid var(--glass-border); color: var(--text-dim);
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.stage-tile:hover { background: var(--layer-3); color: var(--text); }
.stage-tile.active { color: var(--heading); background: var(--layer-3); border-color: var(--text-dim); }
.stage-tile-n { font-size: 22px; font-weight: 700; line-height: 1.1; color: var(--heading); }
.stage-tile-l { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }

/* The stage chip and the timeline dots share one palette, so a stage looks the
   same wherever it is named. Colours carry meaning here: amber for anything the
   alarm is *sitting in* waiting on a clock, cyan for waiting on the device,
   green for an ending, red for a state that means something went wrong. */
.stage-chip { font-size: 10px; letter-spacing: .03em; }
.stage-raised, .alarm-step.stage-raised .alarm-step-dot { --stage-c: var(--red, #e06c75); }
.stage-repeat, .alarm-step.stage-repeat .alarm-step-dot { --stage-c: var(--red, #e06c75); }
.stage-waiting, .alarm-step.stage-waiting .alarm-step-dot { --stage-c: var(--amber); }
.stage-resumed, .alarm-step.stage-resumed .alarm-step-dot { --stage-c: var(--accent); }
.stage-stopped, .alarm-step.stage-stopped .alarm-step-dot { --stage-c: var(--muted, #7f848e); }
/* Blocked shares amber with the other "something is holding this up" stages;
   Finished shares grey with Stopped, because a branch running out of nodes is
   not an outcome to celebrate or to worry about — it is just where it ended. */
.stage-blocked, .alarm-step.stage-blocked .alarm-step-dot { --stage-c: var(--amber); }
.stage-ended, .alarm-step.stage-ended .alarm-step-dot { --stage-c: var(--muted, #7f848e); }
.stage-awaiting_clear, .alarm-step.stage-awaiting_clear .alarm-step-dot { --stage-c: var(--cyan, #56b6c2); }
.stage-clear_held, .alarm-step.stage-clear_held .alarm-step-dot { --stage-c: var(--amber); }
.stage-clear_dropped, .alarm-step.stage-clear_dropped .alarm-step-dot { --stage-c: var(--red, #e06c75); }
.stage-acknowledged, .alarm-step.stage-acknowledged .alarm-step-dot { --stage-c: var(--purple, #c678dd); }
.stage-ack_skipped, .alarm-step.stage-ack_skipped .alarm-step-dot { --stage-c: var(--amber); }
/* A relay step. Amber for the one that acted on the world, red when it could
   not — a beacon that failed to come on is a fault of its own. */
.stage-relay_set, .alarm-step.stage-relay_set .alarm-step-dot { --stage-c: var(--amber); }
.stage-relay_failed, .alarm-step.stage-relay_failed .alarm-step-dot { --stage-c: var(--red, #e06c75); }
.stage-resolved, .alarm-step.stage-resolved .alarm-step-dot { --stage-c: var(--green); }
.badge.stage-chip {
  color: var(--stage-c, var(--text-dim));
  background: color-mix(in srgb, var(--stage-c, var(--text-dim)) 14%, transparent);
  border: 1px solid color-mix(in srgb, var(--stage-c, var(--text-dim)) 36%, transparent);
}
.stage-tile.stage-waiting .stage-tile-n,
.stage-tile.stage-clear_held .stage-tile-n,
.stage-tile.stage-awaiting_clear .stage-tile-n,
.stage-tile.stage-acknowledged .stage-tile-n,
.stage-tile.stage-raised .stage-tile-n { color: var(--stage-c, var(--heading)); }

.alarm-table td { vertical-align: top; }
/* The stage sentence is the column's explanation, not its headline — capped so
   a long one cannot push the row's actual data off the side of the table. */
.alarm-detail { font-size: 11px; line-height: 1.4; margin-top: 3px; max-width: 340px; }
.alarm-row[hidden] { display: none; }
.alarm-timeline-row[hidden] { display: none; }
.alarm-timeline-wrap { padding: 4px 2px 10px; }

/* The step trail: a rail with a dot per step, and an outlined card beside each.
   The outline is the stage colour, which is what makes a long trail scannable —
   the eye finds the one red edge without reading a word of it. */
.alarm-trail-head {
  display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  font-size: 11px; color: var(--text-dim); padding: 2px 0 8px 6px;
}
.alarm-trail-head .mono { color: var(--text); }
.alarm-timeline { list-style: none; margin: 0; padding: 4px 0 0 6px; }
.alarm-step { position: relative; display: flex; gap: 12px; padding: 0 0 10px 0; }
.alarm-step::before {
  content: ''; position: absolute; left: 5px; top: 14px; bottom: 0; width: 1px;
  background: var(--glass-border);
}
.alarm-step:last-child::before { display: none; }
.alarm-step-dot {
  flex: 0 0 auto; width: 11px; height: 11px; margin-top: 9px; border-radius: 50%;
  background: var(--stage-c, var(--text-dim));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--stage-c, var(--text-dim)) 18%, transparent);
  position: relative; z-index: 1;
}
.alarm-step-card {
  flex: 1; min-width: 0; padding: 7px 10px 8px;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in srgb, var(--stage-c, var(--text-dim)) 28%, transparent);
  /* The left edge is the loud one: stacked up the trail it reads as a column of
     stage colours, which is the summary somebody wants before the sentences. */
  border-left: 3px solid var(--stage-c, var(--text-dim));
  background: color-mix(in srgb, var(--stage-c, var(--text-dim)) 5%, var(--layer-1));
}
.alarm-step-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; }
/* Which card on the builder canvas this happened at. Set in the stage colour so
   it reads as part of the chip rather than as another piece of grey metadata. */
.alarm-step-node {
  font-size: 10px; letter-spacing: .03em; text-transform: uppercase;
  color: color-mix(in srgb, var(--stage-c, var(--text-dim)) 75%, var(--text));
}
/* Times sit at the right-hand end and drop under the chip on a narrow screen
   rather than pushing the stage out of view. */
.alarm-step-when {
  margin-left: auto; display: flex; align-items: center; gap: 8px;
  flex-wrap: wrap; color: var(--text-dim);
}
.alarm-step-gap { color: var(--text); }
.alarm-step-detail { font-size: 12px; line-height: 1.5; margin-top: 4px; color: var(--text); }
.alarm-step-due { font-size: 11px; margin-top: 3px; }

/* ─── Alert simulator ────────────────────────────────────── */
.sim-summary { padding: 10px 14px; border-radius: var(--radius-sm); margin-bottom: 12px; font-size: 13px; }
.sim-flow {
  padding: 12px 14px; border-radius: var(--radius-sm); margin-bottom: 10px;
  background: var(--layer-1); border: 1px solid var(--glass-border);
}
.sim-flow-head { font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.sim-step { padding: 7px 0; border-top: 1px solid var(--hairline); }
.sim-step:first-of-type { border-top: none; }
.sim-recips { list-style: none; padding: 6px 0 0 4px; margin: 0; font-size: 12px; line-height: 1.7; }
.sim-recips .ok { color: var(--green); }

/* ─── Live pages ─────────────────────────────────────────── */
/* The badge that replaced a Refresh button. It has to say two things: that the
   page keeps itself current, and that it just did — the second is what stops
   "live" reading as a claim nobody can check. */
.live-pill {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px; border-radius: 999px; font-size: 11px; letter-spacing: .03em;
  color: var(--text-dim); background: var(--layer-1); border: 1px solid var(--glass-border);
}
.live-pill .live-dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--green, #4caf50);
  box-shadow: 0 0 0 0 var(--green, #4caf50);
}
.live-pill.beat { color: var(--heading); border-color: var(--green, #4caf50); }
.live-pill.beat .live-dot { animation: live-beat .9s ease-out; }
@keyframes live-beat {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green, #4caf50) 70%, transparent); }
  100% { box-shadow: 0 0 0 8px transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .live-pill.beat .live-dot { animation: none; }
}

/* ─── Two traps make an alarm (traps page) ───────────────── */
/* A sticky bar rather than a dialog: the choice is made by reading the table,
   so the table has to stay readable while it is being made. */
.trap-pick-bar {
  position: sticky; bottom: 12px; z-index: 30;
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin-top: 14px; padding: 12px 16px; border-radius: var(--radius-sm);
  background: var(--bg-elev); border: 1px solid var(--glass-border);
  box-shadow: var(--elev-pop);
}
/* An author's `display` beats the UA sheet's rule for [hidden], so a flex
   container that is hidden by attribute has to say so itself. */
.trap-pick-bar[hidden] { display: none; }
.trap-pick-text { flex: 1; min-width: 220px; font-size: 13px; line-height: 1.6; }

/* ─── Flow tester ────────────────────────────────────────── */
/* Only one event panel is ever shown; the rest are hidden outright rather than
   collapsed, so a hidden panel's fields never reach the request. */
.test-panel { margin-top: 12px; }
.test-chosen { font-size: 12px; margin: -4px 0 10px; }
.test-vb-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.test-vb-row input:first-child { flex: 2; min-width: 0; }
.test-vb-row input + input { flex: 1; min-width: 0; }
/* The tester's search boxes drop their suggestions over whatever is beneath. */
#test-card .flow-ac-list { max-height: 260px; }

/* ─── New-flow wizard ────────────────────────────────────── */
/* One question a screen. The choices are buttons rather than radios because
   the label and its explanation are both part of the target — reading the
   explanation and then hunting for a 13px circle is the interaction this
   replaces. */
.wiz-progress { display: flex; align-items: center; gap: 5px; font-size: 11px; margin-bottom: 10px; }
.wiz-dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--layer-3);
  border: 1px solid var(--glass-border);
}
.wiz-dot.done { background: var(--text-dim); }
.wiz-dot.on { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.wiz-choice {
  display: block; width: 100%; text-align: left; cursor: pointer;
  padding: 11px 14px; margin-bottom: 8px; border-radius: var(--radius-sm);
  background: var(--layer-1); border: 1px solid var(--glass-border); color: var(--text);
  transition: background .15s ease, border-color .15s ease;
}
.wiz-choice:hover { background: var(--layer-3); }
.wiz-choice.on { border-color: var(--accent); background: var(--accent-soft); }
.wiz-choice-label { display: block; font-weight: 600; color: var(--heading); }
.wiz-choice-help { display: block; font-size: 12px; line-height: 1.5; color: var(--text-dim); margin-top: 3px; }
/* A question whose answer is a list: the box in front says "and this as well"
   before the card is read, which is the whole difference from a choice. */
.wiz-choice.multi { position: relative; padding-left: 40px; }
.wiz-choice-tick {
  position: absolute; left: 14px; top: 11px;
  font-size: 15px; line-height: 1.35; color: var(--text-dim);
}
.wiz-choice.multi.on .wiz-choice-tick { color: var(--accent); }
/* A second question inside one step — "and how does it say so?" — needs to
   read as its own question rather than as three more answers to the first. */
.wiz-subhead {
  font-size: 11px; letter-spacing: .04em; text-transform: uppercase;
  color: var(--text-dim); margin: 16px 0 8px;
}
.wiz-row { display: flex; gap: 12px; flex-wrap: wrap; }
.wiz-row > .flow-field { flex: 1; min-width: 190px; }
/* The read-back before it is built: the whole flow as a sentence per step. */
.wiz-summary { padding: 12px 14px; border-radius: var(--radius-sm); margin: 12px 0; font-size: 13px; }
.wiz-summary-line { padding: 3px 0; }
.wiz-summary-line strong { color: var(--heading); }
#wiz-body .flow-ac-list { max-height: 220px; }

/* Email template editor: body + live preview side by side. */
.tpl-split { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.tpl-split textarea { font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.5; min-height: 320px; resize: vertical; }
.tpl-preview-wrap {
  border-radius: var(--radius-sm); overflow: hidden; height: 100%; min-height: 320px;
  border: 1px solid var(--glass-border);
}
.tpl-preview-wrap iframe { width: 100%; height: 100%; min-height: 320px; border: 0; background: #fff; display: block; }
@media (max-width: 760px) { .tpl-split { grid-template-columns: 1fr; } }

/* ─── Modal ──────────────────────────────────────────────── */
.modal-overlay {
  position: fixed; inset: 0; z-index: 100;
  display: none; align-items: flex-start; justify-content: center;
  background: rgba(6, 8, 13, .6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  padding: 48px 20px; overflow-y: auto;
}
/* Open/close motion for these lives in the Motion section at the end. */
.modal-overlay.open { display: flex; }
.modal {
  width: 100%; max-width: 560px; padding: 24px 26px;
  max-height: calc(100vh - 96px); overflow-y: auto;
}
.modal.wide { max-width: 760px; }
.modal-head {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 18px; gap: 14px;
}
.modal-head h2, .modal-head h3 { margin: 0; }
.modal-close {
  background: none; border: none; color: var(--text-dim); cursor: pointer;
  font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 8px;
}
.modal-close:hover { color: var(--text); background: var(--layer-1); }
.modal pre {
  white-space: pre-wrap; word-break: break-word; margin: 0;
  padding: 16px; border-radius: var(--radius-sm); color: var(--text);
  background: var(--layer-1); border: 1px solid var(--glass-border);
  max-height: 60vh; overflow: auto;
}

/* ─── Selection action bar (hosts) ───────────────────────── */
.action-bar {
  position: fixed; bottom: 26px; left: 50%; transform: translate(-50%, 24px);
  display: none; align-items: center; gap: 14px; z-index: 70;
  padding: 12px 20px; border-radius: var(--radius);
  opacity: 0; transition: opacity .2s ease, transform .2s ease;
}
.action-bar.show { display: flex; opacity: 1; transform: translate(-50%, 0); }
.action-bar #sel-count { font-weight: 700; color: var(--heading); }

/* Mass-edit modal rows: toggle on the left, control on the right. */
.mass-field {
  display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: center;
  padding: 10px 0; border-bottom: 1px solid var(--hairline);
}
.mass-field:last-of-type { border-bottom: none; }
.mass-field > .switch { margin-bottom: 0; }

/* Keep host action buttons aligned across rows. */
.hosts-table td .btn-row { flex-wrap: nowrap; justify-content: flex-start; }

/* ─── Group dropdowns (hosts tab) ────────────────────────── */
details.group-dd { padding: 6px; margin-bottom: 20px; }
details.group-dd > summary {
  list-style: none; cursor: pointer; display: flex; align-items: center; gap: 14px;
  padding: 16px 20px; border-radius: var(--radius); font-weight: 700; color: var(--heading);
  transition: box-shadow .18s ease;
}
details.group-dd > summary::-webkit-details-marker { display: none; }
details.group-dd > summary:hover { background: var(--layer-1); }
.group-dd .g-name { font-size: 15px; }
.group-dd .g-count { color: var(--text-dim); font-size: 12px; font-weight: 500; }
.group-dd .chev { margin-left: auto; color: var(--text-dim); transition: transform .25s ease; }
details.group-dd[open] > summary .chev { transform: rotate(180deg); }
.group-body { padding: 6px 12px 12px; animation: fadeIn .3s ease; }

/* ─── Floating save (settings) ───────────────────────────── */
.floating-save { position: fixed; bottom: 28px; right: 28px; z-index: 60; animation: fadeInUp .4s ease both; }
/* Only the size and the lift here — no background. These buttons are usually
   `.btn.accent`, and overriding the fill left white label text on a white
   surface in the light theme. Whatever variant the button carries supplies
   its own colours. */
.floating-save .btn {
  padding: 14px 26px; font-size: 14px;
  box-shadow: var(--elev-pop);
}
.floating-save .btn:not(.accent):not(.danger):not(.green) { background: var(--bg-elev); }
/* Unsaved changes: the button fills in and pulses, so "there is something to
   save" is visible from the colour rather than only from the hint text. */
.floating-save.dirty .btn {
  background: var(--accent); border-color: transparent; color: #fff;
  animation: savePulse 1.8s ease-in-out infinite;
}
.floating-save .hint { display:block; text-align:right; font-size:11px; color:var(--text-dim); margin-bottom:8px; opacity:0; transition:opacity .2s; }
.floating-save.dirty .hint { opacity: 1; }
@media (max-width: 700px){ .floating-save { bottom: 16px; right: 16px; } }

/* ─── Animations ─────────────────────────────────────────── */
@keyframes fadeInUp { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes savePulse {
  0%, 100% { box-shadow: var(--elev-pop); }
  50% { box-shadow: var(--elev-pop), 0 0 0 4px var(--accent-glow); }
}
@keyframes dotPulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes barRise { from { transform: scaleY(0); } to { transform: scaleY(1); } }

/* ====================================================================
   Motion
   --------------------------------------------------------------------
   Two easing curves do all the work, so movement feels like one system
   rather than a pile of one-off effects:

     --ease-out    things arriving: fast start, soft landing
     --ease-spring things responding to a click: slight overshoot

   Durations stay in the 120–450ms band. Anything an operator sees on
   every page load sits at the short end — this is a console people keep
   open all day, and an animation that is charming once is an obstacle
   the two-hundredth time. Entrances animate opacity/transform only, both
   of which the compositor can handle without re-laying-out the page (a
   real concern on host tables running to several hundred rows).
   ==================================================================== */
:root {
  --ease-out: cubic-bezier(.2, .7, .3, 1);
  --ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}

html { scroll-behavior: smooth; }

/* ─── Page entrance ──────────────────────────────────────────
   Previously only `.grid` children animated, so the dashboard eased in
   while every other page (alerts, hosts, settings — all plain `.card`
   stacks) simply appeared. Cover both, and stagger the first few so the
   page assembles top-down instead of flashing in as one block. */
.fade-in { animation: fadeInUp .45s var(--ease-out) both; }
.app-main > .page-head { animation: fadeInUp .34s var(--ease-out) both; }
.app-main > .card,
.app-main > .grid > .neu { animation: fadeInUp .42s var(--ease-out) both; }
.app-main > .card:nth-of-type(1),
.app-main > .grid > .neu:nth-child(1) { animation-delay: .04s; }
.app-main > .card:nth-of-type(2),
.app-main > .grid > .neu:nth-child(2) { animation-delay: .08s; }
.app-main > .card:nth-of-type(3),
.app-main > .grid > .neu:nth-child(3) { animation-delay: .12s; }
.app-main > .card:nth-of-type(4),
.app-main > .grid > .neu:nth-child(4) { animation-delay: .16s; }
/* Cards past the fourth are usually below the fold — fade them in with no
   extra delay so scrolling never outruns the stagger. */
.app-main > .card:nth-of-type(n+5),
.app-main > .grid > .neu:nth-child(n+5) { animation-delay: .18s; }

/* ─── Cards & stats ──────────────────────────────────────────
   The lift is deliberately small: these are dense data cards, and a card
   that jumps under the cursor makes the table inside it harder to read. On
   glass the lift alone is nearly invisible, so the edge brightens and the
   accent glows faintly underneath — the same cue Trackr's .glass-hover uses. */
.stat, .card { transition: box-shadow .22s ease, border-color .22s ease, transform .22s var(--ease-out); }
.stat:hover, .card:hover {
  transform: translateY(-2px);
  border-color: var(--text-dim);
  box-shadow: var(--elev-card), 0 0 24px var(--accent-glow);
}

/* ─── Sidebar nav ────────────────────────────────────────────
   The active row is marked by a bar growing on its left edge rather than by
   the row moving. Hover is a background change only: nudging a full-width
   row sideways counts as horizontal overflow and pops a scrollbar along the
   bottom of the nav every time the pointer crosses a link. */
.nav-item::before {
  content: ""; position: absolute; left: 0; top: 50%;
  width: 3px; height: 0; border-radius: 0 3px 3px 0; background: var(--accent);
  transform: translateY(-50%);
  box-shadow: 0 0 12px var(--accent-glow);
  transition: height .22s var(--ease-out);
}
.nav-item.active::before { height: 55%; }
.nav-item .ic { transition: transform .22s var(--ease-spring); }
.nav-item:hover .ic { transform: scale(1.14); }

/* ─── Buttons, inputs, toggles ───────────────────────────────*/
.btn:active { transform: scale(.98); }
.btn[disabled] { opacity: .55; transform: none; cursor: not-allowed; }
.switch .track::after { transition: transform .22s var(--ease-spring), background .2s; }

/* ─── Tables ─────────────────────────────────────────────────
   Rows are not staggered on purpose: a host table can run to hundreds of
   rows, and animating each one turns a page load into a long shimmer. */
td { transition: background .14s ease; }
tbody tr:hover td { background: var(--layer-1); }

/* ─── Modals ─────────────────────────────────────────────────
   Closing used to be instant — the modal vanished mid-gesture while the
   backdrop was still fading, which read as a glitch. `.closing` (added by
   closeModal in app.js) plays the entrance in reverse, and the element is
   only removed once it finishes. */
.modal-overlay.open { display: flex; animation: fadeIn .18s ease; }
.modal { animation: modalIn .26s var(--ease-spring) both; }
.modal-overlay.open.closing { animation: fadeOut .16s ease both; }
.modal-overlay.open.closing .modal { animation: modalOut .16s ease-in both; }
@keyframes modalIn  { from { opacity: 0; transform: translateY(16px) scale(.97); }
                      to   { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes modalOut { from { opacity: 1; transform: translateY(0) scale(1); }
                      to   { opacity: 0; transform: translateY(8px) scale(.98); } }
@keyframes fadeOut  { to { opacity: 0; } }

/* ─── Toast ──────────────────────────────────────────────────*/
.toast {
  transition: opacity .2s ease, transform .28s var(--ease-spring);
  transform: translateY(14px) scale(.96);
}
.toast.show { transform: translateY(0) scale(1); }

/* ─── Live status ────────────────────────────────────────────*/
.dot.up { animation: dotPulse 2.4s ease-in-out infinite; }
.dot.down { animation: dotPulse 1.2s ease-in-out infinite; }
.spark .bar { transform-origin: bottom; animation: barRise .5s var(--ease-out) both; }

/* ====================================================================
   Phones (≤ 768px)
   --------------------------------------------------------------------
   The rail goes away entirely and its job is split: the four destinations
   people actually reach for become a tab bar along the bottom, and the rest
   of the app lives one tap behind "More". That is Trackr's arrangement, and
   it beats the hamburger drawer this app used to have for the reason every
   phone OS settled on the same shape — the bottom of the screen is where a
   thumb already is, and the top-left corner is the furthest point from it.

   Everything after the navigation here is touch ergonomics: 16px inputs so
   iOS stops zooming the page on focus, full-width modals, and safe-area
   insets so nothing hides behind a notch or a home indicator once the app
   is installed as a PWA.
   ==================================================================== */
@media (max-width: 768px) {
  .app-sidebar { display: none; }

  .app-main {
    margin-left: 0;
    width: 100%;
    max-width: none;
    /* Respect the safe area on all four sides — the notch/status bar at the
       top, the home indicator *and the tab bar* at the bottom, and the curved
       corners when the phone is held in landscape. */
    padding:
      calc(16px + env(safe-area-inset-top))
      calc(14px + env(safe-area-inset-right))
      calc(var(--bottomnav-height) + 24px + env(safe-area-inset-bottom))
      calc(14px + env(safe-area-inset-left));
  }

  /* ── Tab bar ── */
  .bottom-nav {
    display: flex;
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
    /* box-sizing is border-box, so the safe-area inset has to be added to the
       height as well — otherwise the padding eats into the 64px and squashes
       the icons up against the home indicator. */
    height: calc(var(--bottomnav-height) + env(safe-area-inset-bottom));
    padding-bottom: env(safe-area-inset-bottom);
    padding-left: env(safe-area-inset-left);
    padding-right: env(safe-area-inset-right);
    background: rgba(11, 14, 21, .72);
    backdrop-filter: var(--blur-heavy);
    -webkit-backdrop-filter: var(--blur-heavy);
    border-top: 1px solid var(--glass-border);
  }
  .bottom-nav-item {
    flex: 1; min-width: 0; min-height: 44px;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
    color: var(--text-dim); font: inherit; font-size: 10.5px;
    background: transparent; border: none; cursor: pointer; text-decoration: none;
  }
  .bottom-nav-item .ic { font-size: 18px; line-height: 1; }
  .bottom-nav-item span:last-child {
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .bottom-nav-item.active { color: var(--accent); }
  .bottom-nav-item:active { background: var(--layer-1); }

  /* ── "More" sheet ──
     Slid off the bottom rather than removed, so it animates in; `visibility`
     rides along so a closed sheet is not reachable by tab or screen reader. */
  .sheet {
    display: block;
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 120;
    max-height: 82dvh; overflow-y: auto;
    padding: 6px 12px calc(14px + env(safe-area-inset-bottom));
    background: var(--bg-elev);
    border-top: 1px solid var(--glass-border);
    border-radius: var(--radius) var(--radius) 0 0;
    box-shadow: var(--elev-pop);
    transform: translateY(102%);
    visibility: hidden;
    transition: transform .25s var(--ease-out), visibility .25s ease;
  }
  .sheet.open { transform: translateY(0); visibility: visible; }
  .sheet-grip {
    width: 38px; height: 4px; margin: 8px auto 6px;
    border-radius: 2px; background: var(--layer-3);
  }
  .sheet-body { display: flex; flex-direction: column; gap: 2px; }
  .sheet-divider { height: 1px; margin: 8px 6px; background: var(--glass-border); }
  .sheet-version { padding: 10px 14px 4px; font-size: 11px; color: var(--text-dim); }

  .sheet-backdrop {
    display: block; position: fixed; inset: 0; z-index: 110;
    background: rgba(6, 8, 13, .55);
    backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
    opacity: 0; visibility: hidden;
    transition: opacity .25s ease, visibility .25s ease;
  }
  .sheet-backdrop.show { opacity: 1; visibility: visible; }
  body.sheet-open { overflow: hidden; }

  /* The search panel is full-bleed on a phone: 460px of it would be the whole
     screen anyway, and the top bar has nothing else in it to sit beside. */
  .global-search { max-width: none; }
  .app-topbar { margin-bottom: 16px; }
  /* The "/" shortcut hint is advice for a keyboard nobody here has. */
  .search-kbd { display: none; }
  .search-bar input { padding-right: 14px; }

  /* ── Content ── */
  .page-head { margin-bottom: 18px; }
  .page-head h1 { font-size: 20px; }
  .grid { gap: 14px; }
  .cols-4 { grid-template-columns: repeat(2, 1fr); }
  .stat { padding: 16px 18px; }
  .stat .value { font-size: 26px; }
  .card { padding: 16px 16px; }

  /* iOS zooms the page when a focused control's font is under 16px. */
  input[type=text], input[type=password], input[type=number], input[type=email],
  input[type=search], input[type=time], select, textarea { font-size: 16px; }

  /* Comfortable touch targets. */
  .btn { padding: 12px 18px; min-height: 44px; }
  .btn.sm { min-height: 38px; }

  /* Momentum scrolling for wide tables. */
  .table-wrap { -webkit-overflow-scrolling: touch; }

  /* Modals: use the whole screen width. */
  .modal-overlay { padding: 18px 10px calc(18px + env(safe-area-inset-bottom)); }
  .modal { padding: 20px 16px; max-height: calc(100dvh - 60px); }

  /* Floating chrome clears the tab bar and the home indicator, and stays
     on-screen. The tab bar is 64px of permanently occupied space that the
     desktop layout knows nothing about, so everything anchored to the bottom
     of the viewport has to be lifted over it. */
  .toast {
    left: 14px; right: 14px;
    bottom: calc(var(--bottomnav-height) + 14px + env(safe-area-inset-bottom));
  }
  .action-bar {
    width: calc(100vw - 24px); flex-wrap: wrap; gap: 10px;
    padding: 10px 14px;
    bottom: calc(var(--bottomnav-height) + 12px + env(safe-area-inset-bottom));
  }
  .floating-save, .save-float {
    bottom: calc(var(--bottomnav-height) + 14px + env(safe-area-inset-bottom)); right: 14px;
  }

  /* Login card breathes on small screens. */
  .login-card { padding: 30px 22px; }
}

/* Installed (standalone) PWA: no browser chrome, so the app's own top bar
   carries the status-bar inset — already handled above via safe-area. Kill
   the overscroll rubber-banding that makes a standalone app feel like a
   web page. */
@media (display-mode: standalone) {
  body { overscroll-behavior-y: none; }
}

/* ====================================================================
   Reduced motion
   --------------------------------------------------------------------
   `animation: none` would leave any element whose *resting* state is set
   by an animation's `both` fill (the entrance rules above start at
   opacity 0) stuck invisible. Collapsing every animation and transition
   to ~0s instead runs them to their final frame instantly, so the page
   ends up in exactly the same state — just without the movement.
   ==================================================================== */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-delay: 0ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ====================================================================
   Customer radio-site tablet view
   ==================================================================== */
.site-login-body, .topology-body { min-height: 100dvh; background: var(--bg); color: var(--text); }
.site-login-wrap { min-height: 100dvh; display:grid; place-items:center; padding:28px; }
.site-login-card { width:min(520px, 100%); padding:42px; border-radius:28px; }
.site-login-mark { width:76px; height:76px; display:grid; place-items:center; font-size:36px;
  border-radius:24px; margin-bottom:22px; color:#fff;
  background:linear-gradient(135deg, var(--accent), var(--accent-dim));
  box-shadow:0 8px 28px var(--accent-glow); }
.site-eyebrow { color:var(--accent); font-weight:700; font-size:11px; letter-spacing:.12em; text-transform:uppercase; }
.site-login-card h1 { margin:5px 0 8px; font-size:34px; }
.site-pin-input { text-align:center; font:700 30px/1.2 monospace; letter-spacing:.32em; min-height:64px; }
.site-login-submit { width:100%; justify-content:center; min-height:52px; }
.site-admin-entry { margin-top:28px; padding-top:22px; border-top:1px solid var(--glass-border); }
.site-admin-entry summary { cursor:pointer; color:var(--accent); font-weight:700; }

.topology-body { overflow-x:hidden; }
.topology-header { min-height:86px; padding:18px 28px; display:flex; justify-content:space-between; align-items:center;
  gap:20px; background:var(--layer-1); backdrop-filter:var(--blur-heavy); -webkit-backdrop-filter:var(--blur-heavy);
  border-bottom:1px solid var(--glass-border); position:relative; z-index:5; }
.topology-header h1 { margin:4px 0 0; font-size:24px; }
.topology-actions { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:wrap; }
.global-alert-bar { min-height:62px; padding:11px 28px; display:flex; align-items:center; gap:13px; position:relative; z-index:4; }
.global-alert-bar.all-clear { background:rgba(70,211,154,.11); color:#7fe3bf; }
.global-alert-bar.has-alerts { background:rgba(255,184,77,.16); color:#ffd08a; }
.global-alert-bar.has-alerts.critical { background:rgba(255,95,109,.18); color:#ff9da6; }
.global-alert-icon { width:36px; height:36px; display:grid; place-items:center; flex:none; border-radius:50%; background:rgba(0,0,0,.18); font-weight:900; }
.global-alert-bar .alert-open { margin-left:auto; }
.topology-shell { padding:20px 24px 24px; height:calc(100dvh - 148px); display:grid; grid-template-columns:120px minmax(0,1fr); gap:18px; }
.topology-summary { display:flex; flex-direction:column; gap:12px; align-content:start; }
.topology-summary > div { flex:1; min-height:90px; display:flex; flex-direction:column; justify-content:center; align-items:center;
  border-radius:var(--radius); background:var(--surface); border:1px solid var(--glass-border);
  backdrop-filter:var(--blur-glass); -webkit-backdrop-filter:var(--blur-glass); box-shadow:var(--elev-card); }
.topology-summary span { color:var(--heading); font-size:29px; font-weight:800; }
.topology-summary small { color:var(--muted); text-align:center; }
.topology-map { overflow:auto; border-radius:24px; min-height:480px; }
.topology-canvas { width:100%; min-width:850px; height:100%; min-height:560px; position:relative;
  background-image:radial-gradient(circle, rgba(135,145,165,.15) 1px, transparent 1px); background-size:24px 24px; }
.topology-links { position:absolute; inset:0; width:100%; height:100%; overflow:visible; pointer-events:none; }
.topology-link { stroke:rgba(83,184,255,.56); stroke-width:3; stroke-dasharray:8 7; animation:siteLinkFlow 18s linear infinite; }
@keyframes siteLinkFlow { to { stroke-dashoffset:-200; } }
.topology-link-label { fill:var(--muted); font-size:11px; text-anchor:middle; paint-order:stroke; stroke:var(--bg); stroke-width:5px; }
.topology-site { position:absolute; transform:translate(-50%,-50%); width:180px; min-height:76px; padding:13px 15px;
  display:flex; align-items:center; gap:11px; text-align:left; color:var(--text); border:1px solid var(--glass-border);
  border-radius:var(--radius); cursor:pointer; background:var(--surface);
  backdrop-filter:var(--blur-glass); -webkit-backdrop-filter:var(--blur-glass);
  box-shadow:var(--elev-card); z-index:2; }
.topology-site:hover, .topology-site:focus-visible { transform:translate(-50%,-50%) scale(1.04); outline:2px solid var(--accent); }
.topology-site.status-critical { border-color:rgba(255,95,109,.7); box-shadow:0 0 0 4px rgba(255,95,109,.12), var(--elev-card); }
.topology-site.status-warning { border-color:rgba(255,184,77,.7); }
.topology-site.status-healthy .topology-site-icon { background:rgba(70,211,154,.13); }
.topology-site-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:13px; background:rgba(83,184,255,.13); font-size:21px; flex:none; }
.topology-site-copy { display:flex; flex-direction:column; min-width:0; }
.topology-site-copy strong { color:var(--heading); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.topology-site-copy small { color:var(--muted); margin-top:4px; }
.topology-site-alert { position:absolute; right:-8px; top:-8px; min-width:27px; height:27px; padding:0 7px; display:grid; place-items:center;
  color:white; background:#ff5f6d; border-radius:14px; font-weight:800; box-shadow:0 3px 8px rgba(0,0,0,.35); }
.site-detail-modal { width:min(720px, 96vw); }
.equipment-flow { display:flex; flex-direction:column; align-items:stretch; max-height:65vh; overflow:auto; padding:5px 8px 5px 3px; }
.equipment-card { border:1px solid var(--glass-border); border-left:4px solid var(--text-dim);
  padding:15px 17px; border-radius:var(--radius-sm); background:var(--layer-1); }
.equipment-card.has-critical { border-color:#ff5f6d; }.equipment-card.has-warning { border-color:#ffb84d; }
.equipment-connector { height:28px; display:grid; place-items:center; color:var(--accent); font-size:20px; }
.equipment-head { display:flex; justify-content:space-between; gap:12px; color:var(--heading); font-weight:750; font-size:17px; }
.equipment-state { color:var(--muted); font-size:12px; }.equipment-state.up { color:#46d39a; }.equipment-state.down { color:#ff5f6d; }
.equipment-meta { display:flex; gap:12px; flex-wrap:wrap; color:var(--muted); font-size:12px; margin-top:7px; }
.equipment-alerts { margin-top:12px; display:grid; gap:7px; }
.alert-drawer { position:fixed; right:0; top:0; bottom:0; width:min(520px, 92vw); background:var(--bg-elev);
  border-left:1px solid var(--glass-border); z-index:130;
  padding:24px; box-shadow:-15px 0 35px rgba(0,0,0,.38); transform:translateX(105%); transition:transform .25s ease; overflow:auto; }
.alert-drawer.open { transform:translateX(0); }
.alert-drawer-head { display:flex; justify-content:space-between; align-items:flex-start; position:sticky; top:-24px; padding:24px 0 14px; background:var(--bg-elev); z-index:2; }
.alert-drawer-head h2 { margin:4px 0 0; }.alert-list { display:grid; gap:10px; }
.site-alert-row { display:flex; gap:12px; padding:13px; border-radius:var(--radius-sm); background:var(--layer-1);
  border:1px solid var(--glass-border); border-left:3px solid var(--muted); }
.site-alert-row.sev-critical { border-color:#ff5f6d; }.site-alert-row.sev-warning { border-color:#ffb84d; }
.site-alert-sev { width:28px; height:28px; display:grid; place-items:center; border-radius:50%; background:rgba(255,95,109,.16); font-weight:900; color:#ff8792; flex:none; }
.site-alert-copy { display:flex; flex-direction:column; gap:3px; min-width:0; }.site-alert-copy small { color:var(--accent); }
.site-alert-copy p { margin:2px 0 0; color:var(--muted); font-size:12px; }
.site-backdrop { position:fixed; inset:0; z-index:125; background:rgba(5,7,12,.55); opacity:0; visibility:hidden; transition:.2s; }
.site-backdrop.show { opacity:1; visibility:visible; }

.site-manage-layout { display:grid; grid-template-columns:minmax(250px, .65fr) minmax(420px, 1.35fr); gap:18px; }
.site-customer-list { align-self:start; }.site-customer-row { display:flex; justify-content:space-between; gap:10px; align-items:center; padding:12px;
  border-radius:var(--radius-sm); color:var(--text); text-decoration:none; }
.site-customer-row:hover,.site-customer-row.active { background:var(--accent-soft); text-decoration:none; }
.site-customer-row span:first-child { display:flex; flex-direction:column; gap:3px; }.site-customer-row small { color:var(--muted); }
.site-edit-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(270px,1fr)); gap:14px; }
.site-edit-card { padding:16px; border-radius:var(--radius-sm); }
.site-divider { border:0; border-top:1px solid var(--glass-border); margin:24px 0; }.topology-link-list { display:grid; gap:8px; }
.topology-link-row { padding:10px 12px; border-radius:var(--radius-sm); }

@media (max-width:800px) {
  .topology-header { padding:14px 16px; align-items:flex-start; }.topology-header h1 { font-size:19px; }
  .global-alert-bar { padding:10px 15px; flex-wrap:wrap; }.global-alert-bar .alert-open { margin-left:0; }
  .topology-shell { height:auto; min-height:calc(100dvh - 160px); padding:14px; display:block; }
  .topology-summary { flex-direction:row; margin-bottom:14px; }.topology-summary > div { min-height:72px; }
  .topology-map { height:620px; }.site-manage-layout { grid-template-columns:1fr; }
}
@media (max-width:520px) { .site-login-wrap { padding:14px; }.site-login-card { padding:28px 21px; }
  .topology-actions .dim { display:none; }.topology-actions .btn { padding:8px 10px; min-height:38px; }
  .topology-summary small { font-size:10px; }.topology-summary span { font-size:22px; } }

/* ─── Page and tab transitions ────────────────────────────────
   Moving between sections used to look like what it is: a full document load,
   with the old page torn down before the new one paints. On a dark UI that
   reads as a flash even though the critical CSS already paints the background
   immediately, because *every* pixel changes at once with nothing in between.

   A view transition makes the browser hold a snapshot of the outgoing page and
   cross-fade it to the incoming one. `navigation: auto` opts the whole app in
   for same-origin document navigations — the sidebar links, and any form post
   that redirects — while dashboard.js drives the same machinery by hand for
   the tab switches it swaps in place. Browsers without the API simply navigate
   as before; nothing here is required for the page to work. */
@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .2s;
  animation-timing-function: ease;
}

/* The sidebar is deliberately *not* given a view-transition-name of its own.
   Naming it would snapshot it out of the page, which misbehaves at ≤700px where
   it is an off-canvas drawer sitting under a transform — and the one thing that
   does change across a navigation, the active nav highlight, cross-fades nicely
   as part of the root snapshot anyway. */

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* Settings tabs swap panels in place; fade the incoming one in so the change
   is legible as a change rather than an abrupt substitution. */
.settings-panel:not([hidden]) { animation: panel-in .18s ease-out both; }
@keyframes panel-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}

.std-mib-grid { display: grid; gap: 4px 14px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }


/* ── Site view: freshness, map controls, alert filtering ───────────────────
   Added with the site-view rework. The rules below either replace behaviour
   that was previously implicit (the canvas is now inside a scrolling viewport
   it can be panned in) or are new controls the renderer drives. */

.topology-title { min-width: 0; }
.topology-header h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Freshness. Three states, and they are meant to be readable from across a
   room: calm green, amber while retrying, red once the data should not be
   trusted at all. */
.topology-freshness { display:flex; align-items:center; gap:8px; padding:7px 13px; border-radius:999px;
  background:var(--layer-1); border:1px solid var(--glass-border); color:var(--muted); font-size:12px; font-weight:600; }
.freshness-dot { width:9px; height:9px; border-radius:50%; background:#46d39a; flex:none;
  box-shadow:0 0 0 3px rgba(70,211,154,.16); animation:freshPulse 2.6s ease-in-out infinite; }
.topology-freshness.is-stale { border-color:rgba(255,184,77,.5); color:#ffb84d; }
.topology-freshness.is-stale .freshness-dot { background:#ffb84d; box-shadow:0 0 0 3px rgba(255,184,77,.16); }
.topology-freshness.is-dead, .topology-freshness.is-locked { border-color:rgba(255,95,109,.55); color:#ff8792; }
.topology-freshness.is-dead .freshness-dot, .topology-freshness.is-locked .freshness-dot {
  background:#ff5f6d; box-shadow:0 0 0 3px rgba(255,95,109,.16); animation:none; }
@keyframes freshPulse { 0%,100% { opacity:1; } 50% { opacity:.45; } }

/* Once the data is genuinely old, the map itself has to stop looking live —
   a calm green map that is nine polls behind is the failure this prevents. */
body.data-stale .topology-canvas { filter:grayscale(.7) opacity(.55); transition:filter .4s ease; }

.shared-badge { background:rgba(83,184,255,.14); border:1px solid rgba(83,184,255,.4); color:var(--accent); }

/* Summary tiles are buttons now (they open the drawer), so they need the
   affordance and the focus ring that a plain div did not have. */
.summary-tile { width:100%; flex:0 0 auto; min-height:84px; display:flex; flex-direction:column; justify-content:center;
  align-items:center; gap:2px; padding:10px; border-radius:var(--radius-sm); background:var(--layer-1);
  border:1px solid var(--glass-border); color:inherit; cursor:pointer; transition:.16s; font:inherit; }
.summary-tile:hover, .summary-tile:focus-visible { border-color:var(--accent); transform:translateY(-2px); }
.summary-tile span { color:var(--heading); font-size:27px; font-weight:800; line-height:1.1; }
.summary-tile small { color:var(--muted); text-align:center; font-size:11px; }
.summary-tile.critical.is-active { border-color:rgba(255,95,109,.6); background:rgba(255,95,109,.08); }
.summary-tile.critical.is-active span { color:#ff8792; }

/* Map: a fixed canvas inside a scrolling viewport, so zoom has somewhere to go. */
.topology-map { position:relative; overflow:hidden; }
.topology-viewport { position:absolute; inset:0; overflow:auto; cursor:grab; overscroll-behavior:contain; }
.topology-viewport.is-panning { cursor:grabbing; user-select:none; }
.topology-canvas { --map-zoom:1; width:calc(100% * var(--map-zoom)); height:calc(100% * var(--map-zoom));
  min-width:calc(850px * var(--map-zoom)); min-height:calc(560px * var(--map-zoom)); transition:width .18s, height .18s; }
.map-toolbar { position:absolute; right:14px; top:14px; z-index:4; display:flex; align-items:center; gap:6px;
  padding:6px; border-radius:999px; background:var(--bg-elev); border:1px solid var(--glass-border); box-shadow:var(--elev-card); }
.map-zoom-label { min-width:44px; text-align:center; color:var(--muted); font-size:12px; font-weight:700; }
.map-empty { position:absolute; inset:0; display:grid; place-items:center; margin:0; pointer-events:none; }
/* The base reset's [hidden] rule is display:none, which a later display:grid
   silently beats — so the empty-state message stayed on screen over a map
   that was full of sites. Restated at this specificity to actually win. */
.map-empty[hidden] { display:none; }
.topology-link.is-down { stroke:rgba(255,95,109,.7); animation-duration:6s; }

/* Alert drawer: severity chips and a per-alert timestamp. */
.alert-filter { display:flex; gap:7px; margin:0 0 14px; flex-wrap:wrap; }
.alert-filter .chip { padding:6px 14px; border-radius:999px; border:1px solid var(--glass-border);
  background:var(--layer-1); color:var(--muted); font:600 12px/1 inherit; cursor:pointer; transition:.15s; }
.alert-filter .chip:hover { border-color:var(--accent); }
.alert-filter .chip.active { background:var(--accent); border-color:var(--accent); color:var(--bg); }
.site-alert-head { display:flex; align-items:baseline; justify-content:space-between; gap:10px; }
.site-alert-when { color:var(--muted); font-size:11px; font-weight:600; white-space:nowrap; flex:none; }

/* Share links on the manage page. */
.share-reveal { border:1px solid rgba(70,211,154,.45); background:rgba(70,211,154,.07); border-radius:var(--radius-sm); padding:16px; margin-bottom:16px; }
.share-url-row { display:flex; gap:8px; margin-top:10px; }
.share-url-row input { flex:1; font-family:var(--mono, monospace); font-size:12px; }
.share-link-row { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:12px 14px;
  border-radius:var(--radius-sm); background:var(--layer-1); border:1px solid var(--glass-border); margin-bottom:9px; flex-wrap:wrap; }
.share-link-row.is-dead { opacity:.55; }
.share-link-meta { display:flex; flex-direction:column; gap:3px; min-width:0; }
.share-link-meta small { color:var(--muted); font-size:11px; }

@media (max-width: 860px) {
  .topology-shell { grid-template-columns:minmax(0,1fr); height:auto; }
  .topology-summary { flex-direction:row; flex-wrap:wrap; }
  .summary-tile { min-width:calc(50% - 7px); }
  .topology-map { min-height:60vh; }
}
