/**
 * RTSP multiview — dark by design. A camera wall is viewed for long stretches and
 * video reads better against dark chrome, so this commits to one scheme rather than
 * following prefers-color-scheme.
 */

:root {
  /* Surfaces */
  --c-bg: #0c0f14;
  --c-surface: #141922;
  --c-surface-raised: #1c222d;
  --c-border: #262e3b;
  --c-border-strong: #36415380;

  /* Text */
  --c-text: #e6ebf2;
  --c-text-muted: #8e9bb0;
  --c-text-faint: #5d6a7e;

  /* Accent + state */
  --c-accent: #3d8bfd;
  --c-accent-hover: #5b9dff;
  --c-ok: #2fbf71;
  --c-warn: #e8a33d;
  --c-error: #e05260;
  --c-neutral: #5d6a7e;
  --c-codec: #a855f7;
  --c-ai: #f97316;              /* AI-event brand orange — distinct from --c-warn (motion). (Was a
                                   self-reference `var(--c-ai)` = invalid → any fill:var(--c-ai) fell
                                   back to black; that's why the speed label rendered black.) */
  --c-derived: #14b8a6;         /* OUR-own analytics events (dwell / co-occurrence) — teal, distinct
                                   from the camera's --c-ai orange so the two layers never blur. */
  --c-data: #e6a817;            /* external DATA records (POS receipts, ALPR plates, badge swipes):
                                   gold, in BOTH places they surface — the playback timeline marks
                                   and the live-wall ticker. Was only ever a fallback literal here. */

  /* Detection-class palette — ONE source of truth, shared by timeline bars (.playback__tl-obj--*),
     live SVG boxes (.tile__box--*) and overlay-editor icons (.ov-item--*). */
  --c-obj-unknown: #22c55e;
  --c-obj-person:  #38bdf8;
  --c-obj-vehicle: #f59e0b;
  --c-obj-bike:    #a78bfa;
  --c-obj-face:    #34d399;
  --c-on-accent:   #fff;    /* text/glyph on a saturated color fill */
  --c-video-bg:    #000;    /* letterbox behind video/frames */

  /* Rhythm */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-5: 1.5rem;
  --sp-6: 2rem;

  --radius-sm: 4px;
  --radius: 8px;
  --radius-lg: 12px;

  --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  /* Live-wall camera panel width. THREE rules depend on it (the panel itself, the collapse
     offset, and the handle's resting position) — they must never drift apart, so they all read
     this. See .sidebar for why the panel must also be told it may shrink. */
  --sidebar-w: 13.5rem;
  --fs-xs: 0.6875rem;
  --fs-sm: 0.8125rem;
  --fs-base: 0.9375rem;
  --fs-lg: 1.125rem;
  --fs-xl: 1.375rem;

  --shadow: 0 4px 16px rgb(0 0 0 / 40%);
  --transition: 140ms ease;
}

/* ── Theme ──────────────────────────────────────────────────────────────────
   The app is dark by design; :root above IS the dark theme. LIGHT mode overrides only the
   STRUCTURAL tokens (surfaces, text, borders, shadow, accent contrast) — brand/state hues, the
   detection palette, and the video-fixed tokens (--c-video-bg, --c-on-accent, the checkerboard)
   stay put because video is always dark. The resolved scheme is stamped on <html data-theme> by the
   head script (follows the OS until the user picks an override in Settings › System).
   A subtree can force dark via [data-theme="dark"]: the Live wall uses it so the video keeps its
   dark backdrop while the surrounding chrome goes light. */
[data-theme="light"] {
  --c-bg: #eceff4;
  --c-surface: #ffffff;
  --c-surface-raised: #f3f6fa;
  --c-border: #dbe1ea;
  --c-border-strong: #a7b2c180;
  --c-text: #15202e;
  --c-text-muted: #56637a;
  --c-text-faint: #8a96a8;
  --c-accent: #2f7ae6;              /* a touch deeper than the dark accent for contrast on white */
  --c-accent-hover: #1f66d0;        /* hover DARKENS on light, not lightens */
  --shadow: 0 6px 20px rgb(20 32 54 / 12%);
}
/* Dark island: re-assert the dark STRUCTURAL tokens on any subtree tagged data-theme="dark", so the
   video wall stays dark inside a light app. Deliberately does NOT pin the accent — so the chosen
   theme's accent still flows onto the wall UI (amber wall reads amber, not blue). Used on the Live #grid. */
[data-theme="dark"] {
  --c-bg: #0c0f14;
  --c-surface: #141922;
  --c-surface-raised: #1c222d;
  --c-border: #262e3b;
  --c-border-strong: #36415380;
  --c-text: #e6ebf2;
  --c-text-muted: #8e9bb0;
  --c-text-faint: #5d6a7e;
  --shadow: 0 4px 16px rgb(0 0 0 / 40%);
}

/* ── Colour themes ──────────────────────────────────────────────────────────
   Each theme overrides only the ~11 STRUCTURAL tokens (surfaces / text / borders / accent / shadow);
   brand, state and detection hues stay put (video is always dark). `data-theme` = the theme NAME;
   `data-mode` (light|dark, stamped by the head script) drives the binary logo/icon swaps further down.
   Two themes (aurora / dawn) add a fixed background gradient for a bit of flair. All picked in
   Settings › System and persisted client-side (see theme.js / head.php). */

/* — Dark flavours (light text on a tinted-dark ground) — */
[data-theme="amber"] {
  --c-bg: #14110b; --c-surface: #1e1a12; --c-surface-raised: #2a2416;
  --c-border: #3a3320; --c-border-strong: #5c503080;
  --c-text: #f2ece0; --c-text-muted: #b6a888; --c-text-faint: #7d7052;
  --c-accent: #f59e0b; --c-accent-hover: #fbbf24;
}
[data-theme="forest"] {
  --c-bg: #0a120d; --c-surface: #111c15; --c-surface-raised: #16261c;
  --c-border: #23392c; --c-border-strong: #3d5e4a80;
  --c-text: #e4efe8; --c-text-muted: #8fb0a0; --c-text-faint: #5d7a6b;
  --c-accent: #34d399; --c-accent-hover: #6ee7b7;
}
[data-theme="teal"] {
  --c-bg: #081416; --c-surface: #0f2022; --c-surface-raised: #142b2d;
  --c-border: #204043; --c-border-strong: #35696d80;
  --c-text: #e0f0f0; --c-text-muted: #86b0b2; --c-text-faint: #567a7c;
  --c-accent: #2dd4bf; --c-accent-hover: #5eead4;
}
[data-theme="cherry"] {
  --c-bg: #140a0d; --c-surface: #1e1116; --c-surface-raised: #2a171e;
  --c-border: #3d222b; --c-border-strong: #663a4880;
  --c-text: #f2e4e8; --c-text-muted: #b892a0; --c-text-faint: #7d5563;
  --c-accent: #f43f5e; --c-accent-hover: #fb7185;
}

/* — Light flavour (dark text on a warm-light ground) — */
[data-theme="sand"] {
  --c-bg: #efe7d8; --c-surface: #fbf6ec; --c-surface-raised: #f4ecdd;
  --c-border: #ddd0ba; --c-border-strong: #b3a17f80;
  --c-text: #33291a; --c-text-muted: #6b5d47; --c-text-faint: #9a8b70;
  --c-accent: #b45309; --c-accent-hover: #92400e;
  --shadow: 0 6px 20px rgb(60 45 20 / 12%);
}

/* — Wow: gradient themes (aurora = dark, dawn = light) — */
[data-theme="aurora"] {
  --c-bg: #0a0a16; --c-surface: #15162b; --c-surface-raised: #1e2038;
  --c-border: #2a2c4a; --c-border-strong: #45487a80;
  --c-text: #e8e9f5; --c-text-muted: #a0a2c4; --c-text-faint: #6b6d92;
  --c-accent: #818cf8; --c-accent-hover: #a5b4fc;
}
[data-theme="dawn"] {
  --c-bg: #f6eef0; --c-surface: #ffffff; --c-surface-raised: #fbf3f5;
  --c-border: #ecdde1; --c-border-strong: #cbb0b880;
  --c-text: #2b2530; --c-text-muted: #6a5f6e; --c-text-faint: #9d8f9d;
  --c-accent: #db2777; --c-accent-hover: #be185d;
  --shadow: 0 6px 20px rgb(80 40 60 / 12%);
}
/* Fixed background gradients — shown once .nvr__content goes transparent below (the video wall keeps
   its own dark island, so it's the surrounding chrome that gets the glow). */
[data-theme="aurora"] body {
  background:
    radial-gradient(1200px 620px at 12% -12%, rgb(67 56 202 / 42%), transparent 60%),
    radial-gradient(1000px 700px at 112% 6%, rgb(14 165 233 / 30%), transparent 55%),
    radial-gradient(1000px 900px at 55% 118%, rgb(124 58 237 / 32%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}
[data-theme="dawn"] body {
  background:
    radial-gradient(1100px 600px at 8% -12%, rgb(251 207 232 / 75%), transparent 60%),
    radial-gradient(1000px 700px at 112% -4%, rgb(199 210 254 / 65%), transparent 55%),
    radial-gradient(1000px 820px at 62% 120%, rgb(186 230 253 / 55%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}
/* Let the body gradient show through the main content pane on every fancy theme (Settings/Playback). */
:root[data-fancy] .nvr__content { background-color: transparent; }

/* ── Expressive gradient themes (pronounced personality) ─────────────────────
   Holographic + Opal float a blurred conic gradient behind the chrome and spin it with a TRANSFORM
   (GPU-composited → no per-frame repaint, cheap even on the J1900); Synthwave is a static neon sunset.
   All still keep the ~11 structural tokens dark/light-correct so text stays readable on opaque panels. */
[data-theme="holographic"] {
  --c-bg: #0a0812; --c-surface: #17121f; --c-surface-raised: #221a33;
  --c-border: #302544; --c-border-strong: #4c3a6e80;
  --c-text: #f1ecfb; --c-text-muted: #b3a6d0; --c-text-faint: #7c6e9c;
  --c-accent: #ff6ec4; --c-accent-hover: #ff92d6;
}
[data-theme="synthwave"] {
  --c-bg: #100a1e; --c-surface: #1b1030; --c-surface-raised: #271642;
  --c-border: #392258; --c-border-strong: #5a3a8880;
  --c-text: #f6ecff; --c-text-muted: #c2a9e2; --c-text-faint: #8a72ac;
  --c-accent: #ff2e97; --c-accent-hover: #ff6ec4;
}
[data-theme="opal"] {
  --c-bg: #f3eff8; --c-surface: #ffffff; --c-surface-raised: #f9f4fc;
  --c-border: #e8dcf0; --c-border-strong: #c9b0d880;
  --c-text: #2a2433; --c-text-muted: #6a5f75; --c-text-faint: #9d8fa8;
  --c-accent: #a855f7; --c-accent-hover: #9333ea;
  --shadow: 0 6px 20px rgb(60 40 90 / 14%);
}
/* Rotating iridescent field (behind everything, z-index:-1, blurred into soft clouds). */
[data-theme="holographic"] body::before,
[data-theme="opal"] body::before {
  content: "";
  position: fixed;
  inset: -60%;
  z-index: -1;
  pointer-events: none;
  animation: holo-spin 44s linear infinite;
}
[data-theme="holographic"] body::before {
  background: conic-gradient(from 0deg, #3a1c71, #d76d77, #00c9ff, #56ffa0, #ffd86f, #ff6ec4, #7873f5, #3a1c71);
  filter: blur(60px) saturate(1.15);
  opacity: 0.55;
}
[data-theme="opal"] body::before {
  background: conic-gradient(from 0deg, #ffd6ec, #d6e4ff, #d0f7ee, #efe0ff, #fff0d6, #ffd6ec);
  filter: blur(60px) saturate(1.2);
  opacity: 0.9;
}
@keyframes holo-spin { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: reduce) {
  [data-theme="holographic"] body::before, [data-theme="opal"] body::before { animation: none; }
}
/* Synthwave — a static neon sunset (hot-pink horizon + cyan/violet glows). */
[data-theme="synthwave"] body {
  background:
    radial-gradient(1200px 620px at 50% 128%, rgb(255 46 151 / 45%), transparent 60%),
    radial-gradient(900px 520px at 12% -12%, rgb(0 201 255 / 32%), transparent 55%),
    radial-gradient(820px 600px at 104% 18%, rgb(168 85 247 / 30%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}

/* — Nebula (deep-space clouds) & Molten (ember glow) — static rich gradients — */
[data-theme="nebula"] {
  --c-bg: #070512; --c-surface: #12102a; --c-surface-raised: #1c1838;
  --c-border: #2c2850; --c-border-strong: #4a3f7a80;
  --c-text: #ecebfa; --c-text-muted: #aaa6d2; --c-text-faint: #726ea0;
  --c-accent: #b388ff; --c-accent-hover: #cbb0ff;
}
[data-theme="molten"] {
  --c-bg: #140805; --c-surface: #1f0e08; --c-surface-raised: #2b140b;
  --c-border: #3d2015; --c-border-strong: #6a3a2080;
  --c-text: #fbeae0; --c-text-muted: #cf9f88; --c-text-faint: #966a55;
  --c-accent: #ff5722; --c-accent-hover: #ff7a45;
}
[data-theme="nebula"] body {
  background:
    radial-gradient(1000px 720px at 20% 14%, rgb(124 58 237 / 42%), transparent 55%),
    radial-gradient(920px 620px at 86% 24%, rgb(219 39 119 / 32%), transparent 55%),
    radial-gradient(1100px 820px at 58% 116%, rgb(37 99 235 / 34%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}
[data-theme="molten"] body {
  background:
    radial-gradient(1200px 720px at 50% 126%, rgb(255 87 34 / 52%), transparent 60%),
    radial-gradient(720px 520px at 18% 92%, rgb(234 88 12 / 40%), transparent 55%),
    radial-gradient(720px 520px at 84% 96%, rgb(220 38 38 / 38%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}

/* — Glass (glassmorphism): a vivid gradient behind FROSTED, translucent chrome panels — */
[data-theme="glass"] {
  --c-bg: #0c1220; --c-surface: #182236; --c-surface-raised: #22304a;
  --c-border: rgb(255 255 255 / 14%); --c-border-strong: rgb(255 255 255 / 26%);
  --c-text: #eaf1fb; --c-text-muted: #a6b6cc; --c-text-faint: #7085a0;
  --c-accent: #38bdf8; --c-accent-hover: #7dd3fc;
  --shadow: 0 8px 32px rgb(0 0 0 / 45%);
}
[data-theme="glass"] body {
  background:
    radial-gradient(900px 700px at 14% 8%, rgb(56 189 248 / 42%), transparent 55%),
    radial-gradient(900px 700px at 88% 18%, rgb(168 85 247 / 40%), transparent 55%),
    radial-gradient(1000px 820px at 60% 118%, rgb(236 72 153 / 36%), transparent 55%),
    var(--c-bg);
  background-attachment: fixed;
}
/* Frost the main chrome panels: translucent surface + backdrop blur, so the gradient reads THROUGH the
   UI (opt-in "fancy" theme — backdrop-filter is the one effect a weak GPU dislikes, fine on real HW). */
:root[data-fancy] .nav,
:root[data-fancy] .livebar,
:root[data-fancy] .sidebar,
:root[data-fancy] .livebar__pop-menu,
:root[data-fancy] .dialog,
:root[data-fancy] .treact-modal,
:root[data-fancy] .settings__menu,
:root[data-fancy] .settings__body,
:root[data-fancy] .playback__bar,
:root[data-fancy] .playback__controls,
:root[data-fancy] .playback__timeline {
  background-color: color-mix(in srgb, var(--c-surface) 62%, transparent);
  -webkit-backdrop-filter: blur(16px) saturate(135%);
  backdrop-filter: blur(16px) saturate(135%);
}
/* Overlays that sit ON the video (fisheye preset bar, PTZ pad) get the same rich fancy frost, but stay
   DELIBERATELY denser (78% vs 62%) so controls read against bright/busy footage — not an omission. */
:root[data-fancy] .tile__fisheye,
:root[data-fancy] .tile__ptz {
  background-color: color-mix(in srgb, var(--c-surface) 78%, transparent);
  -webkit-backdrop-filter: blur(16px) saturate(135%);
  backdrop-filter: blur(16px) saturate(135%);
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: var(--c-bg);
  color: var(--c-text);
  font-family: var(--font-ui);
  font-size: var(--fs-base);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

.is-hidden {
  display: none !important;
}

/* ---------------------------------------------------------------- Top bar */

.topbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-5);
  background-color: var(--c-surface);
  border-bottom: 1px solid var(--c-border);
}

.topbar__identity {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.topbar__title {
  margin: 0;
  font-size: var(--fs-xl);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.topbar__tagline {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

.topbar__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--sp-3);
}

/* ------------------------------------------------------------- Form atoms */

/* ── Scrollbars ───────────────────────────────────────────────────────────────
   Themed, thin, and unobtrusive: the stock chrome is a bright slab against these dark surfaces and
   reads as a defect. Firefox takes the two standard properties; WebKit/Blink needs ::-webkit-*.
   The thumb is drawn with a transparent border + background-clip so it looks inset with breathing
   room instead of filling the whole gutter. Track stays transparent so it disappears until used.
   Applied on the root so every internal scroller inherits it; the few places that deliberately hide
   their scrollbar (rewind row, filter legend, icon rail) set scrollbar-width:none and still win. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--c-border-strong) transparent;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background-color: var(--c-border-strong);
  border: 2px solid transparent;
  border-radius: 999px;
  background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background-color: var(--c-text-faint); background-clip: padding-box; }
::-webkit-scrollbar-corner { background: transparent; }

.field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 0;
}

.field--inline {
  flex-direction: column;
}

.field--narrow {
  max-width: 11rem;
}

/* ── Field rows ───────────────────────────────────────────────────────────────
   A line of fields that belong together (address + its ports, username + password). Wrapping keeps
   it honest when a translation runs long; below the mobile breakpoint each row becomes a stack so
   nothing is squeezed into an unusable width. */
.field-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--sp-3) var(--sp-4);
}
.field-row > .field { flex: 1 1 12rem; min-width: 0; }
/* Ports are 5 digits at most — give them just enough and let the address take the remainder. */
.field-row > .field--port { flex: 0 1 7.5rem; }
.field-row > .field--narrow { flex: 0 1 11rem; }
/* A non-.field child of a row (the Enabled switch) still needs a basis, or it sits at flex:0 1 auto
   with min-width:auto — unable to shrink and unable to wrap, so it overflows the dialog. */
.field-row > .switch-field { flex: 0 0 auto; }
/* Push a trailing control to the end of its row. It uses the STACKED switch (caption above, pill
   below), so it aligns with the neighbouring inputs by having the same shape — no magic offset. */
.field-row__end { margin-left: auto; }
.field-row__end .field__label { white-space: nowrap; }
/* Match an input's height so the pill sits where a field's input would. */
.switch-field__row { min-height: 2.25rem; }
@media (max-width: 560px) {
  .field-row { flex-direction: column; gap: var(--sp-3); }
  .field-row > .field,
  .field-row > .field--port,
  .field-row > .field--narrow { flex: 1 1 auto; width: 100%; max-width: none; }
  .field-row__end { margin-left: 0; }
}

/* ── Switch field ─────────────────────────────────────────────────────────────
   The switch itself is the EXISTING .switch / .switch__input / .switch__slider component further
   down this file (used by the playback popover) — do not redefine it here; a second `.switch` rule
   simply overrode the first and produced garbage. This is only the wrapper that pairs one with its
   label and help text. */
.switch-field { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
.switch-field__row { display: flex; align-items: center; gap: var(--sp-2); }
.switch-field__label { font-size: var(--fs-sm); color: var(--c-text); cursor: pointer; }
/* (i) beside a label: the long explanation lives in its tooltip. Muted until hovered/focused so a
   column of them doesn't compete with the controls they annotate. tabindex makes it keyboard-
   reachable, which is the only way :focus-visible can reveal the bubble. */
.field__info {
  display: inline-flex;
  align-items: center;
  color: var(--c-text-faint);
  cursor: help;
  vertical-align: -0.15em;
  transition: color var(--transition);
}
.field__info:hover, .field__info:focus-visible { color: var(--c-accent); }
.field__info .ic { width: 0.95em; height: 0.95em; }
/* Label + its (i) on one line above the control (.field itself is a flex column, so they need
   their own row box or they stack). */
.field__labelrow { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
/* Field glyph: same muted weight as the uppercase label it sits with, so the row reads as one unit. */
.field__icon { flex: 0 0 auto; width: 0.95em; height: 0.95em; color: var(--c-text-faint); }
/* Section glyph in a camdetail__title — accent-coloured, since the heading is the anchor you scan for. */
.camdetail__icon { flex: 0 0 auto; width: 1em; height: 1em; color: var(--c-accent); }
.field__labelrow .field__label { min-width: 0; }
/* Help sits under the whole control, indented past the pill so it reads as belonging to it. */
.switch-field .field__help { margin-left: calc(2.4rem + var(--sp-2)); }
/* The behaviour toggles at the foot of the General tab: one per line, help under each. */
.cam-form__opts { display: flex; flex-direction: column; gap: var(--sp-3); }

.field--checkbox {
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
}

.field__label {
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--c-text-faint);
}

.field__help {
  font-size: var(--fs-xs);
  color: var(--c-text-faint);
}

.input {
  padding: var(--sp-2) var(--sp-3);
  min-width: 0;
  background-color: var(--c-bg);
  color: var(--c-text);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--fs-sm);
  transition: border-color var(--transition);
}

.input:focus-visible {
  outline: none;
  border-color: var(--c-accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent);
}

.input--select {
  cursor: pointer;
}

/* ---------------------------------------------------------------- Buttons */

.btn {
  padding: var(--sp-2) var(--sp-4);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--fs-sm);
  font-weight: 500;
  cursor: pointer;
  transition: background-color var(--transition), border-color var(--transition);
}

/* Compact square icon-only button (equal padding, sized glyph). */
.btn--icon {
  padding: var(--sp-2);
  line-height: 0;
}
.btn--icon svg {
  display: block;
  width: 18px;
  height: 18px;
}

.btn:hover {
  border-color: var(--c-border-strong);
  background-color: var(--c-border);
}

.btn:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 2px;
}

.btn[disabled] {
  opacity: 0.5;
  cursor: progress;
}

.btn--primary {
  background-color: var(--c-accent);
  border-color: var(--c-accent);
  color: var(--c-on-accent);
}

.btn--primary:hover {
  background-color: var(--c-accent-hover);
  border-color: var(--c-accent-hover);
}

.btn--ghost {
  background-color: transparent;
}

.btn--danger {
  background-color: transparent;
  border-color: var(--c-error);
  color: var(--c-error);
}

.btn--danger:hover {
  background-color: var(--c-error);
  border-color: var(--c-error);
  color: var(--c-on-accent);
}

.btn--small {
  padding: var(--sp-1) var(--sp-3);
  font-size: var(--fs-xs);
}

/* ------------------------------------------------------------ Lang switch */

.lang-switch {
  display: inline-flex;
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.lang-switch__btn {
  padding: var(--sp-2) var(--sp-3);
  background-color: transparent;
  color: var(--c-text-muted);
  border: 0;
  font-family: inherit;
  font-size: var(--fs-xs);
  font-weight: 700;
  cursor: pointer;
  transition: background-color var(--transition), color var(--transition);
}

.lang-switch__btn:hover {
  color: var(--c-text);
}

/* Inset ring so it stays visible on both the transparent and the accent-filled button. */
.lang-switch__btn:focus-visible {
  outline: 2px solid var(--c-text);
  outline-offset: -3px;
  color: var(--c-text);
}

.lang-switch__btn--active {
  background-color: var(--c-accent);
  color: var(--c-on-accent);
}

/* ------------------------------------------------------- Banner and toast */

.banner {
  padding: var(--sp-3) var(--sp-5);
  font-size: var(--fs-sm);
  border-bottom: 1px solid transparent;
}

.banner--warn {
  background-color: color-mix(in srgb, var(--c-warn) 12%, transparent);
  border-bottom-color: color-mix(in srgb, var(--c-warn) 35%, transparent);
  color: var(--c-warn);
}

.banner--danger {
  background-color: color-mix(in srgb, var(--c-error) 15%, transparent);
  border-bottom-color: color-mix(in srgb, var(--c-error) 45%, transparent);
  color: var(--c-error);
  font-weight: 600;
}

/* Toast lives TOP-centre (was bottom — it sat over the mobile livebar/toolbar). Glassy card with a
   left accent stripe; a springy entrance pop replays on every show (see toast() forcing reflow). */
.toast {
  position: fixed;
  top: 4rem;                 /* below the 52px top nav, and well clear of the bottom livebar */
  left: 50%;
  translate: -50% 0;
  z-index: 60;
  padding: var(--sp-3) var(--sp-4);
  max-width: min(92vw, 30rem);
  background-color: color-mix(in srgb, var(--c-surface-raised) 90%, transparent);
  -webkit-backdrop-filter: blur(12px) saturate(130%);
  backdrop-filter: blur(12px) saturate(130%);
  border: 1px solid var(--c-border-strong);
  border-left: 3px solid var(--c-accent);
  border-radius: 0;          /* squared on the regular themes; the fancy ring re-rounds it below */
  box-shadow: var(--shadow);
  font-size: var(--fs-sm);
}
.toast--pop { animation: toast-in 360ms cubic-bezier(0.2, 1.15, 0.3, 1) both; }
@keyframes toast-in {
  from { opacity: 0; translate: -50% -16px; scale: 0.95; }
  to   { opacity: 1; translate: -50% 0;     scale: 1; }
}

.toast--error {
  border-color: var(--c-error);
  border-left-color: var(--c-error);
  color: var(--c-error);
}

/* WOW themes: swap the flat border for a slowly-rotating gradient RING (masked so only the 1.5px edge
   paints) plus an accent glow — the toast gets the same iridescent treatment as the rest of the chrome. */
:root[data-fancy] .toast {
  border-color: transparent;
  border-left-color: transparent;
  border-radius: var(--radius-lg);
  box-shadow: 0 10px 40px color-mix(in srgb, var(--c-accent) 32%, transparent), var(--shadow);
}
/* A typed custom angle so it can be ANIMATED — this sweeps the conic gradient's start point around a
   STATIONARY border (a travelling highlight), instead of rotating the whole rectangular ring. On engines
   without @property the angle just stays put → a static gradient border, which is a fine fallback. */
@property --toast-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
:root[data-fancy] .toast::before {
  content: "";
  position: absolute;
  inset: -1.5px;
  z-index: -1;
  border-radius: inherit;
  padding: 1.5px;
  /* One bright band (~200°) fading to dim at the seam → a single highlight orbits as the angle animates. */
  background: conic-gradient(from var(--toast-angle),
    color-mix(in srgb, var(--c-accent) 30%, transparent) 0deg,
    var(--c-accent) 150deg,
    color-mix(in srgb, var(--c-accent) 55%, #fff) 200deg,
    var(--c-accent-hover) 250deg,
    color-mix(in srgb, var(--c-accent) 30%, transparent) 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: toast-ring 4s linear infinite;
}
:root[data-fancy] .toast--error {
  box-shadow: 0 10px 40px color-mix(in srgb, var(--c-error) 32%, transparent), var(--shadow);
}
:root[data-fancy] .toast--error::before {
  background: conic-gradient(from var(--toast-angle),
    color-mix(in srgb, var(--c-error) 30%, transparent) 0deg,
    var(--c-error) 150deg,
    color-mix(in srgb, var(--c-error) 55%, #fff) 200deg,
    var(--c-error) 250deg,
    color-mix(in srgb, var(--c-error) 30%, transparent) 360deg);
}
@keyframes toast-ring { to { --toast-angle: 360deg; } }

@media (prefers-reduced-motion: reduce) {
  .toast--pop { animation: none; }
  :root[data-fancy] .toast::before { animation: none; }
}

/* ------------------------------------------------------------------- Grid */

.stage {
  flex: 1;
  padding: var(--sp-4) var(--sp-5);
  min-height: 0;
  /* Paint the wall backdrop explicitly: .stage carries data-theme="dark", so in light mode this
     stays dark (video reads best on dark) while the surrounding nav/livebar/footer go light. In dark
     mode it's identical to the body. */
  background-color: var(--c-bg);
}

/* Wall fullscreen: the whole stage fills the screen (grid + tiles only, all chrome gone). Edge-to-edge
   black, and the floating exit button is revealed (the livebar's enter button is hidden by fullscreen). */
.stage:fullscreen { padding: 0; background: var(--c-video-bg); }
.stage__fs-exit {
  display: none;
  position: fixed;
  top: var(--sp-3);
  right: var(--sp-3);
  z-index: 30;
  width: 2.5rem;
  height: 2.5rem;
  place-items: center;
  border: 0;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--c-video-bg) 55%, transparent);
  color: var(--c-on-accent);
  cursor: pointer;
}
.stage__fs-exit svg { width: 20px; height: 20px; }
.stage:fullscreen .stage__fs-exit { display: grid; }

.grid {
  display: grid;
  gap: var(--sp-3);
  /* Auto layout: JS sets --cols = ceil(sqrt(N)) so the wall stays roughly square, NVR-style
     (see applyGridLayout in app.js). The fixed presets below override the column count. */
  grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr));
}

/* Fixed presets override the auto-fit track list. */
.grid[data-layout="1"] { grid-template-columns: minmax(0, 1fr); }
.grid[data-layout="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid[data-layout="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid[data-layout="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }

/* While one tile is zoomed the others are removed from flow entirely, so the zoomed
   tile gets the whole stage regardless of the active preset. The double-class selector
   is deliberate: it must out-specify .grid[data-layout="N"] (class + attribute), else a
   fixed layout keeps its column count and the "zoomed" tile only fills one column. */
.grid.grid--zoomed {
  grid-template-columns: minmax(0, 1fr);
}

.grid--zoomed .tile:not(.tile--zoomed) {
  display: none;
}

/* Seamless single-border wall (livebar "grid gap" toggle off): collapse the spacing to 1px dividers
   — the grid's own background bleeds through the 1px gaps as clean grid lines — square the tiles, and
   a 1px frame closes the outer edge. box-sizing:border-box keeps the frame from overflowing the fit. */
.grid--seamless {
  gap: 1px;
  background-color: var(--c-border);
  border: 1px solid var(--c-border);
}
.grid--seamless .tile {
  border: 0;
  border-radius: 0;
}
/* Full-bleed: drop the stage padding so the seamless wall reaches the edges. */
.stage--seamless { padding: 0; }

/* Per-device on-tile overlay toggles (livebar "Affichage" popover). Each hide-* class is set on the
   grid by applyDisplay when its toggle is off. Selectors out-specify the elements' own show rules. */
.grid.hide-names .tile__name,
.grid.hide-live .tile__badge,
.grid.hide-res .tile__resolution,
.grid.hide-stream .tile__variant,
.grid.hide-events .tile__events,
.grid.hide-icons .tile__ov,
.grid.hide-data .tile__data { display: none; }
.grid.hide-rec .tile__rec { display: none; }   /* beats .tile--recording .tile__rec */

/* Video framing (livebar "Affichage" popover): default is contain (fit, letterbox — on .tile__video).
   cover crops to fill; fill stretches to the tile. (The metadata boxes assume contain — misaligned in
   cover/fill, but those are a ?debug view.) */
.grid[data-fit="cover"] .tile__video { object-fit: cover; }
.grid[data-fit="fill"] .tile__video { object-fit: fill; }

/* On narrow screens presets would crush tiles below usability; fall back to one column.
   Excluded in embed mode: an embedded frame is sized by the host dashboard, so a forced
   layout (e.g. ?layout=2) must hold its column count at any width, never collapse. */
/* Non-embed presets collapse to one column on a phone. The nvr wall's own mobile behaviour lives in
   the consolidated block AFTER the fit-to-viewport rules (so it out-orders them) — see "Phone wall". */
@media (max-width: 720px) {
  body:not(.embed):not(.nvr) .grid,
  body:not(.embed) .grid[data-layout="2"],
  body:not(.embed) .grid[data-layout="3"],
  body:not(.embed) .grid[data-layout="4"] {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ------------------------------------------------------------------ Tiles */

.tile {
  position: relative;
  aspect-ratio: 16 / 9;
  min-width: 0;
  background-color: var(--c-video-bg);
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
  overflow: hidden;
  cursor: pointer;
  /* transform is animated by the FLIP glide when the make-room gap shifts tiles (see flipTiles). */
  transition: border-color var(--transition), transform 160ms ease;
}
/* The FLIP "invert" frame must snap instantly (no glide) before we animate to the new position. */
.tile.no-anim { transition: none; }

.tile:hover {
  border-color: var(--c-border-strong);
}

.tile:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 2px;
}

.tile--zoomed {
  aspect-ratio: auto;
  height: calc(100vh - 12rem);
  cursor: zoom-out;
}

/* Wraps the media + overlays (everything except the PTZ pad). By default it's an invisible layer
   filling the tile, so the wall / desktop / fullscreen render exactly as a flat tile would; a
   magnified phone tile re-flows it into a flex column (see the phone media query). It is also the
   digital-zoom container — its box is the picture area, so pinch/pan focal math stays accurate. */
.tile__stage {
  position: absolute;
  inset: 0;
  overflow: hidden;   /* clips the digital zoom to the picture area (keeps it out of the PTZ band) */
  -webkit-touch-callout: none;   /* no long-press "save video" callout over the stream (see .tile__video) */
}

/* Two stacked layers (make-before-break). Both fill the tile; only the front layer is
   ever shown, and only once the tile is live — the back layer stays hidden while it
   connects, so a switch never blanks the picture. */
.tile__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  object-fit: contain;
  background-color: var(--c-video-bg);
  visibility: hidden;
  /* Mobile: keep the <video> from being the long-press target so the WebView can't offer to
     "save video" / download the stream. Interactions (magnify, digital zoom, PTZ) are bound to
     .tile__stage / the tile, not the video, so dropping its pointer events changes nothing. */
  pointer-events: none;
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
}

@keyframes tile-spin {
  to { transform: rotate(360deg); }
}

/* Reduced-motion substitute: convey "working" by pulsing opacity, not by rotating. */
@keyframes rm-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.35; }
}

.tile--live .tile__video--front {
  visibility: visible;
}

.tile__scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgb(0 0 0 / 55%) 0%,
    rgb(0 0 0 / 0%) 22%,
    rgb(0 0 0 / 0%) 78%,
    rgb(0 0 0 / 55%) 100%
  );
  opacity: 0;
  transition: opacity var(--transition);
}

.tile--live .tile__scrim {
  opacity: 1;
}

.tile__head {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  pointer-events: none;
}

.tile__name {
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--c-on-accent);          /* sits OVER the video (always dark) → white in every theme */
  text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
}
/* Angle-tile title: "<preset> · <camera>" — the preset owns the identity, the camera is quiet context. */
.tile__name-sub {
  font-weight: 400;
  color: color-mix(in srgb, var(--c-on-accent) 65%, transparent);
}
.tile__name-sub::before { content: "·"; margin: 0 0.35em; opacity: 0.6; }

.tile__variant {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: color-mix(in srgb, var(--c-on-accent) 65%, transparent);   /* dim white on video, theme-independent */
  text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
}

/* Live event icons in the tile head — each shown only when its category is active. */
.tile__events {
  margin-left: auto;
  display: inline-flex;
  gap: var(--sp-1);
  align-self: center;
}
.tile__ev {
  display: none;
  width: 16px;
  height: 16px;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 80%));
}
.tile--ev-motion .tile__ev--motion { display: block; color: var(--c-warn); }
.tile--ev-ai     .tile__ev--ai     { display: block; color: var(--c-ai); }
.tile--ev-alarm  .tile__ev--alarm  { display: block; color: var(--c-error); }
/* An active alarm should catch the eye. */
.tile--ev-alarm .tile__ev--alarm { animation: rec-pulse 1.2s ease-out infinite; border-radius: 50%; }
@media (prefers-reduced-motion: reduce) { .tile--ev-alarm .tile__ev--alarm { animation: none; } }

.tile__meta {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  pointer-events: none;
}

/* Recording indicator — bottom-right corner (name is top-left, tools top-right, badge
   bottom-left). Hidden until the tile is marked .tile--recording. */
.tile__rec {
  position: absolute;
  right: var(--sp-2);
  bottom: var(--sp-2);
  z-index: 3;
  display: none;
  align-items: center;
  gap: 5px;
  padding: 2px var(--sp-2);
  border-radius: 999px;
  background-color: rgb(0 0 0 / 45%);
  color: var(--c-on-accent);
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  pointer-events: none;
}

.tile--recording .tile__rec {
  display: inline-flex;
}

.tile__rec-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: var(--c-error);
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--c-error) 70%, transparent);
  animation: rec-pulse 1.6s ease-out infinite;
}

