/* Inter — the face the cards are set in. The four faces are checked in under
   public/vendor/inter, so a checkout carries its own fonts and a deploy needs
   nothing fetched. Not Google Fonts: the CSP has no font-src, so it falls back
   to defaultSrc 'self' and an off-origin font would not load anyway.

   Four latin weights, woff2 only: 400 card text, 500 card titles, 600, and 700
   for section headings. It applies from .widget inwards — the header keeps the
   monospace face the app has always used. swap so text is readable in the fallback face
   from the first paint rather than invisible until the font arrives. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../vendor/inter/inter-latin-400-normal.woff2') format('woff2');
  /* Latin-1 covers the French text and the °, µ and ³ the units use. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
    U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('../vendor/inter/inter-latin-500-normal.woff2') format('woff2');
  /* Latin-1 covers the French text and the °, µ and ³ the units use. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
    U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../vendor/inter/inter-latin-600-normal.woff2') format('woff2');
  /* Latin-1 covers the French text and the °, µ and ³ the units use. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
    U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../vendor/inter/inter-latin-700-normal.woff2') format('woff2');
  /* Latin-1 covers the French text and the °, µ and ³ the units use. */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC,
    U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Typography carried over from ~/iot-dashboard/frontend/css/dashboard.css
   (bold monospace, rounded cards). The palette is pure black with a neutral
   grey surface ramp — the surfaces are greys rather than the original browns
   so nothing reads warm against a #000 page. */

:root {
  --bg: #000000;
  --bg-card: #0d0d0d;
  --bg-card-hi: #181818;
  --border: #2a2a2a;
  --fg: #ffffff;
  /* Element text — titles, captions, meta lines, table headers. Brightened
     from the original warm greys. The plots keep their own axis and grid colours
     hardcoded in js/widgets.js, so this does not touch the graphs. */
  --fg-dim: #d6d6d6;
  --fg-faint: #9a9a9a;
  /* UI chrome accent. Chart series colours are deliberately NOT derived from
     this — plot colours live in js/fields.js and keep their own palette. */
  --accent: #ffffff;
  --danger: #ff5c5c;
  --warn: #ffb454;
  --radius: 0.5em;

  /* Makes the browser render its own widgets dark — the native
     datetime-local calendar popup, select dropdowns, scrollbars and focus
     rings. Without it the date picker opens as a white panel over a dark
     dashboard, which no amount of CSS on the input itself can reach. */
  color-scheme: dark;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-weight: 400;
  font-size: 15px;
  -webkit-font-smoothing: antialiased;
}

::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: #000; }
::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: #3d3d3d; }
* { scrollbar-width: thin; scrollbar-color: #2a2a2a #000; }

/* ── compact header with tabs ── */

header {
  position: sticky;
  top: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  gap: 0.5em;
  /* Trimmed vertically only: the chips and tabs inside keep their own padding,
     so the bar gets shorter without anything in it getting smaller. */
  padding: 0.28em 0.8em;
  /* Level with the cards below it. The chips and tabs inside carry their own
     slight emphasis, which is what marks them as things to press. */
  font-weight: 400;
  background: rgba(0, 0, 0, 0.92);
  backdrop-filter: blur(6px);
}

/* The bar is drawn from the first frame; what goes in it — the tenant's
   branding, the tab strip, the time picker — is only known once the settings
   arrive, so its contents fade in together over the same 500ms a card's data
   uses. Once, on the first load: a tab change rebuilds the strip inside a
   header that is already on screen, and fading that back in would read as the
   whole page reloading.

   By id, not by element: the admin shares this stylesheet and has a header of
   its own, whose contents are in the markup and are never filled in by a
   script. Hiding those until a class arrives would hide them for good. */
#appHeader > * {
  opacity: 0;
  transition: opacity 500ms ease-out;
}
#appHeader.ready > * { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  #appHeader > * { opacity: 1; transition: none; }
}

.brand {
  font-size: 1.02em;
  font-weight: 500;
  color: var(--accent);
  white-space: nowrap;
  letter-spacing: 0.02em;
  flex: 0 0 auto;
  /* The tenant name is replaced by a logo once settings arrive, and the image
     is taller than the line of text it replaces. Holding the taller of the two
     from the start means the swap does not push the whole page down. */
  min-height: 30px;
  display: flex;
  align-items: center;
}
.brand-link {
  color: inherit;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
}

/* Scale a tenant logo to the header rather than the other way round. */
.brand.has-logo {
  display: flex;
  align-items: center;
  /* on top of the header's own gap, so the tabs do not crowd the mark */
  margin-right: 0.9em;
}
.brand-link img {
  height: 30px;
  width: auto;
  max-width: 220px;
  object-fit: contain;
  display: block;
}
@media (max-width: 820px) {
  .brand-link img { height: 24px; max-width: 150px; }
  .brand { min-height: 24px; }
}

#tabs {
  display: flex;
  gap: 0.3em;
  overflow-x: auto;
  flex: 1;
  margin-right: 0.5em;
  scrollbar-width: none;
  scroll-behavior: smooth;
  scroll-snap-type: x proximity;
}
#tabs::-webkit-scrollbar { display: none; }
#tabs .tab { scroll-snap-align: start; }

/* Fade the edge the strip can still scroll towards, so it is visible that
   there are more tabs than fit. Applied only when it actually overflows —
   the classes come from the dashboard as it measures the strip. */
