:root {
  --bg: #e2eaf5;
  --panel: #ffffff;
  --panel-border: #c3d3e8;
  --text: #16202e;
  --muted: #5b6b85;
  --accent: #0f7abe;
  --accent-hover: #0c6299;
  --accent-rgb: 15, 122, 190; /* same color as --accent, as r,g,b components - lets hover/focus glows use rgba() without re-deriving it per rule */
  --accent-orange: #e5491a;
  --sidebar-bg: #0f7abe;
  --sidebar-text: #cfe9fb;
  --sidebar-hover-bg: rgba(255,255,255,0.1);
  --sidebar-active-bg: rgba(255,255,255,0.18);
  --good: #16a34a;
  --bad: #dc2626;
  --warn: #d97706;
  --good-bg: #dcfce7;
  --bad-bg: #fee2e2;
  --warn-bg: #fef3c7;
  --accent-bg: #e0f2fe;
  --console-bg: #101826;
  --console-border: #1e293b;
  --console-text: #cbd5e1;
  --console-muted: #7c8aa6;
  --console-good: #4ade80;
  --console-bad: #f87171;
  --console-info: #38bdf8;
  --chart-grid: #e2e8f0; /* app.js's Chart.js axis gridlines - can't read CSS vars itself, see app.js's cssVar() helper which reads this at chart-creation time */
  /* r,g,b of the dark-mode logo glow's current color, same ","-joined
     component convention as --accent-rgb above. Set here only as a
     fallback (this default, near-white, is what shows for one frame
     before JS runs, or if it's disabled) - common.js's
     applyLogoGlowForTimeOfDay() overwrites it on every page load to
     the real circadian-curve color for the current local time (see its
     own comment for the science behind the curve shape). */
  --logo-glow-rgb: 255, 249, 253;
  color-scheme: light;
}

/* Dark theme - toggled server-side via app/theme.py's cookie + the
   {{ theme() }}-driven data-theme attribute on <html> (see base.html and
   every other top-level template), not prefers-color-scheme - same
   fixed-default, explicit-choice pattern app/i18n.py's language cookie
   already uses, so a client who's picked "dark" gets it on every page
   including pre-login ones, not just wherever they happened to set it.
   Placed after every use of the light values above (not merged into one
   conditional block) so [data-theme="dark"]'s higher specificity
   (0,2,0 vs :root's 0,1,0) cleanly wins per-property over both the plain
   :root defaults AND a client's own branding <style> override in
   base.html (branding colors are a per-tenant LIGHT-mode identity choice;
   dark mode is a personal viewer preference that intentionally takes
   priority over them, same as it would for any other theme switch). */
:root[data-theme="dark"] {
  --bg: #0b1220;
  --panel: #141d2e;
  --panel-border: #283651;
  --text: #e7edf7;
  --muted: #93a3c0;
  --accent: #3ea6e0;
  --accent-hover: #5db8ea;
  --accent-rgb: 62, 166, 224;
  --accent-orange: #ff8a5c;
  --sidebar-bg: #0e1b30;
  --sidebar-text: #aac6e3;
  --sidebar-hover-bg: rgba(255, 255, 255, 0.06);
  --sidebar-active-bg: rgba(255, 255, 255, 0.12);
  --good: #34d399;
  --bad: #f87171;
  --warn: #fbbf24;
  --good-bg: rgba(52, 211, 153, 0.15);
  --bad-bg: rgba(248, 113, 113, 0.15);
  --warn-bg: rgba(251, 191, 36, 0.15);
  --accent-bg: rgba(62, 166, 224, 0.15);
  --chart-grid: #26334a;
  color-scheme: dark;
}

* { box-sizing: border-box; }
/* Several element classes here (.btn among them) set their own `display`
   at the same specificity as the browser's built-in `[hidden]` rule, and
   being an author rule beats it on the tie - toggling `el.hidden = true`
   on a `.btn` silently did nothing before this. Found via the floorplan
   zone editor's delete button staying visible despite being hidden. */
[hidden] { display: none !important; }

html { scroll-behavior: smooth; }

/* Motion & micro-interactions (buttons, cards, panels, nav, sliders below
   all lean on this): kept to opacity/transform/box-shadow only, the three
   properties a browser can animate on its own compositor thread without
   re-running layout - a hover/entrance effect that's cheap enough to not
   jank a page that's also polling live sensor data every few seconds.
   `both` fill-mode on the entrance keyframe below holds the pre-animation
   (opacity:0) state until the animation actually starts, so a panel
   revealed later by JS (e.g. `el.hidden = false`) doesn't flash fully
   visible for one frame before fading in. */
@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes status-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.35); }
  50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0); }
}

/* Respected last (highest source-order specificity tiebreak) so it wins
   over every animation/transition declared anywhere else in this file,
   without having to touch each one individually. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* A visible, glowing focus ring everywhere - :focus-visible so it only
   shows for keyboard/assistive-tech navigation, not every mouse click
   (the default browser outline doesn't distinguish the two, which reads
   as a flicker on every button press with a mouse). */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.18);
  border-radius: 4px;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  /* Column flex + min-height so .app-shell (below) always fills whatever
     vertical space is left under the topbar/mock-mode banner, regardless
     of their height - .sidebar's own `align-self: stretch` then makes
     the blue sidebar reach the bottom of the viewport even on a page
     whose main content is shorter than the screen (e.g. a floorplan with
     a small image), not just on pages with enough stacked panels to
     naturally exceed viewport height. */
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  background: var(--panel);
  border-bottom: 2px solid var(--accent);
}