@keyframes rec-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c-error) 70%, transparent); }
  70%  { box-shadow: 0 0 0 6px color-mix(in srgb, var(--c-error) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c-error) 0%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .tile__rec-dot { animation: none; }
}

.tile__badge {
  padding: 1px var(--sp-2);
  border-radius: 999px;
  background-color: var(--c-neutral);
  color: var(--c-on-accent);
  font-size: var(--fs-xs);
  font-weight: 600;
  white-space: nowrap;
}

.tile__resolution {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
}

/* State colours. Driven by data-state so JS sets one attribute, not a class soup. */
.tile[data-state="live"] .tile__badge,
.tile[data-state="ok"] .tile__badge { background-color: var(--c-ok); }
.tile[data-state="connecting"] .tile__badge { background-color: var(--c-accent); }
.tile[data-state="busy"] .tile__badge { background-color: var(--c-warn); }
.tile[data-state="auth_failed"] .tile__badge,
.tile[data-state="failed"] .tile__badge,
.tile[data-state="error"] .tile__badge,
.tile[data-state="server_offline"] .tile__badge,
.tile[data-state="offline"] .tile__badge { background-color: var(--c-error); }
.tile[data-state="codec_unsupported"] .tile__badge { background-color: var(--c-codec); }
.tile[data-state="not_found"] .tile__badge { background-color: var(--c-warn); }

/* The light-toned fills (green ok/live, amber busy/not_found) fail WCAG AA with white
   text; near-black text on them clears it. Red/blue/purple keep the default white. */
.tile[data-state="live"] .tile__badge,
.tile[data-state="ok"] .tile__badge,
.tile[data-state="busy"] .tile__badge,
.tile[data-state="not_found"] .tile__badge { color: var(--c-bg); }

/* --------------------------------------------------------- Tile diagnostic */

.tile__diagnostic {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-4);
  text-align: center;
}

.tile--live .tile__diagnostic {
  display: none;
}

.tile__state {
  font-size: var(--fs-base);
  font-weight: 600;
}

/* Connecting spinner: a theme-accent ring, shown instead of the "Connexion…" line. Because it's the
   diagnostic's only visible child while connecting, the flex-centering lands it dead-centre in the
   tile (the text used to sit high — the hidden actions row still took vertical space). */
.tile__spinner {
  display: none;
  width: 1.9rem;
  height: 1.9rem;
  border-radius: 50%;
  border: 3px solid color-mix(in srgb, var(--c-accent) 22%, transparent);
  border-top-color: var(--c-accent);
  animation: tile-spin 0.8s linear infinite;
}
@keyframes tile-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .tile__spinner { animation-duration: 2.4s; } }
/* Any transient "will (re)appear" state shows the spinner instead of text/black (class set by paintTile);
   hide the message + actions so the lone spinner centres. */
.tile--pending .tile__spinner { display: block; }
.tile--pending .tile__state,
.tile--pending .tile__hint,
.tile--pending .tile__diagnostic-actions { display: none; }
/* Reconnecting (had a go, waiting to reappear) spins the OTHER way — an implicit "this one's coming back
   later", distinct from a fresh clockwise connect. */
.tile--reconnecting .tile__spinner { animation-direction: reverse; }

.tile__hint {
  margin: 0;
  max-width: 34ch;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

.tile__diagnostic-actions {
  display: flex;
  gap: var(--sp-2);
  margin-top: var(--sp-2);
}


/* ------------------------------------------------------------- Tile tools */
/* One toolbar for both grid and fullscreen: HD/SD · Events · Probe · Edit · Fullscreen.
   Hover-revealed on the wall; always on (and enlarged) when magnified or in fullscreen. */

.tile__tools {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);
  display: flex;
  gap: var(--sp-1);
  opacity: 1;   /* default visible — a touch panel has no hover to reveal them */
  transition: opacity var(--transition);
  z-index: 3;
}

/* On devices with real hover, keep the wall clean: hide the tools until hover/focus, or
   when a tile is magnified/fullscreen (so the controls stay reachable). */
@media (hover: hover) {
  .tile__tools { opacity: 0; }
  .tile:hover .tile__tools,
  .tile:focus-within .tile__tools,
  .tile--zoomed .tile__tools,
  .tile:fullscreen .tile__tools { opacity: 1; }
}

/* ---- Wall drag & drop: reorder handle + drop feedback ---- */
.tile__grip {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);   /* top-right corner; the tools cluster shifts left of it on the wall */
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background-color: rgba(0, 0, 0, 0.45);
  color: var(--c-on-accent);
  cursor: grab;
  touch-action: none;   /* touching the grip drags, never scrolls (touch reorder path) */
  transition: opacity var(--transition), background-color var(--transition);
}
.tile__grip:hover { background-color: rgba(0, 0, 0, 0.7); }
.tile__grip:active { cursor: grabbing; }
.tile__grip svg { width: 16px; height: 16px; }
@media (hover: hover) {
  .tile__grip { opacity: 0; }
  .tile:hover .tile__grip,
  .tile:focus-within .tile__grip { opacity: 1; }
}
/* The grip only reorders the WALL — hide it in a single-camera view (where it can't, and would
   collide with the corner toolbar), and in the wall-less embed/single contexts where it does nothing. */
.tile--zoomed .tile__grip,
.tile:fullscreen .tile__grip,
body.embed .tile__grip,
body.single .tile__grip { display: none; }
/* On the wall, sit the toolbar just left of the grip so the two don't overlap in the corner. */
body.nvr .tile:not(.tile--zoomed):not(:fullscreen) .tile__tools {
  right: calc(var(--sp-2) + 1.75rem + var(--sp-1));
}

/* Source being dragged: a list row just fades; a tile leaves the grid layout entirely (its cell is
   taken by the make-room placeholder, so a MOVE keeps the same cell count → no layout jump). */
.camrow.is-dragging { opacity: 0.45; }
.tile.is-dragging { display: none; }

/* Floating label that follows the finger during a TOUCH drag (mouse uses the native drag image). */
.drag-ghost {
  position: fixed;
  z-index: 60;
  transform: translate(-50%, -160%);
  pointer-events: none;
  padding: 4px 10px;
  border-radius: 999px;
  background-color: var(--c-accent);
  color: var(--c-bg);
  font-size: var(--fs-sm);
  font-weight: 600;
  white-space: nowrap;
  box-shadow: 0 6px 18px rgb(0 0 0 / 45%);
}

/* Drop = replace this camera: accent ring + translucent accent wash. */
.tile--drop-replace { outline: 3px solid var(--c-accent); outline-offset: -3px; }
.tile--drop-replace::after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: var(--c-accent);
  opacity: 0.2;
  pointer-events: none;
  z-index: 4;
}
/* Drop = insert: a make-room placeholder cell is inserted at the drop index (a real grid item), so
   the grid reflows and the following cameras slide over to open a slot. Dashed accent = "lands here". */
.tile--placeholder {
  border: 2px dashed var(--c-accent);
  background-color: rgba(0, 0, 0, 0.18);
  box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35);
  cursor: default;
}

.camrow[draggable="true"] .camrow__label { cursor: grab; }

/* Hovering a list row cues its tile with a modern flowing-gradient border. A masked pseudo-element
   draws only a 2px rim (transparent centre) so it rides on top of the live video, and the gradient
   sweeps around via background-position (no @property needed → safe on the FF/Chromium fleet). */
.tile--cued::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 2px;                         /* rim thickness */
  background: linear-gradient(115deg, var(--c-accent), var(--c-on-accent), var(--c-accent), var(--c-on-accent), var(--c-accent));
  background-size: 300% 100%;
  animation: tile-cue-flow 1.8s linear infinite;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;      /* punch out the centre → only the rim paints */
  pointer-events: none;
  z-index: 6;
}
@keyframes tile-cue-flow {
  from { background-position: 0 0; }
  to   { background-position: 300% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .tile--cued::after { animation: none; }
}

.tile__tool {
  min-width: 1.75rem;
  height: 1.75rem;
  padding: 0 var(--sp-1);
  display: grid;
  place-items: center;
  background-color: color-mix(in srgb, var(--c-surface) 85%, transparent);
  color: var(--c-text-muted);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  transition: color var(--transition), border-color var(--transition), background-color var(--transition);
}

.tile__tool svg {
  width: 1.1rem;
  height: 1.1rem;
}

.tile__tool:hover {
  color: var(--c-text);
  border-color: var(--c-border-strong);
}

.tile__tool:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: 1px;
}

/* HD/SD carries a text label, so it's a touch wider than the icon buttons. */
.tile__tool--quality {
  padding: 0 var(--sp-2);
  color: var(--c-text);
}

/* Exit-magnify (X): only meaningful on a magnified tile, so it's hidden everywhere else. Not shown in
   fullscreen — Esc / the compress button own that exit. Gives touch users a visible way out that's
   decoupled from the pinch-to-zoom surface (a tap on the picture is swallowed after a pinch gesture). */
.tile__tool--reduce { display: none; }
.tile--zoomed:not(:fullscreen) .tile__tool--reduce { display: grid; }

/* Snapshot burst belongs to a single-camera view (magnified OR fullscreen) — hidden on the wall. */
.tile__tool--snapshot { display: none; }
.tile--zoomed .tile__tool--snapshot,
.tile:fullscreen .tile__tool--snapshot { display: grid; }

/* Rewind-to-playback: single-camera view AND only when the camera is actually recording (else there's
   nothing to play back — .tile--recording is the same signal as the REC badge). */
.tile__tool--rewind { display: none; }
.tile--recording.tile--zoomed .tile__tool--rewind,
.tile--recording.tile:fullscreen .tile__tool--rewind { display: grid; }

/* The per-tile Events (bell) button is a developer diagnostic — it opens a raw ONVIF event listen.
   Of no use in normal operation, so it's ?debug=1 only (body.debug). When shown it behaves like any
   other tool (revealed with the toolbar). */
.tile__tool--events { display: none; }
body.debug .tile__tool--events { display: grid; }

/* Touch devices: comfortable hit targets for gloved/imprecise taps. */
@media (hover: none) {
  /* Touch (phone portrait AND landscape): the tile tools need big touch targets, which overlap a
     dense wall — so hide them on wall tiles. They reappear, enlarged, only when a tile is zoomed or
     in fullscreen (tap a tile). This is what makes the landscape phone wall usable. */
  body.nvr .tile:not(.tile--zoomed):not(:fullscreen) .tile__tools { display: none; }
  .tile--zoomed .tile__tool,
  .tile:fullscreen .tile__tool { min-width: 2.75rem; height: 2.75rem; font-size: var(--fs-base); }
  /* PTZ belongs to the magnified / single-camera view on touch — hide the pad on wall tiles (it has
     no hover to reveal it, so it would otherwise cover every PTZ-capable tile). */
  .tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz { display: none; }
  /* The pad is the most-used control on a phone, yet its buttons default to 36px (d-pad/zoom/focus)
     and 32px (preset save/delete) — below the 44px touch minimum. Bump them on the magnified pad. */
  .tile--zoomed .tile__ptz-btn,
  .tile:fullscreen .tile__ptz-btn { width: 2.75rem; height: 2.75rem; }
  .tile--zoomed .tile__ptz-mini,
  .tile:fullscreen .tile__ptz-mini { width: 2.75rem; height: 2.75rem; }
  /* Delete sits next to Save and wipes a preset with no undo — separate them so a fat-finger tap on
     Save can't land on Delete. */
  .tile--zoomed .tile__ptz-mini + .tile__ptz-mini,
  .tile:fullscreen .tile__ptz-mini + .tile__ptz-mini { margin-left: var(--sp-3); }
}

/* A tile in the browser's fullscreen fills the screen; its --zoomed height must not cap it. */
.tile:fullscreen {
  width: 100vw;
  height: 100vh;
  aspect-ratio: auto;
  border: 0;
  border-radius: 0;
}

/* Same toolbar, enlarged and higher-contrast for a full-screen view. */
.tile:fullscreen .tile__tools {
  top: var(--sp-4);
  right: var(--sp-4);
  gap: var(--sp-2);
}

.tile:fullscreen .tile__tool {
  min-width: 2.75rem;
  height: 2.75rem;
  padding: 0 var(--sp-3);
  font-size: var(--fs-base);
  background-color: color-mix(in srgb, var(--c-surface) 88%, transparent);
  color: var(--c-text);
  border-color: var(--c-border-strong);
  border-radius: var(--radius);
}

.tile:fullscreen .tile__tool svg {
  width: 1.35rem;
  height: 1.35rem;
}

/* Fullscreen button: expand icon on the wall, compress icon while in fullscreen. */
.tile__icon--compress {
  display: none;
}

.tile:fullscreen .tile__tool--fullscreen .tile__icon--expand {
  display: none;
}

.tile:fullscreen .tile__tool--fullscreen .tile__icon--compress {
  display: block;
}

/* In-button spinner: while a stream swap is in flight, the HD/SD label is replaced by a
   spinner on the very button that triggered it. */
.tile__tool-spin {
  display: none;
  width: 1.15rem;
  height: 1.15rem;
  border: 2px solid color-mix(in srgb, var(--c-text) 25%, transparent);
  border-top-color: var(--c-text);
  border-radius: 50%;
}

.tile--switching .tile__tool--quality {
  cursor: progress;
}

.tile--switching .tile__tool--quality .tile__tool-label {
  display: none;
}

.tile--switching .tile__tool--quality .tile__tool-spin {
  display: block;
  animation: tile-spin 0.7s linear infinite;
}

/* ------------------------------------------------------------- PTZ pad */
/* Shown on any PTZ-capable camera (.tile--ptz), press-and-hold. In the grid the pad reveals on hover
   like the toolbar; magnified/fullscreen keeps it open. On touch (no hover) it's hidden on wall tiles
   and only appears in the magnified / single-camera view — see the @media (hover:none) rule below. */

.tile__ptz {
  position: absolute;
  bottom: var(--sp-3);
  left: 50%;
  transform: translateX(-50%);
  display: none;
  flex-direction: column;
  gap: var(--sp-2);
  padding: var(--sp-2);
  background-color: color-mix(in srgb, var(--c-surface) 82%, transparent);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius);
  z-index: 4;
}

.tile--ptz .tile__ptz {
  display: flex;
}

/* Hover-capable (desktop): hide the pad until the tile is hovered/focused, exactly like the
   toolbar, so grid tiles aren't permanently covered. Magnified/fullscreen tiles keep it open.
   The reveal selectors out-specify the hide rule (later + one extra pseudo/class), matching
   the .tile__tools convention above. */
@media (hover: hover) {
  .tile--ptz .tile__ptz {
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--transition);
  }
  .tile--ptz:hover .tile__ptz,
  .tile--ptz:focus-within .tile__ptz,
  .tile--zoomed .tile__ptz,
  .tile--ptz:fullscreen .tile__ptz {
    opacity: 1;
    pointer-events: auto;
  }
}

/* Digital zoom (RtspZoom) is for pixel-peeping a plate/face — while it's active, drop the tile chrome
   that sits OVER the picture (the gradient scrim + the info line) so it can't cut across the magnified
   image. .rz-zoomed lives on .tile__stage (the zoom container); the scrim + meta are inside it, so
   this covers the magnified, fullscreen and phone-band cases alike. The PTZ pad is left alone — on the
   phone it's a separate band below the stream, and on desktop/fullscreen it's the usual overlay. */
.tile__stage.rz-zoomed .tile__scrim,
.tile__stage.rz-zoomed .tile__meta {
  display: none;
}

/* ── Grid-tile PTZ: a light touch over the picture ────────────────────────────
   The full pad is a panel — surface, border, speed slider, presets, focus, AF — which is right in
   the magnified view where the camera IS the screen, and wrong on a wall tile, where it covered
   most of the frame you were trying to aim. On a grid tile the pad drops its chrome entirely and
   keeps only what you steer with: the d-pad and zoom. Icons carry a drop-shadow instead of a
   background so they stay readable over both bright and dark scenes.
   Scoped with :not(.tile--zoomed):not(:fullscreen) so the magnified pad is untouched. */
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz {
  gap: var(--sp-1);
  padding: 0;
  background: none;
  border: 0;
  bottom: var(--sp-2);
}
/* Secondary controls belong to the magnified pad: speed, presets, focus, autofocus, and the
   arrows/joystick mode switch (the joystick needs a surface to drag on). */
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-speed,
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-presets,
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-focus,
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-af,
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-mode,
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-analog { display: none; }
/* Keys become bare icons: no plate, no border, just a shadow so they read on any scene. */
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-btn {
  width: 1.85rem;
  height: 1.85rem;
  background: none;
  background-image: none;
  border-color: transparent;
  box-shadow: none;
  color: #fff;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 90%)) drop-shadow(0 0 1px rgb(0 0 0 / 70%));
}
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-btn svg { width: 1.05rem; height: 1.05rem; }
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-btn:hover {
  color: var(--c-accent);
  border-color: transparent;
}
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-btn:active {
  background: none;
  border-color: transparent;
  color: var(--c-accent);
  transform: scale(0.92);
}
/* Tighter d-pad + zoom cluster so the whole thing occupies a corner, not the frame. */
.tile--ptz:not(.tile--zoomed):not(:fullscreen) .tile__ptz-move { gap: var(--sp-2); }
/* Quick presets [1]..[6]: a grid-tile affordance only — the magnified pad has the full <select>,
   so showing both would be two ways to do one thing. Same bare-icon treatment as the d-pad: no
   plate, shadowed glyphs, so they read over the picture without becoming a panel. */
.tile__ptz-quick { display: none; }
.tile--ptz-presets:not(.tile--zoomed):not(:fullscreen) .tile__ptz-quick {
  display: flex;
  justify-content: center;
  gap: var(--sp-1);
}
.tile__ptz-qbtn {
  min-width: 1.5rem;
  height: 1.5rem;
  padding: 0 0.3rem;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  color: #fff;
  font: inherit;
  font-size: var(--fs-sm);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  cursor: pointer;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 90%)) drop-shadow(0 0 1px rgb(0 0 0 / 70%));
  transition: color var(--transition), transform var(--transition);
}
.tile__ptz-qbtn:hover { color: var(--c-accent); }
.tile__ptz-qbtn:active { color: var(--c-accent); transform: scale(0.9); }
.tile__ptz-qbtn.is-hidden { display: none; }   /* no preset in that slot */

/* ANALOG MODE FALLBACK — load-bearing. In joystick mode `body.ptz-analog` hides the d-pad AND the
   digital zoom keys (the joystick and shuttle replace them), but we just hid the joystick on grid
   tiles because it needs a surface to drag on. Without these two rules a grid tile in analog mode
   would show an EMPTY pad. Specificity has to beat those hides (3 classes), hence the :not() pair. */
body.ptz-analog .tile--ptz-pt:not(.tile--zoomed):not(:fullscreen) .tile__ptz-dpad { display: grid; }
body.ptz-analog .tile--ptz-zoom:not(.tile--zoomed):not(:fullscreen) .tile__ptz-zoom { display: flex; }

.tile__ptz-move {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

/* Speed: normalized velocity scalar, applied to every move. */
.tile__ptz-speed {
  width: 100%;
  accent-color: var(--c-accent);
  cursor: pointer;
}

.tile__ptz-presets {
  display: none;   /* presets are PTZ positions — shown only with a pan/tilt or zoom node */
  gap: var(--sp-1);
}
.tile--ptz-presets .tile__ptz-presets { display: flex; }

.tile__ptz-select {
  flex: 1;
  min-width: 0;
  padding: 0 var(--sp-2);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--fs-sm);
}

.tile__ptz-mini {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  flex: none;
  background-color: color-mix(in srgb, var(--c-surface-raised) 90%, transparent);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition);
}

.tile__ptz-mini svg {
  width: 1rem;
  height: 1rem;
}

.tile__ptz-mini:hover {
  border-color: var(--c-accent);
}

/* Fisheye virtual-PTZ preset bar — overlays the bottom of a MAGNIFIED fisheye tile only. */
.tile__fisheye {
  display: none;
  position: absolute;
  left: 50%;
  bottom: var(--sp-3);
  transform: translateX(-50%);
  z-index: 5;
  max-width: min(24rem, 86%);
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-1);
  border-radius: var(--radius);
  background-color: color-mix(in srgb, var(--c-surface) 82%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border: 1px solid var(--c-border-strong);   /* match the PTZ pad — reads crisply over bright video */
}
.tile--fisheye.tile--zoomed .tile__fisheye,
.tile--fisheye:fullscreen .tile__fisheye { display: flex; }
.tile__fisheye-select {
  flex: 1;
  min-width: 6rem;
  height: 2rem;
  padding: 0 var(--sp-2);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--fs-sm);
}
.tile__fisheye-mini {
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  flex: none;
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition);
}
.tile__fisheye-mini svg { width: 1rem; height: 1rem; }
.tile__fisheye-mini:hover:not(:disabled) { border-color: var(--c-accent); }
.tile__fisheye-mini:disabled { opacity: .4; cursor: default; }
/* Leading glyph — signals "saved camera views/positions" so the bar reads as PTZ presets, not a
   generic dropdown. Non-interactive. */
.tile__fisheye-badge {
  display: grid;
  place-items: center;
  flex: none;
  width: 1.5rem;
  color: var(--c-text-muted);
}
.tile__fisheye-badge svg { width: 1.05rem; height: 1.05rem; }
/* Split the bar into "navigate" (‹ select ›) and "edit" (save · delete) clusters. */
.tile__fisheye-mini[data-fish-act="save"] { margin-left: var(--sp-2); }
/* Delete is destructive — but stays neutral at rest (the confirm dialog is the real guard) and only
   goes red on intent, so a resting alarm colour doesn't fight the video. */
.tile__fisheye-mini[data-fish-act="del"] { color: var(--c-text-muted); }
.tile__fisheye-mini[data-fish-act="del"]:hover,
.tile__fisheye-mini[data-fish-act="del"]:focus-visible { color: var(--c-error); border-color: var(--c-error); }

/* Thumbnail preset picker — a grid of saved dewarp views, opening above the bar (which is position:absolute,
   so this anchors to it). Click a card to recall that angle. Reused later as the "which views?" add dialog. */