#tabs.can-left {
  mask-image: linear-gradient(to right, transparent 0, #000 26px);
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 26px);
}
#tabs.can-right {
  mask-image: linear-gradient(to left, transparent 0, #000 26px);
  -webkit-mask-image: linear-gradient(to left, transparent 0, #000 26px);
}
#tabs.can-left.can-right {
  mask-image: linear-gradient(to right, transparent 0, #000 26px,
              #000 calc(100% - 26px), transparent 100%);
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 26px,
              #000 calc(100% - 26px), transparent 100%);
}

/* Header controls share one height so the bar reads as a single row rather
   than a set of differently sized chips. */
.tab {
  flex: 0 0 auto;
  padding: 0.5em 1em;
  font: inherit;
  font-size: 0.92em;
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg-dim);
  background: transparent;
  /* No outline on the chrome: a tab is marked by its fill and its colour, not
     by a box drawn round it. */
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.15s, color 0.15s;
}
.tab:hover { background: var(--bg-card); color: var(--fg); }
.tab.active {
  background: var(--bg-card-hi);
  color: var(--accent);
}
.tab-error { color: var(--danger); }

/* Only an admin ever sees these, so mark them as not public. An outline rather
   than a border, which the tabs no longer have to dash. */
.tab-hidden {
  outline: 1px dashed var(--fg-faint);
  outline-offset: -1px;
  opacity: 0.75;
}
.tab-hidden::after { content: ' ·'; }

.header-right {
  display: flex;
  align-items: center;
  gap: 0.4em;
  font-size: 1em;
  color: var(--fg-faint);
  white-space: nowrap;
}

/* ── time range picker ── */

.timepicker {
  display: flex;
  align-items: center;
  gap: 2px;
  position: relative;
  flex: 0 0 auto;
}

.timepicker button,
.timepicker select,
.hdr-btn {
  font: inherit;
  font-size: 0.88em;
  font-weight: 500;
  line-height: 1.2;
  color: var(--fg-dim);
  background: var(--bg-card);
  border: 0;
  border-radius: 0.35em;
  padding: 0.5em 0.7em;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.15s, color 0.15s;
}
.timepicker button:hover,
.timepicker select:hover,
.hdr-btn:hover { background: var(--bg-card-hi); color: var(--fg); }

.hdr-btn {
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
}

/* An emoji is drawn from its own colour font, so it ignores the button's
   muted foreground and sits there at full saturation next to grey text.
   Desaturating it puts it back in the same key as the label. */
.hdr-btn-icon {
  filter: grayscale(1);
  opacity: 0.8;
}

.tp-button {
  display: flex;
  align-items: center;
  gap: 0.45em;
  min-width: 11.5em;
  justify-content: center;
}
.tp-button.active { border-color: var(--accent); color: var(--accent); }
.tp-icon { opacity: 0.8; }


.tp-arrow { padding: 0.5em 0.6em; }

.tp-refresh {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  min-width: 4.6em;
  justify-content: center;
}
.tp-refresh-icon { display: inline-block; line-height: 1; }

/* Auto refresh is on but has nothing to fetch — a fixed window, or one wide
   enough to be drawn from averages. Dimmed rather than relabelled, so the
   button still reads as the same control in the same state. */
.tp-refresh.is-idle { opacity: 0.5; }

/* One turn per refresh tick — enough to notice, not enough to nag. */
.tp-refresh-icon.spin { animation: tp-spin 0.7s ease-in-out; }
@keyframes tp-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
  .tp-refresh-icon.spin { animation: none; }
}

.tp-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 300;
  color-scheme: dark;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
  padding: 0.8em;
  min-width: 430px;
  opacity: 0;
  /* A panel on its way out is still on the page while it fades. Taking the
     clicks with the opacity means it cannot swallow one on the way. */
  pointer-events: none;
  transition: opacity 200ms ease-out;
}

/* Opened and closed on a fade — 200ms, quicker than the 500ms things that
   arrive on their own, because this one answers a click and a control that
   lags behind the finger reads as a slow page rather than a soft one.
   `hidden` stays the panel's off state, but display:none has nothing to fade
   from, so the class carries the opacity and the script only marks it hidden
   once the fade is over. */
.tp-panel.tp-open {
  opacity: 1;
  pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
  .tp-panel { transition: none; }
}
.tp-cols { display: flex; gap: 1.2em; align-items: flex-start; }
.tp-abs { flex: 1 1 45%; min-width: 0; }
.tp-quick { flex: 1 1 55%; min-width: 0; }

.tp-heading {
  font-size: 0.62em;
  color: var(--fg-faint);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 0.6em;
}