.brand { font-size: 20px; font-weight: 700; }
.topbar .brand { display: flex; align-items: center; }
.brand span { font-weight: 400; color: var(--muted); margin-left: 8px; font-size: 15px; }
.brand-logo { height: 32px; max-width: 180px; object-fit: contain; }
/* Every client picks their own logo (admin_client_form.html's
   branding_logo_url) - arbitrary colors/format we have no control over
   and can't recolor per theme the way the one fixed Distrilight wordmark
   below can. A plain-black logo (real example: the "Van Aalst" account's
   own SVG) would otherwise vanish against the dark palette's navy
   background exactly like Distrilight's own wordmark did.
   drop-shadow (not box-shadow) follows the image's own alpha silhouette
   pixel-for-pixel rather than its rectangular bounding box - a
   transparent-background logo (the normal case) gets a soft halo hugging
   its actual letterforms, not a hard-edged card floating around empty
   space. Stacked at three blur radii (tight bright core, soft wide
   falloff) for a genuine glow gradient rather than one flat ring - a
   single drop-shadow reads as a thin outline, not a glow. Works for ANY
   logo regardless of its own colors, so it's the one fix that has to
   live here instead of a per-client asset swap. Colored via
   --logo-glow-rgb (common.js sets it per the current time of day - a
   lighting company's dashboard gets to have a glow that actually behaves
   like real light) rather than a fixed white. */
:root[data-theme="dark"] .brand-logo {
  filter: drop-shadow(0 0 3px rgb(var(--logo-glow-rgb))) drop-shadow(0 0 9px rgb(var(--logo-glow-rgb))) drop-shadow(0 0 20px rgba(var(--logo-glow-rgb), 0.7));
}

.topbar a, .auth-footer-link {
  color: var(--muted);
  font-size: 13px;
  text-decoration: none;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  padding: 6px 14px;
  transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.topbar a:hover, .auth-footer-link:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); }

.lang-toggle {
  display: inline-flex;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  overflow: hidden;
}
.lang-toggle a {
  padding: 6px 12px;
  font-size: 12px;
  font-weight: 600;
  text-decoration: none;
  color: var(--muted);
  /* Overrides .topbar a's own border/radius/padding, which would
     otherwise also apply here (same specificity, .topbar a just comes
     first) and give each link its own pill nested inside this one -
     only visible on pages where .lang-toggle sits inside .topbar, not
     the standalone auth pages, which is why login looked fine already. */
  border: none;
  border-radius: 0;
}
.lang-toggle a.active { background: var(--accent); color: #fff; }
.lang-toggle a:hover:not(.active) { color: var(--accent); }
.auth-page .lang-toggle { position: absolute; top: 20px; right: 20px; }

.theme-toggle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  flex-shrink: 0;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  color: var(--muted);
  text-decoration: none;
  transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, transform 0.2s ease;
}
.theme-toggle-btn:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); transform: rotate(15deg); }
/* The icon's own HTML width="16" height="16" attributes aren't enough on
   their own - a real user report confirmed a browser computing this
   inline <svg>'s flex-item width as 0px while height correctly resolved
   to 16px (getComputedStyle confirmed the mismatch directly), a known
   cross-browser gotcha: flexbox's auto intrinsic-size computation for a
   replaced SVG element with only presentation-attribute sizing (no CSS
   width) can collapse to 0 on the flex container's main axis. An
   explicit CSS size removes the ambiguity outright instead of relying on
   the SVG's own attributes. */
.theme-toggle-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
/* Same corner-pinned treatment as .auth-page .lang-toggle just above,
   offset further left so the two sit side by side instead of overlapping
   - the standalone auth pages (login/admin login/onboarding/settings)
   have no topbar flex row to lay this out in naturally. .lang-toggle's
   own width varies with locale text, but both its EN/NL labels are short
   enough that a fixed ~90px reservation (its right:20px + this button's
   own 30px + an 8px gap) clears it in practice - confirmed against its
   real measured width (82.6px) rather than guessed. */
.auth-page .theme-toggle-btn { position: absolute; top: 20px; right: 112px; }

.btn {
  display: inline-block;
  background: var(--accent);
  color: white;
  border: none;
  border-radius: 8px;
  padding: 9px 16px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 6px 16px rgba(var(--accent-rgb), 0.35); }
.btn:active:not(:disabled) { transform: translateY(0) scale(0.97); box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3); }
.btn-secondary { background: var(--bg); border: 1px solid var(--panel-border); color: var(--text); }
.btn-secondary:hover:not(:disabled) { background: var(--panel); border-color: var(--accent); color: var(--accent); box-shadow: 0 4px 12px rgba(22, 32, 46, 0.08); }
.btn-danger { background: transparent; border: 1px solid var(--bad); color: var(--bad); }
.btn-danger:hover:not(:disabled) { background: var(--bad-bg); box-shadow: 0 4px 12px rgba(220, 38, 38, 0.2); }
.btn-small { padding: 4px 10px; font-size: 12px; }

.status {
  font-size: 13px;
  color: var(--muted);
  padding: 4px 10px;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
}
/* Soft ring pulse (not a color/opacity flash) reinforces "this is a live
   feed, still updating". common.js's loadStatus() re-sets this element's
   className to the same "status ok" string every 5s while healthy -
   assigning an unchanged class value doesn't reset a running CSS
   animation (browsers only restart on an actual value change), so the
   pulse keeps looping smoothly across polls instead of stuttering. */
.status.ok { color: var(--good); border-color: var(--good); background: var(--good-bg); animation: status-pulse 2.4s ease-in-out infinite; }
.status.error { color: var(--bad); border-color: var(--bad); background: var(--bad-bg); }
.status.warning { color: var(--warn); border-color: var(--warn); background: var(--warn-bg); }

.banner {
  background: var(--warn-bg);
  color: #92400e;
  padding: 8px 24px;
  font-size: 13px;
  border-bottom: 1px solid #fde68a;
}
.banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; }