.tile__fisheye-picker {
  position: absolute;
  bottom: calc(100% + var(--sp-2));
  left: 50%;
  transform: translateX(-50%);
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--sp-2);
  width: min(22rem, 90vw);
  max-height: 46vh;
  overflow-y: auto;
  padding: var(--sp-2);
  background-color: color-mix(in srgb, var(--c-surface) 92%, transparent);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  z-index: 6;
}
.tile__fisheye-card { position: relative; }
.tile__fisheye-card-main {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  width: 100%;
  padding: var(--sp-1);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  cursor: pointer;
}
.tile__fisheye-card-main:hover { border-color: var(--c-accent); }
/* "+ tile" — drop this angle onto the wall; sits in the card's top-right corner. */
.tile__fisheye-card-add {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  width: 1.5rem;
  height: 1.5rem;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--c-border-strong);
  border-radius: 50%;
  background-color: color-mix(in srgb, var(--c-surface-raised) 88%, transparent);
  color: var(--c-text);
  cursor: pointer;
}
.tile__fisheye-card-add:hover { border-color: var(--c-accent); color: var(--c-accent); }
.tile__fisheye-card-add svg { width: 0.9rem; height: 0.9rem; }
.tile__fisheye-card-img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background-color: var(--c-bg);
  display: block;
}
.tile__fisheye-card-name {
  font-size: var(--fs-xs);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tile__fisheye-empty {
  grid-column: 1 / -1;
  padding: var(--sp-2);
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}


/* Fisheye ANGLE tile: a session-only wall cell showing one locked dewarp angle of a shared fisheye
   stream. The dewarp canvas fills the stage; a corner button removes it. Kept at a 16:9 frame (even
   where the wall stretches tiles to fill the grid) so the wall preview matches the magnified view. */
/* Fill the grid cell with BLACK and letterbox the 16:9 dewarp centred inside — so the bands around a
   short/tall cell are black too (not the wall backdrop), matching the magnified view. Works in no-spacing
   mode where there's no gap to hide the cell letterbox. */
.tile--view {
  background-color: #000;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* An angle tile's locked dewarp canvas (.tile__dewarp, z-index:2, opaque) fills the cell, so lift the
   title (top-left, like a stream tile) above it — otherwise it's painted over. */
.tile--view .tile__head { z-index: 3; }
.tile--view .tile__stage {
  position: relative;
  inset: auto;
  width: 100%;
  aspect-ratio: 16 / 9;
  max-height: 100%;
}
/* Follow the wall's fit setting (like a camera tile's .tile__video object-fit): contain = the default
   16:9 letterbox above; cover/fill = fill the whole cell. The dewarp keeps pixels square (uAspect), so
   filling the cell simply widens/narrows the visible FOV rather than distorting — no black bars, matching
   the camera tiles. (Cover and fill coincide here: a live reprojection has no fixed frame to stretch.) */
.grid[data-fit="cover"] .tile--view .tile__stage,
.grid[data-fit="fill"] .tile--view .tile__stage {
  aspect-ratio: auto;
  height: 100%;
  max-height: none;
}
/* Federated NVR channel: its RTSP URL, selectable for copy-into-VLC testing. Bottom strip, monospace,
   hidden once live. Selectable (and click-swallowing) so grabbing the text never magnifies the tile. */
.tile__rtsp {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  padding: 2px 6px;
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  line-height: 1.35;
  color: var(--c-on-accent);
  background: rgb(0 0 0 / 62%);
  word-break: break-all;
  text-align: left;
  cursor: text;
  user-select: text;
  -webkit-user-select: text;
}
.tile--live .tile__rtsp { display: none; }   /* the picture's up — no need for the debug URL */

.tile__ptz-dpad {
  display: none;   /* pan/tilt d-pad — shown only when the camera reports pan/tilt */
  grid-template-columns: repeat(3, 2.25rem);
  grid-template-rows: repeat(3, 2.25rem);
  gap: var(--sp-1);
}
.tile--ptz-pt .tile__ptz-dpad { display: grid; }

.tile__ptz-zoom {
  display: none;   /* zoom column — shown only when the camera reports zoom */
  flex-direction: column;
  gap: var(--sp-1);
}
.tile--ptz-zoom .tile__ptz-zoom { display: flex; }

/* Focus (near/far) column — hidden unless ONVIF reported continuous focus for this camera. */
.tile__ptz-focus {
  display: none;
  flex-direction: column;
  gap: var(--sp-1);
}
.tile--ptz-focus .tile__ptz-focus { display: flex; }

/* Autofocus column — hidden unless ONVIF advertised the Imaging Focus AUTO mode. Independent of
   the manual focus column: a camera can expose one, both, or neither. */
.tile__ptz-af {
  display: none;
  flex-direction: column;
  gap: var(--sp-1);
}
.tile--ptz-af .tile__ptz-af { display: flex; }

.tile__ptz-btn {
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  background-color: color-mix(in srgb, var(--c-surface-raised) 90%, transparent);
  /* Raised key: a top-to-bottom sheen + inner top-highlight + soft drop-shadow give it relief. */
  background-image: linear-gradient(to bottom, rgb(255 255 255 / 7%), rgb(0 0 0 / 7%));
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%), 0 2px 4px rgb(0 0 0 / 22%);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  cursor: pointer;
  touch-action: none;   /* press-and-drag must not scroll the panel on touch */
  transition: background-color var(--transition), border-color var(--transition), box-shadow var(--transition);
}

.tile__ptz-btn svg {
  width: 1.2rem;
  height: 1.2rem;
}

.tile__ptz-btn:hover {
  border-color: var(--c-accent);
}

.tile__ptz-btn:active {
  background-color: var(--c-accent);
  background-image: none;
  border-color: var(--c-accent);
  color: var(--c-on-accent);
  box-shadow: inset 0 2px 5px rgb(0 0 0 / 38%);   /* pushed IN — the key depresses */
}

.tile__ptz-btn--up    { grid-column: 2; grid-row: 1; }
.tile__ptz-btn--left  { grid-column: 1; grid-row: 2; }
.tile__ptz-btn--right { grid-column: 3; grid-row: 2; }
.tile__ptz-btn--down  { grid-column: 2; grid-row: 3; }

/* ---- Analog PTZ: joystick (pan/tilt) + vertical zoom shuttle. Alternative to the d-pad, chosen per
   device via body.ptz-analog (set in app.js). Wired in buildTile (wireJoystick / wireZoomShuttle). ---- */

/* Method toggle — small pill, top-right of the pad; only where there's a motor to steer. */
.tile__ptz-mode {
  display: none;
  position: absolute;
  top: calc(-1 * var(--sp-2));
  right: calc(-1 * var(--sp-2));
  width: 1.9rem;
  height: 1.9rem;
  place-items: center;
  padding: 0;
  border: 1px solid var(--c-border-strong);
  border-radius: 50%;
  background-color: var(--c-surface-raised);
  color: var(--c-text-muted);
  cursor: pointer;
  z-index: 2;
}
.tile--ptz-pt .tile__ptz-mode,
.tile--ptz-zoom .tile__ptz-mode { display: grid; }
.tile__ptz-mode:hover { color: var(--c-text); border-color: var(--c-accent); }
.tile__ptz-mode .ptz-mode-ic { width: 1rem; height: 1rem; }
.tile__ptz-mode .ptz-mode-ic--arrows { display: none; }      /* default (arrows mode): offer the joystick */
body.ptz-analog .tile__ptz-mode .ptz-mode-ic--analog { display: none; }
body.ptz-analog .tile__ptz-mode .ptz-mode-ic--arrows { display: block; }

/* The analog block shows only in analog mode; the d-pad + speed slider hide there (deflection = speed). */
.tile__ptz-analog { display: none; }
body.ptz-analog .tile--ptz .tile__ptz-analog {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
}
body.ptz-analog .tile--ptz-pt .tile__ptz-dpad { display: none; }
body.ptz-analog .tile__ptz-speed { display: none; }
/* In analog mode the shuttle owns zoom — hide the digital +/- buttons so the board stays clean. */
body.ptz-analog .tile--ptz-zoom .tile__ptz-zoom { display: none; }
/* Analog mode: lay the whole pad out as a centered horizontal row so the joystick, the zoom shuttle and
   the focus/af column all share ONE line — vertical space is precious on a magnified phone tile, and
   stacking focus BELOW the joystick+shuttle wasted a whole row. Presets (full-width) wrap to their own
   line below. Covers the zoom-only camera too (no joystick → shuttle sits left of focus). */
body.ptz-analog .tile--ptz .tile__ptz {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
}
body.ptz-analog .tile--ptz .tile__ptz-presets { flex-basis: 100%; }

/* Joystick: a round well with a draggable handle that springs back to centre. */
.tile__joystick {
  display: none;
  position: relative;
  width: 8.5rem;
  height: 8.5rem;
  border-radius: 50%;
  /* Precision-instrument face: a crosshair (H+V guides) + concentric rings AT THE SPEED-TIER BOUNDARIES.
     closest-side makes the radial %s map 1:1 to deflection magnitude, so the rings sit exactly where the
     haptic tier changes — keep 40%/72% in step with HAPTIC_T1/HAPTIC_T2 in app.js. Clipped to the circle
     by border-radius. */
  background:
    linear-gradient(to right, transparent calc(50% - 0.5px), color-mix(in srgb, var(--c-text) 15%, transparent) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)),
    linear-gradient(to bottom, transparent calc(50% - 0.5px), color-mix(in srgb, var(--c-text) 15%, transparent) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)),
    radial-gradient(circle closest-side at center,
      color-mix(in srgb, var(--c-accent) 16%, transparent) 0 9%,
      transparent 9% 39.5%,
      color-mix(in srgb, var(--c-text) 22%, transparent) 39.5% 40.5%,
      transparent 40.5% 71%,
      color-mix(in srgb, var(--c-text) 22%, transparent) 71% 72%,
      transparent 72%),
    color-mix(in srgb, var(--c-surface) 70%, transparent);
  border: 1px solid var(--c-border-strong);
  touch-action: none;
  /* Suppress the OS long-press behaviours (text-selection, callout, and its haptic) on a held drag. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  cursor: grab;
}
.tile--ptz-pt .tile__joystick { display: block; }
.tile__joystick:active { cursor: grabbing; }
.tile__joystick-handle {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 3rem;
  height: 3rem;
  margin: -1.5rem 0 0 -1.5rem;
  border-radius: 50%;
  /* Simple darkened knob: a soft convex ball via the gradient alone (no inner shadow), lifted off the
     face by a plain drop shadow. */
  background:
    radial-gradient(circle at 36% 30%,
      color-mix(in srgb, var(--c-accent) 68%, #fff),
      color-mix(in srgb, var(--c-accent) 82%, #000) 50%,
      color-mix(in srgb, var(--c-accent) 46%, #000));
  box-shadow: 0 3px 8px rgb(0 0 0 / 40%);
  transition: transform 180ms cubic-bezier(0.2, 1.1, 0.3, 1);   /* the spring back; killed while dragging */
  pointer-events: none;
}

/* Zoom control as a mixing-console FADER — a rectangular cap that rides a slotted channel up/down
   (zoom only moves on one axis, so a fader is more honest than a round knob). */
.tile__zshuttle {
  display: none;
  position: relative;
  width: 2.4rem;
  height: 8.5rem;
  border-radius: var(--radius-md);
  /* Fader housing with a slot: a thin dark groove down the centre the cap straddles + travels. */
  background:
    linear-gradient(to right,
      transparent calc(50% - 1.5px),
      rgb(0 0 0 / 32%) calc(50% - 1.5px) calc(50% + 1.5px),
      transparent calc(50% + 1.5px)),
    color-mix(in srgb, var(--c-surface) 70%, transparent);
  border: 1px solid var(--c-border-strong);
  box-shadow:                                  /* recessed channel the cap rides in */
    inset 0 2px 6px rgb(0 0 0 / 26%),
    inset 0 -2px 4px rgb(255 255 255 / 5%);
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  cursor: ns-resize;
}
.tile--ptz-zoom .tile__zshuttle { display: block; }
.tile__zshuttle-thumb {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 2.1rem;
  height: 1.15rem;
  transform: translate(-50%, -50%);
  border-radius: 5px;
  /* Raised horizontal key, darkened to match the joystick knob (a control, not a glow). */
  background:
    linear-gradient(to bottom,
      color-mix(in srgb, var(--c-accent) 70%, #fff),
      color-mix(in srgb, var(--c-accent) 82%, #000) 50%,
      color-mix(in srgb, var(--c-accent) 48%, #000));
  box-shadow:
    0 3px 7px rgb(0 0 0 / 45%),
    inset 0 1px 1px rgb(255 255 255 / 22%),
    inset 0 -2px 3px rgb(0 0 0 / 34%);
  transition: top 180ms cubic-bezier(0.2, 1.1, 0.3, 1);
  pointer-events: none;
  z-index: 1;
}
/* The finger notch across the cap — an engraved grip line (dark groove + light rim below it). */
.tile__zshuttle-thumb::before {
  content: "";
  position: absolute;
  left: 18%;
  right: 18%;
  top: 50%;
  height: 2px;
  transform: translateY(-50%);
  border-radius: 1px;
  background: rgb(0 0 0 / 38%);
  box-shadow: 0 1px 0 rgb(255 255 255 / 30%);
}

/* Directional chevrons hint the joystick steers all four ways (diagonals too). Decorative only. */
.tile__joystick-hint {
  position: absolute;
  display: grid;
  place-items: center;
  color: var(--c-text-muted);
  opacity: 0.5;
  pointer-events: none;
}
.tile__joystick-hint svg { width: 0.85rem; height: 0.85rem; }
.tile__joystick-hint--up    { top: 0.2rem;    left: 50%; transform: translateX(-50%); }
.tile__joystick-hint--down  { bottom: 0.2rem; left: 50%; transform: translateX(-50%); }
.tile__joystick-hint--left  { left: 0.2rem;   top: 50%;  transform: translateY(-50%); }
.tile__joystick-hint--right { right: 0.2rem;  top: 50%;  transform: translateY(-50%); }

/* Zoom-in (top) / zoom-out (bottom) magnifier icons mark which way the shuttle goes. Decorative only. */
.tile__zshuttle-cap {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  display: grid;
  place-items: center;
  color: var(--c-text-muted);
  opacity: 0.6;
  pointer-events: none;
}
.tile__zshuttle-cap svg { width: 0.95rem; height: 0.95rem; }
.tile__zshuttle-cap--in  { top: 0.35rem; }
.tile__zshuttle-cap--out { bottom: 0.35rem; }

@media (prefers-reduced-motion: reduce) {
  .tile__joystick-handle, .tile__zshuttle-thumb { transition: none; }
}

/* --------------------------------------------------------- Loading state */

.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-4);
  min-height: 50vh;
  padding: var(--sp-6);
  color: var(--c-text-muted);
}

.loading__spinner {
  width: 2.5rem;
  height: 2.5rem;
  border: 3px solid var(--c-border);
  border-top-color: var(--c-accent);
  border-radius: 50%;
  animation: tile-spin 0.8s linear infinite;
}

.loading__text {
  margin: 0;
  font-size: var(--fs-sm);
}

/* ------------------------------------------------------------- Login page */

.login {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: var(--sp-4);
}

.login__card {
  width: min(92vw, 22rem);
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5);
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.login__logo {
  display: block;
  width: auto;
  height: 44px;
  max-width: 100%;
  margin: 0 auto var(--sp-1);
  object-fit: contain;
}
.login__title {
  margin: 0;
  font-size: var(--fs-lg);
  font-weight: 600;
  text-align: center;
}

.login__form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

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

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-6);
  text-align: center;
  color: var(--c-text-muted);
}

.empty__title {
  margin: 0;
  font-size: var(--fs-lg);
  color: var(--c-text);
}

.empty__text {
  margin: 0;
  font-size: var(--fs-sm);
  max-width: 40ch;
}

/* ----------------------------------------------------------------- Footer */

.footer {
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--c-border);
}

.footer__note {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--c-text-faint);
}

/* ----------------------------------------------------------------- Dialog */

.dialog {
  padding: 0;
  width: min(92vw, 40rem);
  background-color: var(--c-surface);
  color: var(--c-text);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.dialog::backdrop {
  background-color: rgb(4 6 10 / 70%);
}

.dialog__form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5);
}

.dialog__title {
  margin: 0;
  font-size: var(--fs-lg);
  font-weight: 600;
}

.dialog__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-3) var(--sp-4);
}

@media (max-width: 560px) {
  .dialog__grid {
    grid-template-columns: minmax(0, 1fr);
  }
  /* Camera form on mobile: compact "Label : input" rows (label left, field right) instead of the
     tall label-above-input stack. Checkboxes keep their own row; help text wraps under the input. */
  .dialog__grid--inline .field:not(.field--checkbox) {
    display: grid;
    grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr);
    align-items: center;
    column-gap: var(--sp-3);
    row-gap: var(--sp-1);
  }
  .dialog__grid--inline .field:not(.field--checkbox) > .input,
  .dialog__grid--inline .field:not(.field--checkbox) > .field__help {
    grid-column: 2;
  }
  .dialog__grid--inline .field--narrow { max-width: none; }        /* the grid, not a cap, sets width */
  /* Ports stay compact; selects (transport/PTZ) fill the column so their labels don't truncate. */
  .dialog__grid--inline .field--narrow > input[type="number"] { max-width: 7rem; justify-self: start; }
}

/* A field that spans the full width of the two-column dialog grid (e.g. a ports textarea). */
.field--full { grid-column: 1 / -1; }
textarea.input { min-height: 4.5rem; resize: vertical; font-family: inherit; line-height: 1.4; }

.dialog__error {
  margin: 0;
  padding: var(--sp-2) var(--sp-3);
  background-color: color-mix(in srgb, var(--c-error) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-error) 35%, transparent);
  border-radius: var(--radius-sm);
  color: var(--c-error);
  font-size: var(--fs-sm);
}

.dialog__actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;   /* the camera dialog carries up to 5 buttons; wrap rather than overflow (esp. in FR) */
  gap: var(--sp-2);
}

/* Cancel/Save stay right-aligned whether or not the Delete button is present. */
.dialog__actions-right {
  margin-left: auto;
  display: flex;
  gap: var(--sp-2);
}

/* ------------------------------------------------------------- Embed mode */
/* ?embed=1 — view-only widget for an iframe: strip all management chrome. */

body.embed .topbar,
body.embed .footer,
body.embed .empty {
  display: none;
}

body.embed .stage {
  padding: var(--sp-2);
}

/* View-only embed: hide the management tools (probe/edit/events) but keep the viewer
   controls — HD/SD and fullscreen — so a host page still gets quality + fullscreen per tile. */
body.embed .tile__tool--probe,
body.embed .tile__tool--edit,
body.embed .tile__tool--events,
body.embed .tile__diagnostic-actions {
  display: none;
}

/* Always available in embed (no hover — works on touch panels), without magnifying first. */
body.embed .tile__tools {
  opacity: 1;
}

/* ?cam=<id> — a single camera filling the whole frame, no grid gutters. */
body.embed.single .stage {
  padding: 0;
}

body.embed.single .grid {
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
  height: 100vh;
}

body.embed.single .tile,
body.embed.single .tile--zoomed {
  aspect-ratio: auto;
  height: 100vh;
  border: 0;
  border-radius: 0;
}

/* -------------------------------------------------------------- Help modal */

.dialog--wide {
  width: min(94vw, 46rem);
}

/* ---------------------------------------------------- Confirm (delete) modal */

.dialog--narrow {
  width: min(90vw, 26rem);
}

.confirm__body {
  margin: 0;
  color: var(--c-text-muted);
  font-size: var(--fs-sm);
}

/* Rewind picker: a stack of full-width "N min ago" choices — big, thumb-friendly targets. */
/* Rewind quick-dial gets its own (wider) width than the SHARED .dialog--narrow — that class is also the
   delete-confirm modal, which must stay compact. Wider reveals ~2 more chips per row on these screens. */
#rewind-dialog { width: min(92vw, 31rem); }

/* Footage preview at the centred chip's offset (frame.php). object-fit:contain letterboxes any aspect
   (a 1:1 fisheye pillarboxes cleanly). Hidden until a chip is centred / on the empty state. */
.rewind__preview {
  position: relative;
  margin: var(--sp-3) 0;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: #000;
  display: grid;
  place-items: center;
}
.rewind__preview.is-empty { display: none; }
.rewind__preview-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
.rewind__preview-cap {
  position: absolute;
  left: var(--sp-2);
  bottom: var(--sp-2);
  padding: 2px var(--sp-2);
  border-radius: var(--radius-sm);
  background: rgb(0 0 0 / 62%);
  color: #fff;
  font-size: var(--fs-sm);
  font-weight: 600;
}

/* iOS-style wheel picker: one horizontal snap-scroller per unit under a fixed centre band. The chip
   snapped under the band in the ACTIVE row is the selection; the other rows are dimmed + reset. */
.rewind__opts {
  position: relative;   /* hosts the centre selection band */
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  margin: var(--sp-3) 0 var(--sp-1);
}
/* Centre band — two vertical rules framing the selected chip. One per line, shown ONLY on the active
   line, so it's unmistakable you're picking a single value (never a sum across lines). */
.rewind__band {
  display: none;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 3.25rem;
  transform: translateX(-50%);
  border-left: 2px solid var(--c-accent);
  border-right: 2px solid var(--c-accent);
  border-radius: var(--radius-sm);
  pointer-events: none;
  z-index: 2;
}
.rewind__line.is-active .rewind__band { display: block; }
/* One unit line = a FIXED left label overlaying a horizontal scroller. The label lives on the (non-
   scrolling) line wrapper so it stays put while the chips scroll under it. */
.rewind__line {
  position: relative;
  transition: opacity var(--transition);
}
.rewind__line:not(.is-active) { opacity: 0.4; }   /* only the line you're dialling is live */
.rewind__row {
  position: relative;
  display: flex;
  gap: var(--sp-2);
  overflow-x: auto;
  padding: var(--sp-1) 50%;   /* half-viewport ends so ANY chip (incl. first/last) can reach the centre */
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;      /* the band is the focus — hide the scrollbar */
  overscroll-behavior-x: contain;
  touch-action: pan-x;
  transition: transform var(--transition);
}
/* An inactive line's value slides OUT of the centre band; it slides back in when the line is picked. */
.rewind__line:not(.is-active) .rewind__row { transform: translateX(-2.25rem); }
.rewind__row::-webkit-scrollbar { display: none; }
/* Fixed unit tag over the left edge of the LINE (not the scroller) so it never scrolls away. */
.rewind__unit {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  padding: 0 var(--sp-3) 0 var(--sp-1);
  color: var(--c-text-muted);
  font-size: var(--fs-sm);
  font-weight: 600;
  pointer-events: none;
  background: linear-gradient(90deg, var(--c-surface) 72%, transparent);
}
.rewind__opt {
  flex: 0 0 auto;
  scroll-snap-align: center;
  justify-content: center;
  padding: var(--sp-2) var(--sp-3);
  min-height: 2.75rem;   /* 44px finger-target floor on the industrial touch PCs */
  min-width: 2.75rem;
  font-size: var(--fs-base);
  border-radius: 999px;
  white-space: nowrap;
}
/* The chip under the band in the active line = the selection. */
.rewind__line.is-active .rewind__opt.is-sel {
  border-color: var(--c-accent);
  color: var(--c-text);
  font-weight: 700;
}
.rewind__opt:active { transform: scale(0.95); }
/* Offset predates this camera's oldest footage → unavailable: ghosted (faint + struck, no chip frame) so
   it reads as unavailable even on the active line, and snap SKIPS it so it's never a resting stop. */
.rewind__opt:disabled {
  opacity: 1;
  color: var(--c-text-faint);
  background: transparent;
  border-color: transparent;
  text-decoration: line-through;
  cursor: not-allowed;
  scroll-snap-align: none;
  transform: none;
}
.rewind__empty {
  margin: var(--sp-3) 0;
  color: var(--c-text-muted);
  font-size: var(--fs-base);
}
.rewind__earliest { width: 100%; }

.confirm__name {
  display: block;
  margin-top: var(--sp-2);
  color: var(--c-text);
  font-size: var(--fs-base);
  word-break: break-word;
}

/* ------------------------------------------------------------- ONVIF scan */

/* Shared dialog toolbar + status line (scan and events dialogs). */
.dialog__bar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

.dialog__status {
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

/* Small inline provenance/status pill — shared by scan rows and event rows. */
.pill {
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  padding: 0 var(--sp-2);
  white-space: nowrap;
}

.scan__results {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  max-height: 50vh;
  overflow-y: auto;
  overflow-x: auto;   /* wide result tables scroll inside the modal instead of overflowing it */
}

.scan__row {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
}

.scan__info {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1;
}

.scan__host {
  font-family: var(--font-mono);
  color: var(--c-text);
}

.scan__meta {
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.scan__action {
  margin-left: auto;
}

.scan__badge {
  font-size: var(--fs-xs);
  color: var(--c-ok);
  white-space: nowrap;
}

/* ONVIF auto-fill summary inside the camera dialog. */
.onvif-summary {
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
  padding: var(--sp-2) var(--sp-3);
  background-color: color-mix(in srgb, var(--c-accent) 8%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-accent) 25%, transparent);
  border-radius: var(--radius-sm);
}

.onvif-summary--error {
  color: var(--c-error);
  background-color: color-mix(in srgb, var(--c-error) 10%, transparent);
  border-color: color-mix(in srgb, var(--c-error) 30%, transparent);
}

/* ----------------------------------------------------------- ONVIF events */

.event__results {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  max-height: 50vh;
  overflow-y: auto;
}

.event__row {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
}

.event__topic {
  flex: 1;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--c-text);
  word-break: break-word;
}

.event__state {
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
  white-space: nowrap;
}

/* An asserted state (motion=true, input high…) stands out — text and whole row. */
.event__state--active {
  color: var(--c-warn);
  font-weight: 700;
}

.event__row--active {
  border-left: 3px solid var(--c-warn);
  background-color: color-mix(in srgb, var(--c-warn) 8%, var(--c-surface-raised));
}

/* --------------------------------------------------- Async feedback bits */

/* Small inline spinner for a dialog status line (scan/detect/events waits). */
.spinner-inline {
  display: inline-block;
  width: 1rem;
  height: 1rem;
  vertical-align: -0.15rem;
  border: 2px solid var(--c-border);
  border-top-color: var(--c-accent);
  border-radius: 50%;
  animation: tile-spin 0.8s linear infinite;
}

/* Centered message inside a results area (empty / error / hint states). */
.results-note {
  padding: var(--sp-5) var(--sp-3);
  text-align: center;
  color: var(--c-text-muted);
  font-size: var(--fs-sm);
}

.results-note--error {
  color: var(--c-error);
}

/* Brief highlight on the sub/main path inputs when ONVIF fills them. */
.input--flash {
  animation: onvif-flash 1.2s ease-out 1;
}

@keyframes onvif-flash {
  0%   { box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 55%, transparent); }
  100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 0%, transparent); }
}

/* Structured ONVIF detect summary: model heading + one row per profile. */
.onvif-summary__row {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
}

.help__intro {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

.help__subtitle {
  margin: var(--sp-2) 0 calc(-1 * var(--sp-1));
  font-size: var(--fs-base);
  font-weight: 600;
}

.help__table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
}

.help__table td {
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--c-border);
  vertical-align: top;
  color: var(--c-text-muted);
}

.help__table th {
  padding: var(--sp-1) var(--sp-3);
  text-align: left;
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--c-text-faint);
  border-bottom: 1px solid var(--c-border);
}

.help__table td:first-child {
  width: 1%;
  white-space: nowrap;
  color: var(--c-text);
}

.help__table code {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--c-accent-hover);
}

.help__controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  align-items: flex-end;
}

.help__controls .field {
  flex: 1 1 10rem;
}

.help__output {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.help__snippet {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

.help__snippet-row {
  display: flex;
  gap: var(--sp-2);
  align-items: stretch;
}

.help__code {
  flex: 1;
  min-width: 0;
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--c-bg);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--c-text);
  overflow-x: auto;
  white-space: nowrap;
}

.help__code--block {
  white-space: pre-wrap;
  word-break: break-all;
}

.help__snippet-row .btn {
  align-self: flex-start;
  white-space: nowrap;
}

.help__note {
  margin: 0;
  padding: var(--sp-3);
  background-color: color-mix(in srgb, var(--c-accent) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-accent) 30%, transparent);
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
  }
  /* Spinners are status indicators, not decoration — keep a signal, but pulse instead of
     rotate so it doesn't trigger motion sensitivity. */
  .loading__spinner,
  .spinner-inline,
  .tile--switching .tile__tool--quality .tile__tool-spin {
    animation: rm-pulse 1.4s ease-in-out infinite;
  }
}

/* ====================================================================== NVR shell
   The treact-driven chrome (index.php): a top nav bar over a content region that swaps
   between the live wall (iframe island), playback, and settings. Only applies on the
   shell page (body.nvr) — live.php?embed=1 keeps its own layout inside the iframe. */

body.nvr {
  margin: 0;
  /* Exact-viewport app shell: the page itself NEVER scrolls (nav fixed on top, content region
     fills the rest). Pages that have overflow scroll INTERNALLY (Settings tables, future video
     zoom), so Playback's video grid shrinks to the gap between the nav and the bottom deck and
     no page scrollbar ever appears. Height cascade: vh (universal) → dvh (tracks mobile chrome)
     → --vhpx, a JS-measured window.innerHeight (set inline + on resize) that is ALWAYS the true
     visible height — on phones whose browser lacks dvh, 100vh is the URL-bar-hidden size and
     cut ~70px (one toolbar row) off the bottom deck. */
  /* svh = viewport with browser chrome SHOWN — engine-computed, never too tall, no JS. The
     price is a sliver of unused space when mobile chrome auto-hides; never-clipped beats
     never-wasted. --vhpx is only set by JS on browsers without svh (see the @supports gate). */
  height: 100vh;
  height: 100svh;
  height: var(--vhpx, 100svh);
  /* CRITICAL: the base body rule sets min-height:100vh, and min-height BEATS height — without
     this override every .nvr page is forced one URL-bar taller than a mobile screen, clipping
     the bottom under overflow:hidden (the "cannot scroll past" Settings bug). */
  min-height: 0;
  overflow: hidden;
  overscroll-behavior-y: none;   /* the shell is an app screen — no browser pull-to-refresh */
  display: flex;
  flex-direction: column;
}