.tp-field { display: block; margin-bottom: 0.5em; }
.tp-field span {
  display: block;
  font-size: 0.62em;
  color: var(--fg-dim);
  margin-bottom: 0.2em;
}
.tp-field input {
  width: 100%;
  font: inherit;
  font-size: 0.78em;
  color: var(--fg);
  background: var(--bg-card-hi);
  border: 1px solid var(--border);
  border-radius: 0.3em;
  padding: 0.5em 0.55em;
  outline: none;
  transition: border-color 0.15s, background 0.15s;
}
.tp-field input:hover { background: #202020; }
.tp-field input:focus { border-color: var(--fg-dim); background: var(--bg); }
.tp-field input::-webkit-calendar-picker-indicator {
  cursor: pointer;
  opacity: 0.55;
  transition: opacity 0.15s;
}
.tp-field input::-webkit-calendar-picker-indicator:hover { opacity: 1; }
/* the spin/segment highlight the native control paints while editing */
.tp-field input::-webkit-datetime-edit-fields-wrapper { padding: 0; }
.tp-field input::selection { background: rgba(255, 255, 255, 0.22); }

/* Sits inside the panel with the preset buttons, so it reads as one of them
   with emphasis rather than a bright slab pasted on top. */
.tp-apply {
  width: 100%;
  font: inherit;
  font-size: 0.78em;
  color: var(--fg) !important;
  background: var(--bg-card-hi) !important;
  border: 1px solid var(--fg-faint) !important;
  border-radius: 0.3em;
  padding: 0.5em;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.tp-apply:hover {
  background: #242424 !important;
  border-color: var(--fg-dim) !important;
}
.tp-err { color: var(--danger); font-size: 0.62em; min-height: 1em; margin-top: 0.3em; }

/* Tall enough for the whole preset list — sixteen of them at the widest
   setting, which a flat 300px cut off at "Last 30 days". Capped against the
   viewport rather than a pixel count, so a short window still gets a panel
   that fits on screen instead of one running off the bottom. */
.tp-list {
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 8em);
  overflow-y: auto;
}
.tp-preset {
  text-align: left;
  border: none !important;
  background: transparent !important;
  padding: 0.35em 0.5em !important;
  border-radius: 0.3em !important;
}
.tp-preset:hover { background: var(--bg-card-hi) !important; color: var(--fg) !important; }
.tp-preset.active { color: var(--accent) !important; background: var(--bg-card-hi) !important; }

@media (max-width: 820px) {
  .tp-panel { min-width: min(92vw, 430px); right: auto; left: 0; }
  .tp-button { min-width: 7em; }
  .timepicker .tp-arrow { padding: 0.35em 0.35em; }
}

/* ── widget grid: widgets flow in the order they appear in the JSON ── */

/* The flow layout, guarded. A packed page replaces this whole box model with
   the grid below, and .grid-packed is a class — an unguarded id selector here
   would outrank it and leave a packed page laid out as flex with grid
   properties it no longer honours. */
#grid:not(.grid-packed) {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 10px;
  align-content: flex-start;
}
/* The padding is the flow layout's, but the packed grid wants it too. */
#grid { padding: 10px; }

/* Inter starts at the card. The furniture around them — the brand, the tabs,
   the time picker — stays monospace, so the chrome still reads as the app's
   own and the change is to what the cards say rather than to the whole page. */
.widget {
  /* the expand button anchors to the card */
  position: relative;
  font-family: 'Inter', Helvetica, Arial, sans-serif;
  /* Digits one width, so a column of readings lines up the way the monospace
     face gave for free. */
  font-variant-numeric: tabular-nums;
  background: var(--bg-card);
  /* No frame around a card: the panel colour already separates it from the
     page, and forty outlined boxes read as a form rather than as a dashboard. */
  border: 0;
  border-radius: var(--radius);
  padding: 0.7em 0.8em;
  min-width: 0;
  display: flex;
  flex-direction: column;
  transition: border-color 0.2s;
}
/* Nothing left to recolour now the frame is gone — loading reads from the card
   being empty, and from the fade as it fills. */

/* A card's content arrives whole, the moment its query returns, so without
   this it snaps from an empty frame to a full one. The frame itself is already
   there at the right height, so this fades in what lands inside it.

   First paint only. A refresh tick redraws the same card with a new number in
   it, and fading the whole page back in every ten seconds would read as the
   dashboard reloading rather than as one value changing. */
@keyframes widget-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.widget-body.fade-in { animation: widget-fade-in 500ms ease-out both; }

@media (prefers-reduced-motion: reduce) {
  .widget-body.fade-in { animation: none; }
}
/* A failed card still needs marking, and an outline does it without putting a
   frame back on the other forty-nine. */
.widget.error { outline: 1px solid var(--danger); outline-offset: -1px; }

.w-quarter       { flex: 1 1 calc(25%  - 10px); max-width: calc(25%  - 10px); }
.w-third         { flex: 1 1 calc(33.333% - 10px); max-width: calc(33.333% - 10px); }
.w-half          { flex: 1 1 calc(50%  - 10px); max-width: calc(50%  - 10px); }
.w-twothirds     { flex: 1 1 calc(66.666% - 10px); max-width: calc(66.666% - 10px); }
.w-threequarters { flex: 1 1 calc(75%  - 10px); max-width: calc(75%  - 10px); }
.w-full          { flex: 1 1 100%; max-width: 100%; }