.auth-page {
  position: relative;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.auth-card {
  width: 100%;
  max-width: 380px;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 14px;
  padding: 32px;
  box-shadow: 0 4px 24px rgba(22,32,46,0.06);
}
.auth-card .brand { text-align: center; margin-bottom: 24px; }
.auth-card .brand span { display: block; margin: 12px 0 0 0; }
.brand-logo-plate {
  /* Used to be a boxed "plate" (background/border/padding) to make the
     logo stand out against the app's old dark theme - the light theme's
     own card background did that job well enough that it became just a
     full-width wrapper, no visible box, for a long time. Dark mode's
     return (see .brand-logo-plate img's own :root[data-theme="dark"]
     rule below) is what that plate box was for in the first place - same
     fix as .brand-logo's own comment above, just for the fixed
     Distrilight logo shown on the standalone auth pages instead of a
     per-client one. Block + width:100% (not the old inline-flex, which
     sizes to its own content) so the img below has an actual width to
     scale against - it needs to span the same width as the input fields
     further down the card. */
  display: block;
  width: 100%;
}
.brand-logo-plate img {
  display: block;
  width: 71%;
  height: auto;
  margin: 0 auto;
  position: relative;
  left: 6px;
}
/* Same drop-shadow glow as .brand-logo's own dark-mode rule (see its
   comment for why drop-shadow over a boxed background) - a white plate
   here read as a jarring flat banner (the .brand-logo-plate wrapper
   above is deliberately width:100% for light mode's own layout, so a
   background on IT stretched edge-to-edge instead of hugging the logo's
   own much narrower rendered width). filter, not box-shadow, so it
   follows the SVG's actual silhouette. */
:root[data-theme="dark"] .brand-logo-plate img {
  filter: drop-shadow(0 0 6px rgb(var(--logo-glow-rgb))) drop-shadow(0 0 19px rgb(var(--logo-glow-rgb))) drop-shadow(0 0 40px rgba(var(--logo-glow-rgb), 0.7));
}
.auth-field { margin-bottom: 16px; }
.auth-field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
.auth-field input[type="color"] {
  /* type="color"'s swatch fills its whole content box - the padding
     other inputs get would squash it down to a thin sliver instead of
     showing the actual selected color. */
  width: 60px;
  height: 40px;
  padding: 2px;
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  cursor: pointer;
}
.auth-field input:not([type="color"]), .auth-field select {
  width: 100%;
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 10px 12px;
  color: var(--text);
  font-size: 14px;
}
/* Linked-sensors picker (admin_floorplan_edit.html) - plain clickable
   rows instead of a native <select multiple>, which only toggles a row
   with ctrl/cmd held (a real usability complaint: the user had to hold
   ctrl to pick more than one sensor) and truncates long names with no
   wrap. Selection state lives in JS (floorplan_admin.js's
   `selectedSensors` Set), reflected here only via the *-selected class. */
.sensor-picker {
  width: 100%;
  max-height: 240px;
  overflow-y: auto;
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 4px;
}
.sensor-picker-item {
  padding: 8px 10px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  color: var(--text);
  white-space: normal;
  overflow-wrap: anywhere;
  transition: background-color 0.15s ease;
}
.sensor-picker-item:hover { background: rgba(var(--accent-rgb), 0.1); }
.sensor-picker-item-selected, .sensor-picker-item-selected:hover { background: var(--accent); color: #fff; }
.auth-submit {
  width: 100%;
  margin-top: 8px;
  padding: 11px;
  border: none;
  border-radius: 8px;
  background: var(--accent);
  color: white;
  font-weight: 600;
  font-size: 14px;
  cursor: pointer;
}
.auth-submit { transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; }
.auth-submit:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 6px 18px rgba(var(--accent-rgb), 0.35); }
.auth-submit:active { transform: translateY(0) scale(0.98); }
.auth-error {
  background: var(--bad-bg);
  color: #b91c1c;
  border: 1px solid #fecaca;
  border-radius: 8px;
  padding: 10px 12px;
  font-size: 13px;
  margin-bottom: 16px;
}
.auth-success {
  background: var(--good-bg);
  color: #15803d;
  border: 1px solid #bbf7d0;
  border-radius: 8px;
  padding: 10px 12px;
  font-size: 13px;
  margin-bottom: 16px;
}
.auth-footer { text-align: center; margin-top: 18px; font-size: 12px; color: var(--muted); }
.auth-footer a { color: var(--accent); text-decoration: none; }

.app-shell { display: flex; align-items: flex-start; flex: 1; }
.sidebar {
  width: 220px;
  flex-shrink: 0;
  padding: 24px 12px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  position: sticky;
  top: 0;
  background: var(--sidebar-bg);
  align-self: stretch;
}
.sidebar-link {
  display: block;
  position: relative;
  padding: 10px 14px 10px 14px;
  border-radius: 8px;
  color: var(--sidebar-text);
  text-decoration: none;
  font-size: 14px;
  font-weight: 500;
  border: 1px solid transparent;
  overflow: hidden;
  transition: background-color 0.15s ease, color 0.15s ease;
}
/* A left indicator bar that grows in from the middle on hover/active,
   rather than an instant color swap - reads as the nav "pointing at"
   the current/hovered item instead of just flatly recoloring it.
   Absolutely positioned (doesn't consume layout width) and left at the
   link's own edge (not nudging padding-left on hover, which used to
   shrink the text's available width enough to wrap a long label like
   "Light control & presence" onto a second line - a jumpy height change
   the user flagged) - only its own height animates. */
.sidebar-link::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 3px;
  height: 0;
  background: #fff;
  border-radius: 0 3px 3px 0;
  transform: translateY(-50%);
  transition: height 0.18s ease;
}
.sidebar-link:hover { background: var(--sidebar-hover-bg); color: #fff; }
.sidebar-link:hover::before { height: 55%; }
.sidebar-link.active { background: var(--sidebar-active-bg); color: #fff; font-weight: 600; }
.sidebar-link.active::before { height: 65%; }
/* Pins Settings to the bottom of the sidebar (.sidebar is a column flex
   container) instead of grouping it with the page-nav links above it -
   it's account-level configuration, not another dashboard view.
   margin-top:auto alone only pushes it to the bottom of the sidebar's OWN
   box, which stretches to match the full page content height (see
   .sidebar's align-self:stretch) - on any page taller than the viewport
   (most of them), that put it below the fold, invisible without
   scrolling. position:sticky+bottom:24px (matching .sidebar's own 24px
   top/bottom padding, for symmetry with the gap above the first nav
   link) keeps it clamped just above the viewport's bottom edge instead,
   the same way .sidebar itself already sticks to the top - it can never
   escape its own containing block (.sidebar), so it still naturally
   lands at the true end of a short sidebar too. */
.sidebar-link-bottom { margin-top: auto; position: sticky; bottom: 24px; }

main { flex: 1; min-width: 0; padding: 24px; max-width: 1100px; margin: 0 auto; }
main.main-full-width { max-width: none; }

@media (max-width: 720px) {
  .app-shell { flex-direction: column; }
  .sidebar { width: 100%; flex-direction: row; position: static; padding: 12px 24px; flex-wrap: wrap; }
  /* margin:0 auto (for desktop centering below max-width:1100px) has an
     auto cross-axis margin, which suppresses flexbox's default stretch
     behavior once .app-shell becomes a column - without this override,
     main shrink-wraps to its widest child instead of filling the screen
     width, which is what was forcing the whole page wider than the
     viewport on mobile. */
  main { margin: 0; padding: 16px; max-width: 100%; }
  /* main.main-full-width's unconditional `max-width: none` (higher
     specificity than the plain `main` rule above) wins at any width,
     including mobile, unless matched here - without this, the
     Maintenance page (the only page using that class) overflows the
     viewport horizontally on narrow screens instead of respecting the
     100% cap every other page gets. */
  main.main-full-width { max-width: 100%; }
  .topbar { flex-wrap: wrap; gap: 10px; padding: 12px 16px; }
  .topbar > div { flex-wrap: wrap; row-gap: 8px; }
  .status, .topbar a { white-space: nowrap; }
  .panel-header { flex-wrap: wrap; gap: 10px; }
  .panel-header > div { flex-wrap: wrap; }
  .panel-header input { width: 100%; }
  /* .sidebar is a row (not column) flex on mobile, so margin-top:auto
     no longer pushes Settings anywhere - push it to the row's own end
     instead, keeping it visually last/separated from the page-nav links. */
  .sidebar-link-bottom { margin-top: 0; margin-left: auto; }
}

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.card {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  padding: 18px;
  transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
  animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.14); border-color: var(--accent); }
/* Staggered by position within .cards/.cards-in-panel so a whole stat
   row doesn't just pop in as one flat block on page load - capped at a
   handful of steps rather than indexed per-card via JS, since every
   page here has at most a few cards in one row. */
.card:nth-child(2) { animation-delay: 0.05s; }
.card:nth-child(3) { animation-delay: 0.1s; }
.card:nth-child(4) { animation-delay: 0.15s; }
.card:nth-child(5) { animation-delay: 0.2s; }
.card:nth-child(n+6) { animation-delay: 0.25s; }
.card-label { color: var(--muted); font-size: 13px; margin-bottom: 6px; }
.card-value { font-size: 28px; font-weight: 700; }
.card-unit { color: var(--muted); font-size: 12px; margin-top: 4px; }

/* Same grid as .cards, but without its margin-bottom - for a card row
   that's already inside a .panel (which has its own padding), e.g. the
   Expected Energy Usage panel. */
.cards-in-panel {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 16px;
}

.mega-actions {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.mega-btn {
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  padding: 22px 18px;
  font-size: 17px;
  font-weight: 700;
  cursor: pointer;
  color: var(--text);
  background: var(--panel);
  transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
}
.mega-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(22, 32, 46, 0.18); }
.mega-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); box-shadow: 0 4px 10px rgba(22, 32, 46, 0.15); }
.mega-btn:disabled { opacity: 0.6; cursor: wait; }