.nav {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
  padding: 0 var(--sp-4);
  height: 52px;
  flex: 0 0 auto;
  background-color: var(--c-surface);
  border-bottom: 1px solid var(--c-border);
}

.nav__logo {
  height: 28px;
  width: auto;
  flex: 0 0 auto;
  display: block;
}
/* Show the dark-text logo by default (dark theme); swap to the dark-gray-text variant in light. */
.nav__logo--light { display: none; }
[data-mode="light"] .nav__logo--dark { display: none; }
[data-mode="light"] .nav__logo--light { display: block; }

.nav__menu {
  display: flex;
  align-items: stretch;
  gap: var(--sp-1);
  height: 100%;
  flex: 1 1 auto;
}

.nav__item {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0 var(--sp-3);
  border: 0;
  border-bottom: 2px solid transparent;
  background: transparent;
  color: var(--c-text-muted);
  font: inherit;
  font-size: var(--fs-sm);
  cursor: pointer;
  text-decoration: none;
  transition: color var(--transition), border-color var(--transition), background-color var(--transition);
}

.nav__item:hover {
  color: var(--c-text);
  background-color: var(--c-surface-raised);
}

.nav__item.is-active {
  color: var(--c-text);
  border-bottom-color: var(--c-accent);
}

.nav__item svg {
  width: 18px;
  height: 18px;
  flex: 0 0 auto;
}
/* Theme quick-toggle icon: sun in dark (click → light), moon in light (click → dark). */
.nav__theme-moon { display: none; }
[data-mode="light"] .nav__theme-sun { display: none; }
[data-mode="light"] .nav__theme-moon { display: block; }

/* The live wall under the app-shell regime (nav on top, body overflow:hidden): the stage fills the
   gap between the nav and the sticky livebar, and the GRID fits every camera into it — tiles shrink
   to fit rather than the page scrolling (a real NVR never scrolls its wall). Columns = ceil(sqrt N)
   (JS sets --cols) and grid-auto-rows:1fr splits the stage height across the rows, so the whole wall
   is tiled with no overflow. Mobile is the one exception (see the media query below): there the
   stage scrolls and tiles keep their 16/9 shape, since a phone can't show a dense wall legibly. */
body.nvr .stage {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
body.nvr .grid {
  flex: 1 1 auto;
  min-height: 0;
  /* minmax(0,1fr) — NOT plain 1fr (= minmax(auto,1fr)): the `auto` floor stops a row shrinking
     below its content, so on a short viewport the rows can't get small enough and the wall
     overflows. minmax(0,…) lets every row shrink to fit, so N rows always sum to the stage height. */
  grid-auto-rows: minmax(0, 1fr);
}
/* Drop the 16/9 aspect on the desktop wall: with it, a tile computes its own height from its width
   and, once the cell is shorter than that, spills past the cell into the next row (vertical overlap).
   aspect-ratio:auto lets the grid stretch the tile to exactly its cell; the <video> keeps
   object-fit:contain so there's no distortion. (Mobile restores 16/9 below.) */
body.nvr .tile { min-height: 0; aspect-ratio: auto; }
body.nvr .tile--zoomed { height: auto; }   /* zoom fills the stage via grid stretch, not a fixed vh */
/* When there are no cameras the grid is hidden (app.js), so let the empty/loading panels take the
   whole stage and stay centred instead of clinging to the top. */
body.nvr .empty,
body.nvr .loading { flex: 1 1 auto; }

/* ---- Live workspace: left camera panel beside the wall (NVR app page only) ---- */
/* The workspace is now the flex:1 vertical child between the nav and the livebar; it lays the
   sidebar and the stage out in a row. The stage keeps its fit-to-viewport column behaviour and
   just gets narrower. */
body.nvr .workspace {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  position: relative;   /* positioning context for the collapse handle */
}
body.nvr .stage { min-width: 0; }   /* shrink within the workspace row (flex:1 grows it to fill) */

.sidebar {
  flex: 0 0 var(--sidebar-w);
  /* min-width:0 is load-bearing. A flex item's automatic minimum size is its CONTENT's min-content
     width, so without this the panel silently grew past its declared width as camera names got
     longer — and the handle, pinned at that declared width, ended up sitting ON TOP of the list
     (and the collapse no longer slid the panel fully off-screen). .camrow__name already ellipsizes,
     which is what should absorb a long name. */
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background-color: var(--c-surface);
  border-right: 1px solid var(--c-border);
}
.sidebar__head {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: var(--sp-3);
  border-bottom: 1px solid var(--c-border);
}
.sidebar__title { font-weight: 600; font-size: var(--fs-sm); }
.sidebar__count { font-size: var(--fs-sm); color: var(--c-text-muted); font-variant-numeric: tabular-nums; }

/* Collapsible camera panel (desktop/tablet): the handle slides the sidebar off-canvas — a negative
   margin cancels its flex footprint so the wall fills the freed width — and the handle rides to the
   screen edge, chevron flipped, to bring it back. State is persisted per device (app.js). */
.sidebar { transition: margin-left var(--transition); }
body.sidebar-collapsed .sidebar { margin-left: calc(-1 * var(--sidebar-w)); }
.sidebar__handle {
  position: absolute;
  top: 50%;
  left: var(--sidebar-w);
  transform: translateY(-50%);
  z-index: 30;
  width: 1.15rem;
  height: 3.75rem;
  display: grid;
  place-items: center;
  padding: 0;
  /* An attached TAB, not a floating lozenge. It used to be a fully-rounded, raised-surface pill
     sitting off the panel edge, which read as a wart stuck on the list. Now: square on the seam,
     rounded only on the outer side, NO border where it meets the panel (so .sidebar's own
     border-right runs straight through it), and the panel's background — so it looks like the
     edge itself grew a grip. */
  border: 1px solid var(--c-border);
  border-left: 0;
  border-radius: 0 var(--radius) var(--radius) 0;
  background-color: var(--c-surface);
  color: var(--c-text-muted);
  cursor: pointer;
  transition: left var(--transition), color var(--transition), background-color var(--transition);
}
/* The pill stays a slim 1.4rem, but the touch target extends to a comfortable ~44px for tablets. */
.sidebar__handle::before { content: ""; position: absolute; inset: -0.3rem -0.75rem; }
.sidebar__handle svg { width: 0.85rem; height: 0.85rem; transition: transform var(--transition); }
.sidebar__handle:hover { color: var(--c-text); background-color: var(--c-surface-raised); }
/* Collapsed: the panel is off-canvas, so the tab docks at the viewport edge. Its shape already
   reads as attached, so only the position moves. */
body.sidebar-collapsed .sidebar__handle { left: 0; }
body.sidebar-collapsed .sidebar__handle svg { transform: rotate(180deg); }   /* chevron → "open" */
body.single .sidebar__handle { display: none; }   /* no panel in the single-camera view */

.camlist {
  list-style: none;
  margin: 0;
  padding: var(--sp-2);
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* A fisheye row gains a leading fish button (opens the angle chooser); flex keeps it beside the label. */
/* A camera row: a single no-wrap HEAD line [thumb + name] [chevron?] …gap… [counter + dot], with any
   sensor/fisheye leaves stacked BELOW it. The head never wraps — a long name ellipsis-es instead — and
   the dot is pushed far-right (margin-left:auto) so ALL rows' dots align. */
.camrow { display: block; }
.camrow__head { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
.camrow--fish { /* marker only */ }
.camrow--device { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); }   /* NVR rows: no head, label+chevron+leaves direct */
.camrow__sel { flex: 0 0 auto; margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding-right: var(--sp-1); }
.camrow__seln { font-size: var(--fs-xs); color: var(--c-text-muted); font-variant-numeric: tabular-nums; }
/* Multi-sensor selection state on the dot (overrides the connection colour): none / half (partial) / full. */
.camrow__dot--none    { background-color: var(--c-text-muted) !important; }
.camrow__dot--partial { background-image: linear-gradient(90deg, var(--c-ok) 50%, var(--c-text-muted) 50%) !important; }
.camrow__dot--all     { background-color: var(--c-ok) !important; }
.camrow__disclosure {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0;
  border: 0;
  background: none;
  color: var(--c-text-muted);
  cursor: pointer;
}
.camrow__disclosure:hover { color: var(--c-accent); }
.camrow--open .camrow__disclosure { color: var(--c-accent); }
.camrow__disclosure svg {
  width: 1.05rem;
  height: 1.05rem;
  transition: transform var(--transition);   /* chevron points right, rotates down when open */
}
.camrow--open .camrow__disclosure svg { transform: rotate(90deg); }
/* Device (NVR) rows have no far-right dot; let their label grow so the chevron trails at the right edge. */
.camrow--device .camrow__label { flex: 1 1 0; }

/* Virtual-view subtree: leaf rows for a fisheye camera's v-presets. Wraps onto its own full-width line
   under the row, indented under the parent to read as children ("sub-cameras"). */
.camrow__leaves {
  flex-basis: 100%;
  list-style: none;
  margin: var(--sp-1) 0 var(--sp-1) 1.9rem;   /* indent so leaves read as children nested under the row */
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
  border-left: 1px solid var(--c-border);
}
.camleaf {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  margin-left: -1px;                            /* sit the leaf on the guide line */
  border-left: 2px solid transparent;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
  cursor: pointer;
  transition: background-color var(--transition), color var(--transition);
}
.camleaf:hover { background-color: var(--c-surface-raised); color: var(--c-text); }
.camleaf.is-on { color: var(--c-text); border-left-color: var(--c-ok); }
.camleaf--empty { cursor: default; font-style: italic; opacity: 0.7; }
.camleaf--empty:hover { background: none; color: var(--c-text-muted); }
.camleaf__dot {
  flex: 0 0 auto;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: transparent;
  box-shadow: inset 0 0 0 1.5px var(--c-border-strong);   /* hollow when the view is off the wall */
}
.camleaf.is-on .camleaf__dot { background: var(--c-ok); box-shadow: none; }
.camleaf__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }

/* Driver-device group row (an NVR): icon + name + channel-count badge, expands to channel leaves.
   Reuses .camrow--fish (flex-wrap + leaves-below); it has no thumbnail, so it's a lighter header. */
.camrow--device .camrow__label { gap: var(--sp-2); }
.camrow--device .camrow__name { font-weight: 600; }
.camrow__nvr { flex: 0 0 auto; display: grid; place-items: center; color: var(--c-text-muted); }
.camrow__nvr svg { width: 1.05rem; height: 1.05rem; }
.camrow__count {
  flex: 0 0 auto;
  margin-left: auto;
  min-width: 1.3rem;
  padding: 0 0.35rem;
  font-size: var(--fs-xs);
  line-height: 1.4;
  text-align: center;
  color: var(--c-text-muted);
  background-color: var(--c-surface-raised);
  border-radius: 999px;
}
.camrow__label {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex: 0 1 auto;   /* content width (thumb + name), shrinks with ellipsis — so the chevron sits right
                       after the name and the dot is free to align far-right (.camrow__sel margin-auto) */
  width: auto;
  min-width: 0;
  padding: var(--sp-1) var(--sp-2);
  border: 0;
  border-radius: var(--radius);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.camrow__label:hover { background-color: var(--c-surface-raised); }
.camrow__thumb {
  flex: 0 0 auto;
  width: 48px;
  height: 27px;
  object-fit: cover;
  border-radius: 3px;
  background-color: var(--c-video-bg);
}
.camrow__thumb.is-empty { visibility: hidden; }
.camrow__name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-sm);
}
/* Status dot mirrors the tile badge colours (see .tile[data-state] .tile__badge). */
.camrow__dot {
  flex: 0 0 auto;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background-color: var(--c-text-muted);
}
.camrow[data-state="live"] .camrow__dot,
.camrow[data-state="ok"] .camrow__dot { background-color: var(--c-ok); }
.camrow[data-state="connecting"] .camrow__dot { background-color: var(--c-accent); }
.camrow[data-state="busy"] .camrow__dot,
.camrow[data-state="not_found"] .camrow__dot { background-color: var(--c-warn); }
.camrow[data-state="auth_failed"] .camrow__dot,
.camrow[data-state="failed"] .camrow__dot,
.camrow[data-state="error"] .camrow__dot,
.camrow[data-state="offline"] .camrow__dot,
.camrow[data-state="server_offline"] .camrow__dot { background-color: var(--c-error); }
.camrow[data-state="codec_unsupported"] .camrow__dot { background-color: var(--c-codec); }
/* Toggled off the wall: dim the row and grey its thumbnail. */
.camrow.is-off { opacity: 0.55; }
.camrow.is-off .camrow__thumb { filter: grayscale(1); }

/* Single-camera view (?cam=) has nothing to switch — hide the panel. */
body.single .sidebar { display: none; }

/* The camera-list button drives the panel in BOTH layouts: the off-canvas drawer on a phone, and the
   persistent panel's collapse on desktop (the same state the edge handle toggles). It used to be
   phone-only, which left desktop users with just the thin edge handle to find. */
.livebar__drawer-btn { display: inline-flex; }
/* Single-camera view (?cam=) has no list to show. */
body.single .livebar__drawer-btn { display: none; }

/* ---- Phone wall (≤720px) ----
   Placed AFTER the fit-to-viewport rules so it out-orders them (same specificity, later wins). The
   wall drops the desktop fit and instead shows 2 tiles across at 16/9 with the stage SCROLLING, and
   the camera panel becomes an off-canvas DRAWER (toggled from the livebar). */
@media (max-width: 720px) {
  /* Portrait wall: FILL the viewport (no scroll) with the largest streams that fit — JS sets --cols to
     the column count that maximises each 16:9 stream (see gridColumns); a few cameras stack full-width,
     a 2nd column only appears once it yields bigger streams. Tiles stretch to their cell and the video
     stays object-fit:contain, so streams centre with thin letterbox bars and no room is lost. We simply
     STOP overriding the desktop fit rules to the old 2-up scroll here (stage overflow:hidden,
     grid-auto-rows:minmax(0,1fr), tile aspect-ratio:auto all inherit from the desktop block). */
  body.nvr .grid.grid--zoomed { grid-template-columns: minmax(0, 1fr); }  /* zoom stays full width */
  /* (Wall tools are hidden for all touch via @media (hover:none) above.) */

  /* Magnified (phone): become a fixed, full-height single-camera view instead of the scrolling wall —
     nav (page) · stream (fills) · PTZ band (if any) · livebar (page). The stage stops scrolling and
     the single zoomed tile is stretched to fill it; inside, the tile is a flex column of
     stage(stream) + ptz(band). NB two different "stage"s below: `.stage` is the app-level wall
     container; `.tile__stage` is the per-tile media wrapper. */
  body.nvr.has-magnify .stage { overflow: hidden; }
  body.nvr.has-magnify .grid.grid--zoomed {
    flex: 1 1 auto;                       /* fill the stage instead of the wall's 0 0 auto */
    min-height: 0;
    grid-auto-rows: minmax(0, 1fr);       /* the one visible tile takes the whole height */
  }
  body.nvr .grid--zoomed .tile--zoomed {
    /* Both overrides are load-bearing on phone: the phone `body.nvr .tile{aspect-ratio:16/9}` rule
       out-specifies the base `.tile--zoomed{aspect-ratio:auto}`, and `height:auto` drops the desktop
       calc() height — together they let the grid stretch the tile to fill the cell. */
    aspect-ratio: auto;
    height: auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  /* Stream region: fills the height above the pad. The <video> (object-fit:contain) letterboxes
     within it, so the picture is centred in the stream area — not behind the pad. */
  body.nvr .tile--zoomed .tile__stage {
    position: relative;
    inset: auto;
    flex: 1 1 auto;
    min-height: 0;
    width: 100%;
  }
  /* A magnified phone tile ALWAYS fits (contain), whatever the wall's cover/fill display mode: cover
     crops the sides with no way to pan to them, and a fit view reads more naturally full-screen. Pinch
     to zoom in from there. Out-specifies the .grid[data-fit=…] .tile__video rules. */
  body.nvr .grid[data-fit="cover"] .tile--zoomed .tile__video,
  body.nvr .grid[data-fit="fill"] .tile--zoomed .tile__video { object-fit: contain; }
  /* PTZ pad → a real full-width band docked under the stream (was an absolute overlay). */
  body.nvr .tile--ptz.tile--zoomed .tile__ptz {
    position: relative;
    inset: auto;
    left: auto;
    transform: none;
    flex: 0 0 auto;
    width: 100%;
    max-width: none;
    border-radius: 0;
    border-width: 1px 0 0;                /* just a top divider from the stream */
    background-color: var(--c-surface);   /* solid: it's its own band now, not over the picture */
  }
  body.nvr .tile--zoomed .tile__ptz-move { justify-content: center; }

  /* Sidebar → left drawer. Off-canvas by default, slides in with .is-open; a backdrop dims the wall. */
  /* Height of the bottom toolbar, so the drawer and its backdrop can stop ABOVE it instead of
     covering it: from its own box — 2 × --sp-3 padding + a ~36px button — plus the home-indicator
     inset. Same convention as the hardcoded 52px nav height above.
     MUST live inside a selector: a custom property declared at media-block level is invalid and is
     dropped, which leaves every var() referencing it invalid-at-computed-value-time — `bottom` and
     the `inset` shorthand then fall back to auto and the drawer loses its positioning entirely. */
  body.nvr { --livebar-h: calc(3.75rem + env(safe-area-inset-bottom, 0px)); }

  body.nvr .sidebar {
    position: fixed;
    top: 52px;                      /* below the nav */
    left: 0;
    bottom: var(--livebar-h);       /* never overlap the toolbar */
    width: 80vw;
    max-width: 19rem;
    z-index: 45;
    transform: translateX(-100%);
    transition: transform 0.22s ease;
    box-shadow: 2px 0 18px rgb(0 0 0 / 55%);
  }
  body.nvr .sidebar.is-open { transform: translateX(0); }
  .drawer-backdrop {
    position: fixed;
    inset: 52px 0 var(--livebar-h) 0;   /* the toolbar stays reachable while the drawer is open */
    z-index: 44;
    background-color: rgb(0 0 0 / 45%);
  }
  /* Phones use the drawer, not the desktop collapse handle. */
  .sidebar__handle { display: none; }
}

/* Disconnect sits at the far right of the menu. */
.nav__item--end {
  margin-left: auto;
}

.nvr__content {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  display: flex;
  flex-direction: column;
  background-color: var(--c-bg);
}

/* Fill the content region as a flex child rather than height:100% — a percentage height
   doesn't resolve against a flexed parent in every engine, and an iframe with no resolved
   height silently falls back to ~150px, clipping the wall. flex:1 gives it a definite size. */
.live-frame {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  border: 0;
}

/* Placeholder pages (Playback / Settings stubs). */
.page--placeholder {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  padding: var(--sp-6);
  text-align: center;
  color: var(--c-text-muted);
}

.page--placeholder h1 {
  margin: 0;
  font-size: var(--fs-xl);
  color: var(--c-text);
}

.page--placeholder p {
  margin: 0;
  max-width: 42ch;
}

/* Toast host (treact createToast targets #toaster). */
.toaster {
  position: fixed;
  right: var(--sp-4);
  bottom: var(--sp-4);
  z-index: 11000;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  pointer-events: none;
}

/* ── Landscape phone (Live only): reclaim the top-nav + bottom-livebar vertical space so the video
   wall gets FULL height. The whole interface (nav · camera list · controls) stacks into a left 30%
   column; the wall fills the right 70%. Gated on a short landscape viewport = a phone on its side
   (desktop / tablet landscape is taller than 600px and keeps the normal stacked chrome). Only the
   Live page (body.live) has the workspace/sidebar/livebar this rearranges. */
@media (orientation: landscape) and (max-height: 600px) {
  body.nvr.live {
    display: grid;
    grid-template-columns: minmax(0, 30%) minmax(0, 1fr);   /* left interface · right 70% wall */
    grid-template-rows: auto minmax(0, 1fr) auto;
    grid-template-areas:
      "nav   stage"
      "list  stage"
      "bar   stage";
  }
  body.live .nav       { grid-area: nav; }
  body.live .workspace { display: contents; }               /* .sidebar + .stage become grid items */
  body.live .sidebar   { grid-area: list; min-height: 0; }
  body.live .stage     { grid-area: stage; min-width: 0; padding: var(--sp-2); }
  /* nowrap here too: this rule out-specifies the phone media block (body.live .livebar is 0,2,1
     vs .livebar 0,1,0), so without it a short-and-narrow window still wrapped the bar. */
  body.live .livebar   { grid-area: bar; position: static; padding: var(--sp-2); gap: var(--sp-2); justify-content: center; flex-wrap: nowrap; }
  body.live .footer    { display: none; }
  /* Rare warning banners: span the whole width rather than squeezing a column. */
  body.live #insecure-banner, body.live #relay-banner { grid-column: 1 / -1; }

  /* Compact the nav for the narrow left column: drop the wordmark + labels, keep the icon buttons. */
  body.live .nav        { height: auto; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-2); }
  body.live .nav__logo  { display: none; }
  body.live .nav__menu  { width: 100%; flex-wrap: wrap; gap: var(--sp-1); justify-content: space-around; }
  body.live .nav__label { display: none; }
  body.live .nav__item  { padding: var(--sp-2); }
  body.live .nav__item--end { margin-left: 0; }             /* no auto-push in the wrapped compact bar */
}

@media (max-width: 640px) {
  .nav__label {
    display: none;
  }
  .nav {
    gap: var(--sp-3);
  }
}

/* ====================================================================== Playback
   The recordings viewer (playback.php), a standalone island like the live wall. */

/* The island's own body must be EXACTLY the viewport (the base `body` rule only sets
   min-height, which lets the page GROW: the video grid then sizes to its content — a
   width-driven 16:9 video on a widescreen is huge — and shoves the deck below the fold).
   height + overflow:hidden turn the body into a fixed frame: topbar auto, .playback flex:1,
   deck pinned. min-height:0 neutralises the base min-height:100vh (dvh can be < vh on
   mobile). The only scrollbars allowed are internal ones (none today; video zoom later). */
body.pbpage {
  height: 100vh;
  height: 100svh;                /* engine-computed safe height — see body.nvr for rationale */
  height: var(--vhpx, 100svh);   /* JS fallback only where svh is unsupported */
  min-height: 0;
  overflow: hidden;
  overscroll-behavior-y: none;   /* app screen: browser pull-to-refresh is never wanted here */
}

.playback {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-5);
  min-height: 0;
}
body.embed .playback {
  padding: var(--sp-3);
}

/* "No spacing" (seamless) — parallels the Live wall's toggle: drop the page padding and the tile
   chrome so the video fills the width edge-to-edge. The deck (timeline + transport) still separates
   from the video via the .playback flex gap. */
.playback--seamless { padding: 0; }
.playback--seamless .playback__grid { gap: 1px; background-color: var(--c-border); }
.playback--seamless .playback__vtile { border: 0; border-radius: 0; }

/* Video framing (mirrors the Live wall's data-fit): base is object-fit:contain (see .playback__vtile
   video); cover crops to fill, fill stretches. */
.playback__grid[data-fit="cover"] .playback__vtile video { object-fit: cover; }
.playback__grid[data-fit="fill"] .playback__vtile video { object-fit: fill; }

.playback__empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-6);
  text-align: center;
  color: var(--c-text-muted);
}

.playback__panel {
  flex: 1;
  display: flex;
  flex-direction: row;          /* [stage: grid+deck] | [event review column] */
  gap: var(--sp-3);
  min-height: 0;
  min-width: 0;
}
/* The video stage keeps the former panel layout (grid fills height, deck sits at the bottom). */
.playback__stage {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

/* ---- Event review column ------------------------------------------------------------------
   A right-hand column of cropped-thumbnail cards (one per moving-object passage). Desktop: an
   in-flow column that shrinks the stage. Mobile (≤720px): an off-canvas overlay drawer. */
.playback__events {
  flex: 0 0 232px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--c-surface);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.playback__events.is-hidden { display: none; }
.playback__events-head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--c-border);
}
.playback__events-title { font-weight: 600; font-size: var(--fs-sm); }
.playback__events-close { flex: none; }
.playback__events-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--sp-2);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
.playback__events-empty,
.playback__events-foot {
  flex: none;
  color: var(--c-text-muted);
  text-align: center;
}
.playback__events-empty { padding: var(--sp-4) var(--sp-3); font-size: var(--fs-sm); }
.playback__events-empty.is-hidden { display: none; }
.playback__events-foot { padding: var(--sp-1) var(--sp-3) var(--sp-2); font-size: var(--fs-xs); }
.playback__events-foot:empty { display: none; }

/* Card: cropped thumbnail + class icon/label + time. A button (click-to-seek). */
.playback__event {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-1);
  background: var(--c-surface-2);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.playback__event:hover { border-color: var(--c-accent); }
.playback__event:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
/* "Now playing" cursor: the passage the playhead is currently inside (else the most recent one passed). */
.playback__event.is-current {
  border-color: var(--c-accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-accent) 55%, transparent),
              0 0 10px color-mix(in srgb, var(--c-accent) 35%, transparent);
}
.playback__event-thumb {
  position: relative;
  flex: none;
  width: 92px;
  height: 52px;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--c-video-bg);
  display: flex;
  align-items: center;
  justify-content: center;
}
.playback__event-canvas { width: 100%; height: 100%; display: block; }
.playback__event-ic { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--c-text-faint); }
.playback__event-ic svg { width: 22px; height: 22px; }
.playback__event.has-thumb .playback__event-ic { display: none; }   /* real crop landed → hide the icon */
.playback__event.is-thumbfail .playback__event-ic { color: var(--c-warn); }   /* frame fetch failed */
.playback__event-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* TIME is the primary key of an event card (the operator scans for "when"); the class is the caption —
   the thumbnail + icon already carry it. tabular-nums keeps the column of times optically aligned. */
.playback__event-time {
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
}
.playback__event-label {
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Toggle button in the filter bar (styled as a legend item; is-active = column open). */
.playback__events-toggle svg { width: 15px; height: 15px; }

@media (max-width: 720px) {
  /* Off-canvas drawer overlaying the video from the right; the stage keeps full width behind it. */
  .playback__events {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: 60;
    flex-basis: auto;
    width: min(320px, 86vw);
    border-radius: 0;
    box-shadow: -10px 0 28px rgb(0 0 0 / 45%);
  }
}
@media (min-width: 721px) {
  .playback__events-close { display: none; }   /* desktop closes via the toolbar toggle */
}
/* Bottom control deck (VMS layout): scrubber + one control row. The grid above is flex:1 and
   absorbs the height; the deck sizes to its content and stays put at the bottom. */
.playback__deck {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  padding-bottom: env(safe-area-inset-bottom, 0);   /* keep controls above a phone's home bar */
}
.playback__controls {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  flex-wrap: wrap;
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--c-surface);
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
}
.playback__ctl-group {
  display: flex;
  align-items: flex-start;   /* caption-above cells: tops (the labels) share one baseline */
  gap: var(--sp-4);
  flex-wrap: wrap;
}
/* Uniform caption-above cell: a label row over a fixed-height control band, so every ZOOM/FLUX/… caption
   lines up and every control centres on one row regardless of its natural height. */
.field--cell {
  display: grid;
  grid-template-rows: auto 2.25rem;
  gap: var(--sp-1);
  flex: 0 0 auto;
}
.field--cell > .field__label { align-self: end; }
.field--cell > :not(.field__label) { align-self: center; justify-self: start; margin: 0; }
/* Main-screen filter bar (above the timeline): camera picker on the left, event-type filters filling
   the rest and scrolling horizontally if they overflow. */