/* ── packed layout: each column carries on at its own height ──

   The flow layout above wraps cards into lines, so a line is as tall as its
   tallest card and a short one leaves the space under it empty. On a page
   transcribed from an existing dashboard that is most of the page: a 90px
   relay strip next to a 570px chart, and nothing under the strip until the
   chart ends.

   A page with "layout": "packed" lays the same cards on a fine row grid
   instead — one row per PACK_ROW px — and each card spans as many rows as its
   measured height needs. Each column then advances independently, the way the
   grid these pages were transcribed from does.

   Placement is dense, because a card that names its `column` needs to be able
   to start at the top of that column's free space however tall the card
   opposite it is. With every card pinned — which is what a transcribed page
   does — dense reproduces the original stacking exactly, since each column
   fills in source order and no card can rise above one already in it. Leave a
   card unpinned and dense may lift it into an earlier hole instead, so a page
   that cares about its order should pin all of them or none.

   Two things this depends on: `align-items: start`, so a card is its own
   height rather than being stretched to fill its span, and `row-gap: 0` — the
   gap lives inside each card's span, added by the page controller before it
   rounds up to whole rows. The span itself is written by Widgets.packCards();
   the value here is only what shows for the instant before the first
   measurement.

   Keyed on the class alone, not on #grid: the admin's live preview is a second
   grid of the same cards and has to lay them out the same way, or a packed
   page is previewed as something the visitor will never see.
*/
.grid-packed {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-auto-rows: 4px;   /* PACK_ROW in dashboard.js */
  column-gap: 10px;
  row-gap: 0;
  align-items: start;
  grid-auto-flow: row dense;
}
/* max-width is the half of the flow width rules that grid still honours, and
   it would hold every card to half a column. */
.grid-packed .widget { max-width: none; grid-row-end: span 40; }

/* Only the end, so a card carrying a "column" can set its own start inline
   and still take its width from the class. */
.grid-packed .w-quarter       { grid-column-end: span 3; }
.grid-packed .w-third         { grid-column-end: span 4; }
.grid-packed .w-half          { grid-column-end: span 6; }
.grid-packed .w-twothirds     { grid-column-end: span 8; }
.grid-packed .w-threequarters { grid-column-end: span 9; }
.grid-packed .w-full          { grid-column-end: span 12; }
.grid-packed .page-error,
.grid-packed .page-empty      { grid-column: 1 / -1; }

/* ── expand a card to the whole window ──

   The button is invisible until the pointer is on the card, so a page of forty
   cards is not forty buttons. It stays reachable without a pointer: focus
   shows it, which is also what makes it usable from the keyboard at all.

   The expanded card is fixed rather than grown in place. It is a grid item on
   a packed page and a flex item elsewhere, and either way growing it would
   push the rest of the page around behind it. */