/* Each button glows in its own color (not .mega-btn's neutral dark
   shadow) - a resting glow so they read as "lit up" even before you
   touch them, brightening further on hover/press. Same specificity as
   .mega-btn's own :hover/:active rules above (one class + one pseudo
   each) so source order below decides the tie. */
.mega-on { background: linear-gradient(135deg, #2fa968, #4ec48a); border-color: transparent; color: #fff; box-shadow: 0 4px 16px rgba(47, 169, 104, 0.35); }
.mega-off { background: linear-gradient(135deg, #e2635c, #ee8177); border-color: transparent; color: #fff; box-shadow: 0 4px 16px rgba(226, 99, 92, 0.35); }
.mega-auto { background: linear-gradient(135deg, #1f8fc4, #43abdb); border-color: transparent; color: #fff; box-shadow: 0 4px 16px rgba(31, 143, 196, 0.35); }
.mega-on:hover:not(:disabled) { box-shadow: 0 12px 28px rgba(47, 169, 104, 0.5); }
.mega-off:hover:not(:disabled) { box-shadow: 0 12px 28px rgba(226, 99, 92, 0.5); }
.mega-auto:hover:not(:disabled) { box-shadow: 0 12px 28px rgba(31, 143, 196, 0.5); }
.mega-on:active:not(:disabled) { box-shadow: 0 4px 12px rgba(47, 169, 104, 0.4); }
.mega-off:active:not(:disabled) { box-shadow: 0 4px 12px rgba(226, 99, 92, 0.4); }
.mega-auto:active:not(:disabled) { box-shadow: 0 4px 12px rgba(31, 143, 196, 0.4); }

.panel {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  padding: 20px;
  margin-bottom: 24px;
  animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Staggered via sibling combinators (not nth-child - .panel sits among
   other element types like .cards/.mega-actions at the same nesting
   level, so its own position among *siblings of any kind* isn't a
   reliable index) - capped at 3 steps, deeper panels just share the
   last delay rather than getting an ever-growing wait before appearing. */
.panel ~ .panel { animation-delay: 0.08s; }
.panel ~ .panel ~ .panel { animation-delay: 0.16s; }
.panel ~ .panel ~ .panel ~ .panel { animation-delay: 0.24s; }
/* Only table panels need horizontal scroll (wide tables on narrow
   screens) - chart panels never do (chart-wrap/canvas are width:100%).
   Scoping overflow-x to just those matters because setting overflow-x
   to a non-visible value forces the UA to also compute overflow-y as
   auto (CSS Overflow spec), which was clipping chart panels with a
   spurious vertical scrollbar whenever the chart's rendered height came
   out even 1px taller than chart-wrap's fixed height. */
.panel:has(table) {
  overflow-x: auto;
}
.panel h2 { margin: 0 0 16px 0; font-size: 16px; color: var(--text); }
.chart-wrap {
  /* Chart.js needs an explicitly-sized parent when maintainAspectRatio
     is false (set in app.js) - without a fixed height here, the canvas
     would collapse to near-zero height instead of filling the panel. A
     flat height (not width-relative) keeps bars and rotated x-axis
     labels readable at any screen width, mobile included. */
  position: relative;
  width: 100%;
  height: 260px;
}
.panel canvas {
  max-width: 100%;
}
.group-energy-block { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--panel-border); }
.group-energy-block:last-child { padding-bottom: 0; margin-bottom: 0; border-bottom: none; }
.group-energy-block h3 {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  color: var(--text);
  display: flex;
  align-items: center;
  gap: 8px;
}
.group-energy-header {
  margin-bottom: 10px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.group-energy-block.collapsed .group-energy-header { margin-bottom: 0; }
.group-energy-summary { font-size: 12px; color: var(--muted); white-space: nowrap; }
.group-energy-block.collapsed .collapsible-body { display: none; }
.group-energy-block.collapsed .collapsible-chevron { transform: rotate(-90deg); }
.panel-header { display: flex; align-items: center; justify-content: space-between; }
.panel-header input {
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  padding: 6px 10px;
  color: var(--text);
  font-size: 13px;
}
.btn-export {
  color: var(--muted);
  font-size: 13px;
  text-decoration: none;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  padding: 6px 14px;
  white-space: nowrap;
}
.btn-export { transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; }
.btn-export:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12); }

.btn-order {
  display: inline-block;
  color: #fff;
  background: var(--accent-orange);
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
  border-radius: 999px;
  padding: 6px 14px;
  white-space: nowrap;
}
.btn-order { transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; }
.btn-order:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(229, 73, 26, 0.35); }
.btn-order-disabled { background: var(--muted); cursor: not-allowed; }
.btn-order-disabled:hover { opacity: 1; transform: none; box-shadow: none; }

.card-action {
  display: flex;
  flex-direction: column;
}

.card-cta {
  display: block;
  text-align: center;
  margin-top: auto;
  padding: 8px 14px;
  white-space: normal;
}

.hourly-nav {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  color: var(--muted);
}
.hourly-nav #hourly-date-label { min-width: 80px; text-align: center; }
.btn-nav {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  color: var(--text);
  width: 28px;
  height: 28px;
  line-height: 1;
  font-size: 14px;
  cursor: pointer;
}
.btn-nav { transition: transform 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; }
.btn-nav:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); box-shadow: 0 3px 10px rgba(var(--accent-rgb), 0.25); }
.btn-nav:active:not(:disabled) { transform: translateY(0) scale(0.92); }
.btn-nav:disabled { opacity: 0.4; cursor: not-allowed; }

.collapsible-header { cursor: pointer; user-select: none; }
.collapsible-header h2 { margin: 0; display: flex; align-items: center; gap: 8px; }
.collapsible-chevron { display: inline-block; font-size: 11px; color: var(--muted); transition: transform 0.15s; }
.collapsible-body { margin-top: 16px; }
.panel.collapsed .collapsible-body { display: none; }
.panel.collapsed .collapsible-chevron { transform: rotate(-90deg); }

.wsmon-controls {
  display: flex;
  align-items: center;
  gap: 16px;
  font-size: 13px;
  color: var(--muted);
}
.wsmon-controls label { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.wsmon-controls button {
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 6px;
  color: var(--text);
  padding: 4px 10px;
  font-size: 12px;
  cursor: pointer;
}
.wsmon-controls button:hover { border-color: var(--accent); color: var(--accent); }

.wsmon-log {
  margin-top: 14px;
  height: 420px;
  overflow-y: auto;
  background: var(--console-bg);
  border: 1px solid var(--console-border);
  border-radius: 8px;
  padding: 10px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.5;
  color: var(--console-text);
}
.wsmon-entry { padding: 3px 4px; border-radius: 4px; white-space: pre-wrap; word-break: break-all; }
.wsmon-entry + .wsmon-entry { border-top: 1px solid rgba(255,255,255,0.06); }
.wsmon-ts { color: var(--console-muted); margin-right: 8px; }
.wsmon-entry.details { background: rgba(74,222,128,0.12); outline: 1px solid var(--console-good); }
.wsmon-entry.error { background: rgba(248,113,113,0.14); color: var(--console-bad); }
.wsmon-entry.info { color: var(--console-info); }

table.lum-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.lum-table th {
  text-align: left;
  color: var(--muted);
  font-weight: 500;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 8px 10px;
  border-bottom: 1px solid var(--panel-border);
}
.lum-table td {
  padding: 10px;
  border-bottom: 1px solid var(--panel-border);
  transition: background-color 0.15s ease;
  /* A device name with no spaces (a raw model number, e.g.
     "HNB358LBDLPIR(BC/Dim)_Black") can't line-wrap normally, which was
     forcing the whole Naam column - and the table's total width along
     with it - open to fit that one unbroken string on a single line. */
  overflow-wrap: anywhere;
  /* Without a floor, a now-wrappable column is free to shrink almost to
     nothing before wrapping kicks in - browsers only treat unbreakable
     content as a hard minimum, and overflow-wrap just removed that
     signal for this column. Result was viewport-sensitive: enough space
     and it wraps sensibly, too little and text gets chopped into
     unreadable 3-4 character fragments. A min-width makes the table
     properly wider than its panel at narrow viewports instead, which
     correctly triggers .panel's own horizontal scroll (see below)
     rather than squeezing text into illegibility. */
  min-width: 90px;
}
.lum-table th:first-child,
.lum-table td:first-child {
  min-width: 180px;
}
.lum-table tr:hover td { background: var(--accent-bg); }

.badge {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
  line-height: 1.4;
  white-space: nowrap;
}
.badge.online { background: var(--good-bg); color: #15803d; }
.badge.offline { background: var(--bad-bg); color: #b91c1c; }
.badge.lingering { background: var(--warn-bg); color: #92400e; }
.badge.neutral { background: #eef1f6; color: var(--muted); }
.badge.measured {
  background: var(--accent-bg);
  color: var(--accent);
  padding: 1px 5px;
  font-size: 10px;
  font-weight: 700;
  margin-left: 4px;
  cursor: help;
}
.badge.estimated {
  background: #eef1f6;
  color: var(--muted);
  padding: 1px 5px;
  font-size: 10px;
  font-weight: 700;
  margin-left: 4px;
  cursor: help;
}

.scene-activate {
  background: var(--accent);
  color: white;
  border: none;
  border-radius: 6px;
  padding: 6px 14px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.scene-activate:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.35); }
.scene-activate:active:not(:disabled) { transform: translateY(0) scale(0.97); }
.scene-activate:disabled { opacity: 0.6; cursor: wait; }

.watts-input {
  width: 64px;
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 6px;
  color: var(--text);
  padding: 4px 6px;
  font-size: 13px;
}

.switch {
  position: relative;
  display: inline-block;
  width: 42px;
  height: 24px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
  position: absolute; cursor: pointer; inset: 0;
  background-color: #cbd5e1;
  transition: .15s;
  border-radius: 999px;
}
.slider:before {
  position: absolute;
  content: "";
  height: 18px; width: 18px;
  left: 3px; bottom: 3px;
  background-color: white;
  transition: .15s;
  border-radius: 50%;
}
input:checked + .slider { background-color: var(--accent); box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.15); }
input:checked + .slider:before { transform: translateX(18px); }
input:disabled + .slider { opacity: 0.5; cursor: wait; }

/* Floorplan zone overlays - shared by the admin editor
   (floorplan_admin.js) and the client-facing viewer (floorplan.js).
   Positioned in percentages (left/top/width/height set inline per zone,
   as fractions of #floorplan-canvas's own size) rather than pixels, so
   geometry stays correct at any image scale. */
/* display:block + width:100% on both, not inline-block/max-width - an
   inline-block canvas sizes to its image's own intrinsic width first,
   so a percentage width on the image inside it is a no-op (resolves
   against that same shrink-to-fit size) and the floorplan never grows
   past its uploaded image's natural pixel size even on a much wider
   panel. Forcing the canvas to claim the panel's full width first lets
   the image's own width:100% (height:auto keeps it proportional)
   actually stretch to fill it. */
#floorplan-canvas { position: relative; display: block; width: 100%; cursor: crosshair; touch-action: none; }
#floorplan-image { display: block; width: 100%; height: auto; pointer-events: none; user-select: none; }
.zone-box {
  position: absolute;
  box-sizing: border-box;
  border: 2px solid var(--accent);
  background: rgba(15, 122, 190, 0.15);
  border-radius: 4px;
  cursor: move;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  min-width: 12px;
  min-height: 12px;
}
.zone-box-drawing { border-style: dashed; background: rgba(15, 122, 190, 0.08); cursor: crosshair; }
.zone-box-active { border-color: var(--accent-orange); background: rgba(229, 73, 26, 0.15); }
.zone-box-label {
  background: var(--panel);
  color: var(--text);
  font-size: 11px;
  padding: 1px 6px;
  border-radius: 4px;
  margin-top: -10px;
  white-space: nowrap;
  pointer-events: none;
  box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.zone-box-handle {
  position: absolute;
  right: -6px;
  bottom: -6px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent-orange);
  border: 2px solid var(--panel);
  cursor: nwse-resize;
}

/* Client-facing viewer only (floorplan.js) - the live-state summary and
   On/Off buttons shown inside a zone box. */
.zone-box-view { cursor: default; align-items: center; justify-content: center; flex-direction: column; gap: 4px; }
.zone-box-summary { font-size: 11px; color: var(--text); background: var(--panel); padding: 2px 6px; border-radius: 4px; }
/* Two small separate buttons rather than one on/off toggle (by request) -
   each is just an action, not a reflection of current state, so neither
   needs an active/pressed look; the zone box's own green/orange tint
   already shows occupancy, and light_power_pct already shows brightness. */
.zone-onoff-row { display: flex; gap: 4px; }
.zone-onoff-btn {
  border: none;
  border-radius: 4px;
  padding: 3px 10px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  color: #fff;
  transition: transform 0.12s ease, opacity 0.12s ease;
}
.zone-onoff-btn:hover:not(:disabled) { opacity: 0.85; transform: translateY(-1px); }
.zone-onoff-btn:active:not(:disabled) { transform: translateY(0) scale(0.95); }
.zone-onoff-btn:disabled { opacity: 0.6; cursor: wait; }
.zone-onoff-on { background: var(--good); }
.zone-onoff-off { background: var(--bad); }
/* Presence-based box color, in addition to the Present/Absent text line
   - added by request so occupancy reads at a glance without having to
   read the text. Only applied when a zone actually has a known presence
   state (zonePresenceStatus() in floorplan.js) - a zone with no linked
   sensor keeps the plain default blue, not a claim about occupancy it
   can't actually make. */
.zone-box-view.zone-present { border-color: var(--good); background: rgba(22, 163, 74, 0.15); }
.zone-box-view.zone-absent { border-color: var(--warn); background: rgba(217, 119, 6, 0.15); }
/* Zone name label, reusing the admin editor's .zone-box-label look - but
   .zone-box-view's column flex (align-items/justify-content: center)
   would otherwise fold a plain flex child into the centered toggle/
   summary stack instead of sitting on the top border the way the admin
   editor's row-flex + margin-top:-10px does. Taking it out of flow with
   its own absolute position replicates that, but centered exactly ON
   the border (top:0 + translateY(-50%), not a fixed -10px offset) so it
   straddles the border the same way regardless of the label's own
   rendered height, matching the admin editor's look precisely instead
   of floating fully above it. */
.zone-box-view .zone-box-label { position: absolute; top: 0; left: 50%; margin-top: 0; transform: translate(-50%, -50%); }

/* Problem-status dot - reuses .zone-box-handle's corner-dot look (the
   admin editor's resize handle) but repurposed as a clickable health
   indicator: green when every luminaire in the zone's group(s) is fine,
   red when any one of them has a problem (same fault definition as the
   Maintenance page's "needs attention" count - see
   routers/luminaires.py's unit_has_problem, shared by both). Clicking it
   opens #zone-problem-popup. */
.zone-problem-dot {
  position: absolute;
  right: -8px;
  bottom: -8px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid var(--panel);
  cursor: pointer;
}
.zone-problem-dot-ok { background: var(--good); }
.zone-problem-dot-bad { background: var(--bad); }

.zone-problem-popup {
  position: fixed;
  z-index: 50;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
  padding: 16px;
  width: 260px;
  max-height: 320px;
  overflow-y: auto;
}
.zone-problem-popup-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.zone-problem-popup h3 { margin: 0; font-size: 14px; }
.zone-problem-popup-close { background: none; border: none; cursor: pointer; font-size: 18px; line-height: 1; color: var(--muted); padding: 0; }
.zone-problem-popup ul { list-style: none; margin: 12px 0; padding: 0; }
.zone-problem-popup li { padding: 4px 0; font-size: 13px; color: var(--bad); }
.zone-problem-popup-ok-text { font-size: 13px; color: var(--good); margin: 12px 0; }
.zone-problem-popup-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-top: 8px; border-top: 1px solid var(--panel-border); }
.zone-problem-popup-toggle-row span { font-size: 12px; color: var(--muted); }
/* Off/On text flanking the switch, whichever is currently active bolded
   - the switch alone starts wherever the OK luminaires already are
   (checked = on), and clicking an already-checked switch turns it off,
   which read as "inverted" to the user before they realized it started
   checked. These labels make the switch's current meaning unambiguous
   without changing the underlying (already correct) control logic. */
.zone-toggle-labeled { display: flex; align-items: center; gap: 6px; }
.zone-toggle-text-label { font-size: 11px; color: var(--muted); }
.zone-toggle-text-label-active { color: var(--text); font-weight: 600; }
.zone-toggle-explainer { margin: 6px 0 0 0; font-size: 11px; color: var(--muted); line-height: 1.4; }

/* Admin editor (admin_floorplan_edit.html) - floorplan on the left, a
   settings sidebar to the right that's always in view (not just while
   editing) rather than stacked below: it shows the full zone list while
   idle and swaps to the selected/being-drawn zone's form, so the admin
   never loses track of what's there or which one they're editing. */
/* main's global max-width:1100px (line ~260) is fine for the single
   floorplan panel, but a *fixed* budget shared between both columns
   means the 320px settings panel eats directly into the floorplan's own
   width the moment it appears - the image visibly shrinks mid-drag,
   right when a stable reference matters most (reported by the user).
   Removing the cap here and giving the canvas panel its own bounded
   width (flex-shrink allowed, but not flex-grow into the sidebar's
   space) means the settings panel is additional width, not stolen
   width - the row simply grows wider on a large viewport instead. */
body.floorplan-editor-page main { max-width: none; }
.floorplan-editor-row { display: flex; align-items: flex-start; gap: 16px; }
.floorplan-editor-canvas-panel { flex: 0 1 1200px; min-width: 0; }
/* Wider than a typical form sidebar - a plain 320px cut off longer
   sensor names even with wrapping enabled (see .sensor-picker-item). */
.floorplan-editor-form-panel { flex: 0 0 420px; }
@media (max-width: 900px) {
  .floorplan-editor-row { flex-direction: column; }
  .floorplan-editor-form-panel { flex-basis: auto; width: 100%; }
}
.zone-list { list-style: none; margin: 0; padding: 0; }
.zone-list-item {
  padding: 10px 12px;
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  margin-bottom: 8px;
  cursor: pointer;
  font-size: 14px;
  color: var(--text);
  transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.zone-list-item:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.1); }
.zone-list-item-active { border-color: var(--accent-orange); color: var(--accent-orange); }

/* Sensor picker for the "new/edit marker" form (floorplan_sensors_admin.js)
   - a scrollable table instead of a native <select>, so a long sensor list
   with a per-row presence dot and an "already placed" status stays legible
   (a native <option> can't carry more than plain text/disabled). */
.sensor-picker-wrap {
  max-height: 240px;
  overflow-y: auto;
  border: 1px solid var(--panel-border);
  border-radius: 8px;
  background: var(--panel);
}
.sensor-picker-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.sensor-picker-row td { padding: 7px 10px; border-bottom: 1px solid var(--panel-border); transition: background-color 0.15s ease; }
.sensor-picker-row:last-child td { border-bottom: none; }
.sensor-picker-row { cursor: pointer; }
.sensor-picker-row:hover td { background: var(--bg); }
.sensor-picker-row.selected td { background: var(--accent-bg); }
.sensor-picker-row.disabled { cursor: not-allowed; color: var(--muted); }
.sensor-picker-row.disabled td { opacity: 0.55; }
.sensor-picker-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 8px;
  background: var(--muted);
}
.sensor-picker-dot-present { background: var(--good); }
.sensor-picker-network { color: var(--muted); font-size: 11px; margin-top: 1px; }
.sensor-picker-status { color: var(--muted); font-size: 11px; text-align: right; white-space: nowrap; }

/* Occupancy heat map - admin marker editor (floorplan_sensors_admin.js)
   and client viewer (occupancy_heatmap.js) share this same #floorplan-
   canvas/#floorplan-image wrapper as the light-control feature (see
   above), but with point markers instead of rectangular zone boxes. */
.sensor-marker {
  position: absolute;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  cursor: pointer;
  z-index: 5;
}
/* Color means online/offline (green/red) on both the admin marker editor
   and the client heat map - see .sensor-marker-online/-offline and
   .occupancy-marker-hit-online/-offline below. The "currently being
   edited" state (admin only) is shown as a ring instead of a color
   override, since color is reserved for online/offline here. */
.sensor-marker-dot {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--muted); /* fallback for a just-placed marker with no sensor chosen yet */
  border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.sensor-marker:hover .sensor-marker-dot { transform: scale(1.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); }
.sensor-marker-online .sensor-marker-dot { background: var(--good); }
.sensor-marker-offline .sensor-marker-dot { background: var(--bad); }
.sensor-marker-active .sensor-marker-dot { box-shadow: 0 0 0 3px var(--accent-orange); }
.sensor-marker-label {
  font-size: 11px;
  background: var(--panel);
  padding: 1px 6px;
  border-radius: 4px;
  white-space: nowrap;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}

/* Zone-clipped heat circle layer (occupancy_heatmap.js's renderZoneFills)
   - sits between the floorplan image and heatmap.js's own free-spreading
   gradient (#heatmap-container below). A zoned sensor still gets the
   same soft heat circle at its own real position, just clipped to its
   room's own rectangle so it can't bleed past a wall into an unoccupied
   neighboring room - plus that rectangle's own outline (matching
   .zone-box's look) so the room boundary stays visible even when idle. */
#zone-heatmap-canvas { position: absolute; inset: 0; pointer-events: none; width: 100%; height: 100%; }

/* Zone name label on the occupancy heat map - reuses .zone-box-label's
   own look (background/font/padding/shadow) unchanged, just positioned
   directly (floating centered on the zone rectangle's top edge, same
   spot .zone-box-view .zone-box-label floats its own zone name) since
   this label isn't nested inside an actual .zone-box div here - the
   rectangle itself is drawn on #zone-heatmap-canvas, not as a DOM node. */
/* margin-top:0 cancels .zone-box-label's own -10px (meant for its other
   usage, where the parent .zone-box-view resets it the same way) - left
   unset here, the label floated well above the border instead of
   straddling it like the transform alone is supposed to achieve. */
.occupancy-zone-label { position: absolute; margin-top: 0; transform: translate(-50%, -50%); z-index: 6; }

/* The gradient itself (heatmap.js's own canvas gets appended inside this
   div) - pointer-events:none so clicks pass through to the small
   .occupancy-marker-hit circles above it, letting the gradient sit
   underneath without blocking marker interaction. */
#heatmap-container { position: absolute; inset: 0; pointer-events: none; }

.occupancy-marker-hit {
  position: absolute;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  cursor: pointer;
  z-index: 6;
  border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* The translate(-50%,-50%) above centers the dot on its marker
   coordinate - appending a scale (not replacing the transform) keeps
   that centering intact while still growing the dot on hover. */
.occupancy-marker-hit:hover { transform: translate(-50%, -50%) scale(1.3); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.occupancy-marker-hit-online { background: var(--good); }
.occupancy-marker-hit-offline { background: var(--bad); }

.occupancy-range-picker { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.occupancy-range-btn-active { background: var(--accent); color: #fff; border-color: transparent; box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.18); }
.occupancy-custom-range { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.occupancy-custom-range input[type="date"] {
  background: var(--bg);
  border: 1px solid var(--panel-border);
  border-radius: 6px;
  padding: 4px 8px;
  color: var(--text);
  font-size: 13px;
}

.occupancy-day-nav { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.occupancy-day-label { font-size: 14px; font-weight: 600; min-width: 110px; text-align: center; }

/* Below the floorplan image, not above it - the slider scrubs through
   what's already on screen, so the image should stay put while the
   label above the handle is what changes as you drag. */
.occupancy-hour-slider-row { margin-top: 14px; }
.occupancy-hour-label {
  display: block;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 6px;
}
.occupancy-hour-slider-row input[type="range"] { width: 100%; }

/* Shared range-input thumb look (the occupancy hour slider above, and
   the heatmap radius slider in admin_client_form.html - the only two
   <input type="range"> in this app). Track/thumb pseudo-elements aren't
   shared between -webkit-/-moz- prefixes by spec, so both need their own
   copy of the same rule; the glow-on-drag (:active) gives dragging itself
   a bit of visual feedback beyond just the thumb moving. */
input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  height: 20px;
  background: transparent;
  cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 999px;
  background: var(--panel-border);
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  margin-top: -7px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(22, 32, 46, 0.25);
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.18); }
input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(var(--accent-rgb), 0.25); }
input[type="range"]::-moz-range-track {
  height: 4px;
  border-radius: 999px;
  background: var(--panel-border);
}
input[type="range"]::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(22, 32, 46, 0.25);
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
input[type="range"]:hover::-moz-range-thumb { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.18); }
input[type="range"]:active::-moz-range-thumb { transform: scale(1.15); box-shadow: 0 0 0 8px rgba(var(--accent-rgb), 0.25); }
.occupancy-hour-slider-ends {
  display: flex;
  justify-content: space-between;
  font-size: 11px;
  color: var(--muted);
  margin-top: 2px;
}

/* Placed at the very end of the file, after .cards and
   .group-energy-summary's own base rules further up - a media-scoped
   override with equal specificity to its unconditional base rule only
   wins if it comes LATER in source order, regardless of which one is
   "inside a media query". Putting mobile overrides earlier in the file
   than the rule they're meant to override is a real trap - they get
   silently beaten by the later unconditional rule at every viewport
   width, media query or not. */
@media (max-width: 720px) {
  .cards { grid-template-columns: repeat(2, 1fr); }
  .group-energy-summary { white-space: normal; }
}