.playback__filterbar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  min-width: 0;
}
.playback__filterbar .pb-multi { flex: 0 0 auto; }   /* the camera picker keeps its place on the left */
.playback__filterbar .playback__legend {
  flex: 1 1 auto;
  min-width: 0;
  flex-wrap: nowrap;         /* one row… */
  overflow-x: auto;          /* …that scrolls sideways instead of wrapping */
  scrollbar-width: none;     /* no scrollbar chrome — dragging still scrolls it */
}
.playback__filterbar .playback__legend::-webkit-scrollbar { display: none; }
.playback__filterbar .playback__legend-item { flex: 0 0 auto; }   /* chips keep their size + overflow */
/* Pickers + filters container: inline on desktop. */
.playback__drawer { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
/* Sync IA slider: compact inline range + a fixed-width readout so the row doesn't jitter
   while dragging. */
.playback__offset-ctl { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.playback__offset input[type="range"] { width: 110px; accent-color: var(--c-accent); }
.playback__offset-val {
  min-width: 3.2em;
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  color: var(--c-text-muted);
  text-align: right;
}
/* Phase-1 skew probe readout under the Sync IA slider (t−u floor / median). */
.playback__skew-probe {
  font-size: var(--fs-xs);
  color: var(--c-text-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ---- Toolbar revamp: zoom slider · overlay chips · Flux toggle · VIP transport ---- */
/* Zoom = stepped range snapping to four fixed stops; ticks label them under the track. */
.playback__zoomslider { width: 9.5rem; }
.playback__zoom-range { width: 100%; accent-color: var(--c-accent); cursor: pointer; margin: 0; }
.playback__zoom-ticks {
  display: flex; justify-content: space-between;
  font-size: var(--fs-xs); color: var(--c-text-faint);
  margin-top: 1px; font-variant-numeric: tabular-nums;
}
/* Overlay chips (AI / Motion): the legend-item pill look, on one line. */
.playback__overlays { display: inline-flex; gap: var(--sp-2); }
/* Flux SD|HD segmented toggle reuses .pop-fit; a disabled segment (HD unavailable) dims, no pointer. */
.playback__variant .pop-fit__btn:disabled { opacity: 0.35; cursor: default; }
/* Prev/next: the glyph + an inline label of what the button will do ("évt" or "10s"), inside the pill. */
.pb-tbtn--nav {
  display: inline-flex; align-items: center; justify-content: center;
  width: auto; height: auto; min-height: 2.6rem;   /* match the play/pause height */
  padding-inline: var(--sp-3); gap: 4px;
}
.pb-tbtn__sub {
  font-size: var(--fs-xs); font-weight: 600; line-height: 1;
  color: var(--c-text-faint); font-variant-numeric: tabular-nums;
}
.pb-tbtn--nav:hover .pb-tbtn__sub { color: var(--c-text-muted); }
/* Rate readout is a button now — one click resets to 1×. */
.playback__rate {
  cursor: pointer; border: 0; background: none;
  padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm);
}
.playback__rate:hover { background-color: var(--c-surface-raised); color: var(--c-text); }
/* Play/Pause = VIP: icon + text, prominent — the deck's anchor. Fixed min-width so it never resizes
   between the "Lire" and "Pause" states. */
.playback__playpause {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  font-weight: 700; padding-inline: var(--sp-4); min-height: 2.6rem; min-width: 7rem;
}
.playback__playpause-ic { width: 18px; height: 18px; flex: 0 0 auto; }
/* Playing → a subtle sheen sweeps across the button so it reads as "live". Overlaid on the subdued
   is-playing surface; respects reduced-motion. */
.playback__playpause.is-playing {
  background-image: linear-gradient(100deg, transparent 38%, rgba(255, 255, 255, 0.10) 50%, transparent 62%);
  background-size: 220% 100%;
  animation: pb-playing-sheen 2.4s linear infinite;
}
@keyframes pb-playing-sheen { from { background-position: 210% 0; } to { background-position: -110% 0; } }
@media (prefers-reduced-motion: reduce) {
  .playback__playpause.is-playing { animation: none; background-image: none; }
}

/* Sheet chrome exists only on mobile. The toggle's class is doubled because .pb-tbtn (defined
   later) sets display at equal specificity and would win the tie — leaving the button visible
   on desktop. */
.playback__drawer-handle,
.playback__filters-toggle.playback__filters-toggle,
.playback__sheet-backdrop { display: none; }

@media (max-width: 720px) {
  /* The shell is exact-viewport with overflow:hidden (no page scroll), so a tall deck cannot be
     scrolled INTO view — with many cameras the stacked timeline tracks pushed the controls off
     screen and they were simply clipped. Cap the track list and let IT scroll internally.
     touch-action pan-y hands vertical panning to the browser (scrolls this list) while
     horizontal stays ours for scrub/pan and two-finger pinch still reaches the JS handlers. */
  .playback__tl-tracks {
    /* ≈4 tracks (38px each) + a half-row peek to signal scroll — keeps the video grid from getting
       squeezed on a phone; the rest of the cameras are a vertical swipe away. */
    max-height: calc(38px * 4.5);
    overflow-y: auto;
    touch-action: pan-y;
  }

  /* Belt and braces: if ANY viewport quirk still over-sizes the island (an in-app browser with
     odd chrome, a future regression), the deck must stay reachable — let the panel scroll
     internally. Inactive (no scrollbar, no behavior change) whenever everything fits. */
  .playback__panel {
    overflow-y: auto;
  }

  /* Filters/pickers slide up as a bottom-sheet card; the timeline + transport + clock stay put. */
  .playback__drawer {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    z-index: 40;
    flex-direction: column;
    align-items: stretch;
    gap: var(--sp-4);
    /* Near-full-screen sheet: the content's rendered height is DEVICE-dependent (Android font
       inflation makes each field ~90px where desktop renders ~60px), so any small fixed cap
       (the old 75vh) guaranteed that on some phones the tail of the sheet was invisible with
       no scroll affordance — first the filters, then (after reordering) the pickers. Give the
       sheet the screen minus a top reveal; content scrolls inside only when even that isn't
       enough. */
    max-height: calc(100vh - 56px);
    max-height: calc(100svh - 56px);
    max-height: calc(var(--vhpx, 100svh) - 56px);   /* measured height beats a lying svh (Fenix) */
    overflow-y: auto;
    padding: var(--sp-3) var(--sp-4) var(--sp-5);
    background-color: var(--c-surface-raised);
    border-top: 1px solid var(--c-border-strong);
    border-radius: 14px 14px 0 0;
    box-shadow: 0 -8px 24px rgba(0, 0, 0, .4);
    transform: translateY(110%);
    transition: transform .28s ease;
  }
  .playback__drawer.is-open { transform: none; }
  .playback__drawer-handle {
    display: block;
    /* Visual bar stays 40×4 (content-box), but the touch target is the full padded zone —
       a 4px-tall grab area was nearly impossible to hit. */
    width: 40px; height: 4px;
    padding: 14px 60px 10px;
    background-clip: content-box;
    box-sizing: content-box;
    margin: calc(-1 * var(--sp-3)) auto 0;   /* swallow the sheet's top padding into the hit zone */
    border-radius: 2px;
    background-color: var(--c-border-strong);
    touch-action: none;      /* the handle owns its gesture — no browser scroll/pull-to-refresh */
    cursor: grab;
  }
  /* Sheet gestures must not chain into the page (browser pull-to-refresh stole the drag and
     reloaded the app mid-adjustment). */
  .playback__drawer { overscroll-behavior: contain; }
  /* Settings sheet as a 2-column grid: labels align in one column, controls fill the other. Each
     field uses display:contents so its label + control become direct grid cells (every field has
     exactly two — the Sync IA slider+readout are wrapped in .playback__offset-ctl for this). */
  .playback__drawer .playback__ctl-group {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    column-gap: var(--sp-4);
    row-gap: var(--sp-3);
    width: 100%;
  }
  .playback__drawer .playback__ctl-group > .field { display: contents; }
  .playback__drawer .playback__ctl-group > .field > .field__label { white-space: nowrap; }
  .playback__drawer .playback__ctl-group > .field > :not(.field__label) { min-width: 0; }
  /* Full-width controls fill their cell; the Sync IA slider grows to fit too. */
  /* Full-width controls fill their cell. */
  .playback__drawer .pb-multi__btn,
  .playback__drawer #pb-date,
  .playback__drawer .pop-fit,
  .playback__drawer .playback__zoomslider { width: 100%; }
  .playback__drawer .pop-fit { margin-top: 0; }   /* drop the popover top-margin so it centres in its row */
  .playback__drawer .playback__offset-ctl input[type="range"] { flex: 1 1 auto; width: auto; }
  .playback__drawer .switch { justify-self: start; }   /* the switch hugs the left of its cell */
  /* The sheet is taller than its 75vh cap, so late children land in an invisible scroll area —
     the layer/AI filters (this sheet's primary purpose) were "missing". Order them FIRST
     (handle stays on top), pickers scroll below if needed. */
  .playback__drawer-handle { order: -2; }
  /* Event-type filters share the filter bar with the camera picker (above the timeline). Bigger touch
     targets on the chips; the bar scrolls sideways (see .playback__filterbar) rather than wrapping. */
  .playback__filterbar .playback__legend { gap: var(--sp-2); }
  .playback__filterbar .playback__legend-item { padding: var(--sp-2) var(--sp-2); }

  /* .pb-tbtn (defined LATER in the file) wins the display tie at equal specificity, so both
     the base hide and a bare class here silently lose — double up the class to outrank it. */
  .playback__filters-toggle.playback__filters-toggle { display: inline-grid; }
  .playback__sheet-backdrop {
    display: block;
    position: fixed; inset: 0;
    z-index: 39;
    background-color: rgba(0, 0, 0, .45);
    opacity: 0; visibility: hidden;
    transition: opacity .28s ease, visibility .28s ease;
  }
  .playback__sheet-backdrop.is-open { opacity: 1; visibility: visible; }
}

.playback__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: var(--sp-3);
  padding: var(--sp-3);
  background-color: var(--c-surface);
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
}

.playback__meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
}
.playback__hint {
  margin: 0;
  min-height: 1.2em;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}
.playback__legend {
  display: flex;
  gap: var(--sp-3);
  flex-wrap: wrap;
}
.playback__legend-item {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px var(--sp-2);
  border: 1px solid transparent;
  border-radius: 999px;
  background: transparent;
  font: inherit;
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  cursor: pointer;
  transition: opacity var(--transition), border-color var(--transition);
}
.playback__legend-item:hover { border-color: var(--c-border-strong); }
/* Off = filtered out: dim it and grey the swatch so the enabled layers stand out. */
.playback__legend-item:not(.is-active) { opacity: 0.4; }
.playback__legend-item:not(.is-active) .playback__legend-dot { background-color: var(--c-neutral); border-color: var(--c-neutral); }

/* IA event-type dropdown */
.playback__aifilter { position: relative; display: inline-flex; }
.playback__aifilter-caret { margin-left: 3px; color: var(--c-text-faint); font-size: 9px; }
.playback__aifilter-menu {
  position: fixed;            /* placed by JS (placeAiMenu) so it never overflows the viewport */
  z-index: 50;               /* above the mobile filters sheet (z-index 40) */
  min-width: 200px;
  max-height: min(60vh, 320px);
  overflow-y: auto;
  padding: var(--sp-1);
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
}
.playback__aifilter-item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-2);
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
  color: var(--c-text);
  cursor: pointer;
}
.playback__aifilter-item:hover { background-color: var(--c-surface); }
.playback__aifilter-item input { accent-color: var(--c-ai); cursor: pointer; }
.playback__aifilter-ic { display: inline-flex; color: var(--c-ai); }
.playback__aifilter-ic svg { width: 15px; height: 15px; }
.playback__aifilter-label { text-transform: capitalize; }
.playback__aifilter-count {
  margin-left: auto;
  padding: 0 6px;
  border-radius: 999px;
  background-color: color-mix(in srgb, var(--c-ai) 22%, transparent);
  color: var(--c-text);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
}
.playback__aifilter-empty { margin: var(--sp-2); font-size: var(--fs-xs); color: var(--c-text-faint); }

/* AI detection marker on the timeline: a thin orange stem with a diamond head. */
.playback__tl-mark {
  position: absolute;
  top: 4px;
  bottom: 4px;
  width: 2px;
  margin-left: -1px;
  background-color: var(--c-ai);
  cursor: pointer;
}
.playback__tl-mark::before {
  content: "";
  position: absolute;
  top: -2px;
  left: 50%;
  width: 7px;
  height: 7px;
  transform: translateX(-50%) rotate(45deg);
  background-color: var(--c-ai);
  border: 1px solid var(--c-surface);
}
.playback__legend-dot {
  width: 10px;
  height: 10px;
  border-radius: 2px;
}
.playback__legend-dot--rec    { background-color: color-mix(in srgb, var(--c-accent) 55%, transparent); border: 1px solid var(--c-accent); }
.playback__legend-dot--motion { background-color: var(--c-warn); }
.playback__legend-dot--ai     { background-color: var(--c-ai); }
.playback__legend-dot--derived { background-color: var(--c-derived); }
.playback__legend-dot--alarm  { background-color: var(--c-error); }
/* Text-record overlay chip: gold, matching the on-picture line's rule — not the white timeline
   tick, because this chip switches the TEXT on the video, not the marks on the timeline. */
.playback__legend-dot--data   { background-color: var(--c-data, #e6a817); }

/* Analytics markers = our derived events. Same diamond shape as the AI marker, teal instead of
   orange (colour is the whole distinction between the two overlaid layers). */
.playback__tl-mark--derived { background-color: var(--c-derived); }
.playback__tl-mark--derived::before { background-color: var(--c-derived); }

/* Data-search markers (POS/ALPR/… external records) — same diamond, gold. */
.playback__tl-mark--data { background-color: var(--c-data, #e6a817); }
.playback__tl-mark--data::before { background-color: var(--c-data, #e6a817); }

/* "There is text here" ticks — one per external record, always on (the diamonds above mark SEARCH
   hits only). Deliberately apostrophe-sized and pinned to the very top edge of the track: they read
   as a margin annotation on the timeline, and must never compete with the event layers underneath.
   White rather than the data gold: at 2×6px a tinted mark just muddies, and neutral keeps the gold
   diamond of an actual search hit unmistakably distinct from "there is something here". */
.playback__tl-datatick {
  position: absolute;
  top: 1px;
  width: 2px;
  height: 6px;
  margin-left: -1px;
  border-radius: 1px;
  background-color: #fff;
  opacity: 0.8;
  transform: rotate(14deg);
}
/* …but white on the light themes' white surface is an invisible tick, so flip to the foreground. */
[data-mode="light"] .playback__tl-datatick { background-color: var(--c-text); }

/* Data search box (on the time row) + its results dropdown. */
.playback__datasearch { position: relative; display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex: 1 1 8rem; min-width: 0; max-width: 22rem; }
.playback__datasearch-ic { flex: 0 0 auto; display: grid; place-items: center; color: var(--c-text-muted); }
.playback__datasearch-ic svg { width: 1rem; height: 1rem; }
.playback__datasearch-input { flex: 1 1 auto; min-width: 0; }
.playback__datasearch-results {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  left: 0;
  z-index: 40;
  max-height: 50vh;
  overflow-y: auto;
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius);
  box-shadow: 0 10px 28px rgb(0 0 0 / 35%);
}
.playback__dataresult {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) var(--sp-3);
  border: 0;
  border-bottom: 1px solid var(--c-border);
  background: none;
  color: var(--c-text);
  text-align: left;
  cursor: pointer;
  font-size: var(--fs-sm);
}
.playback__dataresult:last-child { border-bottom: 0; }
.playback__dataresult:hover { background-color: var(--c-surface-raised); }
.playback__dataresult-time { flex: 0 0 auto; font-family: var(--font-mono); color: var(--c-accent); }
.playback__dataresult-src { flex: 0 0 auto; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.03em; color: var(--c-text-muted); }
.playback__dataresult-text { flex: 1 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.playback__dataresult--empty { color: var(--c-text-muted); cursor: default; }
.playback__dataresult--empty:hover { background: none; }

/* Video grid: one tile per selected camera, columns set inline to ceil(sqrt(N)). It grows to
   fill the space left between the bar and the (always-visible) timeline; rows share height. */
.playback__grid {
  flex: 1;
  min-height: 0;
  display: grid;
  gap: var(--sp-2);
  grid-template-columns: 1fr;
  grid-auto-rows: 1fr;
}

/* Focus: one camera fills the grid, the rest are hidden (they keep playing in sync). */
.playback__grid--focus {
  grid-template-columns: 1fr !important;
}
.playback__grid--focus .playback__vtile:not(.is-focused) {
  display: none;
}

.playback__vtile {
  position: relative;
  min-height: 0;
  background-color: var(--c-video-bg);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  overflow: hidden;
  cursor: pointer;
}
.playback__vtile video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  background-color: var(--c-video-bg);
}

/* ── Area search: zone overlay + toggle + control bar on the tile; match bar on the track ── */
.playback__area-btn {
  position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 6;
  width: 2rem; height: 2rem; display: grid; place-items: center;
  border: 0; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--c-video-bg) 55%, transparent);
  color: var(--c-on-accent); cursor: pointer; opacity: 0; transition: opacity var(--transition);
}
.playback__vtile:hover .playback__area-btn,
.playback__vtile--area .playback__area-btn { opacity: 1; }
@media (hover: none) { .playback__area-btn { opacity: .8; } }   /* touch: no hover to reveal it */
.playback__area-btn.is-active { background: var(--c-accent); opacity: 1; }
.playback__area-btn svg { width: 18px; height: 18px; }
/* Sound toggle, sitting beside the area (Hexascan) button — same size, same reveal-on-hover, offset
   by one button + a gap so the pair reads as one small toolbar. */
.playback__mute-btn {
  position: absolute; top: var(--sp-2); left: calc(var(--sp-2) + 2rem + var(--sp-1)); z-index: 6;
  width: 2rem; height: 2rem; display: grid; place-items: center;
  border: 0; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--c-video-bg) 55%, transparent);
  color: var(--c-on-accent); cursor: pointer; opacity: 0; transition: opacity var(--transition);
}
.playback__vtile:hover .playback__mute-btn,
.playback__mute-btn.is-active { opacity: 1; }   /* stays visible while a camera is unmuted */
.playback__mute-btn.is-active { background: var(--c-accent); }
.playback__mute-btn svg { width: 18px; height: 18px; }

.playback__zone { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; display: none; pointer-events: none; }
.playback__vtile--area .playback__zone { display: block; pointer-events: auto; cursor: move; touch-action: none; }
.playback__zone-dim { fill: rgb(0 0 0 / 55%); }   /* darken the EXCLUDED area (rect minus polygon) */
.playback__zone-poly {
  fill: color-mix(in srgb, var(--c-accent) 12%, transparent);
  stroke: var(--c-accent); stroke-width: 2px; vector-effect: non-scaling-stroke;
}
.playback__zone-vx { fill: var(--c-accent); stroke: var(--c-on-accent); stroke-width: 1.5px; vector-effect: non-scaling-stroke; cursor: grab; }
.playback__zone-center { fill: color-mix(in srgb, var(--c-accent) 70%, transparent); stroke: var(--c-on-accent); stroke-width: 1.5px; vector-effect: non-scaling-stroke; cursor: move; }

.playback__area-bar {
  position: absolute; left: var(--sp-2); right: var(--sp-2); bottom: var(--sp-2); z-index: 7;
  display: none; align-items: center; gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  background: color-mix(in srgb, var(--c-video-bg) 80%, transparent);
  border-radius: var(--radius-sm); color: var(--c-on-accent); font-size: var(--fs-sm);
}
.playback__area-bar.is-on { display: flex; }
.playback__area-mode {
  border: 1px solid var(--c-border-strong); border-radius: var(--radius-sm);
  background: transparent; color: inherit; font: inherit; padding: 2px var(--sp-2); cursor: pointer;
}
.playback__area-count { flex: 1 1 auto; color: color-mix(in srgb, var(--c-on-accent) 80%, transparent); }
.playback__area-nav {
  border: 0; background: transparent; color: inherit; cursor: pointer;
  font-size: var(--fs-base); line-height: 1; padding: 2px var(--sp-1);
}

/* Timeline: the area-search matches (highlighted; other layers are suppressed while searching). */
.playback__tl-area { position: absolute; top: 3px; bottom: 3px; min-width: 2px; border-radius: 2px; background-color: var(--c-accent); }

/* Digital video zoom (RtspZoom pinch/wheel/pan): the element that RECEIVES the gestures must claim
   them, else the browser pans/pinches the page. Playback: the whole tile is the container. Live: the
   .tile__stage IS the container, so put it there — and only on a magnified / fullscreen tile, so small
   wall tiles keep normal touch-action (the wall scrolls + tiles drag). */
.playback__vtile { touch-action: none; }
body.nvr .tile--zoomed .tile__stage,
body.nvr .tile:fullscreen .tile__stage { touch-action: none; }
.rz-zoomed { cursor: grab; }

/* Fisheye DEWARP (public/js/dewarp.js): a WebGL canvas laid over the <video> in the magnified/focused
   view. Hidden until the engine activates (pinch past 1×); pointer-events:none so it never steals the
   gesture from the container listener or the toolbar/controls underneath — the container catches the
   bubbled events. While dewarping (.rz-dewarping on the container) the flat overlays are hidden: their
   [0,1] coords map through LINEAR video space, which no longer holds after reprojection. */
.tile__dewarp {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
  background: #000;
}
.rz-dewarping { touch-action: none; cursor: grab; }
.rz-dewarping .tile__boxes,
.rz-dewarping .tile__ov,
.rz-dewarping .tile__mask,
.rz-dewarping .playback__boxes,
.rz-dewarping .playback__mask { display: none !important; }

/* ?debug=1 timing badge: the tile's computed frame clock, bottom-right, monospace. */
.playback__vtile-ts {
  position: absolute;
  bottom: var(--sp-2);
  right: var(--sp-2);
  z-index: 4;
  padding: 1px var(--sp-2);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 65%);
  color: #ffd166;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.35;
  white-space: nowrap;
  text-align: left;
  pointer-events: none;
}

/* Click-to-pause feedback: a play/pause glyph that pops and fades in the tile centre
   (YouTube-style). pointer-events:none so it never eats the next click. */
.playback__vtile-flash {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 4;              /* above the bbox overlay (3), below nothing interactive */
}
.playback__vtile-flash-disc {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background-color: rgb(0 0 0 / 60%);
  color: var(--c-on-accent);
  opacity: 0;              /* invisible until the animation runs, then fades back out */
}
.playback__vtile-flash-disc svg { width: 26px; height: 26px; }
.playback__vtile-flash-disc.is-anim { animation: pb-flash .5s ease-out forwards; }
@keyframes pb-flash {
  0%   { opacity: .9; transform: scale(.85); }
  100% { opacity: 0;  transform: scale(1.35); }
}

.playback__vtile-label {
  position: absolute;
  top: var(--sp-2);
  left: var(--sp-2);
  padding: 1px var(--sp-2);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 55%);
  color: var(--c-on-accent);
  font-size: var(--fs-xs);
  font-weight: 600;
  pointer-events: none;
}
.playback__vtile-dl {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 55%);
  color: var(--c-on-accent);
  font-size: var(--fs-base);
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--transition);
}
.playback__vtile:hover .playback__vtile-dl { opacity: 1; }

/* Frame-grab button — sits just left of the download button, same pill treatment. */
.playback__vtile-snap {
  position: absolute;
  top: var(--sp-2);
  right: calc(var(--sp-2) + 26px + var(--sp-1));
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 55%);
  color: var(--c-on-accent);
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--transition);
}
.playback__vtile-snap svg { width: 16px; height: 16px; display: block; }
.playback__vtile:hover .playback__vtile-snap { opacity: 1; }
/* Undecodable codec (e.g. the camera switched to H.265 mid-recording): the file is fine, this browser
   just can't play it. Say that over the tile — a black rectangle reads as "the NVR is broken". */
.playback__vtile.is-codec::after {
  content: attr(data-codec-msg);
  position: absolute;
  inset: 0;
  z-index: 3;
  display: grid;
  place-items: center;
  padding: var(--sp-3);
  background-color: rgb(0 0 0 / 72%);
  color: var(--c-warn);
  font-size: var(--fs-sm);
  text-align: center;
  line-height: 1.4;
}
.playback__vtile.is-empty::after {
  content: "—";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--c-text-faint);
  font-size: var(--fs-xl);
}

/* (Zoom is now a stepped slider — see .playback__zoomslider above.) */

/* Current playback time — its own centered line above the filter bar. */
/* Date picker (left) + wall clock (right) share one line above the filter bar, vertically centred — the
   date is reached constantly so it lives here rather than buried in the settings drawer. */
.playback__timerow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: 0 var(--sp-5);   /* even margin either side so the date/clock aren't jammed into the corners */
  flex-wrap: wrap;
}
.playback__readout {
  align-self: center;
  font-family: var(--font-mono);
  font-size: var(--fs-lg);
  font-variant-numeric: tabular-nums;
  color: var(--c-text);
  /* It's a button now — tap to enter an exact time. Reset the chrome, keep the readout look. */
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
}
.playback__readout:hover { color: var(--c-accent); }
/* The native time input that replaces the clock while editing. */
.playback__timeinput {
  width: auto;
  min-height: 2.75rem;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
/* Milliseconds recede to debug-precision: smaller + faint, so HH:MM:SS stays the readout. */
.playback__readout-ms { font-size: 0.6em; color: var(--c-text-muted); }
/* Calendar glyph stands in for the "Date" text label — narrower, on-brand; select carries the a11y name. */
.playback__daterow-ic { display: grid; place-items: center; color: var(--c-text-muted); }
.playback__daterow-ic svg { width: 1.1rem; height: 1.1rem; }
/* Inline + compact (overrides .field--inline's column stack) so it's a single-height control, not a tall
   label-over-select block that unbalances the row. */
.playback__daterow {
  flex: 0 0 auto;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-2);
}
/* Comfortable tap height — this is the row's primary reach-target on a phone. */
.playback__daterow #pb-date { width: auto; min-height: 2.75rem; }
/* Keep the clock hard-right even if the row wraps (space-between collapses to flex-start per line),
   so the wrapped state still reads date-left / clock-right instead of both-left (the rejected look). */
.playback__readout { margin-left: auto; }

.playback__playpause.is-playing {
  background-color: var(--c-surface-raised);
  border-color: var(--c-border);
  color: var(--c-text);
}

/* Transport: skip « / play-pause / skip » · slow / rate / fast. */
.playback__transport {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  margin-inline: auto;   /* centre the transport; auto margins absorb the free space either side */
}
.pb-tbtn {
  display: inline-grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
  cursor: pointer;
  transition: border-color var(--transition), background-color var(--transition);
}
.pb-tbtn svg { width: 15px; height: 15px; }
.pb-tbtn:hover { border-color: var(--c-accent); }
.pb-tbtn:disabled { opacity: 0.4; cursor: default; }
.playback__transport-sep {
  width: 1px;
  height: 22px;
  margin: 0 var(--sp-1);
  background-color: var(--c-border);
}
.playback__rate {
  min-width: 2.6em;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  color: var(--c-text);
}

/* Speed cluster: discrete slow/rate/ff buttons on desktop; a spring-loaded shuttle on touch. */
.playback__speed { display: inline-flex; align-items: center; gap: var(--sp-1); }
.playback__shuttle { display: none; }
@media (hover: none) {
  /* Touch swaps the three buttons for a jog shuttle: drag right = faster, left = slower; release and
     the thumb springs back to centre = 1× (a hold-to-scan control). */
  .playback__speed #pb-slow,
  .playback__speed #pb-ff,
  .playback__speed .playback__rate { display: none; }
  .playback__shuttle {
    position: relative;
    display: block;
    width: 9rem;
    height: 2.5rem;
    border-radius: 999px;
    background-color: var(--c-surface-raised);
    border: 1px solid var(--c-border);
    touch-action: none;   /* the shuttle owns the horizontal drag */
  }
  .playback__shuttle::before,
  .playback__shuttle::after {   /* faint direction hints */
    position: absolute; top: 50%; transform: translateY(-50%);
    color: var(--c-text-muted); font-size: var(--fs-sm); pointer-events: none;
  }
  .playback__shuttle::before { content: '«'; left: 0.6rem; }
  .playback__shuttle::after  { content: '»'; right: 0.6rem; }
  .playback__shuttle-mark {   /* centre (1×) detent */
    position: absolute; top: 22%; bottom: 22%; left: 50%; width: 2px;
    transform: translateX(-50%); background-color: var(--c-border-strong); pointer-events: none;
  }
  .playback__shuttle-thumb {
    position: absolute; top: 50%; left: 50%;
    display: grid; place-items: center;
    transform: translate(-50%, -50%);
    min-width: 2.75rem; height: 2.05rem; padding: 0 var(--sp-2);
    border-radius: 999px;
    background-color: var(--c-accent); color: var(--c-on-accent);
    font-family: var(--font-mono); font-size: var(--fs-sm); font-variant-numeric: tabular-nums;
    box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
    transition: left 180ms ease;   /* spring-back on release (JS disables it inline during the drag) */
  }
}

/* Multi-camera picker: a button that opens a checkbox list. */
.pb-multi {
  position: relative;
}
.pb-multi__btn {
  min-width: 9rem;
  text-align: left;
  cursor: pointer;
}
.pb-multi__btn::after {
  content: "▾";
  float: right;
  color: var(--c-text-faint);
}
.pb-multi__menu {
  position: fixed;            /* placed by JS (placeCamsMenu): flips above the button when the
                                 deck sits at the bottom and there is no room below */
  z-index: 50;                /* above the mobile filters sheet (40), like the AI menu */
  min-width: 200px;           /* JS raises this to the button's width on open */
  max-height: min(60vh, 320px);
  overflow-y: auto;
  padding: var(--sp-1);
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  white-space: nowrap;
}
.pb-multi__item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
  cursor: pointer;
}
.pb-multi__item:hover { background-color: var(--c-surface); }
.pb-multi__item input { accent-color: var(--c-accent); cursor: pointer; }

/* ---- Timeline ---------------------------------------------------------- */

.playback__timeline {
  flex: none; /* always visible: the grid above absorbs the remaining height */
  position: relative; /* offset parent for the floating preview */
  display: flex;
  flex-direction: column;
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
  background-color: var(--c-surface);
  overflow: visible; /* let the scrub preview escape upward; the track clips its own content */
  user-select: none;
}

.playback__tl-ruler {
  position: relative;
  height: 20px;
  border-bottom: 1px solid var(--c-border);
  border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
  background-color: var(--c-surface-raised);
  overflow: hidden;   /* belt: nothing from the ruler may ever spill past the timeline box */
}
/* A clustered AI marker (several events within one ~7px bucket): slightly wider so density
   still reads at a glance; the title carries the count + time range. */
.playback__tl-mark--multi { width: 4px; opacity: .95; }

/* Per-track activity graph: one canvas, stacked per-class density columns (drawGraph). Behind
   the DOM markers/bars visually where they overlap; never intercepts scrub/pinch gestures. */