.widget-expand,
.widget-grip {
  position: absolute;
  top: 0.35em;
  right: 0.35em;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.7em;
  height: 1.7em;
  padding: 0;
  font: inherit;
  font-size: 0.8em;
  line-height: 1;
  color: var(--fg-dim);
  background: var(--bg-card-hi, rgba(0, 0, 0, 0.6));
  border: 1px solid var(--border);
  border-radius: 0.3em;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
/* The grip sits to the left of the expand button and shares its look. */
.widget-grip {
  right: 2.3em;
  cursor: grab;
  touch-action: none;   /* so a drag does not scroll the page instead */
}
/* On a card with nothing to expand, it takes the corner itself. */
.widget-grip.is-outer { right: 0.35em; }
.widget:hover > .widget-expand,
.widget:hover > .widget-grip,
.widget-expand:focus-visible,
.widget-grip:focus-visible { opacity: 1; }
.widget-expand:hover,
.widget-grip:hover { color: var(--fg); border-color: var(--fg-faint); }

.widget.is-dragging {
  opacity: 0.75;
  cursor: grabbing;
  outline: 1px dashed var(--fg-faint);
  outline-offset: -1px;
}
/* Nothing should select or hover-highlight while a card is in hand. */
html.is-reordering, html.is-reordering * { user-select: none; cursor: grabbing !important; }

/* Unsaved layout edits. It lives in the header rather than over the page,
   because a drag can be followed by three more and the reminder has to stay
   put without covering the thing being arranged. */
.layout-bar {
  display: flex;
  align-items: center;
  gap: 0.4em;
  margin-right: 0.4em;
}
.layout-bar[hidden] { display: none; }
.hdr-btn.is-primary {
  color: #061206;
  background: var(--accent, #63ff84);
  border-color: transparent;
}
.hdr-btn.is-primary:hover { filter: brightness(1.08); }
.hdr-btn[disabled] { opacity: 0.55; pointer-events: none; }

/* Grab the bottom-right corner of a card to resize it. Same reveal as the
   other two controls, and gone while the card is expanded, where the window
   decides the size. */
.widget-resize {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 1.15em;
  height: 1.15em;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: nwse-resize;
  opacity: 0;
  touch-action: none;
  transition: opacity 0.15s;
}
/* Two strokes across the corner, drawn rather than added as markup. Small:
   it marks the corner rather than decorating it, and the hit area around it is
   the button, which stays the size it was. */
.widget-resize::after {
  content: '';
  position: absolute;
  right: 0.25em;
  bottom: 0.25em;
  width: 0.34em;
  height: 0.34em;
  border-right: 1.5px solid var(--fg-faint);
  border-bottom: 1.5px solid var(--fg-faint);
}
/* Fainter than the two buttons: it is a corner mark rather than a control
   competing for attention, and it sits on the plot. Full strength once the
   pointer is on it, so it is clear what is being grabbed. */
.widget:hover > .widget-resize { opacity: 0.45; }
.widget-resize:focus-visible,
.widget:hover > .widget-resize:hover { opacity: 1; }
.widget-resize:hover::after { border-color: var(--fg); }
.widget.is-full > .widget-resize { display: none; }
.widget.is-resizing { outline: 1px dashed var(--fg-faint); outline-offset: -1px; }
html.is-resizing, html.is-resizing * { user-select: none; cursor: nwse-resize !important; }
@media (prefers-reduced-motion: reduce) { .widget-resize { transition: none; } }
@media (hover: none) { .widget-resize { opacity: 1; } }

/* Confirmation that a drag was saved, and the only place a failed save is
   reported — the dashboard has no other chrome to put a message in. */
.grid-flash {
  position: fixed;
  left: 50%;
  bottom: 1.2em;
  transform: translateX(-50%);
  z-index: 300;
  padding: 0.45em 0.9em;
  font-size: 0.8em;
  color: var(--fg);
  background: rgba(0, 0, 0, 0.92);
  border: 1px solid var(--border);
  border-radius: 0.35em;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}
.grid-flash:not(:empty) { opacity: 1; }
.grid-flash.is-error { border-color: var(--danger); color: var(--danger); }
@media (prefers-reduced-motion: reduce) {
  .widget-expand, .widget-grip { transition: none; }
}
/* A touch screen has no hover to reveal them with. */
@media (hover: none) {
  .widget-expand, .widget-grip { opacity: 1; }
}
/* Once expanded the button is the way back out, so it does not wait to be
   hovered — the card is the whole window and there is nothing else to aim at. */
.widget.is-full > .widget-expand { opacity: 1; }

.widget.is-full {
  position: fixed;
  /* Below the header rather than over it, so the time picker stays reachable —
     a chart at window size is exactly when the range is worth changing. The
     offset is the header's measured height, set when the card expands. */
  top: var(--full-top, 0px);
  left: 0;
  right: 0;
  bottom: 0;
  /* Above the other cards, and deliberately below the header's 100.
     The header carries a z-index, which makes it a stacking context, so the
     time panel's own 300 counts only against its siblings inside the header —
     it cannot climb over anything outside it. At 200 the expanded card won
     that comparison and the panel opened underneath the chart. */
  z-index: 50;
  margin: 0;
  max-width: none;
  border-radius: 0;
  border-width: 0;
  padding: 1em 1.1em;
  overflow: auto;
}
/* The plot takes whatever the card is not using, rather than the height its
   page config asks for. */
.widget.is-full > .widget-body {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.widget.is-full .chart-holder,
.widget.is-full .climate-chart {
  flex: 1 1 0%;
  min-height: 0;
}
.widget.is-full .climate-chart { display: flex; flex-direction: column; }
.widget.is-full .chart-holder { overflow: hidden; }
/* Nothing behind it should scroll while it is open. */
html.has-full-widget, html.has-full-widget body { overflow: hidden; }

.widget-title {
  font-size: 1em;
  color: var(--fg);
  /* A caption, not a reading. Held back from the figure underneath it so the
     eye lands on the number first — the cards are read at a glance and from
     across a room. */
  opacity: 0.7;
  /* The page sets bold on the body, and at this size a card title carried it
     heavily enough to compete with the reading underneath. */
  font-weight: 500;
  margin-bottom: 0.55em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  letter-spacing: 0.02em;
}
.widget-body { flex: 1; min-width: 0; }

.widget-error { color: var(--danger); font-size: 0.8em; word-break: break-word; }
.widget-warn  { color: var(--warn);   font-size: 0.7em; margin-bottom: 0.4em; }
.widget-empty { color: var(--fg-faint); font-size: 0.8em; padding: 0.8em 0; }

/* ── stat ── */

/* White, always: the figure carries no colour of its own and no threshold
   tint. See renderStat. */
.stat-value {
  display: flex;
  align-items: baseline;
  gap: 0.25em;
  line-height: 1;
  color: var(--fg);
}
.stat-number { font-size: 1.9em; }
.stat-unit { font-size: 0.9em; opacity: 0.5; }
.stat-meta.stale { color: var(--warn); }
.stat-meta {
  margin-top: 0.5em;
  font-size: 0.68em;
  color: var(--fg-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── climate ── */

.climate {
  display: flex;
  align-items: baseline;
  gap: 0.9em;
  flex-wrap: wrap;
  line-height: 1;
  /* One size for the whole row — heading, emoji, figures and units all
     inherit it, so there is a single number to tune. */
  font-size: 1.25em;
}
/* The card's heading sits on the same row as the readings rather than
   costing a line of its own. */
.climate-title {
  color: var(--fg);
  letter-spacing: 0.02em;
  margin-right: 0.15em;
}
.climate-metric {
  display: flex;
  align-items: baseline;
  gap: 0.22em;
  min-width: 0;
}
.climate-icon { opacity: 0.9; }
/* A reading old enough that printing it beside live ones would misrepresent
   it. The value is replaced by an em dash and its age, so the card says what
   it knows rather than showing a number that stopped being true. */
.climate-metric.is-stale { color: var(--warn) !important; opacity: 0.75; }
.climate-metric.is-stale .climate-value { color: var(--fg-faint); }

.climate-chart { margin-top: 0.7em; }
.climate-chart-label {
  display: flex;
  /* Not baseline. The figures beside the caption are a grid, and a grid takes
     its baseline from its first item — which here is the empty label cell that
     holds the name column open when there are no names to put in it. An empty
     box synthesises its baseline at its bottom edge, so the row aligned the
     caption against nothing and left it sitting 2px low.
     With the line-height below making both boxes the same height, centring
     lines them up exactly and does not care where either baseline is. */
  align-items: center;
  /* Small, because the gap the eye actually sees between the caption and the
     first figure is this plus the stats grid's own column gap below. */
  gap: 0.2em;
  font-size: 0.62em;
  line-height: 1.2;
  color: var(--fg-dim);
  letter-spacing: 0.04em;
  margin-bottom: 0.15em;
}
.climate-chart .widget-empty { padding: 0.4em 0; font-size: 0.7em; }

.climate-meta {
  margin-top: 0.6em;
  font-size: 0.68em;
  color: var(--fg-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.climate-meta.stale { color: var(--warn); }

/* ── gauge ── */

.gauge-svg { width: 100%; max-height: 90px; display: block; }
.gauge-track {
  fill: none;
  stroke: #2a2a2a;
  stroke-width: 10;
  stroke-linecap: round;
}
.gauge-fill {
  fill: none;
  stroke-width: 10;
  stroke-linecap: round;
  transition: stroke-dasharray 0.4s ease;
}
.gauge-readout { text-align: center; font-size: 1.4em; margin-top: -0.4em; }
.gauge-scale {
  display: flex;
  justify-content: space-between;
  font-size: 0.62em;
  color: var(--fg-faint);
  margin-top: 0.3em;
}

/* ── chart ── */

.chart-holder { width: 100%; position: relative; }

.uplot-tooltip {
  position: absolute;
  z-index: 20;
  pointer-events: none;
  background: rgba(0, 0, 0, 0.94);
  border: 1px solid var(--border);
  border-radius: 0.35em;
  padding: 0.4em 0.55em;
  font-size: 0.68em;
  line-height: 1.45;
  white-space: nowrap;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
}
.uplot-tooltip .tt-time { color: var(--fg-faint); margin-bottom: 0.2em; }
.uplot-tooltip .tt-row { display: flex; align-items: center; gap: 0.4em; }
.uplot-tooltip .tt-dot {
  width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto;
}
.uplot-tooltip .tt-name { color: var(--fg-dim); }
.uplot-tooltip .tt-val { color: var(--fg); margin-left: auto; padding-left: 0.7em; }
/* The air belongs under the hour labels, not above this line — this describes
   the plot and reads as part of the same block as the legend it follows. */
.chart-meta { font-size: 0.65em; color: var(--fg-faint); margin-top: 0.3em; }
/* Except when it comes straight after the plot, with no legend between them:
   then this line is what sits under the hours and it takes the gap instead. */
.chart-holder + .chart-meta { margin-top: 0.7em; }

/* max / mean / min under a plot */
/* A grid rather than a run of text: one row per series, one column per
   figure, so now/max/mean/min line up down the legend and can be compared by
   eye. The rows are display:contents so their cells are the grid's items. */
.chart-stats {
  display: grid;
  grid-template-columns: max-content repeat(var(--cs-cols, 4), max-content);
  gap: 0.3em 1.8em;
  justify-content: start;
  font-size: 0.65em;
  /* Inherited bold from the body, which at legend size closed up a monospace
     face until the row read as a solid block rather than as figures. */
  font-weight: 400;
  color: var(--fg-dim);
  /* Above the plot, this bottom margin is the list's separation from it — the
     same 12px the list gets under itself when it sits below the plot instead.
     A legend reads the same either way round, so it should be spaced the same;
     the sibling rule below only ever matches the "below" order, since CSS
     cannot select an element by what follows it. */
  margin: 0 0 12px;
}
/* Desktop only. The legend doubles as the series list and there is room for it
   here; on a phone the same bump costs a card its width, since these are
   max-content columns that wrap rather than shrink. */
@media (min-width: 821px) {
  .chart-stats { font-size: 0.72em; }
  /* The compact legend on a climate, metric or power card sizes from its own
     label row rather than from .chart-stats — which sets 1em and so never saw
     the bump above. Same proportion, applied where it is actually decided. */
  .climate-chart-label { font-size: 0.69em; }
}
/* The series list under a plot. Top is its separation from the chart; bottom
   is room under the list itself, carried by the list rather than as a margin on
   whatever happens to come next — the space belongs to this block, and it stays
   with it when nothing follows at all. */
.chart-holder + .chart-stats { margin: 0.7em 0 12px; }
.chart-stats .cs-row { display: contents; }
.chart-stats .cs-label {
  display: flex;
  align-items: center;
  gap: 0.45em;
  color: var(--fg);
  min-width: 0;
}
/* caption left, figure right, so both edges line up column to column */
.chart-stats .cs-stat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.55em;
  white-space: nowrap;
}
/* Sits after the series name, once per row. The negative margin works against
   the label's own 0.45em gap: the unit belongs to the name and should read
   closer to it than the dot does. */
.chart-stats .cs-unit { margin-left: -0.15em; }
/* On a compact card the unit rides on the caption, which is a plain span with
   no flex gap of its own — so here it needs a real gap rather than a negative
   one. Same visual distance either way. */
.climate-chart-label > span > .cs-unit { margin-left: 0.3em; }
/* "now", "max", "mean", "min" and the unit label every figure on every row,
   so at full strength they repeat louder than the numbers they describe. */
.chart-stats .cs-cap,
.chart-stats .cs-unit { color: var(--fg-dim); opacity: 0.55; }
.chart-stats .cs-row.is-clickable > * { cursor: pointer; }
.chart-stats .cs-row.is-clickable:hover .cs-name { text-decoration: underline; }
/* a series the reader has switched off */
.chart-stats .cs-row.is-off > * { opacity: 0.35; }
.chart-stats .cs-dot {
  width: 6px; height: 6px; border-radius: 50%; flex: 0 0 auto;
}
.chart-stats .cs-name {
  color: var(--fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* the figures read brighter than their labels */
.chart-stats b { color: var(--fg); font-weight: inherit; }
/* All four sides, not just the top. The stats row carries a bottom margin for
   when it sits above a plot; here it sits beside a caption in a centred flex
   line, and flex centres the margin box — so that stray 0.4em lifted the
   figures half its height above the caption. */
.climate-chart-label .chart-stats { margin: 0; font-size: 1em; }
/* Tighter than the full legend's columns. That one stacks a row per series and
   the wide gutters are what keep the figures reading down the page as columns;
   this is a single row beside a caption, where the same gutters only push the
   four things apart. */
.climate-chart-label .chart-stats { column-gap: 1.15em; }

/* Canvas-drawn axis text is not styled from here — that comes from uPlot's own
   `font` option, set in drawPlot. This covers the DOM inside the plot. */
.uplot, .uplot * { font-family: 'Inter', Helvetica, Arial, sans-serif !important; }
.u-select { background: rgba(99, 255, 132, 0.12); }

/* ── table ── */

.table-wrap { overflow-x: auto; max-height: 340px; overflow-y: auto; }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.72em; }
.data-table th {
  position: sticky;
  top: 0;
  background: var(--bg-card-hi);
  color: var(--fg-dim);
  text-align: left;
  padding: 0.4em 0.6em;
  font-weight: bold;
  white-space: nowrap;
}
.data-table td {
  padding: 0.32em 0.6em;
  border-top: 1px solid rgba(255, 255, 255, 0.05);
  white-space: nowrap;
}
.data-table td.ts { color: var(--fg-faint); }
.data-table tbody tr:hover { background: rgba(255, 255, 255, 0.03); }

/* ── summary ── */

/* A printed-report look rather than a log: the cells carry figures a client
   reads across, so this table is set larger than the raw data table and is
   allowed to grow instead of scrolling inside 340px. */
.summary-wrap { overflow-x: auto; }
.summary-table { font-size: 0.85em; }
.summary-table th { white-space: normal; }
.summary-table td, .summary-table th { padding: 0.5em 0.7em; }
.summary-table td { font-variant-numeric: tabular-nums; }
.summary-table td.is-asset { color: var(--fg); font-weight: 500; }
.summary-table td.is-hours { color: var(--fg); font-weight: 600; }
.summary-table tfoot th {
  position: static;
  background: none;
  color: var(--fg);
  border-top: 2px solid var(--border);
  font-size: 1em;
}

.summary-head {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4em 2em;
  margin-bottom: 0.8em;
}
.summary-head-item { display: flex; gap: 0.6em; align-items: baseline; }
.summary-head-label {
  color: var(--fg-faint);
  font-size: 0.72em;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.summary-head-value { color: var(--fg); font-size: 0.85em; }

/* Says how the hours were arrived at. A derived figure presented as a bare
   number in a report is the kind of thing that gets quoted back later. */
.summary-note {
  margin-top: 0.7em;
  color: var(--fg-faint);
  font-size: 0.68em;
  line-height: 1.5;
}

/* ── status ── */

.status-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 6px;
}
.status-item {
  display: flex;
  align-items: center;
  gap: 0.5em;
  padding: 0.4em 0.6em;
  background: var(--bg-card-hi);
  border-radius: 0.35em;
  border-left: 3px solid var(--accent);
  min-width: 0;
}
.status-item.stale { border-left-color: var(--warn); }
.status-item.unknown { border-left-color: var(--danger); opacity: 0.65; }
.status-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent);
  flex: 0 0 auto;
  box-shadow: 0 0 6px rgba(255, 255, 255, 0.5);
}
.status-item.stale .status-dot { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.status-item.unknown .status-dot { background: var(--danger); box-shadow: none; }
.status-info { min-width: 0; }
.status-name {
  font-size: 0.72em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.status-age { font-size: 0.62em; color: var(--fg-faint); }

/* ── heading ── */

/* A section divider, not a card: no surface of its own. */
.widget[data-type="heading"] {
  background: none;
  border: none;
  padding: 1.1em 0.2em 0;
}
.widget[data-type="heading"]:first-child { padding-top: 0.2em; }

.heading {
  display: flex;
  align-items: baseline;
  gap: 0.7em;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.45em;
}
.heading-title {
  font-size: 0.95em;
  color: var(--fg);
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.heading-sub {
  font-size: 0.7em;
  color: var(--fg-faint);
  font-weight: normal;
}

/* ── text ── */

.text-widget { font-size: 0.8em; color: var(--fg-dim); font-weight: normal; }
.text-widget p { margin: 0 0 0.4em; }
.text-widget p:empty { height: 0.5em; }

/* An optional link on a text widget. Sized like the header chips so it reads
   as something to click, not as another line of the note. */
.text-link {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  margin-top: 0.2em;
  padding: 0.45em 0.75em;
  border: 1px solid var(--border);
  border-radius: 0.35em;
  background: var(--bg-card);
  color: var(--fg-dim);
  font-size: 0.95em;
  font-weight: 500;
  text-decoration: none;
  transition: background 0.15s, color 0.15s;
}
.text-link:hover { background: var(--bg-card-hi); color: var(--accent); }

/* ── page-level messages ── */

.page-error, .page-empty {
  flex: 1 1 100%;
  padding: 1.2em;
  border-radius: var(--radius);
  font-size: 0.85em;
}
.page-error { background: rgba(255, 92, 92, 0.1); color: var(--danger); border: 1px solid var(--danger); }
.page-empty { background: var(--bg-card); color: var(--fg-faint); border: 1px dashed var(--border); }

/* ── mobile ── */

@media (max-width: 820px) {
  body { font-size: 14px; }
  .w-quarter, .w-third, .w-half, .w-twothirds, .w-threequarters {
    flex-basis: 100%;
    max-width: 100%;
  }

  .grid-packed { grid-template-columns: 1fr; }
  /* !important because a pinned card sets grid-column-start inline, and on one
     column there is nowhere for column 7 to start. */
  .grid-packed .widget { grid-column: 1 / -1 !important; }

  /* Two rows: brand and time controls first, the tab strip beneath. Keeping
     them on one line either squeezed the picker to nothing or pushed the tabs
     off screen. */
  header {
    flex-wrap: wrap;
    gap: 0.4em 0.5em;
    padding: 0.45em 0.6em;
  }
  .brand { order: 1; }
  .timepicker { order: 2; margin-left: auto; }
  .header-right { order: 3; }
  #tabs {
    order: 4;
    flex: 1 0 100%;
    margin: 0;
    /* the strip scrolls sideways when the tabs outgrow the screen */
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scroll-snap-type: x proximity;
    padding-bottom: 0.15em;
  }
  #tabs .tab { scroll-snap-align: start; }

  /* the admin entry point is not for a phone */
  .header-right { display: none; }

  /* The picker sits right-aligned now, so anchoring the panel to it pushed the
     panel off screen. Pin it to the viewport instead. */
  .tp-panel {
    position: fixed;
    left: 0.6em;
    right: 0.6em;
    /* top is set when the panel opens, from the header's actual height */
    width: auto;
    min-width: 0;
    max-height: calc(100vh - 7.5em);
    overflow-y: auto;
  }
  .tp-cols { gap: 0.9em; }
  /* The panel is already a scroller here, sized off the real header height.
     A second cap inside it meant the list scrolled while the panel had room
     to spare, so the taller rows below would have been paid for twice. */
  .tp-list { max-height: none; overflow-y: visible; }

  .timepicker button, .timepicker select { font-size: 0.8em; padding: 0.45em 0.55em; }

  /* the range itself is what gets read, so it takes the space */
  .tp-button { min-width: 0; font-size: 0.92em; padding: 0.5em 0.7em; }
  .tp-label { max-width: 11em; }

  /* still a comfortable target, just not competing with the label */
  .timepicker .tp-arrow {
    font-size: 1em;
    line-height: 1;
    padding: 0.3em 0.5em;
    min-width: 1.8em;
  }
  /* Slimmer than the desktop field, and tighter at the sides: a
     datetime-local is one unbreakable string plus the browser's calendar
     button, so anything it cannot fit it silently clips off the end — the
     minutes and the AM/PM, the two parts being read. */
  .tp-field input { font-size: 0.82em; padding: 0.55em 0.35em; }
  .tp-label { max-width: 9.5em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .brand.has-logo img { height: 22px; max-width: 130px; }
}

/* Still two columns on a phone. The preset list is a narrow stack of short
   labels; under the date fields it left both halves shrink-wrapped inside a
   full-width panel, each with empty space to its right. Side by side, the
   absolute half takes whatever the presets do not need.

   Not below 360px, where what is left over no longer holds a whole timestamp
   and the field would start clipping — there the old stack is the honest
   layout. */
@media (max-width: 820px) and (min-width: 360px) {
  /* An even split. `flex: 1 1 0` rather than `auto` so neither half is sized
     off its content — the presets are a fixed run of short labels and would
     otherwise take only what they need, leaving the halves lopsided. */
  .tp-abs { flex: 1 1 0; }
  /* Held off the date fields by more than the column gap: on a phone these are
     touch targets sitting a thumb's width from another control, and the run of
     sixteen near-identical labels is easier to aim at spaced out than packed.
     The margin is outside the flex basis, so it costs both halves alike and
     leaves them equal. */
  .tp-quick { flex: 1 1 0; margin-left: 0.9em; }
  /* Half a phone is a hard budget, and a datetime-local clips whatever will
     not fit rather than shrinking to suit. Tied to the viewport so the value
     and the browser's calendar button both stay inside the field at every
     width down to the 360px floor below, and capped so a tablet does not get
     a field larger than the desktop one. */
  .tp-field input { font-size: min(0.82em, 2.5vw); }
  /* Ranged left, against the gap that separates the two halves. Once the
     split is even the column has a hard left edge to sit on, and the labels
     share a leading word — flush left they start at the same place, which is
     what the eye runs down looking for the one it wants. */
  .tp-quick .tp-preset {
    line-height: 1.45;
    padding: 0.5em 0.55em !important;
  }
}

@media (max-width: 359px) {
  .tp-cols { flex-direction: column; gap: 0.8em; }
}