.playback__tl-graph {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.playback__tl-tick {
  position: absolute;
  top: 0;
  bottom: 0;
  border-left: 1px solid var(--c-border);
}
.playback__tl-tick span {
  position: absolute;
  left: 4px;
  top: 3px;
  font-size: var(--fs-xs);
  color: var(--c-text-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Interaction surface: stacked per-camera track rows sharing one time axis. */
.playback__tl-tracks {
  position: relative;
  cursor: crosshair;
  /* JS owns HORIZONTAL scrub/jog (axis-detected); the browser vertical-scrolls the track list so all
     cameras stay reachable. The height cap lives in the media queries (desktop below, mobile at :720)
     — NOT here: the mobile cap rule precedes this one in source, so a cap here would override it. */
  touch-action: pan-y;
  overflow-y: auto;
  overflow-x: hidden;
  border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
}
/* Desktop cap — operator-adjustable via the resize handle (--pb-tracks-h, persisted). Lower it for
   bigger video tiles, raise it to see more tracks. (min-width can't match the phone, so it never fights
   the :720 mobile cap, which stays fixed.) */
@media (min-width: 721px) {
  .playback__tl-tracks { max-height: var(--pb-tracks-h, 45vh); }
}
/* Resize handle between the timeline and the transport (desktop only). */
.playback__tl-resizer {
  flex: none;
  height: 11px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: ns-resize;
  touch-action: none;
  margin: -2px 0;   /* tighten the deck gap so the grip sits snug under the timeline */
}
.playback__tl-resizer-grip {
  width: 46px;
  height: 4px;
  border-radius: 999px;
  background-color: var(--c-border-strong);
  transition: background-color var(--transition);
}
.playback__tl-resizer:hover .playback__tl-resizer-grip,
.playback__tl-resizer:focus-visible .playback__tl-resizer-grip { background-color: var(--c-accent); }
@media (max-width: 720px) { .playback__tl-resizer { display: none; } }
.playback__tl-tracks:focus-visible {
  outline: 2px solid var(--c-accent);
  outline-offset: -2px;
}

.playback__tl-track {
  position: relative;
  height: 38px;
  border-top: 1px solid var(--c-border);
}
.playback__tl-track:first-child { border-top: 0; }

.playback__tl-label {
  position: absolute;
  top: 2px;
  left: 4px;
  z-index: 1;
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
  pointer-events: none;
  text-shadow: 0 1px 2px var(--c-bg);
}

.playback__tl-segments {
  position: absolute;
  inset: 0;
}
.playback__tl-seg {
  position: absolute;
  top: 9px;
  bottom: 7px;
  min-width: 2px;
  background-color: color-mix(in srgb, var(--c-accent) 55%, transparent);
  border: 1px solid var(--c-accent);
  border-radius: 3px;
}

/* Event overlays on the recorded (blue) base: motion=yellow, AI=orange, alarm=red.
   Drawn after the segments (and in severity order) so the strongest colour wins. */
.playback__tl-ev {
  position: absolute;
  top: 9px;
  bottom: 7px;
  min-width: 2px;
  border-radius: 2px;
  pointer-events: none;
}
.playback__tl-ev--motion { background-color: var(--c-warn); }
.playback__tl-ev--ai     { background-color: var(--c-ai); }
.playback__tl-ev--alarm  { background-color: var(--c-error); }

/* Object detections (from the metadata journal), coalesced into per-class presence bars in a
   thin lane at the bottom of the track. Colours match the video box overlay; filtered by the AI
   dropdown's class toggles. */
.playback__tl-obj {
  position: absolute;
  bottom: 3px;
  height: 3px;
  min-width: 3px;
  border-radius: 2px;
  background-color: var(--c-obj-unknown);   /* default / unknown class */
  opacity: .9;
  pointer-events: none;
}
.playback__tl-obj--car    { background-color: var(--c-obj-vehicle); }
.playback__tl-obj--person { background-color: var(--c-obj-person); }
.playback__tl-obj--bike   { background-color: var(--c-obj-bike); }
.playback__tl-obj--face   { background-color: var(--c-obj-face); }

.playback__tl-playhead {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
  background-color: var(--c-error);
  pointer-events: none;
  z-index: 2;
}
.playback__tl-knob {
  position: absolute;
  top: -1px;
  left: 50%;
  width: 12px;
  height: 12px;
  transform: translateX(-50%);
  background-color: var(--c-error);
  border-radius: 3px;
  pointer-events: auto;
  cursor: ew-resize;
}

.playback__tl-tooltip {
  position: absolute;
  top: 2px;
  transform: translateX(-50%);
  padding: 1px var(--sp-2);
  background-color: var(--c-bg);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--c-text);
  white-space: nowrap;
  pointer-events: none;
  z-index: 3;
}

/* Scrub/hover preview: a JPEG frame floating above the track at the pointer time. */
.playback__tl-preview {
  position: absolute;
  bottom: calc(100% + 6px);
  transform: translateX(-50%);
  margin: 0;
  padding: 3px;
  width: 200px;
  background-color: var(--c-bg);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  pointer-events: none;
  z-index: 4;
}
.playback__tl-preview img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background-color: var(--c-video-bg);
  border-radius: 2px;
  opacity: 0;
  transition: opacity var(--transition);
}
.playback__tl-preview-time {
  display: block;
  margin-top: 2px;
  text-align: center;
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--c-text-muted);
}

/* =================================================================== Live control bar
   Sticky bottom bar on the wall: layout + camera management + master record. Shown
   standalone and inside the NVR shell; hidden in a view-only (?embed=1) widget. */

.livebar {
  position: sticky;
  bottom: 0;
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-5);
  /* Keep the controls above a phone's home indicator. MUST come after the `padding` shorthand —
     it used to sit before it and was silently overwritten, so the guard never worked and the bar
     could sit under the home bar on an iPhone. */
  padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
  background-color: var(--c-surface);
  border-top: 1px solid var(--c-border);
}

/* NOTE: everything below overrides .livebar / .btn--icon at EQUAL specificity, so it must stay
   AFTER them in the sheet. A media query adds no specificity — these rules previously sat ~1500
   lines earlier and the base .livebar { gap: var(--sp-4) } silently won, which is why the phone
   bar kept a 1rem gap and wrapped to two lines. */
/* ── Livebar buttons: icon + label ────────────────────────────────────────────
   The bar is wide and was showing four unlabelled glyphs, which asks the operator to remember what
   each one does. A label is ALWAYS shown — including on a phone, where `title` is worthless because
   there is no hover, so an icon-only button is simply unexplained. Two tiers:
     default  — the short label ("Affichage")
     ≥68rem   — the long one ("Options d'affichage")
   When space runs out the label ELLIPSIZES; the bar stays on ONE line. A second row is not
   affordable on a 6" screen — it eats wall height, which is the thing the operator actually came
   for. min-width:0 on the button (and on the label) is what lets them shrink instead of wrapping;
   the glyph never shrinks, so a button degrades to icon + as many characters as fit.
   .btn--icon zeroes line-height and drops horizontal padding for a square glyph button; both have to
   come back now that there is text beside the icon. */
.livebar__btn {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  line-height: normal;
  min-width: 0;            /* shrink to fit the row rather than pushing the bar onto a second line */
}
/* The glyph never shrinks — only the text gives way. */
.livebar__btn > svg,
.livebar__btn > .ic { flex: 0 0 auto; }
.livebar__txt {
  flex: 0 1 auto;
  min-width: 0;            /* required, or the text refuses to shrink and forces the button wide */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.livebar__txt--long { display: none; }
/* Phone: every pixel of chrome is a pixel not spent on the label, and four buttons on one 360px row
   is genuinely tight — so trim the bar's side padding, the inter-button gap, the button padding and
   the label's own size. Below roughly 360px the labels will still ellipsize; that is the honest
   outcome of four controls on one line, and better than four anonymous glyphs. */
@media (max-width: 40rem) {
  /* Centred on a phone: the row is the only chrome on screen and reads as a toolbar rather than a
     left-hugging list. On desktop the bar keeps its left alignment (it shares the row with nothing). */
  /* nowrap is what actually keeps ONE line. flex-wrap:wrap makes wrapping the FIRST resort — items
     are laid out at their hypothetical size and a new line opens as soon as the next won't fit;
     flex-shrink only kicks in once a line is already settled. So shrinkable buttons still wrapped.
     With nowrap they are forced onto one line and min-width:0 lets the labels ellipsize instead. */
  .livebar { gap: var(--sp-2); padding-left: var(--sp-2); padding-right: var(--sp-2);
             justify-content: center; flex-wrap: nowrap; }
  .livebar__btn { padding: var(--sp-2) var(--sp-1); gap: var(--sp-1); }
  .livebar__txt { font-size: var(--fs-xs); }
}
/* Tablet / small laptop: spend less on spacing BEFORE anything is allowed to wrap or ellipsize.
   Trimming the bar's own padding and the gaps buys ~60px, which is a whole label — cheaper than
   losing a line of wall to a second toolbar row. Full roominess returns with the long labels. */
@media (min-width: 40rem) and (max-width: 68rem) {
  .livebar { gap: var(--sp-3); padding-left: var(--sp-3); padding-right: var(--sp-3); flex-wrap: nowrap; }
  .livebar__btn { padding: var(--sp-2); }
}
@media (min-width: 68rem) {
  .livebar__txt--short { display: none; }
  .livebar__txt--long { display: inline; }
}


/* Livebar popover (shared by the Layouts + Display menus) — opens UPWARD (livebar is pinned bottom). */
.livebar__pop {
  position: relative;
  flex: 0 0 auto;
}
.livebar__pop-menu {
  position: absolute;
  bottom: calc(100% + var(--sp-2));
  left: 0;
  min-width: 13rem;
  padding: var(--sp-2);
  display: flex;
  flex-direction: column;
  gap: 1px;
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
  box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
  z-index: 30;
}
.pop-opt {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  border-radius: var(--radius);
  cursor: pointer;
  font-size: var(--fs-sm);
  white-space: nowrap;
}
.pop-opt:hover { background-color: var(--c-surface); }
.pop-opt input { cursor: pointer; margin: 0; }
.pop-sep {
  height: 1px;
  margin: var(--sp-1) 0;
  background-color: var(--c-border);
}
/* Reusable on/off switch (checkbox styled as a pill). The knob lives on a sibling span, not a
   pseudo-element of the <input>, because form controls don't reliably render ::before/::after. */
.switch {
  position: relative;
  display: inline-block;
  flex: 0 0 auto;
  width: 2.4rem;
  height: 1.4rem;
}
.switch__input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.switch__slider {
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background-color: var(--c-border-strong);
  transition: background-color var(--transition);
  pointer-events: none;
}
.switch__slider::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: calc(1.4rem - 4px);
  height: calc(1.4rem - 4px);
  border-radius: 50%;
  background-color: var(--c-on-accent);
  transition: transform var(--transition);
}
.switch__input:checked + .switch__slider { background-color: var(--c-accent); }
.switch__input:checked + .switch__slider::before { transform: translateX(1rem); }
.switch__input:focus-visible + .switch__slider { outline: 2px solid var(--c-accent); outline-offset: 2px; }

/* Segmented control (video framing) inside the popover. */
.pop-fit {
  display: flex;
  gap: 2px;
  margin-top: var(--sp-1);
  padding: 2px;
  border-radius: var(--radius);
  background-color: var(--c-surface);
}
.pop-fit__btn {
  flex: 1 1 0;
  padding: var(--sp-1) var(--sp-2);
  border: 0;
  border-radius: calc(var(--radius) - 2px);
  background: none;
  color: var(--c-text-muted);
  font: inherit;
  font-size: var(--fs-xs);
  cursor: pointer;
  white-space: nowrap;
}
.pop-fit__btn:hover { color: var(--c-text); }
.pop-fit__btn.is-active { background-color: var(--c-accent); color: var(--c-bg); }

/* Saved-layouts list inside the popover. */
.layouts-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
  max-height: 16rem;
  overflow-y: auto;
}
.layouts-list__empty {
  padding: var(--sp-1) var(--sp-2);
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}
.layout-row {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  border-radius: var(--radius);
}
.layout-row:hover { background-color: var(--c-surface); }
.layout-row__load {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: var(--fs-sm);
  text-align: left;
  cursor: pointer;
}
.layout-row__name {
  flex: 1 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Camera count — a muted "size" badge (glyph + number) at the row's trailing edge. */
.layout-row__count {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  color: var(--c-text-muted);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
}
.layout-row__count svg { width: 13px; height: 13px; }
.layout-row__del,
.layout-row__save {
  flex: 0 0 auto;
  width: 1.6rem;
  height: 1.6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: var(--radius);
  background: none;
  color: var(--c-text-muted);
  cursor: pointer;
  line-height: 1;
}
.layout-row__del svg,
.layout-row__save svg { width: 15px; height: 15px; }
/* Delete is destructive — red at rest so the affordance reads at a glance. */
.layout-row__del { color: var(--c-error); }
.layout-row__del:hover { background-color: var(--c-surface-raised); }
/* Update button — accent-tinted so the "unsaved changes" affordance reads at a glance. */
.layout-row__save { color: var(--c-accent); }
.layout-row__save:hover { color: var(--c-accent-hover); background-color: var(--c-surface-raised); }
.layouts-save {
  display: flex;
  gap: var(--sp-1);
}
.layouts-save .input { flex: 1 1 auto; min-width: 0; }
/* "+ layout" trigger — full-width so it reads as the create affordance; hides once the form is revealed. */
.layouts-add__trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-1);
}
.layouts-add__trigger svg { width: 15px; height: 15px; flex: 0 0 auto; }

/* View-only embed: no management surface at all. */
body.embed .livebar {
  display: none;
}

/* Inside the NVR shell the top nav + Disconnect live in the shell chrome, so the live
   page drops its own topbar and footer and leans on the sticky bar. */
body.shell .topbar,
body.shell .footer {
  display: none;
}

/* ================================================================= Settings shell
   The Settings treact page: a submenu column beside a content area (an iframe island). */

.settings {
  flex: 1;
  display: flex;
  min-height: 0;
}
.settings__menu {
  flex: 0 0 190px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-3);
  background-color: var(--c-surface);
  border-right: 1px solid var(--c-border);
  /* The nav grew past a phone's height — scroll it independently (its own bounded height comes from
     .settings/.nvr__content's min-height:0) so the bottom items stay reachable. */
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.settings__item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-3);
  text-align: left;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--c-text-muted);
  font: inherit;
  font-size: var(--fs-sm);
  cursor: pointer;
  transition: background-color var(--transition), color var(--transition);
}
.settings__item-ic { width: 16px; height: 16px; flex: 0 0 auto; }
.settings__item:hover { background-color: var(--c-surface-raised); color: var(--c-text); }
.settings__item.is-active { background-color: var(--c-accent); color: var(--c-on-accent); }

/* Mobile: the submenu collapses to an icon-only rail — labels hidden, icons enlarged to touch
   size. The buttons keep their title attribute, so meaning survives (tooltip / long-press). */
@media (max-width: 720px) {
  .settings__menu {
    flex: 0 0 auto;
    padding: var(--sp-2);
    scrollbar-width: none;   /* the icon rail is too narrow for scrollbar chrome — touch-scrolls */
  }
  .settings__menu::-webkit-scrollbar { display: none; }
  .settings__item {
    justify-content: center;
    padding: var(--sp-3);
  }
  .settings__item .settings__item-label { display: none; }
  .settings__item-ic { width: 20px; height: 20px; }
  .settings__body { padding-left: var(--sp-2); }
  .settings__body.camadmin { padding: var(--sp-3) var(--sp-2); }
}
/* Placeholder body for not-yet-built Settings subsections. */
.settings__stub {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-5);
  text-align: center;
  color: var(--c-text-muted);
}
.settings__stub-title { margin: 0; color: var(--c-text); font-size: var(--fs-lg); font-weight: 600; }
.settings__stub-text { margin: 0; }

/* Reusable progress/meter bar (Settings › Info; reuse anywhere a 0–100% fill is wanted). */
.progress { height: 8px; border-radius: 999px; background: var(--c-border); overflow: hidden; }
.progress__bar {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: var(--c-ok);
  transition: width 0.5s ease, background-color 0.3s ease;
}
.progress__bar--warn { background: var(--c-warn); }
.progress__bar--crit { background: var(--c-error); }

/* Settings › Info — CPU / memory / temperature rows. */
.sysinfo { display: flex; flex-direction: column; gap: var(--sp-3); max-width: 34rem; padding: var(--sp-3); }
.sysinfo__row { display: flex; flex-direction: column; gap: 6px; }
.sysinfo__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); }
.sysinfo__label { font-size: var(--fs-sm); color: var(--c-text-muted); }
.sysinfo__val { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--c-text); }
.sysinfo__meta { margin: var(--sp-1) 0 0; font-size: var(--fs-xs); color: var(--c-text-muted); }
.sysinfo__load { font-variant-numeric: tabular-nums; color: var(--c-text); }

/* Settings › Storage — one card per mounted volume, usage as a .progress bar (same visual language as
   Info). Read-only; the recordings volume is badged. */
.storage { display: flex; flex-direction: column; max-width: 40rem; padding: var(--sp-3); }
.storage__list { display: flex; flex-direction: column; gap: var(--sp-4); margin-top: var(--sp-3); }
.storage__vol { display: flex; flex-direction: column; gap: 6px; }
.storage__head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.storage__dev { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; min-width: 0; word-break: break-all; }
.storage__ic { width: 16px; height: 16px; flex: none; color: var(--c-text-muted); }
.storage__val { flex: none; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--c-text-muted); }
.storage__sub { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); font-size: var(--fs-xs); color: var(--c-text-muted); font-variant-numeric: tabular-nums; }
.storage__meta { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
.storage__badge { flex: none; padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--c-accent) 18%, transparent); color: var(--c-accent); font-weight: 600; letter-spacing: 0.02em; }
.storage__actions { display: flex; justify-content: flex-end; margin-top: 2px; }
.storage__here { font-size: var(--fs-xs); padding: 3px 10px; }
.storage__here--active { display: inline-flex; align-items: center; gap: 5px; color: var(--c-accent); font-weight: 600; }
.storage__here--active svg { width: 14px; height: 14px; }
.storage__current { margin-top: var(--sp-4); font-size: var(--fs-sm); }
.storage__current code { background: var(--c-surface-2, var(--c-surface)); padding: 1px 6px; border-radius: var(--radius-sm, 4px); word-break: break-all; }
.storage__note { margin-top: var(--sp-1); font-size: var(--fs-xs); color: var(--c-text-muted); }
/* Storage targets — named record roots a camera can be assigned to (Phase 2). */
.storage__age { display: flex; gap: var(--sp-2); align-items: flex-end; flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-3); }
.storage__age .field { flex: 0 0 auto; }
.storage__age .field__help { flex: 1 1 100%; }
.storage__targets { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: 6px; }
.storage__tlist { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
.storage__target { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
  padding: 8px 10px; border: 1px solid var(--c-border, rgba(128,128,128,0.25)); border-radius: var(--radius-sm, 6px); }
.storage__tinfo { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.storage__tname { font-weight: 600; }
.storage__tpath { font-size: var(--fs-xs); color: var(--c-text-muted); word-break: break-all; }
.storage__tmeta { display: inline-flex; align-items: center; gap: var(--sp-2); flex: none; font-size: var(--fs-xs); color: var(--c-text-muted); }
.btn--sm { padding: 3px 10px; font-size: var(--fs-xs); }
.storage__tadd { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }
.storage__tadd .input { flex: 1 1 12rem; min-width: 0; }
/* Folder picker (browse the device's drives/NAS instead of typing a path). */
.storage__browser:empty { display: none; }
.storage__browser.is-open { margin-top: var(--sp-2); border: 1px solid var(--c-border, rgba(128,128,128,0.25));
  border-radius: var(--radius-sm, 6px); padding: var(--sp-3); }
.storage__browse-hint { color: var(--c-text-muted); font-size: var(--fs-sm); margin-bottom: 6px; }
.storage__crumb code { display: inline-block; background: var(--c-surface-2, var(--c-surface)); padding: 2px 8px;
  border-radius: var(--radius-sm, 4px); word-break: break-all; }
.storage__browse-nav { display: flex; gap: var(--sp-2); margin: 8px 0; }
.storage__browse-list { display: flex; flex-direction: column; gap: 1px; max-height: 15rem; overflow-y: auto;
  margin: 6px 0; border: 1px solid var(--c-border, rgba(128,128,128,0.18)); border-radius: var(--radius-sm, 6px); }
.storage__browse-item { display: flex; align-items: center; gap: 9px; width: 100%; text-align: left; cursor: pointer;
  background: none; border: 0; padding: 7px 10px; color: inherit; font: inherit; }
.storage__browse-item:hover { background: var(--c-surface-2, rgba(128,128,128,0.12)); }
.storage__browse-ic { flex: none; }
.storage__browse-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.storage__browse-free { flex: none; font-size: var(--fs-xs); color: var(--c-text-muted); font-variant-numeric: tabular-nums; }
.storage__crumb { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); flex-wrap: wrap; }
.storage__browse-empty, .storage__browse-ro { color: var(--c-text-muted); font-size: var(--fs-sm); padding: 8px 4px; }
.storage__browse-mk, .storage__browse-use { display: flex; gap: var(--sp-2); align-items: center; margin-top: 8px; }
.storage__browse-mk .input, .storage__browse-use .input { flex: 1 1 8rem; min-width: 0; }
.storage__browse-foot { margin-top: var(--sp-2); }

/* Inputs / Outputs: a grid of square 1..IO_COUNT tiles + the swappable config panel (treact-animated).
   The body is flex-column with real padding so the grid isn't edge-to-edge (was: bare block, no inset). */
.settings__body.io { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
.io-view__panel { animation: io-panel-in 0.22s ease both; }
@keyframes io-panel-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

.io-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
  gap: var(--sp-3);
  max-width: 66rem;   /* keep it a readable block, not a horizon-to-horizon wall of 128 tiles */
}
.io-tile {
  aspect-ratio: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: var(--sp-1);
  overflow: hidden;
  color: var(--c-text);
  background-color: color-mix(in srgb, var(--c-surface-raised) 90%, transparent);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition), background-color var(--transition), transform var(--transition);
  /* Staggered pop-in on grid render; --anim-i is the tile number (see render_io_grid). */
  animation: io-tile-in 0.24s ease both;
  animation-delay: calc(var(--anim-i, 0) * 3ms);
}
@keyframes io-tile-in { from { opacity: 0; transform: scale(0.82); } to { opacity: 1; transform: none; } }
.io-tile:hover { border-color: var(--c-accent); }
.io-tile:active { transform: scale(0.94); }
/* Number is the dominant info; the optional name is small + muted beneath it (2 lines max). */
.io-tile__ic { width: 1.6rem; height: 1.6rem; flex: none; color: var(--c-text-muted); }
.io-tile--named .io-tile__ic { color: var(--c-accent); }
.io-tile__num { font-weight: 700; font-size: 1.75rem; line-height: 1; font-variant-numeric: tabular-nums; }
.io-tile__name {
  font-size: var(--fs-sm); line-height: 1.15; color: var(--c-text-muted); text-align: center; max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* A named (configured) channel is accented so it stands out from the empty ones. */
.io-tile--named { border-color: color-mix(in srgb, var(--c-accent) 55%, var(--c-border-strong)); }
.io-tile--named .io-tile__num { color: var(--c-accent); }

.io-config__head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.io-config__title { margin: 0; color: var(--c-text); font-size: var(--fs-lg); font-weight: 600; }
.io-config__body { max-width: 28rem; display: flex; flex-direction: column; gap: var(--sp-3); }
/* Icon picker: a wrap grid of icon buttons; the active one is accented. */
/* Hollow square icon button — shared base for the I/O icon picker and the overlay-editor palette. */
.io-iconbtn, .ov-pal {
  display: grid; place-items: center; width: 2.5rem; height: 2.5rem; padding: 0; cursor: pointer;
  color: var(--c-text); background: color-mix(in srgb, var(--c-surface-raised) 90%, transparent);
  border: 1px solid var(--c-border-strong); border-radius: var(--radius-sm);
  transition: border-color var(--transition), color var(--transition), background-color var(--transition);
}
.io-iconbtn svg, .ov-pal svg { width: 1.25rem; height: 1.25rem; }
.io-iconbtn:hover, .ov-pal:hover { border-color: var(--c-accent); color: var(--c-accent); }
.io-iconbtn.is-active { border-color: var(--c-accent); color: var(--c-accent); background: color-mix(in srgb, var(--c-accent) 15%, transparent); }
.io-iconpick { display: flex; flex-wrap: wrap; gap: var(--sp-1); }

/* ---- Perspective calibration editor ----------------------------------- */
/* The four canvas editors FILL the Configure modal. They used to carry a fixed width — min(38rem,
   82vw) ≈ 608px — sized for treact's old 640px dialog; once the modal grew to min(920px,94vw) they
   sat short of the panel and their rules + save bars stopped reaching the edges. The modal is what
   caps the width now, so each editor simply takes 100% of the panel it is given. */
.calib-editor { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
/* Editor stages (calibration / mask / overlay) share one frame + empty-state checker; per-editor
   deltas (mask's crosshair cursor, mask-bg's pointer-events) live with their own sections below. */
.calib-stage, .mask-stage, .ov-stage {
  position: relative; width: 100%; background: var(--c-video-bg);
  border-radius: var(--radius); overflow: hidden; user-select: none; touch-action: none;
  -webkit-touch-callout: none;   /* iOS: no long-press "save image" callout while dragging handles */
}
/* pointer-events:none is load-bearing on MOBILE: it stops the frame <img> from ever being the
   long-press target, so Chrome/WebView can't offer to download it (downloadfile.bin) when a drag
   handle is pressed but not yet moved. Taps land on the stage, which owns the vertex/handle logic.
   -webkit-user-drag:none also blocks the native image drag. */
.calib-bg, .mask-bg, .ov-bg {
  display: block; width: 100%; height: auto;
  pointer-events: none; -webkit-user-drag: none;
}
.calib-bg.is-empty, .mask-bg.is-empty, .ov-bg.is-empty {
  min-height: 12rem; background: repeating-linear-gradient(45deg, #111 0 10px, #191919 10px 20px);
}
.calib-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.calib-poly { fill: color-mix(in srgb, var(--c-accent) 14%, transparent); stroke: var(--c-accent); stroke-width: 2; vector-effect: non-scaling-stroke; }
.calib-grid line { stroke: color-mix(in srgb, var(--c-accent) 55%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; }
.calib-handles { position: absolute; inset: 0; }
.calib-handle {
  position: absolute; transform: translate(-50%, -50%); width: 1.5rem; height: 1.5rem;
  display: grid; place-items: center; cursor: grab; touch-action: none;
  background: var(--c-accent); color: var(--c-on-accent); border: 2px solid var(--c-on-accent);
  border-radius: 50%; font-size: var(--fs-xs); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / 60%);
}
.calib-handle:active { cursor: grabbing; }
.calib-dims { display: flex; flex-wrap: wrap; gap: var(--sp-3); }

/* ---- Privacy-mask editor (Settings › Cameras → Masks) ------------------- */
.mask-editor { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.mask-stage { cursor: crosshair; }        /* frame shared with .calib-stage / .ov-stage above */
.mask-preview { position: absolute; inset: 0; pointer-events: none; }   /* RtspMask paints the real look here */
.mask-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
/* Edit outline over the preview: accent stroke (no fill — the preview shows the fill/blur). */
.mask-poly { fill: none; stroke: var(--c-accent); stroke-width: 2; vector-effect: non-scaling-stroke; }
.mask-poly--active { stroke-dasharray: 5 3; }
.mask-handles { position: absolute; inset: 0; pointer-events: none; }
.mask-handle {
  position: absolute; transform: translate(-50%, -50%);
  width: 1.1rem; height: 1.1rem; border-radius: 50%;
  background: var(--c-accent); border: 2px solid var(--c-on-accent); box-shadow: 0 1px 3px rgb(0 0 0 / 60%);
  cursor: grab; touch-action: none; pointer-events: auto;
}
.mask-handle--active { box-shadow: 0 0 0 2px var(--c-accent), 0 1px 3px rgb(0 0 0 / 60%); }
.mask-controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-3); }
.mask-controls .mask-precision { width: 8rem; accent-color: var(--c-accent); }
.mask-actions-inline { display: flex; gap: var(--sp-2); margin-left: auto; }

/* ---- Analytics editor (zones + rules) — reuses .mask-stage / .mask-handle ---- */
.analytics-editor { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.zone-handles { position: absolute; inset: 0; pointer-events: none; }
.zone-poly { fill: color-mix(in srgb, var(--c-accent) 12%, transparent); }   /* zones get a faint fill (masks are fill:none) */
.zone-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-2); }
.zone-pills { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.zone-pill { padding: 2px var(--sp-2); border: 1px solid var(--c-border); border-radius: 999px; background: none; color: var(--c-text-muted); font: inherit; font-size: var(--fs-xs); cursor: pointer; }
.zone-pill.is-active { background: var(--c-accent); color: var(--c-on-accent); border-color: var(--c-accent); }
.arule-sec { display: flex; flex-direction: column; gap: var(--sp-2); }
.arule-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.arule-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.arule-empty { margin: 0; font-size: var(--fs-sm); color: var(--c-text-muted); }
.arule-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-2); padding: var(--sp-2); border: 1px solid var(--c-border); border-radius: var(--radius-sm); }
.arule-row .input { min-width: 0; }
.arule-classes { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.arule-cls { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); white-space: nowrap; }
.arule-ms { width: 6rem; }
.arule-del { align-self: center; }

/* ---- ONVIF explorer (diagnostic tree) --------------------------------- */
.onvif-explorer { display: flex; flex-direction: column; gap: var(--sp-3); }
.onvif-exp__bar { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.onvif-exp__filter { flex: 1 1 12rem; min-width: 8rem; }
.onvif-exp__out { display: flex; flex-direction: column; gap: var(--sp-2); }
/* Monospace tree so tokens/URIs/numbers line up and read like the XML they are. */
.onvif-exp__out { font-family: var(--font-mono); font-size: var(--fs-sm); }

.oxs { border: 1px solid var(--c-border); border-radius: var(--radius-sm); background: var(--c-surface); }
.oxs > summary { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: baseline; padding: var(--sp-2) var(--sp-3);
  cursor: pointer; font-weight: 600; }
.oxs__label { color: var(--c-accent); }
.oxs__meta { color: var(--c-text-muted); font-weight: 400; }
.oxs__url { color: var(--c-text-muted); font-weight: 400; margin-left: auto; opacity: .7; }

.oxo { border-top: 1px solid var(--c-border); }
.oxo > summary { display: flex; gap: var(--sp-2); align-items: center; padding: var(--sp-1) var(--sp-3); cursor: pointer; }
.oxo__badge { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.oxo__badge--ok { background: var(--c-obj-unknown, #22c55e); }
.oxo__badge--fault { background: var(--c-error); }
.oxo__err { color: var(--c-error); font-size: .75rem; }
.oxo__errmsg { color: var(--c-error); padding: var(--sp-1) var(--sp-3) var(--sp-2) 2rem; white-space: pre-wrap; }
.oxo__body { padding: var(--sp-1) 0 var(--sp-2) 1.4rem; }

/* Element tree: each level indents; branches are <details>, leaves are name + value. */
.oxt { margin-left: .9rem; }
.oxt > summary { cursor: pointer; padding: 1px 0; }
.oxt__kids { border-left: 1px dotted var(--c-border); margin-left: .35rem; padding-left: .5rem; }
.oxt--leaf { margin-left: .9rem; padding: 1px 0; }
.oxt__name { color: var(--c-text); }
.oxt__val { color: var(--c-accent); }
.oxt__val::before { content: ": "; color: var(--c-text-muted); }
.oxt__empty { color: var(--c-text-muted); font-style: italic; }
.oxt__attr { color: var(--c-text-muted); font-size: .75rem; }

/* ---- Camera maintenance (time/NTP + reboot) --------------------------- */
/* Same story as the canvas editors: a width sized for the old 640px dialog. Fill the panel. */
.cam-maint { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.cam-maint__out { display: flex; flex-direction: column; gap: var(--sp-3); }
.cam-maint__card {
  display: flex; flex-direction: column; gap: var(--sp-2);
  padding: var(--sp-3);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  background: var(--c-surface);
}
.cam-maint__row { display: flex; justify-content: space-between; gap: var(--sp-3); align-items: baseline; }
.cam-maint__k { color: var(--c-text-muted); font-size: var(--fs-sm); }
.cam-maint__v { font-family: var(--font-mono); font-size: var(--fs-sm); }
.cam-maint__v--warn { color: var(--c-warn); font-weight: 600; }
.cam-maint__ntp { display: flex; gap: var(--sp-2); }
.cam-maint__ntp-in { flex: 1 1 auto; }
.cam-maint__danger { border-color: color-mix(in srgb, var(--c-error) 40%, var(--c-border)); }
.cam-maint__reboot { color: var(--c-error); border-color: color-mix(in srgb, var(--c-error) 50%, transparent); }

/* ---- Image controls (live imaging tuning; reused by the Live magnified panel) --------- */
.image-ctl__out { display: flex; flex-direction: column; gap: var(--sp-3); }
.image-ctl { display: flex; flex-direction: column; gap: var(--sp-1); }
.image-ctl__label { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--fs-sm); color: var(--c-text); }
.image-ctl__val { font-family: var(--font-mono); color: var(--c-accent); font-size: var(--fs-sm); }
.image-ctl__range { width: 100%; accent-color: var(--c-accent); cursor: pointer; }
.image-ctl__select { width: 100%; }
/* A control gated by a mode it doesn't currently have (shutter/gain in AUTO exposure, WDR level off). */
.image-ctl--off { opacity: .45; }

/* Image tuning panel in the magnified/fullscreen Live tile (right rail, below the toolbar, so the
   picture stays visible while dragging). Reuses the .image-ctl* rows. Gated like the toolbar tools. */
.tile__tool--image { display: none; }
.tile--img.tile--zoomed .tile__tool--image,
.tile--img:fullscreen .tile__tool--image { display: grid; }
.tile__tool--image.is-active,
.tile--img-open .tile__tool--image { color: var(--c-accent); }

.tile__img {
  position: absolute;
  top: calc(var(--sp-3) + 3.2rem);
  right: var(--sp-3);
  width: min(17rem, 46%);
  max-height: calc(100% - 6rem);
  overflow-y: auto;
  display: none;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-3);
  background-color: color-mix(in srgb, var(--c-surface) 90%, transparent);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius);
  z-index: 5;
}
.tile--img-open.tile--zoomed .tile__img,
.tile--img-open:fullscreen .tile__img { display: flex; }
.tile__img .image-ctl__save { width: 100%; }

/* ---- Providers modal (Destinations) ----------------------------------- */
.prov-body { display: flex; flex-direction: column; gap: var(--sp-4); }
.prov-sec { border-top: 1px solid var(--c-border); padding-top: var(--sp-4); }
.prov-sec:first-of-type { border-top: 0; padding-top: 0; }
.prov-sec__title { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-3); font-size: var(--fs-lg); font-weight: 600; }

/* ---- Camera overlay editor -------------------------------------------- */
.ov-editor { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.ov-intro { margin: 0; font-size: var(--fs-sm); color: var(--c-text-muted); }
.ov-palette { display: flex; flex-wrap: wrap; gap: var(--sp-1); }

/* .ov-stage / .ov-bg / .ov-bg.is-empty share the editor-stage group above (Masks section). */
.ov-layer { position: absolute; inset: 0; }

/* Icons sit hollow over the frame (no plate) — a drop-shadow keeps them legible on any scene. */
.ov-item {
  position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column;
  align-items: center; gap: 2px; padding: 3px; cursor: grab; touch-action: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .9));
}
.ov-item:active { cursor: grabbing; }
.ov-item .ov-ic { width: 1.9rem; height: 1.9rem; display: block; }
.ov-item__lab { font-size: .65rem; font-weight: 700; line-height: 1; color: var(--c-on-accent); text-shadow: 0 1px 2px rgba(0, 0, 0, .95); white-space: nowrap; }
.ov-item--input  { color: var(--c-obj-person); }
.ov-item--output { color: var(--c-obj-vehicle); }
.ov-item--io     { color: var(--c-obj-bike); }
.ov-item--nogfx::before { content: ""; width: 1rem; height: 1rem; border-radius: 50%; border: 2px solid currentColor; }
.ov-item.is-selected { background: color-mix(in srgb, var(--c-accent) 22%, transparent); border-radius: var(--radius-sm); box-shadow: 0 0 0 2px var(--c-accent); }

.ov-config { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: flex-end; padding-top: var(--sp-2); border-top: 1px solid var(--c-border); }
/* Data-ingest key: the second kind of overlay this camera can carry (text records, not icons), so it
   sits in the same tab behind its own rule. The help line becomes a pasteable URL → let it wrap and
   stay selectable, in mono so an installer can read it character by character. */
.ov-data { padding-top: var(--sp-2); border-top: 1px solid var(--c-border); }
.ov-data__title { margin: 0 0 var(--sp-2); font-size: var(--fs-sm); color: var(--c-text-muted); font-weight: 600; }
.ov-data-help { font-family: var(--font-mono); word-break: break-all; user-select: text; }
.ov-config .ov-del { margin-left: auto; }
/* Multi-camera picker on an input's config: a scrollable checkbox list. */
.io-camlist { display: flex; flex-direction: column; gap: var(--sp-1); max-height: 15rem; overflow-y: auto; padding: var(--sp-1); border: 1px solid var(--c-border); border-radius: var(--radius-sm); }
.io-camrow { margin: 0; }
.io-camrow .camthumb { height: 32px; }
.io-camrow__host { color: var(--c-text-muted); font-size: var(--fs-xs); }

/* Device endpoints (HTTP actions) — list rows in the endpoints modal. */
.ep-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.ep-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3); border: 1px solid var(--c-border); border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--c-surface-raised) 60%, transparent);
}
.ep-row__info { min-width: 0; }
.ep-row__name { font-weight: 600; color: var(--c-text); }
.ep-row__meta { font-size: var(--fs-xs); color: var(--c-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ep-row__method { color: var(--c-accent); font-weight: 600; font-variant-numeric: tabular-nums; }
.ep-row__actions { display: flex; gap: var(--sp-1); flex: none; }
.ep-list__bar { margin-top: var(--sp-3); }

@media (prefers-reduced-motion: reduce) {
  .io-view__panel, .io-tile { animation: none; }
}
/* The first-paint bootstrap (data-trigger="load") lives here until its section's handler answers.
   Pessimistic UI: render the wait, don't hide it. flex:1 so the beat occupies the panel the body
   is about to fill, instead of collapsing the rail against the viewport edge. */
.settings__loading {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  color: var(--c-text-dim);
  font-size: 0.9rem;
}
/* Swap target for a subsection change (handlers/settings/<sub>.php re-renders only this). It must
   stay INVISIBLE to layout: .settings is the flex row and .settings__body expects to be its direct
   child, so display:contents drops the wrapper out of the box tree and the body keeps flexing
   against the rail exactly as before. Give this box any other display and every subsection loses
   its height regime. */
.settings__panel { display: contents; }
/* min-height:0 + internal scroll so a tall body (Users list, future panels) scrolls WITHIN the
   fixed shell instead of clipping. .settings__body.camadmin overrides to overflow:hidden below and
   lets .camadmin__tablewrap own the scroll (keeping its sticky header). */
.settings__body { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; position: relative; }
/* Treact camera admin lives directly in the settings body (no iframe), so it must supply the
   padding + column layout the old standalone .camadmin__main used to. overflow:hidden keeps the
   toolbar fixed and lets .camadmin__tablewrap own the scroll (sticky header intact). */
.settings__body.camadmin {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5);
  min-height: 0;
  overflow: hidden;
}
.settings-frame { display: block; width: 100%; height: 100%; border: 0; }

/* ---------------------------------------------------- Settings › Network
   The read-only network page reuses the camdetail cards, which were designed for the dense
   detail MODAL: used full-page they need page rhythm — a bounded grid (Addressing | Services
   side by side when wide, remote card full width), card surfaces that stand out from the page
   background, and roomier rows. */
.settings__net {
  display: flex;
  flex-direction: column;
  padding: var(--sp-4) var(--sp-5);
  max-width: 1000px;          /* key-value rows are unreadable stretched across a widescreen */
}
.settings__system { padding: var(--sp-5); max-width: 30rem; }

/* Theme gallery — a swatch per palette; the active one is ringed. Selection is client-side (theme.js). */
.theme-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
  gap: var(--sp-2);
  margin-top: var(--sp-2);
}
.theme-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-2) var(--sp-1);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  cursor: pointer;
  color: var(--c-text-muted);
  font-size: var(--fs-xs);
  transition: background-color var(--transition), border-color var(--transition), color var(--transition);
}
.theme-card:hover { background-color: var(--c-surface-raised); }
.theme-card.is-active {
  border-color: var(--c-accent);
  background-color: var(--c-surface-raised);
  color: var(--c-text);
}
.theme-card__sw {
  width: 100%;
  aspect-ratio: 16 / 10;
  border-radius: var(--radius-sm);
  border: 1px solid var(--c-border);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 4%);
}
.theme-card__lbl { line-height: 1.1; text-align: center; }

/* Swatches: surface + an accent wedge; the gradient themes show their actual gradient. */
.theme-card--system .theme-card__sw { background: linear-gradient(135deg, #141922 0 50%, #eceff4 50% 100%); }
.theme-card--dark   .theme-card__sw { background: linear-gradient(135deg, #141922 0 62%, #3d8bfd 62% 100%); }
.theme-card--light  .theme-card__sw { background: linear-gradient(135deg, #ffffff 0 62%, #2f7ae6 62% 100%); }
.theme-card--amber  .theme-card__sw { background: linear-gradient(135deg, #1e1a12 0 62%, #f59e0b 62% 100%); }
.theme-card--forest .theme-card__sw { background: linear-gradient(135deg, #111c15 0 62%, #34d399 62% 100%); }
.theme-card--teal   .theme-card__sw { background: linear-gradient(135deg, #0f2022 0 62%, #2dd4bf 62% 100%); }
.theme-card--cherry .theme-card__sw { background: linear-gradient(135deg, #1e1116 0 62%, #f43f5e 62% 100%); }
.theme-card--sand   .theme-card__sw { background: linear-gradient(135deg, #fbf6ec 0 62%, #b45309 62% 100%); }
.theme-card--aurora .theme-card__sw {
  background: radial-gradient(circle at 28% 22%, #818cf8, transparent 60%),
              radial-gradient(circle at 82% 82%, #0ea5e9, transparent 55%), #0a0a16;
}
.theme-card--dawn .theme-card__sw {
  background: radial-gradient(circle at 24% 22%, #fbcfe8, transparent 62%),
              radial-gradient(circle at 82% 82%, #c7d2fe, transparent 58%), #f6eef0;
}
.theme-card--holographic .theme-card__sw {
  background: conic-gradient(from 0deg, #3a1c71, #d76d77, #00c9ff, #56ffa0, #ffd86f, #ff6ec4, #3a1c71);
}
.theme-card--synthwave .theme-card__sw {
  background: radial-gradient(circle at 50% 118%, #ff2e97, transparent 62%),
              radial-gradient(circle at 14% 8%, #00c9ff, transparent 55%), #100a1e;
}
.theme-card--opal .theme-card__sw {
  background: conic-gradient(from 0deg, #ffd6ec, #d6e4ff, #d0f7ee, #efe0ff, #fff0d6, #ffd6ec);
}
.theme-card--nebula .theme-card__sw {
  background: radial-gradient(circle at 22% 18%, #7c3aed, transparent 55%),
              radial-gradient(circle at 82% 78%, #db2777, transparent 55%), #070512;
}
.theme-card--molten .theme-card__sw {
  background: radial-gradient(circle at 50% 116%, #ff5722, transparent 60%),
              radial-gradient(circle at 82% 82%, #dc2626, transparent 55%), #140805;
}
.theme-card--glass .theme-card__sw {
  background: linear-gradient(120deg, rgb(255 255 255 / 24%), rgb(255 255 255 / 3%) 42%),
              radial-gradient(circle at 20% 20%, #38bdf8, transparent 55%),
              radial-gradient(circle at 82% 78%, #a855f7, transparent 55%), #0c1220;
}

/* Horizontal tab bar (Info / FTP / SMTP), treact-loaded — each tab re-renders #net-view. */
.settings__tabs {
  display: flex;
  gap: var(--sp-1);
  border-bottom: 1px solid var(--c-border);
  margin-bottom: var(--sp-4);
  /* 10 camera-config tabs overflow a narrow modal — scroll the bar instead of wrapping/squishing.
     The modal is now wide enough for all ten on a desktop; this keeps mobile/narrow usable. */
  overflow-x: auto;
  overflow-y: hidden;        /* the active underline sits on the bar's border; no vertical scroll */
  scrollbar-width: thin;
  scrollbar-color: var(--c-border-strong) transparent;
}
.settings__tabs::-webkit-scrollbar { height: 6px; }
.settings__tabs::-webkit-scrollbar-thumb { background: var(--c-border-strong); border-radius: 3px; }
.settings__tab {
  flex: 0 0 auto;            /* keep each tab's width; the bar scrolls rather than squishing them */
  white-space: nowrap;
  padding: var(--sp-2) var(--sp-3);
  background: none;
  border: 0;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;        /* sit the active underline on the bar's border */
  color: var(--c-text-muted);
  font: inherit;
  font-size: var(--fs-sm);
  cursor: pointer;
  transition: color var(--transition), border-color var(--transition);
}
.settings__tab:hover { color: var(--c-text); }
.settings__tab.is-active { color: var(--c-accent); border-bottom-color: var(--c-accent); }

/* The active tab's panel. The Info tab lays its cards out in the responsive grid; stub panels
   just carry a line of text (the single-item grid is harmless). */
.settings__tabpanel {
  display: grid;
  /* min(320px,100%) so a narrow phone column can shrink below 320 instead of overflowing the
     card past the right edge (long values like the multi-IP list forced it — seen in repro). */
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  gap: var(--sp-4);
  align-content: start;
}
.settings__tabpanel > * { min-width: 0; }   /* grid items must be allowed to shrink (default min:auto) */
.settings__tabpanel > .settings__stub-text { grid-column: 1 / -1; margin: 0; }

/* Per-camera Configure modal: a fixed-ish width so switching tabs (form / overlay editor /
   calibration / remote) doesn't reflow the modal, and the tab bar reuses .settings__tabs. */
.cam-config { width: 100%; }
/* Stack the panel's children vertically: the General tab holds the form AND its .dialog__actions
   footer (which carries a top-border separator), so they must sit form-above-actions, not side by
   side. Other tabs have a single child, so column vs row makes no difference there. */
.cam-config__panel { display: flex; flex-direction: column; }
.cam-config__panel > * { min-width: 0; }
.cam-config__pad { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-2) 0; }
/* Sensors tab — one card per imaging sensor (title + its SD/HD path pair). */
.sensor-cards { display: flex; flex-direction: column; gap: var(--sp-2); margin-top: var(--sp-2); }
.sensor-card { border: 1px solid var(--c-border); border-radius: var(--radius-sm); padding: var(--sp-2);
  display: flex; flex-direction: column; gap: var(--sp-2); background: var(--c-surface-2, var(--c-surface)); }
/* Title row: the label input takes the room, the Remove (×) sits at the far right. The primary
   card has no Remove, so the input simply fills the row — no layout special-case needed. */
.sensor-card__title { display: flex; align-items: center; gap: var(--sp-2); }
/* Remove-this-stream. Muted until hovered so a row of cards doesn't read as a row of × buttons. */
.sensor-card__del {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--c-text-muted);
  cursor: pointer;
  transition: color var(--transition), background var(--transition), border-color var(--transition);
}
.sensor-card__del:hover { color: var(--c-error); border-color: var(--c-border); background: var(--c-surface); }
.sensor-card__del:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
/* The add button sits under the card list, left-aligned with them. */
.sensor-cards__add { margin-top: var(--sp-2); }
/* Screen-reader-only text: visible to assistive tech, absent from the layout. Used to carry a
   button's tooltip text into its accessible name, so the hint isn't hover-only. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* CSS-only tooltip. Zero application JS (treact rule), so the text rides in data-tip and the bubble
   is a pseudo-element. Shown on hover AND :focus-visible so it is reachable from the keyboard.

   NO SCROLLBARS — the rule that shapes all of this. The bubble lives inside .treact-modal-body,
   which is overflow-y:auto (so BOTH axes scroll). An absolutely-positioned box extends its scroll
   container's scrollable region in the END directions only: overflow past the bottom or the right
   grows the scrollbars, while overflow past the top or left is simply not scrollable. So the
   DEFAULT opens UPWARD and is right-clamped, and only elements with guaranteed room below opt into
   --down. No `title` attribute alongside — the browser would draw its own tooltip on top. */
.has-tip { position: relative; }
.has-tip::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 6px);        /* block-START: cannot grow the scroll region */
  left: 0;
  right: auto;
  z-index: 6;
  width: max-content;
  /* Never wider than the dialog it sits in: min() against a viewport unit keeps it inside on a
     phone, where 20rem alone would overflow and pull in a horizontal scrollbar. */
  max-width: min(20rem, 60vw);
  padding: var(--sp-2);
  background-color: var(--c-bg);
  border: 1px solid var(--c-border-strong);
  border-radius: var(--radius-sm);
  color: var(--c-text);
  font-size: var(--fs-xs);
  font-weight: 400;
  line-height: 1.45;
  text-align: left;
  white-space: normal;
  overflow-wrap: anywhere;         /* a long path must wrap, not widen the bubble */
  box-shadow: 0 4px 14px rgb(0 0 0 / 28%);
  pointer-events: none;   /* never eat the click meant for the button */
  /* DISPLAY-toggled, not visibility-toggled. A visibility:hidden box is still LAID OUT, so every
     idle bubble kept contributing right-edge overflow to the scroll container — five tooltips in the
     Capabilities tab were enough to raise a permanent horizontal scrollbar nobody could explain.
     display:none removes it from layout entirely. Cost: no fade-in (display can't transition); a
     tooltip appearing instantly is normal, a phantom scrollbar is not. */
  display: none;
}
.has-tip:hover::after,
.has-tip:focus-visible::after { display: block; }
/* Anchor to the right edge (inline-START overflow) for triggers that sit at the end of a row, so a
   wide bubble grows leftwards into the dialog instead of rightwards out of it. */
.has-tip--end::after { left: auto; right: 0; }
/* An (i) in the LAST field of a multi-field row sits in the right half of the panel, so its bubble
   must grow leftwards or it runs past the dialog edge while hovered. Declarative, so the renderer
   doesn't have to know which column it built. :not(:only-child) keeps a single full-width field
   (Audio) anchored to its icon on the left, where there is room either way. */
.field-row > .field:not(:only-child):last-child .field__info::after { left: auto; right: 0; }
/* Opt-in downward, for a trigger with content guaranteed below it — the Streams tab's TOP action
   bar, where upward would be hidden behind the tab strip and the modal header. */
.has-tip--down::after {
  bottom: auto;
  top: calc(100% + 6px);
}
/* Large triggers (a gallery card) host the bubble INSIDE their own box: it can't overflow anything,
   so it can never move a scrollbar, and it reads as a caption on the picture it describes. */
.has-tip--inside::after {
  inset: auto var(--sp-1) var(--sp-1) var(--sp-1);
  bottom: var(--sp-1);
  width: auto;
  max-width: none;
}
/* (No reduced-motion block needed: display-toggling has no animation to suppress.) */
/* ONVIF "replace paths?" diff: label · current → proposed, per stream. */
.onvif-diff { display: flex; flex-direction: column; gap: var(--sp-1); margin: var(--sp-3) 0; }
.onvif-diff__head,
.onvif-diff__row {
  display: grid;
  grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: var(--sp-2);
}
.onvif-diff__head { font-size: var(--fs-xs); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.onvif-diff__row { padding: var(--sp-1) 0; border-top: 1px solid var(--c-border); }
.onvif-diff__label { font-size: var(--fs-sm); color: var(--c-text-muted); }
.onvif-diff__row code { word-break: break-all; font-size: var(--fs-sm); }
.onvif-diff__new code { color: var(--c-accent); }
.onvif-diff__arrow { color: var(--c-text-muted); }
.onvif-diff__empty { color: var(--c-text-muted); font-size: var(--fs-sm); }
/* Unchanged row: nothing would be lost, so mute it instead of drawing the eye. */
.onvif-diff__row.is-same .onvif-diff__new code { color: var(--c-text-muted); }
@media (max-width: 560px) {
  .onvif-diff__head { display: none; }
  .onvif-diff__row { grid-template-columns: minmax(0, 1fr); }
  .onvif-diff__arrow { display: none; }
}
.sensor-card__name { font-weight: 600; font-size: var(--fs-sm); color: var(--c-text); }
.sensor-card__label { width: 100%; }
.sensor-card__paths { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.sensor-card__path { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.sensor-card__rec { margin: 0; align-self: flex-start; font-size: var(--fs-xs); }
.sensor-card__rec span { letter-spacing: 0.04em; }
@media (max-width: 560px) { .sensor-card__paths { grid-template-columns: 1fr; } }
/* ── Stream-test results ──────────────────────────────────────────────────────
   Rows are painted pending and replaced one by one as each probe returns (tFlush), so the table is
   a progress display as much as a result. */
.camtest__table { width: 100%; border-collapse: collapse; margin-top: var(--sp-3); }
.camtest__table th {
  text-align: left;
  padding: var(--sp-1) var(--sp-2);
  border-bottom: 1px solid var(--c-border);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--c-text-faint);
  font-weight: 600;
}
.camtest__table td { padding: var(--sp-2); border-bottom: 1px solid var(--c-border); vertical-align: top; }
.camtest__what b { display: block; font-size: var(--fs-sm); }
.camtest__what code { font-size: var(--fs-xs); color: var(--c-text-muted); word-break: break-all; }
.camtest__verdict { white-space: nowrap; font-size: var(--fs-sm); }
.camtest__verdict small { display: block; color: var(--c-text-faint); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.camtest__detail { font-size: var(--fs-sm); color: var(--c-text-muted); }
.camtest__row.is-pending { opacity: 0.6; }
.camtest__row.is-ok .camtest__detail { color: var(--c-text); }
.camtest__row.is-bad .camtest__verdict { color: var(--c-error); }
.camtest__summary { margin: var(--sp-3) 0 0; font-size: var(--fs-sm); }
.camtest__summary.is-ok { color: var(--c-ok); }
.camtest__summary.is-bad { color: var(--c-warn); }
.camtest__unused { color: var(--c-text-faint); font-size: var(--fs-xs); }
/* Per-stream breakdown inside a fleet row: role · path · codec/size, one line each. */
.camtest__stream { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-2); padding: 1px 0; }
.camtest__stream + .camtest__stream { border-top: 1px dashed var(--c-border); }
.camtest__stream-role { color: var(--c-text-faint); font-size: var(--fs-xs); min-width: 6.5rem; }
.camtest__stream code { font-size: var(--fs-xs); color: var(--c-text-muted); word-break: break-all; }
.camtest__stream-meta { margin-left: auto; font-size: var(--fs-xs); font-variant-numeric: tabular-nums; color: var(--c-text); }
.camtest__stream.is-bad .camtest__stream-meta { color: var(--c-error); }

/* The results modal carries a table with a per-stream column — give it the same room the Configure
   modal gets rather than treact's 640px default (see the --top/--footer bars above for the pattern). */
body.nvr .treact-modal:has(.camtest) { width: min(920px, 94vw); }

/* Detected-streams gallery: a snapshot per stream so thermal vs visible reads at a glance. */
.stream-shots { list-style: none; margin: var(--sp-2) 0 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--sp-2); }
.stream-shot { display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--c-border);
  border-radius: var(--radius-sm); padding: 4px; background: var(--c-surface-2, var(--c-surface)); }
/* The gallery card is a button: click re-grabs the frame and re-probes the stream. */
.stream-shot { cursor: pointer; transition: border-color var(--transition), transform var(--transition); }
.stream-shot:hover { border-color: var(--c-accent); }
.stream-shot:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
.stream-shot:active { transform: scale(0.985); }
/* In flight: the handler flushes this class before the capture, so the wait is visible rather than
   the card just sitting there for a few seconds (and being clicked again). */
.stream-shot.is-refreshing { border-color: var(--c-accent); opacity: 0.55; pointer-events: none; }
.stream-shot.is-refreshing .stream-shot__img { filter: grayscale(1); }
@media (prefers-reduced-motion: reduce) {
  .stream-shot, .stream-shot:active { transition: none; transform: none; }
}
.stream-shot__img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; display: block;
  border-radius: calc(var(--radius-sm) - 2px); background: #000; }
.stream-shot.is-empty .stream-shot__img { visibility: hidden; }   /* broken grab → keep the box, hide the img */
.stream-shot__meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stream-shot__meta code { font-size: var(--fs-xs); color: var(--c-text); word-break: break-all; }
.stream-shot__meta small { font-size: var(--fs-xs); color: var(--c-text-muted); }
.stream-shot__role { align-self: flex-start; font-size: var(--fs-xs); text-transform: uppercase;
  letter-spacing: .03em; color: var(--c-accent); border: 1px solid var(--c-accent); border-radius: var(--radius-sm); padding: 0 5px; }
@media (max-width: 560px) { .stream-row { flex-wrap: wrap; } .stream-row__label { flex-basis: 100%; max-width: none; } }
.settings__net .camdetail__sec {
  background-color: var(--c-surface);   /* the modal's --c-bg vanishes against the page bg */
  padding: var(--sp-4) var(--sp-5);
  border-radius: var(--radius);
}
.settings__net .camdetail__title { display: flex; align-items: center; gap: var(--sp-2); }
.camdetail__title { margin-bottom: var(--sp-3); }
.settings__net .camdetail__kv { padding: var(--sp-2) 0; }   /* the modal's 3px rows are too dense */
/* Remote access reads as one full-width card under the two info cards. */
.settings__net-remote { grid-column: 1 / -1; }
.settings__net-remote .field { margin-top: var(--sp-3); max-width: 420px; }
.settings__net-warn {
  margin: var(--sp-3) 0 0;
  font-size: var(--fs-sm);
  color: var(--c-warn);
}

/* ================================================================= Camera admin
   The Settings › Cameras management table (cameras.php). */

body.camadmin { min-height: 100vh; }

.camadmin__main {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5);
  min-height: 0;
}
body.embed .camadmin__main { padding: var(--sp-3); }

.camadmin__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
}
.camadmin__hint {
  margin-left: auto;
  font-size: var(--fs-sm);
  color: var(--c-text-muted);
}

.camadmin__tablewrap {
  flex: 1;
  min-height: 0;
  overflow: auto;
  border: 1px solid var(--c-border);
  border-radius: var(--radius);
}

.camtable {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
}
.camtable th {
  position: sticky;
  top: 0;
  text-align: left;
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--c-surface-raised);
  border-bottom: 1px solid var(--c-border);
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--c-text-faint);
  white-space: nowrap;
}
.camtable td {
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--c-border);
  vertical-align: middle;
}
.camtable tr:hover td { background-color: color-mix(in srgb, var(--c-surface) 60%, transparent); }
.camtable__c { text-align: center; }
.camtable__actions { display: flex; gap: var(--sp-1); align-items: center; justify-content: center; flex-wrap: wrap; }
/* Recording status in the Cameras table (read-only; toggled in the Sensors tab). Dot = recording state,
   half-filled + "(N/M)" hint = only some of the camera's streams record. Red matches the tile REC dot. */
.rec-status { display: inline-flex; align-items: center; gap: 5px; }
.rec-status__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c-text-muted); flex: none; }
.rec-status--on .rec-status__dot { background: var(--c-error); }
.rec-status--partial .rec-status__dot { background-image: linear-gradient(90deg, var(--c-error) 50%, var(--c-text-muted) 50%); }
.rec-status__hint { font-size: var(--fs-xs); color: var(--c-text-muted); }
/* Device presence: per-port badges coloured by the last poll, + the "last checked" meta. */
.devports { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.devport { font-size: 0.8em; padding: 1px 6px; border-radius: 4px; background: rgba(128, 128, 128, 0.14); }
.devport--open    { color: var(--c-ok); }
.devport--closed  { color: var(--c-error); text-decoration: line-through; }
.devport--unknown { color: var(--c-text-muted); }
.devstatus__ago { color: var(--c-text-muted); }
.devmac { font-family: var(--font-mono); font-size: 0.9em; white-space: nowrap; }
/* Vendor shown x-small under the MAC (its own column was too wide for the scan modal). */
.devvendor { display: block; font-family: ui-sans-serif, system-ui, sans-serif; font-size: var(--fs-xs); color: var(--c-text-muted); }
/* The camera whose admin page is currently exposed on the single remote proxy port. */
.camtable__actions .btn.is-active { background-color: var(--c-accent); color: var(--c-on-accent); border-color: var(--c-accent); }
/* Non-interactive marker in an actions cell (e.g. the 🔒 on the protected admin user). */
.camtable__badge { display: inline-flex; align-items: center; font-size: var(--fs-sm); opacity: 0.75; }
/* A scanned host already registered as a camera in the NVR — accent it so the unity reads. */
.camtable__badge--cam { color: var(--c-accent); opacity: 1; white-space: nowrap; gap: 4px; }
.badge-ic { width: 14px; height: 14px; flex: none; }
/* Inline lucide icon sized to the surrounding text (ui_icon). ic--ok/ic--bad tint status marks. */
.ic { width: 1em; height: 1em; vertical-align: -0.125em; flex: none; }
.ic--ok  { color: var(--c-ok); }
.ic--bad { color: var(--c-error); }

.camtable__state {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-variant-numeric: tabular-nums;
}
.camtable__state::before {
  content: "";
  width: 8px; height: 8px; border-radius: 50%;
  background-color: currentColor;
}
.camtable__state--ok      { color: var(--c-ok); }
.camtable__state--bad     { color: var(--c-error); }
.camtable__state--unknown { color: var(--c-text-muted); }
.camtable__state--off     { color: var(--c-text-faint); }

.camadmin__empty {
  padding: var(--sp-6);
  text-align: center;
  color: var(--c-text-muted);
}

/* ---- Camera detail panel (Settings › Cameras, click a status) ---- */
.camtable__status-cell { cursor: pointer; }
.camtable__status-cell:hover .camtable__state { text-decoration: underline; }

.camdetail {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--sp-4);
}
.camdetail__sec {
  background-color: var(--c-bg);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  padding: var(--sp-3);
}
/* Section header. It was --fs-xs uppercase faint — IDENTICAL to .field__label, so a section and the
   fields inside it carried the same visual weight and the grouping read as noise. One step up the
   scale plus full-strength text makes the hierarchy obvious without shouting; the fields keep the
   smaller, faded uppercase treatment. */
.camdetail__title {
  margin: 0 0 var(--sp-2);
  font-size: var(--fs-sm);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--c-text);
}
.camdetail__kv {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: 3px 0;
  font-size: var(--fs-sm);
  border-bottom: 1px solid color-mix(in srgb, var(--c-border) 50%, transparent);
}
.camdetail__kv:last-child { border-bottom: 0; }
.camdetail__kv span { color: var(--c-text-muted); }
.camdetail__kv b { font-weight: 600; text-align: right; word-break: break-word; }

.camdetail__chips { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-2); }
.camdetail__chip {
  padding: 1px var(--sp-2);
  border-radius: 999px;
  background-color: var(--c-surface-raised);
  border: 1px solid var(--c-border);
  font-size: var(--fs-xs);
}
.camdetail__recent { margin-top: var(--sp-2); max-height: 180px; overflow-y: auto; }
.camdetail__ev {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: 2px 0;
  font-size: var(--fs-xs);
  color: var(--c-text-muted);
}
.camdetail__ev b { color: var(--c-text); font-weight: 500; }

/* ── treact modals ─────────────────────────────────────────────────────────
   treact.min.css ships a light modal (white, 500px) and, being loaded AFTER
   app.css, wins on equal specificity — so these rules are prefixed with
   `body.nvr` to override it. Our Settings dialogs are server-rendered into this
   modal; theme it to the NVR dark surface and give the camera form room.
   Applies to every treact modal in the app for consistency. */
body.nvr .treact-modal {
  background: var(--c-surface);
  color: var(--c-text);
  border: 1px solid var(--c-border);
  border-radius: 12px;
  width: 640px;
}
/* The Configure modal packs a TEN-tab bar + the overlay/calibration/analytics editors — it needs
   far more room than the 640px default. Widen it only when it holds #cam-config-view; .cam-config
   fills whatever it gets, so on a browser without :has() the modal stays 640px and nothing
   overflows (graceful — the tab bar just scrolls, as it still does on a narrow viewport).

   920px is sized from the FRENCH labels, which are the long ones: ~87 characters at --fs-sm
   (13px) ≈ 550px of text, + 10 tabs × 24px padding + 9 × 4px gaps + the body's own padding
   ≈ 890px. So all ten tabs sit on one row at full width instead of hiding behind a scroll. The
   General tab is a 2-column .dialog__grid, so the extra width widens both columns rather than
   stretching one field across the dialog. */
body.nvr .treact-modal:has(#cam-config-view) { width: min(920px, 94vw); }
body.nvr .treact-modal-header {
  border-bottom-color: var(--c-border);
  background: var(--c-surface-raised);
  border-radius: 12px 12px 0 0;
}
body.nvr .treact-modal-title { color: var(--c-text); }
body.nvr .treact-modal-close { color: var(--c-text-muted); }
body.nvr .treact-modal-close:hover { background: var(--c-surface); color: var(--c-text); }
/* Let the body scroll within the 90vh cap instead of overflowing the viewport. */
body.nvr .treact-modal-body { flex: 1 1 auto; min-height: 0; }
/* The modal already provides the panel chrome; the inner form must not double it. */
body.nvr .treact-modal-body .dialog__form { background: none; border: none; box-shadow: none; padding: 0; margin: 0; }
/* The footer sits OUTSIDE the form (siblings in the modal body), so the form's gap doesn't reach it —
   give the action bar its own separated footer instead of gluing it to the last field. */
body.nvr .treact-modal-body .dialog__actions { margin-top: var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--c-border); }
/* Same bar, hoisted above the panel's content (Streams tab): mirror the separator so it reads as a
   header rule rather than a footer rule. Must follow the rule above — equal specificity, so order
   is what wins. The form below supplies its own top padding via .cam-config__pad. */
/* Commit bar pinned to the foot of the dialog. Save/Cancel/Delete must be reachable without
   scrolling to the end of a long tab (Streams with a full gallery, General on a phone), so the bar
   STICKS to the bottom of the scrollport and the panel scrolls under it.
   The negative margins span .treact-modal-body's own 20px padding so the bar reaches the dialog's
   edges, and `bottom` cancels that same padding so it sits flush instead of floating with a strip
   of scrolling content visible beneath it. --top (actions) and --footer (commit) are deliberately
   different jobs: what you might do, versus what you do to finish. */
/* Commit bar locked while a save is in flight (the handler flushes this class before a probe that
   can run 20s). Not just cosmetic: it stops a second click queueing another save behind the first. */
.dialog__actions.is-busy { pointer-events: none; opacity: 0.55; }
body.nvr .treact-modal-body .dialog__actions--footer {
  position: sticky;
  bottom: -20px;
  z-index: 2;
  margin: var(--sp-4) -20px -20px;
  padding: var(--sp-3) 20px;
  background-color: var(--c-surface);
  border-top: 1px solid var(--c-border);
}

/* ── One bounded scroll area per tab ──────────────────────────────────────────
   When a tab supplies .cam-config__scroll, the SCROLL MOVES OFF the modal body and onto that box,
   so the scrollbar spans only the content — it stops at the commit bar instead of running the whole
   height of the dialog and passing behind it. The footer then needs no stickiness: it is simply the
   next flex item, permanently outside the scrollport.
   :has() gates the whole thing, so a tab that doesn't opt in (the canvas editors, whose actions are
   nested inside their own wrapper) keeps the previous whole-body scroll and nothing regresses. */
body.nvr .treact-modal-body:has(.cam-config__scroll) {
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.treact-modal-body:has(.cam-config__scroll) .cam-config { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.treact-modal-body:has(.cam-config__scroll) .settings__tabs { flex: 0 0 auto; }
.treact-modal-body:has(.cam-config__scroll) .cam-config__panel { flex: 1 1 auto; min-height: 0; }
.cam-config__scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* Reserve the gutter so content doesn't shift sideways when a tab switch changes the scroll. */
  scrollbar-gutter: stable;
}
/* Outside the scrollport now — drop the sticky offsets that assumed it was inside one. */
.treact-modal-body:has(.cam-config__scroll) .dialog__actions--footer {
  position: static;
  flex: 0 0 auto;
  margin: 0 -20px -20px;
}
.treact-modal-body:has(.cam-config__scroll) .dialog__actions--top {
  flex: 0 0 auto;
  margin-bottom: 0;
}
body.nvr .treact-modal-body .dialog__actions--top {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
  margin-bottom: var(--sp-3);
  padding-bottom: var(--sp-3);
  border-bottom: 1px solid var(--c-border);
}

/* ── camera thumbnails ─────────────────────────────────────────────────────
   Cached live-frame "favicon" per camera (thumb.php). A visual identifier in
   the Settings table and the playback camera picker. 16:9, dark backdrop while
   the image loads so a slow/failed grab doesn't flash. */
.camthumb {
  display: inline-block;
  height: 22px;
  width: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 3px;
  background: var(--c-surface-raised);
  border: 1px solid var(--c-border);
  vertical-align: middle;
  flex: 0 0 auto;
}
/* Settings table: name cell pairs the thumbnail with the label on one line. */
.camtable__nameline {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
}
.camtable__nameline .camthumb { height: 27px; }
/* Playback picker: the thumbnail sits between the checkbox and the name. */
.pb-multi__item .camthumb { height: 22px; }

/* ── live object-metadata overlay (Step 3) ─────────────────────────────────
   SVG boxes over the live video. Positioned identically to .tile__video; the
   viewBox aspect is set to the video's intrinsic size in JS, so 'meet' letterboxes
   exactly like object-fit:contain — boxes land on the real image, not the bars.
   non-scaling-stroke keeps borders a crisp 2px at any tile size. */
.tile__boxes {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
  z-index: 3;                 /* above the scrim, below the head/tools UI */
}

/* Speed label over a moving object (calibrated cameras). WHITE text with a thin (~1px) BLACK outline
   so it reads on any scene; drawn in the .tile__boxes SVG so it letterboxes with the video. The 2px
   non-scaling stroke (paint-order:stroke → ~1px shows outside the fill) stays 1px at any tile size. */
.tile__speed {
  fill: #fff;
  stroke: #000;
  stroke-width: 2px;
  vector-effect: non-scaling-stroke;
  paint-order: stroke;
  font-weight: 700;
  font-family: var(--font-ui);
}

/* User-placed I/O overlay icons over the live video. The layer ignores pointer events; only the
   actionable (output/io) items opt back in. Hollow — a drop-shadow keeps them legible on any scene. */
/* Full-box by default; app.js positionOverlay() overrides left/top/width/height to the video's
   letterboxed image rect so % coords track the image, not the tile (matters when magnified). */
.tile__ov { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 3; }
.tile__ov-item {
  position: absolute; transform: translate(-50%, -50%); display: flex; align-items: center;
  justify-content: center; color: var(--c-on-accent); pointer-events: none;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 90%));
}
.tile__ov-ic { display: block; width: 1.9rem; height: 1.9rem; }
.tile__ov-item--nogfx::before { content: ""; width: 1rem; height: 1rem; border-radius: 50%; border: 2px solid currentColor; }
/* Status colour (input + io follow their input channel): green on / red off. */
.tile__ov-item--input.is-on,  .tile__ov-item--io.is-on  { color: var(--c-ok); }
.tile__ov-item--input.is-off, .tile__ov-item--io.is-off { color: var(--c-error); }
.tile__ov-item--output { color: var(--c-accent); }
/* Actionable (output / io): clickable, with hover/press feedback + a fire flash. */
.tile__ov-item--btn { pointer-events: auto; cursor: pointer; transition: transform var(--transition); }
.tile__ov-item--btn:hover  { transform: translate(-50%, -50%) scale(1.15); }
.tile__ov-item--btn:active { transform: translate(-50%, -50%) scale(0.92); }
.tile__ov-item.is-firing { opacity: 0.5; }
.tile__ov-item.is-flash-ok   { animation: ov-flash-ok 0.9s ease; }
.tile__ov-item.is-flash-err  { animation: ov-flash-err 0.9s ease; }
.tile__ov-item.is-flash-wait { animation: ov-flash-wait 0.9s ease; }
@keyframes ov-flash-ok   { 30% { color: var(--c-ok);    filter: drop-shadow(0 0 7px var(--c-ok)); } }
@keyframes ov-flash-err  { 30% { color: var(--c-error); filter: drop-shadow(0 0 7px var(--c-error)); } }
@keyframes ov-flash-wait { 30% { color: var(--c-warn);  filter: drop-shadow(0 0 7px var(--c-warn)); } }

/* Privacy-mask polygons (RtspMask, shared by Live/Playback/editor). The LAYER stays FULL-tile (inset:0)
   so under digital zoom it shares the video's transform-origin and never drifts; the letterbox is baked
   into each shape's clip-path instead (RtspMask.render frame). Each shape is a clip-path div — opaque
   fill or a frosted backdrop-blur. Display-only (never redacts the real pixels). z-index 2 = above the
   video, below the AI boxes/head/tools. */
.tile__mask, .playback__mask {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 2;
}
.rmask { position: absolute; inset: 0; }
.rmask--opaque { background-color: #0a0a0a; }
.rmask--blur { -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }

/* Data ticker (livebar "Affichage" › Données texte): external text records — POS receipts, ALPR
   plates, badge swipes — printed on the tile of the camera they are pinned to. The analog-DVR text
   insertion, revived: monospace on a dark plate so it stays readable over any picture, bottom-left
   above the meta row, oldest line at the top. Never intercepts clicks (PTZ/zoom live underneath). */
.tile__data {
  position: absolute;
  left: var(--sp-2);
  right: var(--sp-2);
  bottom: calc(var(--sp-2) + 1.4rem);   /* clear the resolution/badge meta row */
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 2px;
  align-items: flex-start;
  pointer-events: none;
}
.tile__data-line {
  max-width: 100%;
  display: flex;
  gap: var(--sp-2);
  padding: 1px var(--sp-2);
  border-left: 2px solid var(--c-data);   /* same gold as the playback timeline's data marks */
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 68%);
  color: #f2f2f2;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.45;
  /* One record = one line: a 40-item receipt must not swallow the picture. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  animation: data-line-in 240ms ease-out;
}
/* data-source is on the node (pos / alpr / access / …) as a styling hook: one gold "data" language
   for now, per-source tints only if a real wall ever mixes enough sources to need them. */
.tile__data-time { flex: none; opacity: 0.72; }
.tile__data-text { overflow: hidden; text-overflow: ellipsis; }

@keyframes data-line-in {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .tile__data-line { animation: none; }
}

/* Playback reuses the ticker verbatim, minus the wall's badge row: a playback tile has nothing at
   bottom-left, so the lines sit on the edge. MUST come after .tile__data — same specificity, so it
   is source order alone that lets these win.
   Area search puts a control bar along that edge; lift the text above it while that mode is on. */
.playback__data { bottom: var(--sp-2); }
.playback__vtile--area .playback__data { bottom: calc(var(--sp-2) + 2.2rem); }

/* AI counters chip (live wall + playback tiles): per-class object counts for the displayed
   instant — the production replacement for the boxes while their frame-exact alignment carries
   an unresolved pipeline term (?debug=1 shows the boxes). Hidden when the scene is empty. */
.tile__counts {
  position: absolute;
  left: var(--sp-2);
  bottom: var(--sp-2);
  z-index: 4;
  padding: 2px var(--sp-2);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 60%);
  color: var(--c-on-accent);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
/* AI-Sync HUD (?debug=1 only): frame-epoch vs box-epoch diagnostic, top-left so it clears the
   bottom-left count chip. Dev instrument — English-only, mono, non-interactive. */
.tile__aisync {
  position: absolute;
  left: var(--sp-2);
  top: var(--sp-2);
  z-index: 5;
  padding: 1px var(--sp-2);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 68%);
  color: #ffd166;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.35;
  white-space: nowrap;
  pointer-events: none;
}
body:not(.debug) .tile__aisync { display: none; }

/* Copyable AI-Sync dump (?debug=1): a read-only, selectable textarea aggregating every tile's HUD
   lines. Fixed top-right corner (clears the top-left live HUD and the bottom video/deck), resizable,
   semi-transparent until hovered/focused so it stays out of the way. Dev instrument, English-only. */
.aisync-dump {
  position: fixed;
  top: var(--sp-2);
  right: var(--sp-2);
  z-index: 60;
  width: 320px;
  height: 150px;
  max-width: 46vw;
  max-height: 45vh;
  resize: both;
  overflow: auto;
  padding: var(--sp-2);
  border: 1px solid rgb(255 255 255 / 20%);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 78%);
  color: #ffd166;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.35;
  white-space: pre;
  opacity: .82;
}
.aisync-dump:hover,
.aisync-dump:focus { opacity: 1; }

/* Incoherence line in the AI-Sync HUD (?debug=1): a physically-impossible reading (GOT after REQ,
   preroll over bound, painted frame outside the served clip). Red + bold so it can't be missed. */
.aisync-warn { color: #ff5555; font-weight: 700; }

/* ---- Box fan (?boxfan=1): many time-hypothesis bboxes at once + legend ---------------------- */
.playback__boxfan { z-index: 5; }             /* above the normal .tile__boxes overlay (inherits its box/position) */
.playback__boxfan text { paint-order: stroke; stroke: rgb(0 0 0 / 75%); stroke-width: 3px; font-family: var(--font-mono); }

/* On-canvas legend key: colour → hypothesis → its computed time. Top-left, out of the video's way. */
.boxfan-legend {
  position: absolute;
  left: var(--sp-2);
  top: var(--sp-2);
  z-index: 6;
  max-width: 62%;
  padding: 3px 6px;
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 68%);
  color: #eee;
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.4;
  pointer-events: none;
}
.boxfan-legend.is-hidden { display: none; }
.boxfan-legend .boxfan-off { opacity: .45; }   /* hypothesis matched no record this frame */
/* Rows are clickable (toggle a hypothesis on/off) even though the legend box itself ignores pointers. */
.boxfan-legend .boxfan-row { pointer-events: auto; cursor: pointer; user-select: none; border-radius: 3px; }
.boxfan-legend .boxfan-row:hover { background-color: rgb(255 255 255 / 14%); }
.boxfan-legend .boxfan-hidden { opacity: .4; text-decoration: line-through; }   /* operator toggled it off */
.boxfan-sw {
  display: inline-block;
  width: 9px;
  height: 9px;
  margin-right: 5px;
  border: 1px solid rgb(255 255 255 / 35%);
  vertical-align: baseline;
}

/* Copyable box-fan legend dump: bottom-right so it clears the top-right AI-Sync dump. */
.boxfan-dump {
  position: fixed;
  right: var(--sp-2);
  bottom: var(--sp-2);
  z-index: 60;
  width: 460px;
  height: 190px;
  max-width: 60vw;
  max-height: 45vh;
  resize: both;
  overflow: auto;
  padding: var(--sp-2);
  border: 1px solid rgb(255 255 255 / 20%);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 80%);
  color: #ffd166;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.35;
  white-space: pre;
  opacity: .82;
}
.boxfan-dump:hover,
.boxfan-dump:focus { opacity: 1; }

/* B_rec restart-bias measurement instrument (?brec=1): bottom-left floating panel, marks persist across
   start.sh restarts so the offset-vs-restart correlation can be built. */
.pb-brec {
  position: fixed;
  left: var(--sp-2);
  bottom: var(--sp-2);
  z-index: 61;
  width: min(94vw, 460px);
  max-height: 62vh;
  overflow: auto;
  padding: 6px 8px;
  border: 1px solid rgb(255 255 255 / 25%);
  border-radius: var(--radius-sm);
  background-color: rgb(0 0 0 / 86%);
  color: #eee;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.4;
}
.pb-brec.is-hidden { display: none; }
.pb-brec__head { position: relative; padding-right: 16px; margin-bottom: 4px; font-weight: bold; color: #ffd166; }
.pb-brec__x { position: absolute; right: 0; top: 0; cursor: pointer; opacity: .7; }
.pb-brec__x:hover { opacity: 1; }
.pb-brec__read { margin-bottom: 5px; color: #b2ff59; }
.pb-brec__btns { display: flex; gap: 6px; margin-bottom: 5px; }
.pb-brec__btns button {
  font: inherit; cursor: pointer; padding: 2px 8px; border-radius: 4px;
  border: 1px solid rgb(255 255 255 / 25%); background-color: rgb(255 255 255 / 12%); color: #eee;
}
.pb-brec__btns button:hover { background-color: rgb(255 255 255 / 22%); }
.pb-brec__mark { color: #ff8080; }
.pb-brec__truthbox { margin-bottom: 5px; }
.pb-brec__truthbox.is-hidden { display: none; }
.pb-brec__truthimg { display: block; width: 100%; max-width: 440px; border: 1px solid rgb(255 255 255 / 25%); border-radius: 3px; }
.pb-brec__truthcap { margin-top: 2px; color: #ffd166; }
.pb-brec__elog { margin-bottom: 5px; color: #9cf; white-space: pre; }
.pb-brec__elog:empty { display: none; }
.pb-brec__tbl { width: 100%; border-collapse: collapse; }
.pb-brec__tbl th, .pb-brec__tbl td {
  padding: 1px 4px; text-align: right; white-space: nowrap; border: 1px solid rgb(255 255 255 / 15%);
}
.pb-brec__tbl th { color: #9cf; }

.tile__box rect {
  fill: none;
  stroke: var(--c-obj-unknown);            /* default / unknown class */
  stroke-width: 2px;
  /* No CSS transition: the overlay now interpolates boxes itself at display refresh (see interpolatedObjects).
     A transition here would make the rect chase ~1 frame behind the computed position while the label (no
     transition) sits exactly on it — the box and its text would visibly separate. */
}
.tile__box--vehicle rect { stroke: var(--c-obj-vehicle); }   /* amber  — matches the AI event hue */
.tile__box--person  rect { stroke: var(--c-obj-person); }   /* cyan */
.tile__box--bike    rect { stroke: var(--c-obj-bike); }   /* violet */
/* Centre "+" mark (Settings › UI toggle) — matches the box colour, crisp non-scaling stroke, with a dark
   halo so it's readable on any scene. Empty `d` when the toggle is off. */
.tile__box-center {
  fill: none;
  stroke: var(--c-obj-unknown);
  stroke-width: 1.5px;
  paint-order: stroke;
  filter: drop-shadow(0 0 1px rgb(0 0 0 / 85%));
}
.tile__box--vehicle .tile__box-center { stroke: var(--c-obj-vehicle); }
.tile__box--person  .tile__box-center { stroke: var(--c-obj-person); }
.tile__box--bike    .tile__box-center { stroke: var(--c-obj-bike); }
/* Trajectory trails (Settings › UI toggle) — faint per-class polylines linking object centres over the
   last few seconds; a remanence of where objects passed (heat-map seed). Painted under the boxes. */
.tile__path {
  fill: none;
  stroke: var(--c-obj-unknown);
  stroke-width: 3.5px;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.85;
  filter: drop-shadow(0 0 1.5px rgb(0 0 0 / 70%));   /* dark halo so the bold trail pops on any scene */
}
.tile__path--vehicle { stroke: var(--c-obj-vehicle); }
.tile__path--person  { stroke: var(--c-obj-person); }
.tile__path--bike    { stroke: var(--c-obj-bike); }
/* Heading triangles on the trails: which WAY the object moved (filled, sized in screen px by the JS).
   Reads at a glance on a wall of tiles — the visual groundwork for wrong-way detection. */
.tile__arrow {
  fill: var(--c-obj-unknown);
  stroke: none;
  opacity: 0.95;
  filter: drop-shadow(0 0 1.5px rgb(0 0 0 / 80%));   /* same dark halo as the trail */
}
.tile__arrow--vehicle { fill: var(--c-obj-vehicle); }
.tile__arrow--person  { fill: var(--c-obj-person); }
.tile__arrow--bike    { fill: var(--c-obj-bike); }
/* Per-tile sound toggle: accent + subtle ring while THIS tile is the one you hear. */
.tile__tool--audio.is-on { color: var(--c-accent); box-shadow: 0 0 0 1px var(--c-accent) inset; }
.tile__tool--audio .is-hidden { display: none; }
/* Push-to-talk mic: RED while transmitting (an open mic is a state you must not miss). */
.tile__tool--talk.is-on {
  color: var(--c-danger, #e5484d);
  box-shadow: 0 0 0 1px var(--c-danger, #e5484d) inset;
  animation: talk-pulse 1.2s ease-in-out infinite;
}
@keyframes talk-pulse { 50% { opacity: 0.55; } }

/* Camera motion — the individual active grid CELLS drawn as solid red pixels (no box, no label), so the
   raw motion texture is self-explanatory. No transition: cells change every frame, tweening smears them. */
.tile__box--motion  rect { fill: rgb(239 68 68 / 68%); stroke: none; transition: none; }
.tile__box text {
  fill: #fff;
  font-weight: 700;
  font-family: var(--font-ui);
  paint-order: stroke;        /* draw the dark outline under the fill for legibility */
  stroke: #000;
  stroke-width: 2px;          /* thin outline, proportional to the smaller label text */
  vector-effect: non-scaling-stroke;
}
.tile__box--vehicle text { fill: #fbbf24; }
.tile__box--person  text { fill: #7dd3fc; }
.tile__box--bike    text { fill: #c4b5fd; }

/* ---------------------------------------------------------------------------
   Settings › ALPR (LAPI) — plate-read ingestion over FTP.
   Two distinct jobs on one page: facts to COPY into the camera (monospace, selectable), and reads
   that have ARRIVED (a compact log). Both lean on the shared field/section atoms.
   --------------------------------------------------------------------------- */
.alpr__status { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.alpr__dot {
  flex: none; width: 10px; height: 10px; border-radius: 50%;
  background-color: var(--c-text-faint);
}
.alpr__dot.is-on { background-color: var(--c-ok, #3fb950); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-ok, #3fb950) 25%, transparent); }
.alpr__counts { color: var(--c-text-muted); font-size: var(--fs-sm); }

.alpr__conn { display: flex; flex-direction: column; gap: var(--sp-1); }
.alpr__kv { display: flex; align-items: baseline; gap: var(--sp-2); flex-wrap: wrap; }
.alpr__kv-k { flex: 0 0 11rem; color: var(--c-text-muted); font-size: var(--fs-sm); }
/* user-select so an installer can drag-copy the password straight into the camera's web form */
.alpr__kv-v {
  font-family: var(--font-mono); user-select: all;
  padding: 1px var(--sp-2); border-radius: var(--radius-sm);
  background-color: var(--c-surface-raised); color: var(--c-text);
}

/* The reads log scrolls on its own so a long filename can never widen the settings panel. */
.alpr__tablewrap { overflow-x: auto; }
.alpr__table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
.alpr__table th {
  text-align: left; padding: var(--sp-1) var(--sp-2);
  color: var(--c-text-muted); font-weight: 600; border-bottom: 1px solid var(--c-border);
}
.alpr__table td { padding: var(--sp-1) var(--sp-2); border-bottom: 1px solid var(--c-border); }
.alpr__table tr:last-child td { border-bottom: 0; }
.alpr__plate {
  font-family: var(--font-mono); font-weight: 700; letter-spacing: 0.04em;
  padding: 1px var(--sp-2); border-radius: var(--radius-sm);
  border: 1px solid var(--c-border-strong); background-color: var(--c-surface-raised);
}
.alpr__file { font-family: var(--font-mono); color: var(--c-text-muted); white-space: nowrap; }
.alpr__muted { color: var(--c-text-faint); }
.alpr__rule.is-off td { opacity: 0.5; }
.alpr__mode {
  font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.04em;
  padding: 1px var(--sp-2); border-radius: var(--radius-sm);
  border: 1px solid var(--c-border-strong); color: var(--c-text-muted);
}
.alpr__rowact { white-space: nowrap; text-align: right; }
.alpr__addrow { margin-top: var(--sp-3); }

/* Settings › Info — live delivery. "Relayed" is the one an operator must be able to spot: it means
   the silent HLS fallback is in use and the stream is crossing the mothership at full bitrate. */
.transport__table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
.transport__table th {
  text-align: left; padding: var(--sp-1) var(--sp-2);
  color: var(--c-text-muted); font-weight: 600; border-bottom: 1px solid var(--c-border);
}
.transport__table td { padding: var(--sp-1) var(--sp-2); border-bottom: 1px solid var(--c-border); }
.transport__table tr:last-child td { border-bottom: 0; }
.transport__path { font-family: var(--font-mono); color: var(--c-text-muted); }
.transport__rate { font-family: var(--font-mono); text-align: right; white-space: nowrap; }
.transport__badge {
  font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.04em;
  padding: 1px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid transparent;
}
.transport__badge.is-direct  { color: var(--c-ok, #3fb950); border-color: color-mix(in srgb, var(--c-ok, #3fb950) 45%, transparent); }
.transport__badge.is-relayed { color: var(--c-warn); border-color: color-mix(in srgb, var(--c-warn) 45%, transparent); }
.transport__viewers { text-align: right; font-variant-numeric: tabular-nums; }
.transport__badge.is-mixed { color: var(--c-warn); border-color: color-mix(in srgb, var(--c-warn) 45%, transparent); }
