:root {
  color-scheme: light;
  /* Three steps, not two. `--page` is the desk, `--surface` is a card on it,
     `--raised` is a control on the card - so depth comes from the ramp rather
     than from borders doing all the work. The old pair differed by 3% and read
     flat.

     Neutral greys throughout: the previous values carried a slight green cast
     (#f9f9f7 / #1a1a19), which is invisible alone and muddy next to the status
     colours, all of which are saturated. */
  --page: #f4f4f5;
  --surface: #ffffff;
  --raised: #fafafa;
  --ink: #111113;
  --ink-2: #4a4a52;
  --muted: #8a8a93;
  --hairline: #e4e4e7;
  --border: rgba(17, 17, 19, 0.10);
  --accent: #2a6fd6;
  --status-good: #12934c;
  --status-warning: #c77a09;
  --status-critical: #cc3a35;
  --shadow: 0 1px 2px rgba(17, 17, 19, 0.05), 0 1px 3px rgba(17, 17, 19, 0.04);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    /* The same three steps. `--page` was #0d0d0d against `--surface` #1a1a19 -
       near-identical, so nothing read as layered. */
    --page: #0c0c0e;
    --surface: #17171a;
    --raised: #1f1f23;
    --ink: #f4f4f6;
    --ink-2: #b4b4bd;
    --muted: #85858f;
    --hairline: #2a2a30;
    --border: rgba(255, 255, 255, 0.09);
    --accent: #4b8fe8;
    --status-good: #35c46f;
    --status-warning: #e0a02a;
    --status-critical: #e05a52;
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  /* The same three steps. `--page` was #0d0d0d against `--surface` #1a1a19 -
     near-identical, so nothing read as layered. */
  --page: #0c0c0e;
  --surface: #17171a;
  --raised: #1f1f23;
  --ink: #f4f4f6;
  --ink-2: #b4b4bd;
  --muted: #85858f;
  --hairline: #2a2a30;
  --border: rgba(255, 255, 255, 0.09);
  --accent: #4b8fe8;
  --status-good: #35c46f;
  --status-warning: #e0a02a;
  --status-critical: #e05a52;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
}

* { box-sizing: border-box; }

/* Figures line up in a column when they are the same width. Percentages,
   counts and quota fractions all sit in right-aligned columns here, and
   proportional digits make them shuffle by a pixel or two as they change -
   which reads as the layout being unsettled without anyone seeing why. */
.m-value, .stat-value, .quota-figure, .tier-gauge + b,
.meter-row b, .pressure-row b, td, th {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

body {
  margin: 0;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  font-size: 15px;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
  border-bottom: 1px solid var(--hairline);
  background: var(--surface);
  position: sticky;
  top: 0;
  /* Above the selection bar (10), which is also sticky and would otherwise
     scroll up over the top of it - the header is the one thing on the page
     that nothing scrolling underneath should ever cover. Still below the
     settings overlay (15) and the lightbox (20), both of which are meant to
     cover everything. */
  z-index: 12;
}
/* `hidden` alone loses to the class rule above, which also sets `display` -
   the map page's embed mode (`maps.js`, `?embed=1`) hides the topbar via
   `.hidden`, and without this override it painted anyway. The select
   bar's own hidden rules further down this file say the same. */
.topbar[hidden] { display: none !important; }
.topbar h1 { font-size: 18px; font-weight: 600; margin: 0; }
.topbar-right { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.primary-nav { display: flex; align-items: center; gap: 4px; margin-right: auto; margin-left: 28px; }
.primary-nav-link { display: inline-flex; align-items: center; min-height: 34px; padding: 6px 10px;
  border: 0; border-bottom: 2px solid transparent; border-radius: 6px 6px 0 0;
  background: none; color: var(--ink-2); font: inherit; font-size: 14px; text-decoration: none; cursor: pointer; }
.primary-nav-link:hover { color: var(--ink); background: var(--raised); }
.primary-nav-link[aria-current="page"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }

.poll-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-2); font-size: 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--status-good); }
.dot.error { background: var(--status-critical); }

.icon-btn {
  /* Sized to the icon rather than to a glyph's line box: a square target with
     the 20px artwork centred, so all seven sit on one baseline whatever their
     drawing happens to occupy. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  border-radius: 8px;
  color: var(--ink-2);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: color 120ms ease, background-color 120ms ease,
              border-color 120ms ease;
}
.icon-btn:hover {
  color: var(--ink);
  background: var(--raised);
  border-color: var(--border);
}
.icon-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.icon-btn svg { display: block; }
/* The close buttons in dialogs are still glyphs and want the old proportions. */
.settings-head .icon-btn { width: 30px; height: 30px; font-size: 14px; }

/* Wide enough to fill a monitor, still bounded so an ultrawide does not get
   eight photos per row and camera cards stretched across a metre of glass.
   `100vw` keeps the cap out of the way below that, where the padding governs. */
main { max-width: min(2200px, 100vw); margin: 0 auto; padding: 20px; }
.view-heading { display: flex; justify-content: space-between; gap: 18px; align-items: start; margin-bottom: 18px; }
.view-heading h2 { margin: 0; font-size: 20px; }
.view-heading p { margin: 4px 0 0; color: var(--ink-2); }
.view-actions, .wildlife-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.camera-health-summary { display: flex; align-items: center; gap: 9px; min-height: 42px; margin-bottom: 14px; padding: 8px 12px;
  border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--ink-2); font-size: 13px; }
.camera-health-summary b { color: var(--ink); }
.camera-health-alert { color: var(--status-warning); font-weight: 600; }
.camera-health-open { margin-left: auto; border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; }
.camera-health-open:focus-visible, .more-filters:focus-visible, .primary-nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Camera status cards */
/* The fleet is a page section, not a carousel. Cards fill a responsive grid
   on larger screens and become one full-width, naturally scrolling list on a
   phone, so a large camera roster is visible without side-scrolling. */
.camera-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
  align-items: stretch;
  position: relative;
  gap: 14px;
  margin-bottom: 22px;
}
.camera-card {
  min-width: 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  /* The ramp does most of the lifting; the shadow is the last few percent that
     makes a card read as sitting *on* the page rather than being a differently
     coloured region of it. Deliberately small - two soft steps, no blur halo. */
  box-shadow: var(--shadow);
  padding: 15px 16px 16px;
  /* The 420ms long-press that lifts a card for reordering is the same gesture
     iOS Safari reads as "show the callout" (Copy / Look Up) over the card
     name. `removeAllRanges()` at the lift clears a selection the browser
     already made; it cannot pre-empt an OS menu the touch itself triggers.
     `user-select` is left alone here on purpose - it only stops a *drawn*
     highlight and this rule must not touch it, or
     `test_the_row_is_selectable_when_nothing_is_being_dragged` would no
     longer be pinning anything. */
  -webkit-touch-callout: none;
}
/* Dragging a card into place.

   `cursor: grab` only on a device that has a cursor: on a phone the pointer is
   a finger and the hint belongs to the long press, not to a hover state that
   cannot happen. */
@media (hover: hover) and (pointer: fine) {
  .camera-card { cursor: grab; }
  .camera-row.reordering .camera-card { cursor: grabbing; }
}
/* The lifted card sits above the row and stops taking pointer events, so
   `elementFromPoint` behind it - and therefore the drop calculation - sees the
   cards it is being dragged past rather than itself. */
.camera-card.lifted {
  position: absolute;
  left: var(--drag-left);
  top: var(--drag-top);
  width: var(--drag-width);
  height: var(--drag-height);
  z-index: 2;
  pointer-events: none;
  /* `--drag-x` and `--drag-y` are set by app.js on every pointermove and make the
     card follow the finger; the scale is the lift. Composed here rather than
     written whole from JS so the reduced-motion rule below can drop the scale
     without JS having to know about the preference. */
  transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)) scale(1.03);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.28);
  /* Opaque, deliberately. A lifted card overlaps its neighbour by most of its
     width, and at any transparency the card underneath shows through as text
     behind text - which reads as a rendering fault rather than as depth. The
     shadow and the scale are already saying "off the row"; see
     `tools/fixtures/reorder.html`, where the translucent version was caught. */
  background: var(--surface);
}
/* The cards being displaced slide; the lifted one must not, or it lags the
   finger by the length of the transition. */
.camera-row.reordering .camera-card:not(.lifted) {
  transition: transform 140ms ease;
}
/* Once a card is lifted the gesture belongs to reordering. Paired with the
   non-passive touchmove handler in app.js for the gesture already in flight. */
.camera-row.reordering {
  min-height: var(--drag-grid-height);
  touch-action: none;
}
/* Motion is the whole affordance here, so it is reduced rather than removed:
   the lift still reads as a lift, the displaced cards just stop sliding. */
@media (prefers-reduced-motion: reduce) {
  .camera-row.reordering .camera-card:not(.lifted) { transition: none; }
  .camera-card.lifted { transform: translate(var(--drag-x, 0px), var(--drag-y, 0px)); }
}
.camera-card-head {
  margin: 0;
  display: flex;
  align-items: flex-start;
  gap: 8px;
}
.camera-card h3 { min-width: 0; margin: 0; font-size: 16px; font-weight: 650; line-height: 1.25; }
.camera-card .cam-edit { width: auto; height: 30px; gap: 5px; padding: 0 9px; font-size: 12px; }
.camera-card .cam-name {
  overflow: hidden;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}
.cam-actions { margin-left: auto; display: flex; gap: 4px; flex: none; }
.camera-card-meta { display: grid; grid-template-rows: auto 18px; gap: 4px; min-height: 40px; margin: 5px 0 11px; }
.camera-card-status { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; min-width: 0; }
.settings-panel [hidden] { display: none; }
/* The visual pass stated one casing rule - "uppercase is for small meta
   labels: the day divider, the source badge; never for text you click" - and
   named this badge as an example of it. Travis asked for `GardePro` and
   `SpyPoint` instead, so the rule is overridden here deliberately, not
   forgotten.

   The reason it is worth overriding for this one label: these are proper
   nouns with vendor-chosen internal capitals. GARDEPRO and SPYPOINT destroy
   the camelcase that is how each brand is actually written, and the badge
   stops naming the vendor and starts looking like a category. The rule still
   holds for the day divider, which is generic text where caps only signal
   "this is a label, not content". The remaining `text-transform: uppercase`
   rules are untouched. */
.cam-src {
  font-size: 10px;
  font-weight: 600;
  color: var(--muted);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 1px 7px;
  letter-spacing: 0.03em;
  flex: none;
}
/* Same pill shape as `.cam-src`, tinted with the sheet's one warning colour -
   this is the card's only always-visible sign that `scene.py` thinks the
   view moved, so it needs to read as "look at this" rather than as another
   vendor badge. A `<button>`, not a `<span>` - it opens the settings dialog
   scrolled to "View check" - so the usual button resets (`background`,
   `font`, `cursor`) sit alongside the badge's own look rather than in a
   second rule. */
.view-changed {
  font: inherit;
  font-size: 10px;
  font-weight: 600;
  color: var(--status-warning);
  background: none;
  border: 1px solid var(--status-warning);
  border-radius: 999px;
  padding: 1px 7px;
  letter-spacing: 0.03em;
  flex: none;
  cursor: pointer;
}
.view-changed:hover { background: var(--status-warning); color: var(--surface); }
.view-changed:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.camera-card .sub { color: var(--muted); font-size: 12px; }
.camera-card .sub .warn-note { color: var(--ink-2); font-weight: 500; }

/* The value column sizes to its text; the meter takes what is left.
   
   It was a fixed 74px with `white-space: nowrap`, so anything longer simply
   clipped - and the longest form the Solar row can produce (`≈100% · 14.0 V
   ⚡`, once an estimate and a charging pack are both present) is longer. The
   74px is now a floor rather than a ceiling, and `minmax(0, 1fr)` lets the
   meter give up width instead of the words losing their end. */
.meter-row { display: grid;
  grid-template-columns: 62px minmax(0, 1fr) minmax(74px, auto);
  align-items: center; gap: 8px; margin: 7px 0; }
.meter-row .m-label { color: var(--ink-2); font-size: 12px; }
.meter-row .m-value { color: var(--ink); font-size: 12px; text-align: right; white-space: nowrap; }
.meter {
  height: 6px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--meter-color, var(--accent)) 18%, var(--surface));
  overflow: hidden;
}
.meter > i {
  display: block;
  height: 100%;
  border-radius: 4px;
  background: var(--meter-color, var(--accent));
}
/* Green is the *absence* of a severity class, so this needs no JS: `severity()`
   already returns "warning"/"critical" and an empty string for healthy, and
   healthy was falling through to the accent blue - which reads as "a value"
   rather than as "a value that is fine". */
.meter-row:not(.warning):not(.critical) { --meter-color: var(--status-good); }
.meter-row.warning { --meter-color: var(--status-warning); }
.meter-row.critical { --meter-color: var(--status-critical); }
.meter-row.warning .m-value { color: var(--status-warning); }
.meter-row.critical .m-value { color: var(--status-critical); }

.cameras-note {
  color: var(--muted);
  font-size: 13px;
  padding: 6px 2px;
}

/* The boot-failure banner. A class that sets `display` beats the `hidden`
   attribute's own UA-stylesheet `display: none` (see the `.select-actions`
   note below for the shipped consequence of forgetting this), so the hidden
   state needs its own rule stated explicitly rather than trusted to win by
   default. */
.boot-error {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 20px 14px;
  padding: 10px 14px;
  border: 1px solid var(--status-critical);
  border-radius: 8px;
  background: var(--surface);
  color: var(--status-critical);
  font-size: 13px;
}
.boot-error[hidden] { display: none !important; }
.boot-error #boot-retry {
  background: var(--page);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 5px 12px;
  font-size: 13px;
  cursor: pointer;
}
.boot-error #boot-retry:hover { border-color: var(--accent); }

/* Filters */
.filters { display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
           flex-wrap: wrap; }
.more-filter-controls { display: contents; }
.more-filter-controls[hidden], .camera-health-summary[hidden], .app-view[hidden] { display: none !important; }
.more-filters { background: none; color: var(--ink-2); border: 1px dashed var(--border); border-radius: 8px;
  padding: 7px 11px; font: inherit; font-size: 13px; cursor: pointer; }
.more-filters:hover { border-color: var(--accent); color: var(--ink); }
.wildlife-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.wildlife-action { display: flex; min-height: 116px; flex-direction: column; align-items: start; justify-content: center; gap: 7px;
  padding: 18px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow); }
.wildlife-action:hover { border-color: var(--accent); }
.wildlife-action span { color: var(--ink-2); font-size: 13px; }
/* The patterns panel opened from one of the four Wildlife cards shows only
   that card's blocks. Written as "hide every tagged block that is not this
   section's" so the loader's own `hidden` toggling (a section with no data
   stays hidden either way) is untouched; a panel with no `data-pat-section`
   still shows everything, which is the older behaviour. */
#patterns-overlay .settings-panel[data-pat-section="activity"] [data-pat-group]:not([data-pat-group="activity"]),
#patterns-overlay .settings-panel[data-pat-section="weather"] [data-pat-group]:not([data-pat-group="weather"]),
#patterns-overlay .settings-panel[data-pat-section="rut"] [data-pat-group]:not([data-pat-group="rut"]),
#patterns-overlay .settings-panel[data-pat-section="sits"] [data-pat-group]:not([data-pat-group="sits"]),
#patterns-overlay .settings-panel[data-pat-section="scorecard"] [data-pat-group]:not([data-pat-group="scorecard"]) { display: none; }
/* The panel title already says "Sit log" (or "Camera scorecard"); each
   section's own heading right under it reads as a stutter in the render -
   tests M1, 2026-09-20 audit found the scorecard has the identical problem
   this rule already fixed for the sit log. The other sections' headings
   differ from their card names ("Weather and activity") and stay. */
#patterns-overlay .settings-panel[data-pat-section="sits"] #pat-sits > h3 { display: none; }
#patterns-overlay .settings-panel[data-pat-section="scorecard"] #pat-scorecard > h3 { display: none; }

/* Multi-select dropdowns. Built rather than borrowed: a native <select
   multiple> cannot show a count, cannot be dismissed by clicking away, and on
   a phone renders as a list box tall enough to push the gallery off screen. */
.dropdown { position: relative; }
.dd-button {
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 7px 11px;
  font: inherit;
  font-size: 13px;
  /* Sentence case, like every other control.

     One rule, applied everywhere: uppercase is for small meta labels - the day
     divider, the source badge - and never for text you click. "CAMERAS" beside
     a sentence-case "Stack bursts" checkbox was two typographic registers in
     one row, which reads as careless without anyone being able to say why. */
  cursor: pointer;
}
.dd-button:hover { border-color: var(--accent); }
/* An active filter is signalled by the blue count and by nothing else.
   
   There is deliberately no `.dd-button.has-selection` rule. The whole button
   was coloured first, then given an accent border when that was rejected -
   the same idea moved to a different property, and rejected again. The count
   *is* the signal; anything added beside it is a second one saying the same
   thing louder. */
.dd-count { color: var(--accent); font-variant-numeric: tabular-nums; }
/* Reserved so the button does not resize as a count appears and disappears -
   a row of controls that shuffles sideways on every tick looks cheap. */
.dd-count:empty { display: inline-block; min-width: 0; }
.dd-count:not(:empty) { margin-left: -2px; }
.dd-caret { color: var(--muted); font-size: 10px; }
.dd-menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 20;
  min-width: 220px;
  max-height: 320px;
  overflow-y: auto;
  padding: 6px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
}
.dd-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 6px 8px;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
}
.dd-item:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
/* The unavailable case explains itself instead of presenting an empty menu -
   a control that does nothing and says nothing is indistinguishable from one
   that is broken. */
.dd-note { margin: 4px 6px; font-size: 12px; color: var(--muted); line-height: 1.4; }
.dd-presets { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 6px 8px; }
.dd-preset {
  background: none;
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--ink-2);
  font: inherit;
  font-size: 12px;
  padding: 3px 10px;
  cursor: pointer;
}
.dd-preset:hover { border-color: var(--accent); }
/* The active preset is a toggle, so it has to look pressed - otherwise
   clicking it again to clear reads as a no-op. */
.dd-preset.on {
  color: var(--accent);
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.dd-date {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 4px 6px;
  font-size: 12px;
  color: var(--muted);
}
.dd-date input {
  background: var(--page);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 4px 7px;
  font: inherit;
  font-size: 12px;
}
.dd-clear {
  background: none;
  border: none;
  color: var(--muted);
  font: inherit;
  font-size: 13px;
  text-decoration: underline;
  cursor: pointer;
  padding: 0;
}
.dd-clear:hover { color: var(--ink); }
.filters select {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 7px 10px;
  font-size: 14px;
}
.muted { color: var(--muted); font-size: 13px; }

/* Gallery */
/* The gallery is now a stack of day sections, each holding the grid the
   gallery itself used to be. The grid rule moved to `.day-grid` unchanged, so
   card sizing and wrapping behave exactly as before within a day. */
.gallery {
  display: flex;
  flex-direction: column;
  gap: 22px;
}
.day-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 12px;
}
.day-head {
  /* Quieter than the content it labels: uppercase and small, so it reads as a
     divider rather than competing with the camera names on the cards below.
     A hairline under it does the separating that a larger, louder heading
     would otherwise have to do. */
  margin: 0 0 12px;
  padding: 6px 0 7px;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  border-bottom: 1px solid var(--hairline);
  /* Sticky so the day you are looking at stays named while you scroll a long
     day - the header is the answer to "when was this", and scrolling past it
     takes the answer away. */
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--page);
}
.photo-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  padding: 0;
  text-align: left;
}
.photo-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: var(--hairline); }
.panorama-reel {
  position: relative; display: block; aspect-ratio: 4 / 3;
  overflow: hidden; background: var(--hairline);
}
.photo-card .panorama-reel img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0;
  animation: panorama-frame calc(var(--frames) * 1.35s) steps(1, end) infinite;
  animation-delay: calc(var(--frame) * 1.35s);
}
@keyframes panorama-frame {
  0% { opacity: 1; }
  calc(100% / var(--frames)) { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .photo-card .panorama-reel img { animation: none; opacity: 0; }
  .photo-card .panorama-reel img:first-child { opacity: 1; }
}
.photo-card .thumb { position: relative; display: block; }
.photo-card .thumb video { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; background: var(--hairline); }
.photo-card .thumb .play {
  position: absolute; inset: 0; margin: auto; width: 44px; height: 44px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: rgba(0, 0, 0, 0.55); color: #fff;
  font-size: 16px; padding-left: 3px; pointer-events: none;
}
/* One quiet line, not two columns pushed apart. `space-between` put the camera
   hard left and the time hard right, so on a wide card the two halves of a
   single fact sat at opposite ends with nothing between them - and on a narrow
   one they collided. Read as `camera · time`, with the camera taking whatever
   room is left after the time, which never wraps. */
.photo-card .meta {
  padding: 7px 10px 0;
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: 12px;
  line-height: 1.35;
}
.photo-card .cam {
  color: var(--ink-2);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.photo-card .cam:not(:empty)::after {
  content: "·";
  color: var(--border);
  margin-left: 6px;
}
.photo-card .time { color: var(--muted); white-space: nowrap; }
.photo-card .labels { padding: 6px 10px 9px; display: flex; flex-wrap: wrap; gap: 4px; }
/* Room for the names underneath.

   The name strip is absolutely positioned at the wrapper's bottom-left,
   and the chips are the last thing in the card's flow - the same corner.
   With short labels they missed each other; `Human Activity` is long
   enough to reach, and "Tall-8" painted over it read as "Tall-8 n
   Activity".

   Reserved on every card rather than only on named ones. A `:has()`
   would spare the empty band, at the cost of the row's rhythm changing
   as photos get named - cards growing a little the moment a deer is
   given a name, which is a worse thing to watch than a consistent
   strip of space. */
.tile .photo-card .labels,
.stack .photo-card .labels { padding-bottom: 28px; }
.chip {
  font-size: 11px;
  color: var(--ink-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 1px 8px;
  background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
.chip.empty { background: none; color: var(--muted); }
.chip.reviewed { border-color: color-mix(in srgb, var(--status-good) 55%, var(--border)); }
/* Named by the wildlife detector rather than the vision model (phase two).
   A weight on the text plus a tinted edge, both from existing tokens - no new
   glyph and no second element, because this sits at 11px over a photograph
   where an added mark is noise and a colour alone would not survive either
   theme. It cannot collide with `.reviewed`: a reviewed photo's chips are
   built from the human labels, and `photo_reviews.canonical_labels` strips
   `source` off those - so a chip a person has settled never claims a source at
   all, which is the right answer rather than a styling accident. */
.chip.detector {
  border-left: 3px solid color-mix(in srgb, var(--accent) 70%, var(--border));
  font-weight: 600;
}

.load-wrap { text-align: center; margin: 18px 0; }
.load-more {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 9px 22px;
  font-size: 14px;
  cursor: pointer;
}
.load-more:hover { border-color: var(--accent); }
.load-more:disabled { opacity: 0.6; cursor: default; }
/* A failed page has to look like something went wrong, or "tap to retry"
   reads as an ordinary button somebody forgot to remove. */
.load-more.has-error { border-color: var(--status-critical); color: var(--status-critical); }

.empty-note { color: var(--muted); text-align: center; margin: 48px 0; }

/* Settings */
.settings-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 40px 16px;
  overflow-y: auto;
  z-index: 15;
}
.settings-overlay[hidden] { display: none; }

/* An overlay that has to clear a Leaflet map.

   Leaflet builds its own stacking world - panes at 200 through 700, controls
   at 800, and 1000 at the top - and the shared overlay above sits at 15. On
   the gallery that is right: the lightbox at 20 is meant to open over it. On
   the map it meant the dialog rendered *behind* the map and could not be
   reached at all, while rendering perfectly in a fixture that had no map in
   it.

   Scoped to the overlays that need it rather than raised globally, because
   raising the shared class would put the settings panel over the lightbox on
   a page that has no map on it. A new dialog over a map wants this class.

   The number only has to beat Leaflet's highest; a test reads that maximum out
   of `leaflet.css` and compares, so an upgrade that raises Leaflet's stack
   fails there rather than silently burying this again. */
.settings-overlay.over-leaflet { z-index: 1100; }
.settings-panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  /* A dialog is further off the page than a card, so it gets a deeper version
     of the same shadow rather than a different kind of one. */
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), var(--shadow);
  width: min(480px, 100%);
  padding: 18px 22px;
}
.settings-head { display: flex; align-items: center; justify-content: space-between; }
.settings-head h2 { font-size: 17px; margin: 0; }

/* --- the map popup: a small frame of the real map page -------------------

   Owner's ask (2026-09-17): "Show on map" jumped straight to the map page,
   heavier than the question deserved. A native `<dialog>` rather than the
   `.settings-overlay` div pattern above - `showModal()` gets focus-trapping,
   Escape and an inert background for free, and its own `::backdrop` is
   exactly what a dimmed background wants, so there is nothing left for a
   second copy of `.settings-overlay`'s manual backdrop-click handling to do.
   Sized and coloured to match `.settings-panel` beside it (same corner
   radius, same two-layer shadow) without sharing markup - a dialog element
   cannot be a `.settings-panel`'s child the way every other dialog's
   content is. */
.map-popup {
  padding: 0;
  border: 0;
  border-radius: 14px;
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), var(--shadow);
  width: 92vw;
  height: 86vh;
  flex-direction: column;
  overflow: hidden;
}
/* `display` ONLY when open: a bare `display: flex` on the class beats the UA
   stylesheet's `dialog:not([open]) { display: none }`, and the owner saw the
   closed popup sitting on the Wildlife page, empty, the night it shipped
   (2026-09-18). */
.map-popup[open] { display: flex; }
.map-popup:not([open]) { display: none; }
.map-popup::backdrop { background: rgba(0, 0, 0, 0.5); }
@media (min-width: 700px) {
  /* `min()` rather than a bare 960x640: a desktop window narrower than that
     (a snapped half-screen, say) would otherwise overflow past its own
     edge - the same phone-sized `92vw`/`86vh` bound the rule below this one
     uses, just no longer the number that wins on an ordinary monitor. */
  .map-popup { width: min(960px, 92vw); height: min(640px, 86vh); }
}
.map-popup-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 10px 10px 10px 16px;
  border-bottom: 1px solid var(--hairline);
  flex: none;
}
/* One line: a long animal name must truncate rather than wrap the row onto
   two and push the close button down where a thumb reaching for the
   dialog's own corner would miss it. */
.map-popup-head h2 {
  font-size: 15px;
  margin: 0;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.map-popup-head-actions { display: flex; align-items: center; gap: 6px; flex: none; }
.map-popup-open {
  font-size: 13px;
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
  border-radius: 4px;
}
.map-popup-open:hover { text-decoration: underline; }
.map-popup-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.map-popup-frame { flex: 1; width: 100%; border: 0; background: var(--page); }

.settings-panel fieldset {
  border: 1px solid var(--hairline);
  border-radius: 10px;
  margin: 14px 0 0;
  padding: 10px 14px 14px;
}
.settings-panel legend { font-size: 13px; color: var(--ink-2); padding: 0 6px; }
.settings-panel label { display: block; font-size: 13px; color: var(--ink-2); margin: 10px 0 0; }
.settings-panel input,
.settings-panel select {
  display: block;
  width: 100%;
  margin-top: 4px;
  background: var(--page);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
  font-size: 14px;
}
.settings-panel input:focus,
.settings-panel select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.hint { font-size: 12px; color: var(--muted); margin: 8px 0 0; }
.check-label { display: flex !important; align-items: center; gap: 8px; }
.check-label input { width: auto !important; display: inline !important; margin: 0 !important; }
/* Marks a control as belonging to the checkbox above it, the way the
   maintenance list's rows read as subordinate to their own heading - here by
   indent rather than a box, since these are two plain number fields rather
   than a list. Roughly the checkbox's own width plus its label gap, so the
   text lines up under the words rather than the box. */
.settings-suboption { margin-left: 26px; }
.quiet-times { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }

/* The view-changed comparison: two frames side by side, same two-column
   grid `.quiet-times` already uses in this dialog rather than a new layout
   idiom - at the panel's own narrowest width (390px, `.settings-overlay`'s
   16px side padding plus the panel's and fieldset's own) each column comes
   out around 140px, comfortably two-across rather than needing to stack. */
.scene-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 10px 0 0; }
.scene-compare-frame {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 0;
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  font: inherit;
  text-align: left;
  color: var(--ink);
}
.scene-compare-frame img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: var(--raised);
}
.scene-compare-cap { font-size: 11px; color: var(--ink-2); padding: 4px 6px; }
.scene-compare-frame:hover { border-color: var(--accent); }
.scene-compare-frame:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Sized to the dialog rather than to its own frame's aspect ratio - the clip
   is letterboxed already, so a fixed height keeps the settings panel from
   jumping every time a different camera's clip loads. */
#cs-timelapse-video { width: 100%; max-height: 40vh; background: #000; }

.clock-picker { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.clock-picker select { width: auto; min-width: 70px; }
.clock-colon { color: var(--ink); font-weight: 600; }

.maint-list { display: flex; flex-direction: column; gap: 2px; }
.settings-panel label.maint-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  padding: 8px 4px;
  font-size: 14px;
  color: var(--ink);
  cursor: pointer;
}
.settings-panel .maint-row input {
  display: inline-block;
  width: auto;
  margin: 0;
  flex: none;
}
.maint-row .m-name { flex: 1; }
.maint-row .m-note { color: var(--muted); font-size: 12px; }
.maint-row .m-note.on { color: var(--accent); }
.mono { font-family: ui-monospace, Consolas, monospace; }
.test-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.btn {
  background: var(--page);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 7px 14px;
  font-size: 13px;
  cursor: pointer;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn:disabled { opacity: 0.6; cursor: wait; }
.test-result { font-size: 13px; color: var(--ink-2); }
.test-result.ok { color: var(--status-good); }
.test-result.err { color: var(--status-critical); }
.settings-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 16px;
}

/* Lightbox */
.lightbox {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.88);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20;
}
.lightbox[hidden] { display: none; }
.lightbox figure {
  margin: 0; max-width: 92vw; max-height: 94vh; text-align: center;
  overflow-y: auto; scrollbar-width: thin;
}
.lightbox img { max-width: 92vw; max-height: 68vh; border-radius: 6px; display: block; }
.lb-video { max-width: 92vw; max-height: 68vh; border-radius: 6px; display: block; background: #000; }
.lb-video[hidden] { display: none; }
/* `pan-y` hands vertical scrolling back to the browser while keeping the
   horizontal axis for the swipe handler. Without it the two fight: either the
   page scrolls under a sideways drag, or - with `none` - the review panel
   below a tall photo can no longer be scrolled to at all. */
/* The stage is now a window onto a three-slot strip rather than the photo
   itself, so it needs a size of its own: a strip whose height followed
   whichever photo was passing would make the caption jump on every swipe. The
   height is the same 68vh cap the photo already had, so a tall photo fills it
   exactly and a wide one sits centred in it - which is what a camera roll
   does. */
.lb-stage {
  position: relative;
  /* Not `height: 68vh`, which was the first attempt and looked wrong the
     moment it was rendered: 68vh is the *cap* on a photo, not the shape of
     one. A landscape photo at 92vw is nowhere near that tall, so every photo
     sat in a band of empty space with the caption pushed a third of a screen
     down.

     4:3 is the shape the gallery already assumes - `.photo-card img` has used
     it since the grid was written - so the common case is the size it has
     always been. A 16:9 clip letterboxes a little; a portrait photo, which no
     trail camera here produces, is the case this trades away.

     A stage that matched each photo exactly would be the ideal, and cannot be
     had: it would resize mid-slide, and the caption would jump on every
     swipe - which is the thing a strip is supposed to stop doing. */
  aspect-ratio: 4 / 3;
  max-height: 68vh;
  overflow: hidden;
  touch-action: pan-y;

  /* Width from *whichever* dimension binds, not from width alone.
     `width: 92vw` with a 4:3 ratio silently assumes height is plentiful,
     which is true holding the phone upright and false holding it sideways: at
     844x390 it asks for a 776x582 box in a 390px-tall viewport, `max-height`
     squashes that to a 776x203 band, and the review panel is pushed off the
     bottom. Taking the smaller of the two keeps the ratio and lets the stage
     shrink to what actually fits, so the caption and the panel keep their
     room in either orientation. */
  /* The stage box, named once and referenced by the photo inside it.

     A percentage could not do this job. `max-height: 100%` on the image
     resolves against `.lb-frame`, and the frame hugs the photo - so the photo
     is bounded by a box the photo itself defines, which is circular and
     resolves to no constraint. In fullscreen that left an 800x600 image at
     natural size in a 298px-tall viewport, cropped by `overflow: hidden`,
     which is the missing timestamp strip Travis photographed.

     Viewport units resolve against the viewport, so there is nothing
     circular to break. The frame goes on hugging the photo, which is what the
     outline boxes are positioned against. */
  --lb-w: min(92vw, calc(68vh * 4 / 3));
  --lb-h: calc(var(--lb-w) * 3 / 4);
  width: var(--lb-w);
  /* It used to be exactly the figure's width, so `text-align: center`
     on the figure appeared to centre it and never did - centring text
     does not centre a block. Now that it can be narrower than the
     figure, the difference shows. */
  margin-inline: auto;

  /* The one place this number exists.

     It is both the space between the photos and part of the distance from one
     to the next, and the transforms in `gallery_core` refer to it rather than
     carrying their own copy - so the gap and the travel cannot disagree. Widen
     it here and the swipe still lands square.

     Travis's number, judged on the phone. The ladder in
     `tools/fixtures/filmstrip.html` said 18 and read 10 as a seam rather than
     a gap; on the device the seam is what he wanted - just enough that the
     photos are separate objects passing, and not a space to be crossed. A
     render can show a gap and cannot say how a gap feels at swipe speed. */
  --lb-gap: 5px;
}
/* Fullscreen: the photograph and nothing else.

   `touch-action` is per mode rather than one value serving both. `pan-y` in
   the summary view exists so the review panel can scroll under a sideways
   drag; fullscreen has no panel to scroll, so the constraint that forced it
   does not travel. `none` there hands every axis to the gesture code, which is
   what stops the browser zooming the whole page underneath a pinch and
   leaving two zooms fighting. */
.lightbox.fullscreen {
  background: #000;
  padding: 0;
}
.lightbox.fullscreen figcaption,
.lightbox.fullscreen .lb-review,
.lightbox.fullscreen .lb-outline { display: none; }
.lightbox.fullscreen figure { max-width: 100vw; max-height: 100vh; overflow: hidden; }
.lightbox.fullscreen .lb-stage {
  /* The whole viewport, and the photo fits inside it rather than filling it.
     Fit and fill coincide for a landscape photo in a portrait stage, which is
     why this held up until the phone was turned. */
  --lb-w: 100vw;
  --lb-h: 100vh;
  width: 100vw;
  max-height: 100vh;
  aspect-ratio: auto;
  height: 100vh;
  touch-action: none;
}
/* While zoomed the photo owns the drag, so nothing may scroll under it. */
.lightbox.zoomed .lb-stage { touch-action: none; }

.lb-strip {
  display: flex;
  height: 100%;
  gap: var(--lb-gap);
  will-change: transform;

  /* The resting position, declared here and not only in the gesture handlers.

     It was only in the handlers, and an untranslated strip shows slot 0 -
     which is the *previous* photo's slot. Travis opened the newest photo,
     where that slot is empty, saw the caption over nothing, and his first
     touch settled the strip and slid the photo in like a swipe he had not
     made.

     Blank was the lucky face of it. Open anything but the newest photo and
     slot 0 holds a real photograph, so the lightbox rendered the *previous*
     one under the caption, timestamp and camera name of the one asked for -
     right-looking, silently wrong, with nothing to notice. A reader who knows
     only the blank symptom goes looking for a loading failure, which is a
     different subsystem entirely.

     It survived every render and test because an inline transform persists
     once set - only the very first open was ever wrong.

     The real defect was that "no transform" meant "blank" instead of
     "centred". Now every path that fails to set one lands on the middle slot,
     which is the state a lightbox should open in. `stripRestTransform()` emits
     this same expression, and a test asserts the two agree. */
  transform: translateX(calc(-100% - 1 * var(--lb-gap)));
}
/* One stage wide, three times over - not one *photo* wide, which would differ
   per aspect ratio and walk the strip out of alignment. */
.lb-slot {
  flex: 0 0 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Stated rather than left to `stretch`. A percentage max-height resolves
     against the containing block's height, and a flex item stretched to fit
     does not hand its children a definite one - so the `max-height: 100%`
     below silently became no constraint at all. That is what let a 566px-tall
     photo sit in a 203px stage and be cropped by `overflow: hidden`, taking
     the camera's timestamp strip with it. */
  height: 100%;
  min-height: 0;
}
/* The zoom transform lands here, on the box that hugs the photo - so the
   outline boxes inside it scale and pan with it for free, and the neighbours,
   which have frames of their own, cannot scale at all. */
.lb-frame {
  position: relative;
  display: inline-block;
  line-height: 0;
  transform-origin: 50% 50%;
  max-width: 100%;
  max-height: 100%;
}
/* Inside a slot the photo is bounded by the slot, not by the viewport. The
   92vw/68vh caps still apply - the slot is cut from them - but a photo that
   reached them directly would hang out of its slot and over its neighbour. */
.lb-slot img, .lb-slot video {
  max-width: var(--lb-w, 100%);
  max-height: var(--lb-h, 100%);
  width: auto;
  height: auto;
  /* Fit, never fill. The two coincide for a landscape photo in a portrait
     stage, which is why this held up until the phone was turned. */
  object-fit: contain;
}
/* The frame hugs the photo, so it has to be bounded too - otherwise it is the
   frame that overflows and the crop moves rather than going away. */
.lb-frame { max-width: 100%; max-height: 100%; }
/* Carries the commit and the return, and nothing else: left on permanently,
   every mid-gesture move would animate toward the finger instead of tracking
   it, which reads as lag on a cheap phone. */
.lb-strip.settling { transition: transform 220ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .lb-strip.settling { transition: none; }
}
/* A neighbouring video is a stand-in, not a third <video> element - see
   `neighbourSlot`. */
.lb-stand-in {
  display: flex;
  align-items: center;
  justify-content: center;
  width: min(60vw, 320px);
  height: 40vh;
  color: rgba(255, 255, 255, 0.75);
  font-size: 40px;
  background: #000;
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 6px;
}
.lb-boxes { position: absolute; inset: 0; pointer-events: none; }
.lb-box { position: absolute; border: 2px solid #fab219; border-radius: 3px; }
.lb-box > span {
  position: absolute;
  top: -20px;
  left: -2px;
  background: #fab219;
  color: #1a1a19;
  font-size: 11px;
  font-weight: 600;
  padding: 1px 6px;
  border-radius: 3px;
  white-space: nowrap;
}
/* Undo the frame's zoom for the label only. The outline must scale - it is
   describing a region of the photo - but its text is a caption, and a caption
   that reaches 6x is unreadable in a different way from too small. */
.lb-box > span { transform: scale(calc(1 / var(--lb-zoom, 1))); }
.lb-box > span { transform-origin: 0 100%; }
.lb-box.low > span { top: auto; bottom: -20px; }
.lb-outline {
  position: fixed;
  top: 14px;
  left: 16px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 8px;
  color: #fff;
  font-size: 13px;
  padding: 6px 12px;
  cursor: pointer;
  z-index: 21;
}
.lb-outline.on { background: #fab219; color: #1a1a19; border-color: #fab219; }
#lb-hd { left: auto; right: 60px; }
#lb-hd.on { background: #3987e5; color: #fff; border-color: #3987e5; }
#lb-hd:disabled { opacity: 0.7; cursor: default; }
.lightbox figcaption { color: #c3c2b7; font-size: 14px; margin-top: 10px; }
.lb-panorama {
  display: grid; grid-template-columns: repeat(6, minmax(70px, 1fr));
  gap: 8px; width: min(92vw, 900px); margin: 12px auto 0;
}
.lb-panorama[hidden] { display: none; }
.pano-thumb {
  min-width: 0; padding: 0; border: 2px solid transparent;
  border-radius: 5px; overflow: hidden; background: none; color: #c3c2b7;
  cursor: pointer; font: inherit;
}
.pano-thumb.current { border-color: #63b3ff; }
.pano-thumb.triggered:not(.current) { border-color: rgba(255, 255, 255, 0.45); }
.pano-thumb img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 0; }
.pano-direction, .pano-number { display: block; font-size: 12px; font-weight: 600; }
.pano-direction { padding: 2px 3px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pano-number { padding: 3px; }
.pano-trigger { font-size: 17px; line-height: 10px; margin-right: 4px; color: #fff; }
@media (max-width: 700px) {
  .lb-panorama { gap: 3px; width: 96vw; }
  .pano-direction, .pano-number { font-size: 10px; }
}
.lb-review {
  width: min(720px, 92vw); margin: 10px auto 0; padding: 10px 12px;
  border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 9px;
  color: #eee; background: rgba(0, 0, 0, 0.35); text-align: left;
}
.lb-review[hidden] { display: none; }
.lb-review-summary { font-size: 12px; line-height: 1.5; color: #c3c2b7; }
.lb-review-summary .review-line { display: block; }
.lb-review-summary .stale { color: #fab219; }
.lb-review-actions { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 8px; }
.lb-review .btn { background: rgba(0, 0, 0, 0.4); color: #fff; border-color: rgba(255, 255, 255, 0.3); }
.lb-review .btn.primary { background: #3987e5; border-color: #3987e5; }
.lb-review-editor { margin-top: 10px; }
.lb-review-editor[hidden] { display: none; }
.lb-review-row, .lb-review-columns {
  display: grid; grid-template-columns: minmax(120px, 1fr) 72px 110px auto;
  gap: 7px; margin-bottom: 7px; align-items: center;
}
.lb-review-columns { font-size: 11px; color: #c3c2b7; }
.lb-review-row input, .lb-review-row select {
  min-width: 0; background: #1a1a19; color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 6px; padding: 7px;
}
.lb-review-row .btn { padding: 6px 9px; }
.lb-review-editor-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 8px; }
.lb-review .test-result { display: block; margin-top: 6px; }
.review-metrics {
  margin-top: 10px; font-size: 12px; color: var(--muted);
  line-height: 1.5; overflow-x: auto;
}
.review-metrics table { width: 100%; border-collapse: collapse; margin-top: 6px; }
.review-metrics th, .review-metrics td { text-align: right; padding: 3px 5px; border-bottom: 1px solid var(--hairline); }
.review-metrics th:first-child, .review-metrics td:first-child { text-align: left; }

/* The label sprint: one disagreement at a time, side by side. */
.sprint-card img#sprint-img { max-width: 100%; border-radius: 8px; margin-top: 8px; }
.sprint-columns { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
.sprint-columns > div { flex: 1 1 200px; min-width: 160px; }
.sprint-columns h3 { font-size: 12px; color: var(--muted); margin: 0 0 4px; }
.sprint-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.sprint-other-form { display: flex; gap: 8px; margin-top: 10px; }
.sprint-other-form input { flex: 1; }

/* The crop check (research), below the sprint in the same overlay: a list
   rather than one-at-a-time, because the question here is how the crop answer
   behaves across the archive rather than which single photo to label next. */
#crop-compare { margin-top: 18px; border-top: 1px solid var(--hairline); padding-top: 14px; }
#crop-compare h3 { font-size: 13px; margin: 0 0 4px; }
#crop-compare-cameras { line-height: 1.5; }
.crop-card {
  margin-top: 12px; padding-top: 12px;
  border-top: 1px solid var(--hairline);
}
/* The positioning context the outline is measured against, and it has to be
   exactly the image's box - the outline's left/top/width/height are
   percentages of THIS element, so any part of it that is not the photograph
   displaces the box by that much. Two properties, both load-bearing, both
   found by rendering `tools/fixtures/crop_compare.html` and reading the
   numbers rather than looking at the picture:

   `inline-block`, not `block`: as a block this wrapper stretched to the
   panel's full width (829px measured) while the image sat at its natural 720,
   which put the outline 61px right of the animal and 15px too wide - a
   plausible-looking box around the wrong thing, which is the one defect a
   screenshot cannot tell from a correct one.

   `line-height: 0`, because an inline image otherwise leaves a descender strip
   under it inside the wrapper, and a percentage height measured against that
   taller box sits the outline high of the animal. */
.crop-frame { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
.crop-frame img { max-width: 100%; border-radius: 8px; }
/* The same amber as the lightbox's own detector outlines (`.lb-box`), and
   hardcoded for the same reason rather than by omission: this line is drawn
   over a photograph, not over the page, so a theme token would make it follow
   a background it is never on. */
.crop-outline { position: absolute; border: 2px solid #fab219; border-radius: 3px; }
.crop-columns { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
.crop-columns > div { flex: 1 1 180px; min-width: 150px; }
.crop-columns h4 { font-size: 12px; color: var(--muted); margin: 0 0 4px; font-weight: 600; }
.crop-columns p { margin: 0; font-size: 13px; line-height: 1.45; }
.crop-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.lb-close, .lb-nav {
  position: fixed;
  background: none;
  border: none;
  color: #fff;
  cursor: pointer;
  font-size: 30px;
  padding: 10px 16px;
  opacity: 0.8;
  /* Above the stage, and the reason is that without it *document order*
     decided this. `lb-close` and `lb-prev` are written before `.lb-stage` and
     `lb-next` after it, so with everything at `auto` the photo painted over
     two of the three controls and under the third.

     It only showed once a photo was wide enough to reach the screen edges - a
     16:9 GardePro frame in landscape does, a 4:3 SpyPoint frame letterboxes
     and leaves the controls sitting in the black bars. So it read as
     vendor-specific and was not.

     The second half of the report was the same fault: "the left arrow exits
     fullscreen". It never received the tap. The stage was on top of it, and
     the stage's tap handler is what exits. `lb-prev`'s handler was fine and
     had nothing to do with it.

     One rule for all three rather than three rules, because the thing being
     stated is that controls sit above the picture - not three separate
     opinions that happen to agree. 22 sits above the stage and below the HD
     window at 25, matching `.lb-outline` at 21. */
  z-index: 22;
}

/* Fullscreen tucks them tighter and gives them something to sit on.

   The offsets are already 6px, but `padding: 10px 16px` puts the glyph about
   22px in - which is a lot of photo to cover when the photo is the whole
   screen. And in fullscreen there is no bar or letterbox to guarantee
   contrast: a white glyph over a bright frame is invisible wherever the frame
   happens to be bright. The disc is what makes them readable over any photo
   rather than most of them. */
.lightbox.fullscreen .lb-close,
.lightbox.fullscreen .lb-nav {
  padding: 8px;
  background: rgba(0, 0, 0, 0.45);
  border-radius: 999px;
  line-height: 1;
}
.lightbox.fullscreen .lb-close { top: 8px; right: 8px; }
.lightbox.fullscreen .lb-prev { left: 4px; }
.lightbox.fullscreen .lb-next { right: 4px; }
.lb-close:hover, .lb-nav:hover { opacity: 1; }
.lb-close { top: 10px; right: 14px; font-size: 22px; }
.lb-prev { left: 6px; top: 50%; transform: translateY(-50%); font-size: 44px; }
.lb-next { right: 6px; top: 50%; transform: translateY(-50%); font-size: 44px; }

/* The phone overrides that lived here moved to the end of the sheet: an override at equal specificity only wins by source order, and
   one at line 1100 of a ~2800-line sheet was dead for every rule declared after it (see the comment at the end of the file). */

/* HD window picker */
.hd-window {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  z-index: 25;
}
.hd-window[hidden] { display: none; }
.hd-window-box {
  background: var(--surface);
  border: 1px solid var(--hairline);
  border-radius: 10px;
  padding: 20px;
  width: 320px;
  max-width: 100%;
}
.hd-window-box h3 { margin: 0 0 8px; font-size: 16px; }
.hd-hint { font-size: 12px; color: var(--ink-2); margin: 0 0 14px; }
.hd-window-row { display: flex; gap: 12px; margin-bottom: 16px; }
.hd-window-row label {
  flex: 1; display: flex; flex-direction: column; gap: 4px;
  font-size: 12px; color: var(--ink-2);
}
.hd-window-row input {
  background: var(--page); color: var(--ink);
  border: 1px solid var(--border); border-radius: 6px;
  padding: 8px; font-size: 14px;
}
.hd-window-actions { display: flex; justify-content: flex-end; gap: 8px; }

/* Notifications feed */
#events-camera { margin: 10px 0; max-width: 240px; }
.events-list { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
.event-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 10px;
  border-radius: 8px;
}
.event-row:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.event-row.clickable { cursor: pointer; }
.event-thumb {
  width: 56px; height: 42px;
  object-fit: cover;
  border-radius: 6px;
  background: var(--hairline);
  flex: none;
}
.event-icon {
  width: 56px; height: 42px;
  display: flex; align-items: center; justify-content: center;
  font-size: 20px;
  flex: none;
}
.event-body { min-width: 0; }
.event-msg { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
.event-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }

/* Help panel */
.help-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
  margin: 6px 0;
}
.help-table th {
  text-align: left;
  color: var(--muted);
  font-weight: 500;
  padding: 4px 8px 4px 0;
}
.help-table td {
  padding: 5px 8px 5px 0;
  border-top: 1px solid var(--hairline);
  color: var(--ink-2);
  vertical-align: top;
}
.help-table td:first-child { white-space: nowrap; font-weight: 600; color: var(--ink); }
.help-table .t-good { color: var(--status-good); }
.help-table .t-warn { color: var(--status-warning); }
.help-table .t-crit { color: var(--status-critical); }

/* Quota and surplus panel: ten columns of mostly-short values, so the table
   is genuinely wider than a phone rather than merely styled to look that
   way - `white-space: nowrap` is what makes it overflow its wrapper instead
   of wrapping each cell onto two lines, and the wrapper (not the page body)
   is what scrolls, the same shape `.pat-scorecard-scroll` uses one panel
   over. `th` gets the same rule as `td` (page L1): without it "Photos until
   it steps down" and "Burst: yours -> held" wrap onto three or four lines
   each, tripling the header's height above single-line body rows.

   The panel itself gets its own, wider `.settings-panel.wide` (page H2/quota
   L4) rather than the shared 820px every other wide dialog uses - ten
   `nowrap` columns clipped the last one ("Last check-in") at 1280 in both
   themes, `Sep 21, 5:22 AM` cut to `Sep 21, 5:22 A`, with ~460px of unused
   room outside the panel doing nothing about it. Scoped by id, the same
   reason `#patterns-overlay .settings-panel` is: `.settings-panel.wide` is
   shared by nine dialogs, and widening the class itself would take the
   settings, camera-settings, help and maintenance dialogs with it. */
/* Ten columns did not fit a 1,000 px laptop viewport even at 1180 px and the
   Mac hides the wrapper's scrollbar, so the table looked cut off ("dang
   couldn't even fit it all on the screen", 2026-09-22). Nine columns now,
   short headers (the dials' "yours -> held" moved into the intro line), a
   slightly smaller face, and the panel takes the room the viewport has. */
#quota-overlay .settings-panel.wide { width: min(1400px, 96vw); }
.quota-table-scroll { overflow-x: auto; }
#quota-table { font-size: 13px; }
/* Cells may wrap: a no-wrap table cannot shrink to its container, and at a
   1,000 px viewport the last column fell off the panel's right edge behind a
   scrollbar the Mac does not draw. Wrapped, the browser fits nine columns. */
#quota-table th, #quota-table td { white-space: nowrap; padding-left: 6px; padding-right: 6px; vertical-align: top; }
/* The cells that may wrap: the camera name, the tier's "(observed only)",
   the used cell's second line, the waiting text and the check-in time. The
   dials stay on one line so "1 min -> 1 min" never breaks mid-arrow. */
#quota-table th, #quota-table td:nth-child(1), #quota-table td:nth-child(2), #quota-table td:nth-child(3),
#quota-table td:nth-child(8), #quota-table td:nth-child(9) { white-space: normal; }
#quota-table .quota-sub { display: block; color: var(--muted); font-size: 12px; }

/* Quota pressure gauge on the camera card - the label column lines up with
   the meters above it; the track spans deep surplus (green) through relaxed
   (neutral) to critical (red) with a marker at the camera's current tier. */
.pressure-row {
  display: grid;
  grid-template-columns: 62px minmax(0, 1fr);
  align-items: center;
  column-gap: 8px;
  margin: 7px 0;
  font-size: 12px;
}
.pressure-row > span:first-child { color: var(--ink-2); }
.tier-gauge {
  position: relative;
  height: 8px;
  min-width: 40px;
  border-radius: 4px;
  /* A quiet track with a coloured marker, not a rainbow.

     The gradient ran the full spectrum at full saturation, so the gauge shouted
     louder than the four flat bars stacked above it and read as a stray
     artifact rather than a sibling. The scale still matters - left is spare
     capacity, right is running out - so it is kept as a wash at low alpha:
     legible as a direction, quiet enough to sit in the same family. The tier's
     own colour is carried by the marker and by the label beside it, both of
     which are already coloured. */
  background: linear-gradient(to right,
    color-mix(in srgb, var(--status-good) 22%, var(--hairline)) 0%,
    var(--hairline) 46%,
    var(--hairline) 56%,
    color-mix(in srgb, var(--status-warning) 22%, var(--hairline)) 78%,
    color-mix(in srgb, var(--status-critical) 30%, var(--hairline)) 100%);
}
.tier-gauge i {
  position: absolute;
  top: 50%;
  width: 4px;
  height: 13px;
  transform: translate(-50%, -50%);
  border-radius: 2px;
  background: var(--ink);
  box-shadow: 0 0 0 1.5px var(--surface);
}
/* Pressure carries the explanation now, so it carries the help cursor - but no
   underline. That was added on the reasoning that a tooltip with nothing
   marking it is a tooltip nobody hovers, and the owner's answer was that a
   dotted line under every camera's tier reads as damage rather than as an
   invitation. The cursor costs nothing and is still there for anyone who
   passes over it. */
.pressure-row b {
  grid-column: 2;
  display: flex;
  flex-wrap: wrap;
  gap: 2px 5px;
  min-width: 0;
  margin-top: 3px;
  font-weight: 600;
  cursor: help;
}
.pressure-detail { color: var(--ink-2); font-weight: 500; }
.pressure-row.tier-relaxed b { color: var(--ink-2); font-weight: 500; }
.pressure-row.tier-elevated b,
.pressure-row.tier-high b { color: var(--status-warning); }
.pressure-row.tier-critical b { color: var(--status-critical); }
.pressure-row.tier-surplus b,
.pressure-row.tier-deep_surplus b { color: var(--status-good); }

/* Activity patterns */

/* The widest dialog in the app, and the one place a number is the whole
   reason. At the shared 480px this panel is a column: a fortnight of hours -
   373 of them - had 434px of chart to live in, which is a bar per pixel, day
   labels every third day and a wind line no arrow could sit on legibly. The
   weather timeline is the only drawing here whose x axis is *time*, so its
   readability is linear in the panel's width in a way no other dialog's is.

   `width`, not `max-width`, and `min(..., 100%)` - the same idiom as
   `.settings-panel.wide` below, for the same two reasons: `.settings-panel`
   sets `width: min(480px, 100%)`, so a `max-width` above it constrains
   nothing, and the `100%` is of the overlay's already-padded content box, so a
   narrow window gets the viewport less the overlay's 16px gutters rather than
   a horizontal scrollbar. Verified at a 492px viewport: the panel comes back
   460 wide and nothing overflows.

   Scoped to this overlay by id rather than by a modifier class on the panel:
   `.settings-overlay`/`.settings-panel` are shared by nine dialogs, and
   widening the class itself would take the settings, camera-settings, help,
   maintenance and naming dialogs with it. */
/* The vertical padding comes down with it. The shared 18px inside and 40px
   outside are a frame for a 480px dialog floating over the gallery; this one
   is the width of the window, and the question about it is whether the whole
   panel is on screen at once - where 80px of shadow is 80px of the answer. The
   horizontal 22px stays: the chart's width is measured off it. */
#patterns-overlay .settings-panel {
  width: min(1560px, 100%);
  padding: 14px 22px;
}
#patterns-overlay { padding: 12px 16px; }

/* The top half of the panel: provenance beside the hour-of-day histogram.

   The first attempt at this only widened the dialog, and a wider single column
   is the same panel with more air in it - the same vertical scroll, and two
   small charts stretched to the full width, where 24 bars across 1500px is a
   sparse smear rather than a histogram. Both blocks up here are short and
   narrow by nature, so they sit side by side and the width buys height back;
   the weather timeline below spans the whole dialog, because its x axis is
   time and it is the only thing in here that a pixel is worth something to.

   `minmax(0, 1fr)` rather than `1fr`: a grid item's automatic minimum is its
   content, so one wide child would push its column past half and shove the
   other one narrow instead of wrapping inside its own share. */
.pat-top {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 0.4rem 2rem;
  align-items: start;
}
.pat-top-col { min-width: 0; }

@media (max-width: 900px) {
  .topbar { align-items: flex-start; gap: 8px; flex-wrap: wrap; }
  .topbar h1 { white-space: nowrap; }
  .primary-nav { order: 3; width: 100%; margin: 0; overflow-x: auto; }
  .topbar-right { margin-left: auto; gap: 6px; }
  /* Back to one column. Two 430px columns is neither a provenance block nor a
     histogram, and the dialog is still the full width of a phone here. */
  .pat-top { grid-template-columns: minmax(0, 1fr); }
}

/* Prose gets a reading measure. Text is the one thing a wider dialog makes
   worse: a hint set across 1500px is a line nobody can track back to the start
   of, and this panel is mostly explanation. */
#patterns-overlay .hint,
#patterns-overlay .pat-ok,
#patterns-overlay .pat-warn,
#patterns-overlay .pat-summary { max-width: 100ch; }

/* These three are controls, not columns: `.settings-panel select` sets
   `width: 100%`, which at this dialog's width is a half-metre dropdown for
   a control offering a handful of fixed choices. `#pat-scorecard-days` and
   `#pat-survey-days` had the identical problem and were missed when this
   rule first landed for `#pat-camera` (tests L5, 2026-09-20 audit). */
#pat-camera, #pat-scorecard-days, #pat-survey-days { max-width: 22rem; }

.pat-provenance {
  border: 1px solid var(--hairline);
  border-radius: 10px;
  padding: 0.55rem 0.85rem;
  /* No bottom margin: it is the whole of its own grid column now, and the row
     gap already separates it from the timeline below. */
  margin-bottom: 0;
  background: var(--surface);
}
.pat-provenance h3 { margin: 0 0 0.4rem; font-size: 1rem; }
.pat-provenance p { margin: 0.3rem 0; }
.pat-ok { color: var(--status-good); margin: 0.35rem 0; }
.pat-warn {
  color: var(--status-critical);
  margin: 0.35rem 0;
  line-height: 1.45;
}
/* Three vendors across rather than stacked. Three lines of "SpyPoint | 951
   dated, peak 07:00" one under another is a list that was a list because the
   dialog was 480px wide; side by side they are one glance, and the height they
   were spending is height the weather timeline below needs.

   `auto-fit` rather than a fixed three and a media query: the column this sits
   in is half of a dialog that is itself `min(1560px, 100%)`, so its width is a
   continuum and not two cases. Three fit above about 690px of column, two
   below that, one on a phone - each of them at a width the longest of these
   lines ("1266 dated, 37 undated, peak 18:00") does not wrap at, which is the
   only thing the number is chosen for. */
.pat-rows {
  margin: 0.5rem 0 0.2rem;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
  gap: 0 0.9rem;
}
.pat-rows div {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.15rem 0;
  border-top: 1px solid var(--hairline);
  /* A notch down from 0.9rem: three of these across one column, and
     "1266 dated, 37 undated, peak 18:00" wrapping to a second line doubles the
     height of all three. */
  font-size: 0.84rem;
}
.pat-rows span { color: var(--ink-2); }
.pat-summary {
  color: var(--ink-2);
  font-size: 0.9rem;
  margin: 0.6rem 0 0.4rem;
}
/* 24 equal columns, so it stays readable on a phone through the tunnel.

   Capped rather than filling its column: 24 bars have a shape, and stretched
   across the whole of a 1560px dialog they are 60px wide and read as blocks.
   Roughly 25px a bar against a 140px plot is the proportion this was designed
   at, and the cap keeps it wherever the column ends up. The height came down
   from 160px with the same argument - a bar is a quantity, not a tower. */
.pat-chart {
  display: grid;
  grid-template-columns: repeat(24, 1fr);
  align-items: end;
  gap: 2px;
  height: 140px;
  max-width: 620px;
  margin-bottom: 0.3rem;
}
.pat-col { display: flex; flex-direction: column; height: 100%; }
.pat-stack {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: 0;
}
.pat-stack i { display: block; width: 100%; }
.pat-stack i.detection { background: var(--accent); }
.pat-stack i.empty { background: var(--muted); }
.pat-stack i.unanalyzed { background: var(--status-warning); }
.pat-hour {
  font-size: 0.65rem;
  color: var(--muted);
  text-align: center;
  padding-top: 2px;
}
.pat-legend {
  display: flex;
  gap: 0.9rem;
  font-size: 0.8rem;
  color: var(--ink-2);
  flex-wrap: wrap;
}
.pat-legend .swatch {
  display: inline-block;
  width: 0.7rem;
  height: 0.7rem;
  border-radius: 2px;
  margin-right: 0.3rem;
  vertical-align: -1px;
}
.pat-legend .swatch.detection { background: var(--accent); }
.pat-legend .swatch.empty { background: var(--muted); }
.pat-legend .swatch.unanalyzed { background: var(--status-warning); }

/* Per-vendor hour-of-day curves, for eyeballing whether the shapes agree.

   Capped for the reason the histogram above is: these are three 24-bin
   sparklines, and at the dialog's full width each bin is a 60px slab - three
   rows of blocks that no longer have a shape to compare. Around 460px leaves a
   bin about 16px wide against a 26px row, which is a sparkline. */
.pat-curves { margin: 0.5rem 0 0.2rem; max-width: 460px; }
.pat-curve { display: flex; align-items: center; gap: 0.5rem; margin: 0.18rem 0; }
.pat-curve-name {
  width: 4.6rem;
  flex: none;
  font-size: 0.78rem;
  color: var(--ink-2);
}
.pat-curve-bins {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(24, 1fr);
  align-items: end;
  gap: 1px;
  height: 26px;
}
.pat-curve-bins i { display: block; width: 100%; min-height: 1px; }
.pat-curve.sp .pat-curve-bins i { background: var(--accent); }
.pat-curve.gp .pat-curve-bins i { background: var(--status-warning); }
/* Stealth Cam. Missing until 2026-08-30, and the row had been drawing 765
   photos as nothing for as long as that vendor has existed: the bars were
   rendered with a height and no background, so the row read as "this camera
   has no photos" rather than as a stylesheet gap.
   `test_patterns_vendor_rows.py` fails if a fourth vendor's row arrives
   without a colour, because the symptom is indistinguishable from an empty
   camera and nobody looks for it in the CSS. */
.pat-curve.st .pat-curve-bins i { background: var(--status-good); }
.pat-curve-key { margin-top: 0.15rem; }

/* The detection calendar: one cell per local day, GitHub-contribution
   style. Same border-top treatment as `.pat-weather` below it, so the
   sections under the histogram read as one stack. */
.pat-calendar {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}
.pat-calendar h3 { margin: 0 0 0.15rem; font-size: 1rem; }
.pat-calendar-grid { overflow-x: auto; }
.pat-cal-cell { stroke: var(--border); stroke-width: 1; }
/* A day with no photos at all draws no fill - only the sheet's muted
   border, so it reads as "nothing happened" rather than as the lightest
   step on the same ramp every other level uses. */
.pat-cal-level-0 { fill: none; stroke: var(--muted); }
.pat-cal-level-1 { fill: color-mix(in srgb, var(--accent) 25%, var(--surface)); }
.pat-cal-level-2 { fill: color-mix(in srgb, var(--accent) 50%, var(--surface)); }
.pat-cal-level-3 { fill: color-mix(in srgb, var(--accent) 75%, var(--surface)); }
.pat-cal-level-4 { fill: var(--accent); }
/* The camera was awake and saw nothing - distinct from level 0 (no photos
   at all) and from level 1 (the lightest real detection day): the same
   lightest fill as level 1, at half strength, so a dead camera and a quiet
   one never draw the same cell. `fill-opacity` rather than `opacity`: this
   rect also carries `.pat-cal-cell`'s shared stroke, and a bare `opacity`
   fades that stroke along with the fill - every quiet cell drawing a
   fainter outline than the rest of the grid for no reason the legend
   explains. */
.pat-cal-level-quiet { fill: color-mix(in srgb, var(--accent) 25%, var(--surface)); fill-opacity: 0.5; }
.pat-calendar-legend {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 0.8rem;
  color: var(--ink-2);
  margin-top: 0.4rem;
  flex-wrap: wrap;
}
.pat-cal-key { display: inline-flex; align-items: center; gap: 0.3rem; }
.pat-cal-key i {
  display: inline-block;
  width: 0.7rem;
  height: 0.7rem;
  border-radius: 2px;
  border: 1px solid var(--border);
}
/* Same background/border split as the SVG fills above, so the legend
   swatches and the cells they explain never drift apart. */
.pat-cal-key i.pat-cal-level-0 { background: none; border-color: var(--muted); }
.pat-cal-key i.pat-cal-level-1 { background: color-mix(in srgb, var(--accent) 25%, var(--surface)); }
.pat-cal-key i.pat-cal-level-2 { background: color-mix(in srgb, var(--accent) 50%, var(--surface)); }
.pat-cal-key i.pat-cal-level-3 { background: color-mix(in srgb, var(--accent) 75%, var(--surface)); }
.pat-cal-key i.pat-cal-level-4 { background: var(--accent); }
/* Same fade-the-outline bug as `.pat-cal-level-quiet` above, on the `<i>`
   swatch's `border` instead of an SVG stroke - there is no `border-opacity`
   to reach for, so the 50% blend is baked into the background colour itself
   (half of a 25% accent mix against the same surface it sits on works out
   to a 12.5% mix), leaving the shared `.pat-cal-key i` border at full
   strength. */
.pat-cal-key i.pat-cal-level-quiet { background: color-mix(in srgb, var(--accent) 12.5%, var(--surface)); }
.pat-calendar-readout {
  margin: 0.3rem 0 0;
  font-size: 0.85rem;
  color: var(--ink-2);
  min-height: 1.1em;
}

/* Weather against activity.

   One shared x-axis for three drawings: the activity strip, the pressure /
   cloud / precipitation chart and the temperature / wind chart. They are
   separate SVGs stacked in one relatively-positioned box so a single cursor
   can cross all of them, and every one of them carries the same viewBox width
   and padding - a chart with its own geometry is a chart whose cursor points
   at the wrong hour.

   Each viewBox is exactly as tall as its CSS height. That is load-bearing:
   `preserveAspectRatio="none"` stretches x only, so a viewBox y equals a CSS
   pixel and the wind arrows - HTML, because a rotated glyph inside a
   non-uniformly scaled SVG shears - can be placed at the y the line was drawn
   at. Change a height here and the matching PATW_* constant in app.js has to
   change with it - `test_the_charts_are_as_tall_as_the_renderer_thinks_they_are`
   compares the two, because the symptom otherwise is every wind arrow sitting
   a few pixels off the line it describes. */
.pat-weather {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}
.pat-weather h3 { margin: 0 0 0.35rem; font-size: 1rem; }
.patw-plot { margin-top: 0.25rem; }
/* `pan-y` rather than `none`: a finger dragging sideways scrubs the chart, a
   finger dragging down still scrolls the panel. `none` would trap the page
   under the one element people put their thumb on. */
.patw-stack { position: relative; touch-action: pan-y; }
.patw-svg {
  display: block;
  width: 100%;
  background: var(--raised);
  border-radius: 6px;
}
.patw-strip { height: 64px; }
.patw-chart { height: 80px; }
/* The gap between drawings goes on the wrapper, never on the chart inside
   `.patw-area`. The wind arrows are placed from that area's top edge in the
   SVG's own y units, so a margin on the SVG itself would slide every arrow off
   the wind line by exactly that margin - four pixels of a silent, plausible
   lie about where the wind was. */
.patw-ribbon + .patw-chart, .patw-area { margin-top: 4px; }

/* The three outcomes, in the palette the histogram above already uses - one
   set of colours for "what a photo turned out to be", so a change lands in
   both charts. Empty and not-analysed are deliberately different hues rather
   than two weights of one: an Ollama backlog reading as quiet woods is the
   failure this panel exists to prevent. */
.patw-strip rect.detection { fill: var(--accent); }
.patw-strip rect.empty { fill: var(--muted); opacity: 0.65; }
.patw-strip rect.unanalyzed { fill: var(--status-warning); opacity: 0.65; }
.patw-base { stroke: var(--hairline); stroke-width: 1; vector-effect: non-scaling-stroke; }

/* The forecast. Shaded, never drawn under: nothing here predicts activity. */
.patw-future { fill: currentColor; opacity: 0.05; }
.patw-now {
  stroke: var(--ink-2);
  stroke-width: 1;
  opacity: 0.55;
  vector-effect: non-scaling-stroke;
}
/* The forecast outlook: three strengths of one hue, low along the strip's
   floor. `--status-good` is the only hue the strip does not already spend on
   an outcome - detections are `--accent`, empty is `--muted`, a backlog is
   `--status-warning` - so a band cannot be read as any of the three, and the
   fixed six-pixel height in `app.js` keeps it from being read as a quantity
   either. Ordered by opacity rather than by hue: they are one scale, and three
   colours would make them three categories. `busy` stops well short of the
   detection bars' full-strength `--accent`, because the loudest thing in this
   strip has to remain something that was measured. */
.patw-outlook { fill: var(--status-good); }
.patw-outlook.quiet { opacity: 0.22; }
.patw-outlook.typical { opacity: 0.4; }
.patw-outlook.busy { opacity: 0.6; }

/* Local midnights. Faint - a reading aid, not a series. */
.patw-day {
  stroke: var(--ink-2);
  stroke-width: 1;
  opacity: 0.16;
  vector-effect: non-scaling-stroke;
}

/* Solid strokes throughout, including the wind line. A dashed stroke inside a
   `preserveAspectRatio="none"` SVG is exactly where `vector-effect` support
   differs between engines - the power chart's crosshair had to leave the SVG
   over it - so the lines are told apart by hue and weight instead. */
.patw-pressure {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.patw-cloud { fill: var(--ink-2); opacity: 0.12; }
.patw-precip { fill: var(--accent); opacity: 0.5; }
/* `--muted` at 0.7 read as a suggestion of a bar against the cloud fill in
   dark theme, not a bar - both are greys, and the cloud area sits directly
   behind it. `--ink` is the one token that is unambiguously off-white in dark
   theme and unambiguously off-black in light theme, so full opacity reads as
   a mark rather than a shade in either one, and it shares no colour with the
   accent-blue precipitation bars beside it or the faint `--ink-2` cloud under
   both. */
.patw-snow { fill: var(--ink); opacity: 0.9; }
.patw-temp {
  fill: none;
  stroke: var(--status-critical);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.patw-wind {
  fill: none;
  stroke: var(--status-good);
  stroke-width: 1.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* The wind arrows sit over the chart in element coordinates, so they are never
   sheared by the horizontal stretch. `arrowAngle` has already added the 180
   degrees that turns a "wind from" bearing into "blowing towards". */
.patw-area { position: relative; }
.patw-arrow {
  position: absolute;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  line-height: 14px;
  text-align: center;
  font-size: 11px;
  color: var(--status-good);
}
.patw-arrow i { display: block; font-style: normal; }

/* The day ribbon. Moons on their own row above the dates: on one row the date
   either clips to its own day, which is unreadable at a fortnight's width, or
   overflows onto the next day's glyph. */
.patw-ribbon { position: relative; height: 30px; margin: 3px 0 1px; }
.patw-moons, .patw-dates { position: absolute; left: 0; right: 0; height: 14px; }
.patw-moons { top: 0; }
.patw-dates { top: 15px; }
.patw-moon {
  position: absolute;
  transform: translateX(-50%);
  font-style: normal;
  font-size: 10px;
  line-height: 14px;
  color: var(--ink-2);
}
.patw-date {
  position: absolute;
  white-space: nowrap;
  font-size: 0.62rem;
  font-weight: 600;
  line-height: 14px;
  color: var(--ink-2);
  padding-left: 3px;
  border-left: 1px solid var(--hairline);
}

/* One cursor for every chart, spanning the whole stack. Dashed as a border on
   an element rather than as an SVG stroke, for the reason the power chart's
   is: a dash pattern in a stretched coordinate system renders differently
   between engines. */
.patw-cursor {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 0;
  z-index: 2;
  border-left: 2px dashed var(--ink);
  opacity: 0.8;
  pointer-events: none;
}
.patw-cursor[hidden] { display: none; }

/* The readout is a line under the charts rather than a box beside the cursor.
   A box has to be positioned away from the pointer, kept inside the plot and
   shrunk on a phone - three rules the power chart needed and this does not,
   because the line is always in the same place and never covers the thing it
   describes. */
.patw-readout {
  /* A flex row rather than inline spans. The parts are built with no
     whitespace between them, so as inline boxes there is no break opportunity
     between two of them and the whole reading runs off the side of the panel
     - which it did, at 435px of chart. Flex items wrap on their own. */
  display: flex;
  flex-wrap: wrap;
  gap: 0 0.75rem;
  margin: 0.35rem 0 0.2rem;
  font-size: 0.78rem;
  color: var(--ink-2);
  line-height: 1.6;
  min-height: 1.6em;
}
.patw-read { white-space: nowrap; }
.patw-read b {
  color: var(--ink);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.patw-cap {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  font-size: 0.75rem;
  color: var(--ink-2);
  margin-top: 0.25rem;
}
.patw-key i {
  display: inline-block;
  width: 0.7rem;
  height: 0.7rem;
  border-radius: 2px;
  margin-right: 0.3rem;
  vertical-align: -1px;
}
.patw-key.detection i { background: var(--accent); }
.patw-key.empty i { background: var(--muted); opacity: 0.65; }
.patw-key.unanalyzed i { background: var(--status-warning); opacity: 0.65; }
.patw-key.pressure i { background: var(--ink); }
.patw-key.cloud i { background: var(--ink-2); opacity: 0.4; }
.patw-key.precip i { background: var(--accent); opacity: 0.5; }
.patw-key.temp i { background: var(--status-critical); }
.patw-key.wind i { background: var(--status-good); }
/* Wide and short rather than the square every other key uses: the outlook is
   the one entry describing a band along a floor, and a legend row carrying two
   green swatches wants the shape to tell them apart before the words do. Drawn
   at the middle tier, because the text names all three. */
.patw-key.outlook i {
  width: 1.4rem;
  height: 0.4rem;
  background: var(--status-good);
  opacity: 0.4;
}

/* What the outlook learned, under the legend. Quieter than the legend itself:
   it is provenance for the shading, not a key to it. */
.patw-learned {
  font-size: 0.72rem;
  color: var(--muted);
  margin-top: 0.25rem;
}

/* Today's sun and moon, directly under the panel's own title. A plain line
   rather than a card - it is one fact, not a collection of them - and it sits
   above the two-column layout below because it is true regardless of which
   camera that layout is filtered to. */
.pat-sun { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--ink-2); }
.pat-sun span[title] {
  text-decoration: underline dotted var(--muted);
  text-underline-offset: 2px;
}

/* The tier badge, shared by the daily and hourly cards - the same three
   opacities of `--status-good` the outlook bands on the chart use
   (`.patw-outlook.quiet/typical/busy`), so there is one palette for "how this
   hour compares to typical" no matter which drawing shows it. `color-mix`
   rather than `opacity` on the badge itself: an `opacity`'d badge also fades
   its own text, and the text is the one thing on it that has to stay legible
   at every tier. */
.patw-tier {
  display: inline-block;
  padding: 0.05rem 0.5rem;
  border-radius: 999px;
  font-size: 0.68rem;
  font-weight: 600;
  text-transform: capitalize;
  color: var(--status-good);
  background: color-mix(in srgb, var(--status-good) 22%, transparent);
}
.patw-tier.typical { background: color-mix(in srgb, var(--status-good) 40%, transparent); }
.patw-tier.busy { background: color-mix(in srgb, var(--status-good) 60%, transparent); }
.patw-tier-sm { padding: 0.02rem 0.35rem; font-size: 0.62rem; }

/* The daily outlook cards: a flex row that wraps rather than a grid, because
   up to five cards at a card's own natural width is exactly what wraps
   cleanly at both the wide dialog and a phone. */
.patw-days {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.4rem 0;
}
.patw-day-card {
  min-width: 9rem;
  padding: 0.4rem 0.55rem;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  background: var(--raised);
  font-size: 0.78rem;
}
.patw-day-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.4rem;
}
.patw-day-card-best { margin-top: 0.15rem; color: var(--ink-2); }
.patw-day-card-mult { margin-top: 0.1rem; font-variant-numeric: tabular-nums; }

/* The hourly forecast strip: a horizontal scroller, never a page-level one -
   `overflow-x: auto` is the whole point of the rule, and 36 cards at a
   readable width are wider than the dialog on purpose. */
.patw-hours {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 0.1rem 0.1rem 0.5rem;
  margin: 0.4rem 0;
}
.patw-hour-card {
  flex: 0 0 auto;
  width: 4.4rem;
  padding: 0.35rem 0.3rem;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  background: var(--raised);
  text-align: center;
  font-size: 0.72rem;
}
.patw-hour-label { color: var(--ink-2); }
.patw-hour-icon {
  display: block;
  width: 22px;
  height: 22px;
  margin: 0.2rem auto;
  color: var(--ink-2);
}
.patw-hour-icon-none { height: 22px; }
.patw-hour-temp { font-variant-numeric: tabular-nums; font-weight: 600; }
.patw-hour-wind {
  display: block;
  margin-top: 0.15rem;
  color: var(--status-good);
  white-space: nowrap;
}
/* Same glyph and the same rotation convention as the wind chart's own
   `.patw-arrow i` - `arrowAngle` has already turned a "wind from" bearing
   into "blowing towards" before either one ever sees the number. */
.patw-hour-wind i {
  display: inline-block;
  font-style: normal;
  margin-left: 0.15rem;
}
.patw-hour-card .patw-tier { margin-top: 0.2rem; }

/* --- Map page ---------------------------------------------------------- */

/* The map is the page, so it gets the height rather than the 20px-padded
   column the gallery wants. */
.map-page { max-width: none; padding: 0; }

.map-panel {
  display: grid;
  grid-template-columns: 1fr 260px;
  grid-template-rows: auto 1fr;
  gap: 0;
  height: calc(100vh - 56px);
}
/* Embed mode (`maps.js`, `?embed=1` - the map popup's iframe) hides the
   topbar outright rather than leaving an empty 56px band where it was, so
   the height budget above - sized to make room for it - has to give that
   space back. */
.map-embed .map-panel {
  height: 100vh;
  /* One column, not two. With the control strip and the side panel hidden
     (below), the grid still had its 260px side column, so the caption bar
     took the first cell and the map fell into the side column on an
     auto-height row: a sliver of tile top-right and nothing under it
     (2026-09-18, the third broken popup). Caption on top, map below. */
  grid-template-columns: 1fr;
  grid-template-rows: auto 1fr;
}
/* The phone rule further down gives the canvas 60vh of the page; inside the
   frame the map is the page, so it fills what the caption leaves. */
.map-embed .map-embed-caption { grid-row: 1; }
/* Pinned to row 2 by name. Auto-placement put the canvas in row 1 whenever
   the caption bar was not displayed (no note yet, or none for this page):
   the auto row, so `height: 100%` of nothing - a black frame. */
.map-embed .map-canvas { grid-row: 2; grid-column: 1; height: 100%; min-height: 0; }
.map-controls {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  /* Wrapping to a second line rather than squashing. Without this the layer
     labels break mid-phrase on a narrow window - "Radar 8:10 PM" stacked over
     three lines - which is the state a phone in the field opens in. */
  flex-wrap: wrap;
  gap: 8px 12px;
  padding: 8px 16px;
  border-bottom: 1px solid var(--hairline);
  background: var(--surface);
}
.map-canvas { height: 100%; background: var(--page); }
.locate-btn { display: block; width: 30px; height: 30px; line-height: 30px; text-align: center;
  font-size: 18px; color: #333; background: #fff; text-decoration: none; }
.locate-btn.on { color: #2f7cf6; font-weight: 700; }
.me-icon { position: relative; }
.me-dot { position: absolute; left: 6px; top: 6px; width: 14px; height: 14px; border-radius: 50%;
  background: #2f7cf6; border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.25); }
.me-arrow { position: absolute; left: 0; top: 0; width: 26px; height: 26px; transform-origin: 50% 50%; }
.me-arrow::before { content: ""; position: absolute; left: 8px; top: -6px; border-left: 5px solid transparent;
  border-right: 5px solid transparent; border-bottom: 11px solid #2f7cf6; }
.me-arrow[hidden] { display: none; }
.map-side {
  overflow-y: auto;
  padding: 12px 16px;
  border-left: 1px solid var(--hairline);
  background: var(--surface);
}
@media (max-width: 800px) {
  /* The side list stops being a sidebar and becomes a section under the map;
     a 260px column on a phone leaves the map too narrow to place anything. */
  .map-panel { grid-template-columns: 1fr; height: auto; }
  .map-canvas { height: 60vh; }
  .map-side { border-left: none; border-top: 1px solid var(--hairline); }
}

.map-empty { padding: 40px 20px; text-align: center; }
.map-hint { font-size: 13px; color: var(--muted); }

.style-switch { display: flex; gap: 0; }
.style-btn {
  border: 1px solid var(--border);
  background: var(--page);
  color: var(--ink-2);
  font: inherit;
  font-size: 13px;
  padding: 4px 12px;
  cursor: pointer;
}
.style-btn + .style-btn { border-left: none; }
.style-btn:first-child { border-radius: 6px 0 0 6px; }
.style-btn:last-child { border-radius: 0 6px 6px 0; }
.style-btn[aria-pressed="true"] { background: var(--accent); color: #fff; border-color: var(--accent); }

.side-head { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin: 16px 0 6px; }
.unplaced-list, .placed-list { list-style: none; margin: 0; padding: 0; }
.unplaced-list li { padding: 4px 0; font-size: 13px; }
/* A placed entry is three stacked lines, so consecutive ones need more than
   the 4px an unplaced one does or the names run into the note above them. */
.placed-list li { padding: 8px 0; font-size: 13px; }
.placed-list li + li { border-top: 1px solid var(--hairline); }
.place-btn {
  background: none; border: none; padding: 0; font: inherit; font-size: 13px;
  color: var(--accent); cursor: pointer; text-align: left;
}
.placed-name { display: block; }
.placed-note { display: block; font-size: 11px; color: var(--muted); }
.clear-btn {
  background: none; border: none; padding: 0; font: inherit; font-size: 11px;
  color: var(--muted); cursor: pointer; text-decoration: underline;
}

/* Leaflet's divIcon ships a white box with a border; the pins are drawn here
   instead, so that default has to go or every pin sits in a white square.

   This element carries NO transform and NO background of its own. Leaflet
   writes `transform: translate3d(...)` onto it as an inline style to place the
   marker, and an inline style beats a stylesheet - so a `rotate` declared here
   is simply discarded, leaving the teardrop's square corner where the
   un-rotated box put it: bottom-left, which reads as a tip pointing southwest.
   The shape lives one level in, on an element Leaflet never touches. */
.pin { background: none; border: none; }

/* A teardrop with a camera glyph and no name. The name label was legible at
   four pins and unreadable at eight overlapping ones - it lives in the popup
   and the side list now. */
.pin-body {
  display: flex; align-items: center; justify-content: center;
  width: 26px; height: 26px;
  background: var(--arc);
  /* Square corner bottom-left. Rotated -45 degrees about the centre, that
     corner swings to bottom-centre and the tip points straight down. The
     corner and the angle are a pair - either one alone points it elsewhere. */
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
/* Counter-rotated by exactly what the body turned, so the camera sits upright
   in a teardrop that does not, and `currentColor` so one glyph works on every
   swatch. */
.pin-glyph {
  width: 15px; height: 15px;
  transform: rotate(45deg);
  color: var(--surface);
  display: block;
}

/* The wedges showing roughly where a camera looks. Fill only just visible: a
   camera pointed two ways draws two of these, and at eight cameras they
   overlap - an opaque one hides the ground the map exists to show. */
.cone { fill: var(--arc); stroke: var(--arc); fill-opacity: 0.18; stroke-width: 1; }

/* A travel corridor: measured from the archive, not placed by hand like the
   boundary - drawn in the same accent used everywhere else on this page a
   line means "this is what the data shows," translucent so an overlapping
   line never reads as a border around the ground beneath it. */
.corridor { stroke: var(--accent); stroke-opacity: 0.55; fill: none; }
.corridor-arrow { width: 100%; height: 100%; fill: var(--accent); display: block; }

/* This week's activity: one bubble per placed camera, radius from
   `core.activityRadius` - same accent token as the corridor lines above, at
   low fill opacity so an overlapping bubble never hides a pin or the ground
   under it (`drawActivity` also keeps every bubble below the marker pane).
   Zero sightings draws hollow - a ring, not a dot, so "this camera exists
   and produced nothing" still reads as a placed camera rather than an
   absence indistinguishable from one that was never drawn at all. */
.activity-pin { stroke: var(--accent); stroke-opacity: 0.75; stroke-width: 1.5px;
  fill: var(--accent); fill-opacity: 0.22; }
.activity-pin-zero { fill: none; stroke-opacity: 0.5; }

.activity-tooltip {
  white-space: pre;
  max-width: calc(100vw - 24px);
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The corridor tooltip's timing sentence sits on its own line under the
   count/minutes line - a literal newline in the one text node it is built
   from (see `drawCorridors`). Leaflet's own `.leaflet-tooltip` rule is
   `white-space: nowrap`, which collapses that newline to a space same as any
   other whitespace; this overrides it for corridor tooltips only, so the
   line break renders without loosening wrapping anywhere else on the map.

   `pre`, not `pre-line`: Leaflet's tooltip pane has no width of its own, so
   `nowrap` is what sizes the box to its content in the first place, and
   `pre-line` still permits wrapping - measured at collapsing the tooltip to
   a 59x320 px column, one word per line, the moment the two-clause timing
   sentence made the text long enough to actually wrap. `pre` breaks only at
   the literal newline above and never wraps, sizing the box to its longest
   line - measured at a sane 329x50, two lines.

   `max-width` guards the same box against a phone's own width: `pre`'s
   longest-line sizing has no ceiling of its own, so a corridor between two
   long camera names could size a tooltip wider than a 390 px viewport and
   run off both edges of it. `overflow: hidden` with `text-overflow:
   ellipsis` on that capped width truncates the long line rather than
   letting it spill - measured at a real 390 px viewport with two long
   names, the box now stays inside the screen instead of extending past its
   right edge. */
.corridor-tooltip {
  white-space: pre;
  max-width: calc(100vw - 24px);
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The corridor popup opens on a tap, since phones have no hover for the
   tooltip above. `corridorPopupHtml` and `wireCorridorEvidence` (both
   maps_core.js, the second moved there from maps.js so a render fixture can
   call the real wiring) build its markup and wire the tap-to-enlarge strip
   below. Opposite whitespace choice from the tooltip on purpose: a popup's
   content box already has a width (`maxWidth: Math.min(340, window.innerWidth
   - 48)` at the `bindPopup` call site), so `pre` here would run a long
   camera name's sentence straight off the edge of it instead of wrapping.

   `pre-line` only ever matters for the PLAIN-TEXT fallback popup - no
   `pair.evidence`, an older server or a pair too new to have earned any -
   which is still exactly the two sentences joined by one literal newline
   they always were; every ruled-off block below is new with the evidence
   strip and inherits this rule same as any other descendant text would, but
   nothing in it depends on it. */
.corridor-popup .leaflet-popup-content { white-space: pre-line; }

/* The evidence strip's own summary line repeats the two sentences above the
   fallback text uses, each its own block so the line break is real markup
   rather than another `pre-line` newline. */
.corridor-popup-summary { margin-bottom: 6px; }
.corridor-popup-summary > div + div { margin-top: 2px; font-size: 12px; color: var(--muted); }

/* The strip: newest trip first, thumbnails side by side. Capped height with
   its own vertical scrollbar rather than letting a busy pair's popup grow
   past the screen - `max-height` is roughly three rows (see the module's own
   `EVIDENCE_TRIPS` cap; three is `.corridor-thumb`'s own height, not a count
   guarantee) with `overflow-y` the only axis that scrolls, so a long camera
   name still cannot force the popup to scroll sideways. */
.corridor-evidence-strip {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 260px;
  overflow-y: auto;
  overflow-x: hidden;
}
.corridor-trip { display: flex; flex-direction: column; gap: 3px; }
.corridor-trip-thumbs { display: flex; align-items: center; gap: 6px; }

/* ~120px wide per the brief, side by side with the arrow between them - a
   real glyph (`→`), never "->" text, both here and in the caption below. */
.corridor-thumb {
  flex: 0 0 auto;
  width: 120px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: none;
  cursor: pointer;
  overflow: hidden;
}
.corridor-thumb img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; }
.corridor-thumb:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.corridor-trip-arrow { flex: 0 0 auto; font-size: 16px; color: var(--muted); }
.corridor-trip-caption { font-size: 11px; color: var(--muted); }
.corridor-evidence-more { margin-top: 4px; font-size: 11px; color: var(--muted); }

/* The enlarged frame, swapped in for the strip on a tap
   (`wireCorridorEvidence`) - `[hidden]` is declared explicitly on both
   halves rather than left to the browser default, because an author rule
   setting `display` on an element always wins over the UA stylesheet's own
   `[hidden]` rule regardless of specificity; leaving it implicit here is
   exactly the trap that once left two states painted at once on this page's
   select-mode swap. */
.corridor-evidence-strip-wrap[hidden] { display: none; }
.corridor-evidence-detail { display: flex; flex-direction: column; gap: 6px; }
.corridor-evidence-detail[hidden] { display: none; }
.corridor-evidence-back {
  align-self: flex-start;
  padding: 3px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: none;
  color: var(--ink-2);
  font-size: 12px;
  cursor: pointer;
}
.corridor-evidence-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* `object-fit: contain`, not `cover`: this is the full frame the owner taps
   in to verify, and cropping it the way a thumbnail is cropped would hide
   the very edges he is trying to check. */
.corridor-detail-img { width: 100%; max-height: 220px; object-fit: contain; border-radius: 4px; background: var(--hairline); }
.corridor-detail-caption { font-size: 12px; color: var(--muted); }

/* One animal's route (`?animal=<id>`, drawn by `loadAnimalRoute`/
   `drawAnimalRoute` in maps.js): dashed rather than the corridor's solid
   line, so a highlighted deer's path never reads as a second, measured
   corridor - it is one animal's own history, not an inferred travel pattern. */
.animal-route { stroke: var(--accent); stroke-width: 3; stroke-dasharray: 8 6; fill: none; }
.animal-route-marker .pin-body {
  box-shadow: 0 0 0 3px var(--accent), 0 1px 3px rgba(0, 0, 0, 0.4);
}

/* A species' own highlighted cameras (`?species=<name>`, drawn by
   `loadSpeciesHighlight`/`drawSpeciesHighlight` in maps.js): a different
   colour from `.animal-route-marker` so the two rings cannot be mistaken for
   each other if both query parameters were ever present at once, and no
   line at all - a species is many animals, not one with a path to draw. */
.predator-marker .pin-body {
  box-shadow: 0 0 0 3px var(--status-critical), 0 1px 3px rgba(0, 0, 0, 0.4);
}

/* A stand's downwind scent cone: the same translucent wedge treatment as a
   camera's facing cone (`.cone` above), tinted by the current hour's
   verdict instead of a picked colour - the one shape on this page where a
   colour is reporting a fact rather than a preference. Calm gets the
   caution tint, not the good one: scent still drifts on a calm hour, it is
   just not aimed anywhere in particular. */
.scent-cone { fill: var(--scent); stroke: var(--scent); fill-opacity: 0.22; stroke-width: 1; }
.scent-cone.stand-ok { --scent: var(--status-good); }
.scent-cone.stand-bad { --scent: var(--status-critical); }
.scent-cone.stand-calm { --scent: var(--status-warning); }

/* A settled position and a vendor guess are drawn differently on purpose: the
   vendor's coordinates have been seen to drift while the cameras sat still,
   and one style for both would assert the guess is a fact.

   The distinction lives in the *outline*, never the fill, because the fill is
   the owner's colour now - a palette choice must not be able to erase where a
   position came from. */
.pin-placed .pin-body { border: 2px solid var(--surface); }
.pin-vendor .pin-body { border: 2px dashed var(--ink); }
.cone-settled { stroke-dasharray: none; }
.cone-provisional { stroke-dasharray: 4 3; }

/* The palette. Each id sets one custom property and nothing else: the pin body
   is nested inside the element that carries the class, the wedge and the
   swatch are the element itself, and a custom property is the one declaration
   that reaches all three. Ids only ever arrive as class names - a colour value
   never travels as text - so adding one here is the whole cost of adding one. */
.arc-blue { --arc: var(--accent); }
.arc-amber { --arc: #e8951f; }
.arc-green { --arc: #2f9e44; }
.arc-violet { --arc: #8a52d6; }
.arc-red { --arc: #d03b3b; }
.arc-teal { --arc: #0d9488; }

/* --- Leaflet's own chrome, on this app's surfaces ----------------------- */

/* Leaflet ships a white popup with dark text and a white tip, which on a dark
   page is a floating sheet of paper. These are the only rules that reach into
   its markup, and they set colours only - the geometry is Leaflet's, so an
   upgrade does not have to be re-fought here. */
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.leaflet-popup-close-button { color: var(--muted) !important; }
.leaflet-popup-close-button:hover { color: var(--ink) !important; }

/* The controls sit on the same map and come from the same stylesheet; leaving
   them white would be the same complaint one pass later. */
.leaflet-bar a,
.leaflet-bar a:hover {
  background: var(--surface);
  color: var(--ink);
  border-bottom-color: var(--hairline);
}
.leaflet-bar a:hover { background: var(--page); }
.leaflet-control-attribution {
  background: var(--surface) !important;
  color: var(--muted);
}
.leaflet-control-attribution a { color: var(--ink-2); }

/* --- the stand wind panel -------------------------------------------------

   The toggle button reuses the same styled-button rule every other layer
   control does - only the body below it is new. The panel is the direct
   flex child of the control bar's own wrapping row; the body nested inside
   it is not. So `flex-basis: 100%` belongs on the panel, not the body -
   set there, it is what actually puts the panel on its own line under the
   layer box, without a fixed width that would either crowd a narrow window
   or waste a wide one. Whichever of the row's later children still had room
   beside the panel - the wind readout, then the map hint - wrap down onto
   the line after it too, for the same reason: nothing fits beside a child
   that alone claims the full row width.

   (Deliberately not spelling any of these classes out with its leading dot
   in this comment: `tests/test_places.py`'s `body(selector)` finds a rule
   by the *first* occurrence of the selector text in the file, and a
   comment sitting above the real rule would shadow it and read this
   block's braces instead.) */
.standwind-panel { flex-basis: 100%; }
.standwind-body {
  display: flex; flex-wrap: wrap; gap: 6px 24px;
  margin-top: 4px; font-size: 12px; color: var(--ink-2);
}
/* Without this, the `hidden` attribute set in maps.js loses to the plain
   class rule's `display: flex` above (same trap documented at
   the boot-error and select-actions hidden rules) and the panel can
   never actually be collapsed. */
.standwind-body[hidden] { display: none !important; }
.standwind-stand h4 { margin: 0 0 4px; font-size: 13px; color: var(--ink); }
.standwind-stand ul { margin: 0; padding-left: 16px; }

/* --- the wind readout --------------------------------------------------- */

.wind-readout {
  display: flex; align-items: center; gap: 6px;
  padding-left: 14px;
  border-left: 1px solid var(--hairline);
  font-size: 13px; color: var(--ink-2);
  white-space: nowrap;
}
.wind-arrow { width: 17px; height: 17px; color: var(--accent); display: block; flex: none; }
/* Freshness is part of the reading, not a footnote: the ingest samples hourly
   and the newest hour is a forecast until reanalysis revises it, so an
   unqualified figure would claim to be live. */
.wind-detail { font-size: 11px; color: var(--muted); }
.wind-missing { color: var(--muted); }

/* --- who is holding a dial ---------------------------------------------- */

/* The settings dialog's copy of the hold sentences. The card has no dial rows
   at all now - a sentence under each held dial doubled the card's height, and
   a row without the sentence said a number with no way to ask why - so this is
   the only place the explanation is readable rather than hovered, which
   matters on a phone where hover does not exist. */
.hold-note { display: block; margin: 6px 0 0; font-size: 11px;
  color: var(--muted); font-weight: normal; }

/* --- burst stacks -------------------------------------------------------- */

.stack-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px;
  color: var(--ink-2); white-space: nowrap; }
.stack-toggle input { margin: 0; }

/* The three weather filter selects (Wind from / Temperature / Pressure) -
   given their own class instead of reusing `.stack-toggle` (page M1): that
   class is shaped for a checkbox and its label, and three bare native
   selects wearing it sat among seven pill dropdowns with no common width,
   so the More-filters row broke mid-group at 1280 ("Wind from" stranding
   "Stack bursts" on its own line) and wrapped raggedly at 492. Same idiom
   `.layer-select` (maps.html) already uses for its own picker: sized and
   coloured to read as one group with its neighbours rather than a stray
   form control. `.filters select` (below) still supplies the shared
   background/border/padding every select in this row gets. */
.weather-select { display: flex; align-items: center; gap: 6px; font-size: 13px;
  color: var(--ink-2); white-space: nowrap; }
.weather-select select { min-width: 112px; }

/* A hint sentence, not a pill: `flex-basis: 100%` puts it on its own line in
   the wrapping row (the same fix `#corridor-note` uses on maps.html) so it
   never crowds a select sideways instead of wrapping under it. */
.weather-hint { flex-basis: 100%; font-size: 12px; }

/* A stack occupies one cell of the day grid and its lead fills it, so a
   stacked gallery has the same rhythm as an unstacked one. A `.tile` is the
   same wrapper around a single photo: it exists only so the naming control has
   somewhere to sit that is not inside the card's <button>. */
/* No text selection while a card is being moved.

   Nothing suppressed it, so the browser read press-and-move as "select text"
   and dragged a selection across every card the pointer crossed.

   Scoped to the drag rather than applied to the row at rest: copying a camera
   name off a card is a reasonable thing to do, and a row that can never be
   selected takes that away to fix a problem that only exists mid-drag.

   This is half the fix. It stops a highlight being *drawn*; it cannot undo one
   the browser has already begun, which is what the `preventDefault` on the
   press does. Neither covers the other. */
.camera-row.reordering,
.camera-row.reordering .camera-card,
.camera-card.lifted {
  -webkit-user-select: none;
  user-select: none;
}

.stack, .tile { position: relative; }
.stack .photo-card, .tile .photo-card { width: 100%; }

.stack-badge {
  position: absolute; top: 6px; right: 6px;
  background: rgba(0, 0, 0, 0.72); color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 999px;
  font: inherit; font-size: 12px; font-weight: 600;
  padding: 2px 8px; cursor: pointer;
}
.stack-badge:hover { background: var(--accent); border-color: var(--accent); }

/* The span, not a single time. With rolling gaps an all-night feeder visit is
   legitimately one stack, and one timestamp would make that look like a
   moment. */
.stack-span {
  /* Over the image, opposite the count - not at the bottom of the card. The
     card's lower strip is the camera name, the time and the detection chips,
     and a span pinned there covered all three. */
  position: absolute; left: 6px; top: 6px;
  background: rgba(0, 0, 0, 0.72); color: #fff;
  border-radius: 4px; padding: 1px 6px; font-size: 11px;
  pointer-events: none;
}

/* Expanded, a stack takes the full row and lays its photos out inside, so the
   ones that belong together stay together rather than dispersing into the
   day's grid. */
.stack.open {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 10px;
  padding: 10px;
  border: 1px solid var(--accent);
  border-radius: 10px;
}
.stack-collapse {
  grid-column: 1 / -1;
  justify-self: start;
  background: none; border: 1px solid var(--border); border-radius: 6px;
  color: var(--ink-2); font: inherit; font-size: 12px;
  padding: 3px 10px; cursor: pointer;
}
.stack-collapse:hover { color: var(--ink); }

/* L13: the Animals list's retired fold used to borrow `.stack-collapse`
   above, which carries `grid-column: 1 / -1; justify-self: start;` for its
   real job inside a burst's CSS grid (`.day-grid`) - properties with no
   meaning outside one. This button sits directly in `.settings-panel`, a
   plain block container, not a grid or a flex box, so its own class keeps
   only what actually does something there: a plain `<button>` is already
   sized to its content and left-aligned with no positioning rule at all. */
.animals-roster-retired-toggle {
  background: none; border: 1px solid var(--border); border-radius: 6px;
  color: var(--ink-2); font: inherit; font-size: 12px;
  padding: 3px 10px; cursor: pointer;
}
.animals-roster-retired-toggle:hover { color: var(--ink); }

/* --- the layer controls and the property outline ------------------------ */

/* Separated from the style switcher rather than merely spaced from it: at one
   gap the two groups read as a single row of seven controls, and "Hybrid" and
   "Boundary" look like alternatives to each other. */
.map-layers {
  display: flex; align-items: center; gap: 8px;
  /* Wraps. This said `nowrap` first, on the reasoning that a group breaking
     across lines has come apart - which is true and beside the point: at a
     phone width the group ran off the edge and Cancel, Delete and the radar
     toggle were not merely ugly, they were unreachable. A group split over
     two lines is still a group; a control past the edge is gone.

     What has to hold is that no *label* breaks mid-phrase, and that is on the
     items themselves. */
  flex-wrap: wrap;
  padding-left: 14px; margin-left: 2px;
  border-left: 1px solid var(--hairline);
}
.layer-toggle {
  display: flex; align-items: center; gap: 5px;
  font-size: 13px; color: var(--ink-2); white-space: nowrap;
}
.layer-toggle input { margin: 0; }
.layer-toggle input:disabled + span { color: var(--muted); }
/* There was a `.layer-stale` rule here, marking a radar frame several
   publishing cycles old. It went with RainViewer: the NEXRAD composite
   publishes no frame times at all, so there is nothing to compare against and
   an amber label would be a claim about freshness this app cannot make. */
.layer-btn {
  border: 1px solid var(--border);
  background: var(--page);
  color: var(--ink-2);
  font: inherit; font-size: 13px;
  padding: 4px 10px; border-radius: 6px; cursor: pointer;
  /* The toggles beside these got `nowrap` and the buttons did not, so at a
     narrow window "Redraw on map" broke across two lines and stood a row
     taller than its neighbours. The bar wraps between groups; nothing inside
     one breaks mid-phrase. */
  white-space: nowrap;
}
.layer-btn:hover { color: var(--ink); }

/* The corridor window picker - a select rather than a button row, since it
   is one choice among three rather than an on/off. Sized and coloured like
   the buttons beside it so the layer box reads as one group. */
.layer-select {
  border: 1px solid var(--border);
  background: var(--page);
  color: var(--ink-2);
  font: inherit; font-size: 13px;
  padding: 3px 6px; border-radius: 6px;
}

/* `#corridor-note` is a flex sibling inside `.map-layers` (same wrapping row
   the layer toggles and this select live in), not a block of its own - so
   without a claim on the row's full width, a long note ("Corridors could not
   be loaded.") pushes every control after it, the Stand winds checkbox
   included, sideways along the same row instead of moving it. Same fix as
   `.standwind-panel`'s own comment above: `flex-basis: 100%` puts it on its
   own line, and nothing later in the row has room to crowd beside it. */
#corridor-note { flex-basis: 100%; }

/* Same fix, same reason, for the activity layer's own note (tests M5/M6,
   2026-09-21 audit): a failed or refused activity report used to be
   completely silent, so the owner could not tell "no deer this window" from
   "the request failed" or "a label moved in the category editor". */
#activity-note { flex-basis: 100%; }

/* --- embed mode: the map popup index.html opens inside a small iframe ---

   `.map-embed` (set once at boot by `maps.js`, from `?embed=1`) collapses
   the layer controls to what still makes sense inside a small frame. The
   plain on/off toggles beside these (`.layer-toggle` - Boundary, Waypoints,
   Travel corridors, Stand winds) stay: they fit on one line even at 390px,
   and a popup with none of its layers reachable at all would read as broken
   rather than as a small view of the same map. What goes is everything that
   EDITS the map rather than shows it - dropping a waypoint or drawing a
   boundary mid-drag would strand the owner in a flow they cannot see past
   the frame's own edge to finish - plus the two heavier panels (radar,
   stand winds for the next three days) a quick "where are this animal's
   cameras" glance has no use for. */
.map-embed .map-controls,
.map-embed #standwind-panel,
.map-embed .map-side { display: none; }
/* The one line kept from the toolbar, moved out of it at boot by maps.js:
   "coyote: 25 sightings on 4 cameras" (or the animal's route note). */
.map-embed-caption {
  padding: 8px 12px;
  font-size: 13px;
  color: var(--ink-2);
  border-bottom: 1px solid var(--hairline);
}
.map-embed-caption .hint[hidden] { display: none; }
.map-embed-caption .hint { display: inline; }
/* No note yet (or none for this page): no empty band above the map. */
.map-embed-caption:not(:has(.hint:not([hidden]))) { display: none; }

/* The traced outline. A fill light enough to read the ground through - the
   whole point of tracing it on satellite imagery is seeing what is inside. */
.boundary { fill: var(--accent); fill-opacity: 0.08; stroke: var(--accent); stroke-width: 2; }
/* While it is being drawn: dashed, so a half-finished shape does not look
   like a saved one. */
.boundary-draft { stroke-dasharray: 6 4; fill-opacity: 0.04; }
.boundary-corner {
  background: var(--surface);
  border: 2px solid var(--accent);
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}

/* --- the boundary coordinate editor ------------------------------------- */

/* The primary way in. Ten corners is a lot of clicking to get exactly right,
   and a number typed twice is the same number - a click repeated is not. */
.corner-rows { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.corner-row { display: grid; grid-template-columns: 16px 1fr 1fr 22px; gap: 4px;
  align-items: center; }
.corner-number { font-size: 11px; color: var(--muted); text-align: right; }
/* Two classes, to outrank `.settings-panel input` - the dialog shell makes
   every input a full-width block, which would stack the latitude above the
   longitude instead of beside it. */
.corner-row .corner-input {
  /* Without `min-width: 0` a number input keeps its ~20-character intrinsic
     width and refuses to shrink into a `1fr` track, which pushes the whole
     side panel wider than its column. */
  min-width: 0;
  background: var(--page); color: var(--ink);
  border: 1px solid var(--border); border-radius: 5px;
  padding: 3px 5px; font: inherit; font-size: 12px;
}
.corner-row .corner-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.corner-remove {
  background: none; border: 1px solid var(--border); border-radius: 5px;
  color: var(--muted); font: inherit; font-size: 12px; line-height: 1;
  padding: 3px 0; cursor: pointer;
}
.corner-remove:hover { color: var(--ink); }
.corner-actions { display: flex; gap: 6px; margin-top: 8px; }
.corner-actions .layer-btn { flex: 1; }
/* Beside the rows, not in the map hint: the mistake is in one of these boxes
   and that is where the eye already is. */
.corner-error { margin: 6px 0 0; font-size: 12px; color: var(--status-critical); }

/* --- the pin popup ----------------------------------------------------- */

/* Four labelled sections, one spacing rhythm, one control size. Everything in
   here was already present and read as a run of unrelated widgets - a compass
   grid with an unexplained "Add" beside it, and a row of colours with no
   heading at all. */
/* A fixed width, so the layout is one thing rather than a negotiation with
   whatever Leaflet's `maxWidth` happens to be. Everything inside is sized to
   fit it, and the `nowrap` stats grow the *gap* rather than the popup. */
.pin-popup { width: 250px; font-size: 13px; }
.pop-name { margin: 0; font-size: 14px; }
.pop-model { margin: 0 0 8px; font-size: 11px; color: var(--muted); }

/* One grid for the whole list, not one per row.

   `dt` and `dd` alternate as direct children, so the label column is sized
   once from the widest label and every value starts at the same x-position.
   A wrapper element per row gives each row its own column widths, which is
   what shipped: Battery's value at one x, Signal's at another, Photos' at a
   third.

   `max-content` on the labels and `nowrap` on both cells: the popup gets
   wider before anything breaks onto a second line. "Last seen" wrapped, and
   so did the date beside it. */
.pop-stats {
  margin: 0 0 8px;
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 16px;
  row-gap: 3px;
  align-items: baseline;
}
.pop-stats dt { color: var(--muted); white-space: nowrap; }
.pop-stats dd { margin: 0; text-align: right; white-space: nowrap; }
.pop-note { margin: 0 0 8px; font-size: 11px; color: var(--muted); }

.pop-gallery { display: inline-block; margin-bottom: 4px; font-size: 12px; color: var(--accent); }

.pop-section { padding-top: 8px; margin-top: 8px; border-top: 1px solid var(--hairline); }
.pop-heading {
  margin: 0 0 6px; font-size: 11px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
}
.pop-form { display: flex; flex-direction: column; gap: 6px; }

/* One field shape for every labelled control, so a coordinate box, the degrees
   box and the reach slider line up instead of each finding their own width. */
.pop-field { display: grid; grid-template-columns: 96px 1fr; align-items: center; gap: 8px; }
.pop-field > span { font-size: 12px; color: var(--ink-2); }
.pop-reading { margin: 0; font-size: 12px; color: var(--ink-2); }

/* And one button row, so Save, Add and Clear are the same width instead of
   three sizes decided by their own text. */
.pop-buttons { display: flex; gap: 6px; }
.pop-buttons button { flex: 1; }

.pop-compass { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; }
.compass-btn {
  font: inherit; font-size: 11px; padding: 3px 0; cursor: pointer;
  border: 1px solid var(--border); background: var(--page); color: var(--ink-2);
}
.compass-btn[aria-pressed="true"] { background: var(--accent); color: #fff; border-color: var(--accent); }

.pop-swatches { display: flex; gap: 6px; }
.swatch {
  width: 18px; height: 18px; border-radius: 50%; cursor: pointer;
  border: 2px solid transparent; padding: 0;
  background: var(--arc);
}
.swatch[aria-pressed="true"] { border-color: var(--ink); }

/* The popup's own controls inherit nothing from the settings panel, so a white
   input on a dark popup is the same bug one level down. */
.pin-popup input,
.pin-popup button {
  background: var(--page);
  color: var(--ink);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 4px 8px;
  font: inherit;
  font-size: 12px;
  width: 100%;
}
.pin-popup button { cursor: pointer; }
/* Without this a number input keeps its default intrinsic width - about twenty
   characters - and a grid or flex track sized `1fr` still refuses to shrink
   below it. That is what pushed the whole popup wider than its container and
   sent every value off the right-hand edge. */
.pin-popup input,
.pin-popup button { min-width: 0; }
.pin-popup input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
/* The swatches are their own colour and the compass keeps its grid sizing, so
   both opt out of the shared box above rather than being restyled by it. */
/* The shared input/button rule above sets a background, and this one is more
   specific but did not re-declare it - so every swatch rendered in the page
   colour and the palette was six identical dark circles. */
.pin-popup .swatch {
  width: 18px; border-radius: 50%; padding: 0;
  border: 2px solid transparent; background: var(--arc);
}
.pin-popup .swatch[aria-pressed="true"] { border-color: var(--ink); }
.pin-popup .compass-btn { border-radius: 0; padding: 3px 0; }
.pin-popup .pop-range { padding: 0; border: none; background: none; }

/* --- what the automation is currently overriding -------------------------- */

.overrides { margin: 0 0 14px; font-size: 12px; }
.overrides .ov-none { color: var(--muted); margin: 0; }
.overrides table { width: 100%; border-collapse: collapse; }
.overrides caption { text-align: left; color: var(--ink-2); font-weight: 600;
  padding: 0 0 6px; }
.overrides th { text-align: left; font-weight: normal; color: var(--muted);
  padding: 3px 10px 3px 0; white-space: nowrap; vertical-align: baseline; }
.overrides td { padding: 3px 0; vertical-align: baseline; }
.overrides b { color: var(--status-warning); font-weight: 600; }
.overrides .ov-was { color: var(--muted); }

/* A control holding a value the automation set, not the one you saved. The
   same accent the summary block uses, so the two halves of one answer look
   like one answer. */
select.is-overridden, input.is-overridden { color: var(--status-warning); }
.ov-flag { display: block; font-size: 11px; font-weight: 600;
  color: var(--status-warning); margin: 2px 0 4px; }

/* --- settings tabs -------------------------------------------------------- */

.share-create { display: flex; gap: 8px; align-items: end; }
.share-create label { flex: 1; margin-top: 0; }
.share-list { margin-top: 14px; }
.share-row { display: flex; justify-content: space-between; align-items: center;
  gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
.share-url { overflow-wrap: anywhere; }

/* Wider than the other dialogs: this one holds credentials, URLs and tokens,
   which wrap badly at 480px, and the tab row needs to fit on one line.
   `width`, not `max-width` - `.settings-panel` sets `width: min(480px, 100%)`,
   and a max-width above it constrains nothing. */
.settings-panel.wide { width: min(820px, 100%); }
.tab-bar { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 16px;
  border-bottom: 1px solid var(--hairline); }
.tab-btn { appearance: none; background: none; border: 0;
  border-bottom: 2px solid transparent; margin-bottom: -1px;
  padding: 8px 12px; font: inherit; font-size: 13px; color: var(--ink-2);
  cursor: pointer; border-radius: 6px 6px 0 0; }
.tab-btn:hover { color: var(--ink); background: var(--surface); }
.tab-btn[aria-selected="true"] { color: var(--ink); font-weight: 600;
  border-bottom-color: var(--accent); }
.tab-panel > fieldset:first-child { margin-top: 0; }

/* The burst's own answer, on the collapsed stack. Sits opposite the count so
   the two pieces of information about a visit - how many frames, and what was
   in them - are on one line and do not fight. */
.stack-label {
  position: absolute;
  top: 6px; left: 6px;
  max-width: calc(100% - 58px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink);
  background: color-mix(in srgb, var(--surface) 82%, transparent);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 2px 8px;
  pointer-events: none;
}

/* --- named individuals on a burst ---------------------------------------- */

.stack-names {
  position: absolute;
  left: 6px; bottom: 6px;
  display: flex;
  align-items: center;
  gap: 4px;
  max-width: calc(100% - 12px);
  flex-wrap: wrap;
}
.animal-pill {
  font-size: 11px;
  font-weight: 600;
  color: var(--ink);
  background: color-mix(in srgb, var(--accent) 20%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
  border-radius: 999px;
  padding: 1px 8px;
}
/* Quiet until the stack is hovered: naming is an occasional act, and a button
   on every visit competes with the photographs for attention. It stays
   reachable from the keyboard regardless. */
.stack-tag {
  width: 20px; height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  font: inherit;
  font-size: 13px;
  line-height: 1;
  color: var(--ink-2);
  background: color-mix(in srgb, var(--surface) 85%, transparent);
  border: 1px solid var(--border);
  border-radius: 999px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 120ms ease, color 120ms ease;
}
.stack:hover .stack-tag,
.tile:hover .stack-tag,
.stack-tag:focus-visible { opacity: 1; }
.stack-tag:hover { color: var(--ink); }

/* The predator grid's own caption (`app/predators.py`'s `sighting`, folded
   by `animalCore.sightingCaption`) - top-left, NOT the bottom-left corner
   `.stack-names`/`.animal-pill` use: that corner is only clear on a card
   whose `.labels` row reserves 28px under it (see the comment above that
   rule), and `photoChips` renders no `.labels` element at all - reserving
   nothing - for a photo `effective_labels` is still null on, i.e. one the
   vision model has not analysed yet. That is the ORDINARY case for a
   detector sighting: `detector.py` scores photos independently of `ai.py`
   and does not wait for it, so a render caught this pill sitting directly
   on top of the camera-name/time caption the first time it was tried there.
   Top-left has no such row to collide with - `.stack-badge` is the only
   other thing ever painted in a tile's corner, and it sits top-RIGHT, on a
   stack this grid never forms (photos only, never bursts). */
.predator-sighting-tag {
  position: absolute; left: 6px; top: 6px;
  font-size: 11px; font-weight: 600;
  color: var(--ink);
  background: color-mix(in srgb, var(--accent) 20%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
  border-radius: 999px;
  padding: 1px 8px;
}

/* "2 of 3" - which frame of its own multi-frame sighting a card is. Top
   RIGHT, not left: `.predator-sighting-tag` already owns the top-left
   corner, and `photoCard`'s own `.meta` strip owns the bottom, so the only
   free corner left on the card is this one. */
.predator-frame-tag {
  position: absolute; right: 6px; top: 6px;
  font-size: 11px; font-weight: 600;
  color: var(--ink);
  background: color-mix(in srgb, var(--border) 60%, var(--surface));
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 1px 8px;
}

/* --- the naming picker ---------------------------------------------------- */

.settings-panel.narrow { width: min(380px, 100%); }

/* Capped and scrollable rather than growing with the herd: the "Add" field has
   to stay reachable without scrolling past every deer on the property, and on a
   phone in landscape the whole dialog is barely taller than this. */
.animal-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: min(46vh, 320px);
  overflow-y: auto;
  margin: 10px 0;
}
.animal-row {
  display: grid;
  grid-template-columns: 18px 1fr auto;
  align-items: center;
  gap: 8px;
  width: 100%;
  /* 44px, because this is tapped with a thumb from a truck seat. A row that is
     comfortable with a mouse is a coin toss with a thumb. */
  min-height: 44px;
  padding: 6px 10px;
  font: inherit;
  font-size: 14px;
  text-align: left;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: 10px;
  cursor: pointer;
}
.animal-row:hover { border-color: var(--border); }
.animal-row.on {
  background: color-mix(in srgb, var(--accent) 14%, var(--surface));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.animal-row-tick { color: var(--accent); font-weight: 700; }
/* Wraps rather than truncating: a name is the thing being chosen, and
   "The three-legged doe by the..." is not a choice he can make. */
.animal-row-name { font-weight: 600; min-width: 0; }
.animal-row-seen { color: var(--muted); font-size: 12px; white-space: nowrap; }

.animal-new { display: flex; gap: 6px; }
.animal-new input {
  flex: 1;
  min-width: 0;
  /* 16px: anything smaller and iOS Safari zooms the page on focus, which on a
     phone leaves the dialog off-screen mid-typing. */
  font-size: 16px;
  padding: 8px 10px;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--border);
  border-radius: 10px;
}
.animal-add {
  font: inherit;
  font-weight: 600;
  padding: 8px 14px;
  color: #fff;
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 10px;
  cursor: pointer;
}
.animal-error { color: var(--status-critical); font-size: 13px; margin: 8px 0 0; }

/* --- the Animals list and one animal's own page --------------------------- */

.animals-roster {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: min(46vh, 320px);
  overflow-y: auto;
  margin: 10px 0;
}
.animals-roster-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 44px;
  padding: 6px 10px;
  font: inherit;
  font-size: 14px;
  text-align: left;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: 10px;
  cursor: pointer;
}
.animals-roster-row:hover { border-color: var(--border); }
.animals-roster-row-name { font-weight: 600; min-width: 0; }
.animals-roster-row-detail { color: var(--muted); font-size: 12px; white-space: nowrap; }

/* The pinned "Predators" entry above the named-animal roster in the same
   overlay - the same row styling, with a bottom margin standing in for a
   divider so it does not read as the first name in the list under it. */
.animals-roster-predators { margin-bottom: 10px; border-color: var(--border); }

/* The Retired checkbox used its own `.animal-profile-retired-row` rule here,
   which put the box and the word "Retired" side by side but detached from
   each other - no `.check-label` idiom (`app/static/index.html` ~747) means
   no click target you can hit by the label text, unlike every other
   checkbox+label pair in this app. Removed 2026-09-11 (M3); the markup now
   just carries `.check-label` like the rest. */
.animal-profile-notes {
  width: 100%;
  margin: 8px 0 0;
  padding: 8px 10px;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--border);
  border-radius: 10px;
  resize: vertical;
}

.animal-profile-cameras { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
.animal-profile-camera-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px;
  padding: 6px 10px;
  font-size: 13px;
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: 8px;
}
.animal-profile-camera-name { font-weight: 600; min-width: 0; }
.animal-profile-camera-detail { color: var(--muted); font-size: 12px; white-space: nowrap; }

/* The walked sequence, as prose rather than a diagram - `animalCore.routeText`
   is the whole of the logic; this is only where it lands. */
.animal-profile-route { font-size: 13px; color: var(--ink-2); }
.animal-profile-map-link { display: inline-block; margin: 4px 0 6px; }

/* Twenty-four bars, one per local hour, floored by `animalCore.hourBars` so a
   single 3 AM sighting still draws something beside a camera's daytime peak -
   the same MIN_BAR_PERCENT idea `.pat-chart`'s bars use, in a file of its own
   because this panel counts one series, not three. */
.animal-profile-hours {
  display: grid;
  grid-template-columns: repeat(24, 1fr);
  align-items: end;
  gap: 2px;
  height: 60px;
  margin: 8px 0 4px;
}
.animal-profile-hour-bar {
  background: var(--accent);
  border-radius: 2px 2px 0 0;
  min-height: 1px;
}
/* The axis: four labels, each at the left edge of its six-hour block, so
   "12 AM" sits under the first bar and "6 PM" under the nineteenth - the
   same grid the bars use, four tracks wide instead of twenty-four. */
.animal-profile-hour-axis {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  font-size: 11px;
  color: var(--muted);
  margin: 0 0 6px;
  font-variant-numeric: tabular-nums;
}

.animal-profile-grid { margin-top: 8px; }

/* Where there is no pointer to hover with, a hover-revealed button is an
   invisible one - and the phone is exactly where this gets used. */
@media (hover: none) {
  .stack-tag { opacity: 1; }
}

/* The category editor.

   A group is a block with its labels beneath it. Not drag-and-drop: the target
   list is long enough to scroll past on a phone, and a dropdown per label says
   where a label goes without asking anyone to hold a finger down while the
   page moves under it. */
.cat-editor { display: grid; gap: 14px; margin: 10px 0; }
.cat-group { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.cat-group h4 {
  display: flex; align-items: center; gap: 8px;
  margin: 0 0 6px; font-size: 0.95rem;
}
.cat-group h4 > span:first-child { font-weight: 600; }
.cat-labels { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.cat-label { display: flex; align-items: center; gap: 8px; }
/* The name takes the slack, so every dropdown lines up down the column. */
.cat-label-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.cat-count {
  font-variant-numeric: tabular-nums; opacity: 0.65; font-size: 0.85rem;
}
.cat-move { max-width: 46%; }
.cat-actions { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
.cat-actions input { flex: 1; min-width: 0; }
.linkish {
  background: none; border: 0; padding: 0; font: inherit; font-size: 0.85rem;
  color: var(--accent); cursor: pointer; text-decoration: underline;
}

/* Desktop zoom controls, over the photo.

   `position: absolute` inside `.lb-stage`, which is `position: relative` and
   clips - so they cannot escape the photo's box the way a fixed control could.
   Above `.lb-strip` in the stacking order, below the close button and the
   arrows, which sit at `z-index: 22`. */
.lb-zoom-controls {
  position: absolute;
  /* Inset by however much the photo falls short of the stage, so the corner
     they sit in is the *photo's* corner. `placeZoomControls` measures that from
     the frame's layout box; the fallback of 0 puts them back on the stage's
     corner, which is where they were and is merely ugly rather than broken. */
  right: calc(10px + var(--lb-photo-inset-x, 0px));
  bottom: calc(10px + var(--lb-photo-inset-y, 0px));
  z-index: 12;
  display: flex;
  gap: 6px;
}
.lb-zoom-controls button {
  min-width: 34px;
  height: 34px;
  padding: 0 8px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 6px;
  background: rgba(20, 20, 22, 0.62);
  color: #f2f2f0;
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
  /* Dimmed until wanted, like the arrows - the photo is the subject and three
     lit buttons parked on it compete with whatever is in that corner. */
  opacity: 0.55;
}
.lb-zoom-controls button:hover { opacity: 1; }
/* Fit only means something once there is something to undo. Hidden rather than
   disabled: a permanently greyed button is a control that looks broken. */
#lb-zoom-reset { display: none; }
.lightbox.zoomed #lb-zoom-reset { display: block; }

/* Dragging a zoomed photo.

   `grab` only where there is a cursor to change, the same condition the camera
   cards use: on a phone the pointer is a finger and a hover state cannot
   happen. And only when zoomed, because at fit there is nothing to drag - a
   hand cursor over a photo that will not move is a promise the app does not
   keep. Nothing else on a desktop says the photo is draggable, so this is the
   whole of the affordance. */
@media (hover: hover) and (pointer: fine) {
  .lightbox.zoomed .lb-stage { cursor: grab; }
  .lightbox.zoomed .lb-stage:active { cursor: grabbing; }
}
/* The browser's own image drag, refused a second way. The attribute in
   index.html is what actually stops it; this covers the video poster and any
   future child of the frame that is draggable by default. */
.lb-frame img, .lb-frame video { -webkit-user-drag: none; user-select: none; }

/* --- selecting photos, for deleting --------------------------------------- */

/* --- selecting photos, for deleting --------------------------------------- */

/* The controls live in the gallery's count bar, not in a sticky strip of their
   own. The strip is gone and this is why it is not coming back: it had to be
   layered against the header and the lightbox, and lost to each of them at
   different times - once painting over an open photo and its arrows, once
   scrolling up over the toolbar. Both were real, both were reported, and both
   were only possible because it floated over things it had no relationship
   with. Inside the row it belongs to, it cannot overlap anything. */
/* A class that sets `display` beats the `hidden` attribute, so without these
   two rules Cancel and Delete rendered permanently and Select never yielded -
   the swap happened in the DOM and not on screen (2026-08-28). */
/* `hidden` has to win, and it does not by default.
   
   `[hidden]`'s `display: none` comes from the UA stylesheet, which loses to any
   class rule here that sets `display`. That shipped: both states painted at
   once, so the row showed Select *and* Cancel/Delete together. The JS was
   correct and the tests asserted it set `.hidden` - which is exactly what a
   test cannot see the difference between. Stated first and explicitly so the
   next `display` added below cannot quietly beat it again. */
.select-actions[hidden], #select-toggle[hidden] { display: none !important; }

/* The right-hand group: Select, or the two ways out of selecting. Pushed right
   by `margin-left: auto` on the wrapper rather than on the buttons, so both
   states land in the same place instead of shifting as their widths change. */
.select-group { margin-left: auto; display: inline-flex; align-items: center;
  gap: 8px; }
.select-actions { display: inline-flex; align-items: center; gap: 8px;
  flex-wrap: wrap; }

/* Outlined, like SpyPoint's - a button, not a link. It sits beside `Clear
   filters`, which is a bare link, and the entry point to a destructive mode
   should not look like one more piece of filter chrome. */
.select-entry, .select-actions button { padding: 4px 12px; border-radius: 6px;
  font: inherit; cursor: pointer; color: var(--ink); background: transparent;
  border: 1px solid var(--line, #333); text-decoration: none; }
.select-entry:hover, .select-actions button:hover { border-color: var(--ink-2, #9aa4ad); }

/* Red only when it will do something. Disabled it is grey and reads as
   inactive; `opacity: .5` on red left a washed-out red button, which still
   looks like a live control and invites the press that does nothing. */
.select-actions .danger { background: var(--bad, #a33); color: #fff;
  border: 1px solid var(--bad, #a33); }
.select-actions .danger:disabled { background: transparent;
  border-color: var(--line, #333); color: var(--muted); opacity: 1;
  cursor: default; }

.day-pick { width: 20px; height: 20px; margin-right: 8px; padding: 0;
  vertical-align: middle; border-radius: 50%; cursor: pointer;
  border: 2px solid var(--ink-2, #9aa4ad); background: transparent; }
.day-pick.on { background: var(--accent, #6aa9ff);
  border-color: var(--accent, #6aa9ff); }
/* A tick, and the same typo the tile circle carried - see that rule. */
.day-pick.on::after { content: "✓"; color: #fff; font-size: 13px;
  line-height: 1; display: block; }
/* Part of the day, which is a different claim from all of it. A dash rather
   than a fainter tick: a tick that means "some" is a tick a reader trusts as
   "all", and this circle now decides how many photos a Delete removes.
   Deliberately not `.on` with an opacity - see `.select-actions .danger` for
   the same lesson, where a faded red still read as a live button. */
.day-pick.part { border-color: var(--accent, #6aa9ff); }
.day-pick.part::after { content: "–"; color: var(--accent, #6aa9ff);
  font-size: 15px; line-height: 1; display: block; }
/* Fetching the day's ids. The circle is disabled while this shows, so a second
   tap cannot start a second pass over the same pages. */
.day-pick.busy { cursor: progress; opacity: .6; }
.day-pick.busy::after { content: "⋯"; color: var(--ink-2, #9aa4ad);
  font-size: 13px; line-height: 1; display: block; }

.tile-pick { position: absolute; top: 6px; left: 6px; z-index: 5;
  padding: 2px; border-radius: 50%; background: rgba(0, 0, 0, .55);
  display: flex; cursor: pointer; }
/* A finger-sized target. The default checkbox is about 13px, which on a phone
   is a tap that misses more often than it lands.
   Round, to match the day circle - the two are the same control at two scopes,
   and a square on the tile beside a circle on the header reads as two
   different mechanisms. */
.tile-pick input { width: 20px; height: 20px; margin: 0; cursor: pointer;
  appearance: none; border-radius: 50%; background: transparent;
  border: 2px solid #fff; }
.tile-pick input:checked { background: var(--accent, #6aa9ff);
  border-color: var(--accent, #6aa9ff); }
/* A tick, not a number. This read `"¹3"` - a superscript one and a three,
   which paints as "13" inside the circle and looks like a count of the
   selection. Reported 2026-08-30. */
.tile-pick input:checked::after { content: "✓"; color: #fff;
  font-size: 13px; line-height: 16px; display: block; text-align: center; }
.tile.picked { outline: 2px solid var(--accent, #6aa9ff); outline-offset: -2px; }

/* The disconnected solar row has no meter, so its value takes the width the
   bar would have used. Without this the grid still reserves the bar's track
   and the text wraps under the label - which is the overflow this fixes. */
.meter-row.solar-gone .m-value { grid-column: 2 / -1; }

/* A connected panel that is not delivering - dark, or failing. Dimmed rather
   than absent so the row keeps its shape and the difference is visible at a
   glance; the title says which of the two vendor questions was answered. */
/* "idle" beside the level: present, doing nothing. Muted rather than warned -
   a panel that is not delivering at 9pm is the expected state, and colouring
   it would cry wolf every night. */
.m-value .solar-quiet { opacity: .55; font-size: 11px; }

/* A full card is losing photos, so it is not a muted note like `idle`. */
.m-value .sd-full { color: var(--bad, #e06c6c); font-size: 11px;
  font-weight: 600; }

/* --- the power page ------------------------------------------------------- */

/* Power charts use the horizontal room a desktop has: a 60-day line and its
   photo bars are needlessly dense in the shared 480px dialog. `100%` still
   means the overlay's padded content box, so the wider desktop cap cannot
   create a horizontal scrollbar on a phone. Keep the text and the one control
   short enough to read and operate rather than stretching them with the chart. */
#power-overlay .settings-panel { width: min(1100px, 100%); }
#power-overlay .hint { max-width: 78ch; }
#power-days { max-width: 12rem; }

.pw-camera { margin: 0 0 22px; }
.pw-camera h3 { margin: 0 0 2px; font-size: 15px; }
.pw-latest { font-size: 12px; opacity: .75; margin-bottom: 6px; }

/* Full width, fixed aspect via the viewBox. `preserveAspectRatio="none"` lets
   a week and a day of data both fill the panel - the x axis is already
   labelled by the window control, so stretching costs nothing and a
   letterboxed chart in a wide panel reads as broken. */
.pw-chart { width: 100%; height: 150px; display: block;
  background: var(--panel-2, rgba(127,127,127,.06)); border-radius: 6px; }

.pw-battery { fill: none; stroke: var(--accent, #6aa9ff); stroke-width: 2;
  stroke-linejoin: round; stroke-linecap: round; }
/* Thinner and dashed: two solid lines of similar weight read as one series
   crossing itself. */
.pw-solar { fill: none; stroke: var(--good, #5fbf7f); stroke-width: 1.5;
  stroke-dasharray: 4 3; stroke-linejoin: round; }
/* Behind everything and barely there - context for the battery line, not a
   series anyone reads a value off. */
.pw-bar { fill: currentColor; opacity: .13; }
.pw-band { fill: var(--good, #5fbf7f); opacity: .09; }

.pw-projection { margin: 6px 0 0; font-size: 13px; }
.pw-projection.warn { color: var(--bad, #e06c6c); }
.pw-projection.ok { color: var(--good, #5fbf7f); }
.pw-range, .pw-rate { opacity: .7; font-size: 12px; margin-left: 6px; }

/* A counter reset - a new billing cycle, or a swapped card. Drawn rather than
   left as a gap: the rate across a reset is unknown, and a missing bar reads
   as a quiet day instead of a missing measurement. */
.pw-reset { fill: var(--bad, #e06c6c); opacity: .35; }
.pw-note { opacity: .6; margin-left: 8px; font-style: italic; }

/* --- the hover crosshair -------------------------------------------------- */

/* The plot wraps the SVG so the popup can be placed in CSS pixels. The chart
   is `preserveAspectRatio="none"`, so its own coordinates stretch with the
   card - a popup positioned inside it would land nowhere near the crosshair on
   a wide screen. */
.pw-plot { position: relative; padding-left: 34px; }
/* Everything positioned in chart coordinates lives here, with no padding,
   so a percentage means the same thing to the SVG, the crosshair and the
   popup. */
.pw-area { position: relative; }

/* Faint, and behind everything. Gridlines are a reading aid, not a series -
   at the same weight as the battery line they compete with it. */
.pw-grid { stroke: var(--ink-2, #9aa4ad); stroke-width: 1; opacity: .12;
  vector-effect: non-scaling-stroke; }

/* Labels are HTML, not SVG text. The chart is `preserveAspectRatio="none"`, so
   anything in its coordinate space stretches with the card - which is right
   for lines and wrong for words. */
.pw-axis-y { position: absolute; left: 0; top: 0; bottom: 0; width: 32px;
  pointer-events: none; }
.pw-y { position: absolute; right: 4px; transform: translateY(-50%);
  font-size: 10px; color: var(--ink-2); opacity: .75; }
.pw-axis-x { position: relative; height: 16px; margin: 2px 0 4px 34px; }
.pw-x { position: absolute; transform: translateX(-50%); white-space: nowrap;
  font-size: 10px; color: var(--ink-2); opacity: .75; }
/* A day boundary is a different kind of label, so it looks like one. */
.pw-x.day { opacity: 1; font-weight: 600; }

/* Dotted, and drawn in the SVG so it stretches with the plot exactly as the
   series do. A CSS overlay would need the same scaling done twice, by hand. */
/* An HTML element, not an SVG line.

   The chart is `preserveAspectRatio="none"`, and a dashed stroke inside a
   non-uniformly scaled SVG is exactly where `vector-effect` support differs
   between engines - @director: WebKit is what Travis is holding. A border on a
   div dashes identically everywhere and needs no scaling rules at all.

   Above the SVG by document order and z-index both, so "topmost" does not
   depend on one of them. */
.pw-crosshair { position: absolute; top: 0; bottom: 0; width: 0; z-index: 2;
  border-left: 2px dashed var(--ink, #e6e9ec); opacity: .9;
  pointer-events: none; }
.pw-crosshair[hidden] { display: none; }

.pw-hover { position: absolute; top: 6px; z-index: 3; min-width: 150px;
  padding: 7px 9px; border-radius: 6px; font-size: 12px; line-height: 1.45;
  background: var(--surface); border: 1px solid var(--line, #333);
  box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
  /* Never in the way of the pointer that summoned it: a box under the cursor
     would steal the next `pointermove` and freeze the reading it is showing. */
  pointer-events: none; white-space: nowrap; }
.pw-hover[hidden] { display: none; }
.pw-hover-at { color: var(--ink-2); margin-bottom: 3px; }
/* Right-aligned values, so the percent - the last thing on every row - lines
   up down the box whether or not that row carries volts. Left to shrink-wrap,
   a row without volts pulled its percent leftward and the column zig-zagged. */
.pw-hover-row { display: flex; gap: 12px; justify-content: space-between; }
.pw-hover-row b { text-align: right; font-variant-numeric: tabular-nums; }
.pw-hover-row span { color: var(--ink-2); }

/* Narrow screens: smaller, tighter, and the wording shortened in
   `hoverLines` - together they usually let the box sit beside the line
   instead of over it. */
.pw-hover.compact { font-size: 11px; padding: 5px 7px; min-width: 0;
  line-height: 1.35; }
.pw-hover.compact .pw-hover-row { gap: 8px; }

/* When neither side clears the line, the popup sits at the very top of the
   plot rather than beside the reading it is describing.

   Inside the plot, not above it: `bottom: 100%` put it in whatever space
   happened to be over the chart, and on a section whose heading is one line
   that is off the top of the page - the fixture caught it, showing two rows of
   a five-row box. Here it covers the top of the chart, which holds the 100%
   line and rarely a reading, and the crosshair stays visible below it. */
.pw-hover.above { top: 0; }

/* --- navigate to a camera ------------------------------------------------

   Bottom left. The zoom and locate controls sit top left and Leaflet's
   attribution sits bottom right, so this is the only corner that covers
   nothing - `test_map_navigate.py` pins that, because "which corner is free"
   is exactly the fact a later control will quietly invalidate. */
.map-navigate { position: absolute; left: 10px; bottom: 24px; z-index: 900;
  min-width: 132px; padding: 10px 12px 12px; border-radius: 10px;
  background: rgba(16, 20, 24, .88); color: #fff; text-align: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .4); }
.map-navigate[hidden] { display: none !important; }
/* The arrow is the control's whole point, so it is the biggest thing in it -
   readable at arm's length, walking, in the dark. */
.nav-arrow { font-size: 34px; line-height: 1; transition: transform .2s ease; }
.nav-distance { font-size: 20px; font-weight: 600; margin-top: 2px; }
.nav-to { font-size: 12px; opacity: .85; overflow-wrap: anywhere; word-break: break-word; }
/* The honest limits, small but present. A compass under tree cover wanders by
   10-20 degrees and the reader has no way to know that from the arrow. */
.nav-note { font-size: 11px; opacity: .75; margin-top: 6px; max-width: 190px; }
.nav-close { position: absolute; top: 2px; right: 4px; padding: 2px 6px;
  border: 0; background: transparent; color: #fff; font-size: 16px;
  line-height: 1; cursor: pointer; }
.pop-navigate { display: block; width: 100%; margin: 6px 0 2px; padding: 6px 8px;
  border-radius: 6px; border: 1px solid var(--accent, #6aa9ff);
  background: transparent; color: var(--accent, #6aa9ff); cursor: pointer; }

/* --- map waypoints ------------------------------------------------------ */

/* SVG, never emoji: the same wolf, gate or camp reads the same on every
   phone, and the family tint distinguishes a travel marker from a warning. */
/* The chip in the picker wears the same tint as the pin it becomes, so the
   default family colour is shared; only the label under a chip is ink. */
.waypoint-marker, .waypoint-chip { color: #f2bb44; }
.waypoint-marker.waypoint-hazard, .waypoint-marker.waypoint-gate,
.waypoint-marker.waypoint-blood,
.waypoint-chip.waypoint-hazard, .waypoint-chip.waypoint-gate,
.waypoint-chip.waypoint-blood { color: #dc625d; }
.waypoint-marker.waypoint-water, .waypoint-marker.waypoint-fish,
.waypoint-marker.waypoint-photo, .waypoint-marker.waypoint-parking,
.waypoint-chip.waypoint-water, .waypoint-chip.waypoint-fish,
.waypoint-chip.waypoint-photo, .waypoint-chip.waypoint-parking { color: #4f9eea; }
.waypoint-marker.waypoint-deer, .waypoint-marker.waypoint-bear,
.waypoint-marker.waypoint-bird, .waypoint-marker.waypoint-canine,
.waypoint-marker.waypoint-cat, .waypoint-marker.waypoint-hog,
.waypoint-marker.waypoint-small-game, .waypoint-marker.waypoint-tracks,
.waypoint-marker.waypoint-shed,
.waypoint-chip.waypoint-deer, .waypoint-chip.waypoint-bear,
.waypoint-chip.waypoint-bird, .waypoint-chip.waypoint-canine,
.waypoint-chip.waypoint-cat, .waypoint-chip.waypoint-hog,
.waypoint-chip.waypoint-small-game, .waypoint-chip.waypoint-tracks,
.waypoint-chip.waypoint-shed { color: #7fbe73; }
.waypoint-marker.waypoint-camp, .waypoint-marker.waypoint-stand,
.waypoint-marker.waypoint-rub, .waypoint-marker.waypoint-scrape,
.waypoint-chip.waypoint-camp, .waypoint-chip.waypoint-stand,
.waypoint-chip.waypoint-rub, .waypoint-chip.waypoint-scrape { color: #b37bd5; }
.waypoint-marker.waypoint-vehicle, .waypoint-marker.waypoint-access,
.waypoint-marker.waypoint-turn, .waypoint-marker.waypoint-crossing,
.waypoint-chip.waypoint-vehicle, .waypoint-chip.waypoint-access,
.waypoint-chip.waypoint-turn, .waypoint-chip.waypoint-crossing { color: #e8894b; }
/* The current hour's stand-wind verdict, over whichever family tint above
   the type would otherwise wear. Same specificity (two classes) as the
   family rules, so source order decides - declared after them, this wins. */
.waypoint-marker.stand-ok { color: var(--status-good); }
.waypoint-marker.stand-bad { color: var(--status-critical); }
.waypoint-marker.stand-calm { color: var(--status-warning); }
/* A square, rotated -45deg, for the same reason `.pin-body` is one: rotating
   a non-square box about its centre skews the corners instead of just
   turning the shape, so the old 30x38 box drew a lopsided teardrop rather
   than a symmetric one. Coloured fill with a surface-coloured outline, same
   as a placed camera pin (`.pin-placed .pin-body`), so the two read as the
   same kind of mark on the map. */
.waypoint-body { display: grid; place-items: center; box-sizing: border-box;
  width: 28px; height: 28px; background: currentColor; border: 2px solid var(--surface);
  border-radius: 50% 50% 50% 0; box-shadow: 0 1px 3px rgba(0, 0, 0, .45); transform: rotate(-45deg); }
.waypoint-glyph { width: 16px; height: 16px; transform: rotate(45deg); }
/* Scoped to the marker body, not the picker chip: a chip's own rules
   (below) already decide its glyph colour by selection state, and this must
   not override that. Surface-coloured so the glyph shows against the
   now-coloured body, same as `.pin-glyph` on the camera pin. */
.waypoint-body .waypoint-glyph { color: var(--surface); }
.waypoint-popup { width: 272px; max-width: 100%; font-size: 13px; }
.waypoint-name { margin: 0; font-size: 15px; }
.waypoint-type { margin: 2px 0 8px; color: var(--muted); font-size: 12px; }
.waypoint-form { display: grid; gap: 6px; margin-top: 8px; }
/* No `select` for the *type* here: the picker below replaces that one with
   chips. The bad-wind sector's two selects are a plain pair of fixed compass
   points with no glyph or tint to lose, so they share this rule instead of
   growing a chip picker of their own. */
.waypoint-form input, .waypoint-actions button, .waypoint-sector-field select {
  min-width: 0; box-sizing: border-box; width: 100%; background: var(--page); color: var(--ink);
  border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; font: inherit;
}
.waypoint-actions { display: flex; gap: 6px; }
.waypoint-actions button { cursor: pointer; }
.waypoint-sector-line { margin: 0 0 8px; font-size: 12px; color: var(--ink-2); }
.waypoint-sector-row { display: flex; gap: 8px; }
.waypoint-sector-field { flex: 1; display: flex; flex-direction: column;
  gap: 2px; font-size: 11px; color: var(--muted); }

/* --- waypoint type picker: onX-style chips instead of a plain <select>, so
   choosing a type looks like choosing the marker it becomes. */
.waypoint-type-picker { margin: 4px 0 2px; }
.waypoint-picker-heading { margin: 8px 0 4px; font-size: 11px; font-weight: 600;
  letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
.waypoint-picker-heading.waypoint-picker-subheading { margin-top: 0; font-weight: 400;
  text-transform: none; letter-spacing: normal; }
.waypoint-chip-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.waypoint-chip { display: grid; justify-items: center; gap: 3px; border: 0; background: none;
  font: inherit; font-size: 11px; cursor: pointer; padding: 0; }
.waypoint-chip-icon { box-sizing: border-box; width: 44px; height: 44px; border-radius: 50%;
  border: 2px solid currentColor; display: grid; place-items: center; }
.waypoint-chip[aria-pressed="true"] .waypoint-chip-icon { background: currentColor; }
.waypoint-chip[aria-pressed="true"] .waypoint-chip-icon .waypoint-glyph { color: var(--surface); }
.waypoint-chip .waypoint-glyph { transform: none; width: 20px; height: 20px; }
.waypoint-chip-label { text-align: center; line-height: 1.15; overflow-wrap: anywhere;
  color: var(--ink); }
.waypoint-chip-more { color: var(--muted); }
.waypoint-all-types { max-height: min(220px, 40vh); overflow-y: auto; margin-top: 4px; }
@media (max-height: 700px) {
  .waypoint-all-types { max-height: 120px; }
}
/* <div>s, not <p>s, above and below: leaflet.css sets a margin on every `p`
   inside a popup at higher specificity than these classes. */
.waypoint-picker-empty { margin: 6px 0; color: var(--muted); font-size: 12px; }

/* --- the rut calendar: a regional lean under the two measured sections
   above it (`.pat-weather`, and the histogram further up). Same border-top
   treatment as `.pat-weather` so the three sections read as one stack rather
   than three unrelated cards. */
.pat-rut {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}
.pat-rut h3 { margin: 0 0 0.15rem; font-size: 1rem; }
.pat-rut-region { margin: 0 0 0.3rem; color: var(--ink-2); font-size: 0.9rem; }

/* The strip: five widths from `rutSegments`, the pill and the date range
   placed by a left percentage the same way `.patw-cursor` tracks one hour
   regardless of how wide the plot is laid out - `transform: translateX(-50%)`
   centres the label on that point instead of starting it there. */
.pat-rut-strip-wrap { position: relative; margin: 1.6rem 0 0.35rem; }
.pat-rut-segs {
  display: flex;
  gap: 2px;
  height: 10px;
  border-radius: 5px;
  overflow: hidden;
}
.pat-rut-seg { display: block; height: 100%; background: var(--muted); opacity: 0.5; }
.pat-rut-seg.selected { background: var(--accent); opacity: 1; }
.pat-rut-pill {
  position: absolute;
  top: -1.5rem;
  left: 0;
  transform: translateX(-50%);
  white-space: nowrap;
  background: var(--accent);
  color: #fff;
  font-size: 0.75rem;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 10px;
}
.pat-rut-range {
  position: absolute;
  top: calc(100% + 3px);
  left: 0;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: 0.75rem;
  color: var(--ink-2);
}
.pat-rut-nav { display: flex; margin: 1.1rem 0 0.3rem; }
/* Prev stays left and Next stays right whichever one `patRutRender` hides at
   either end of the season - `justify-content: space-between` put the lone
   visible button on the left on the first phase, because a single flex item
   under space-between sits at the row's start rather than spread across it.
   Pushing Next right with its own margin does not depend on how many
   siblings are visible, which space-between's split does. */
#pat-rut-next { margin-left: auto; }
.pat-rut-behaviors { margin-top: 0.4rem; }
.pat-rut-behaviors h4 { margin: 0 0 0.35rem; font-size: 0.85rem; color: var(--ink-2); }
.pat-rut-behavior-cols {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.6rem;
}
.pat-rut-behavior b { display: block; font-size: 0.85rem; }
.pat-rut-behavior p { margin: 0.15rem 0 0; font-size: 0.8rem; color: var(--ink-2); }
@media (max-width: 560px) {
  .pat-rut-behavior-cols { grid-template-columns: 1fr; }
}

/* Twelve weeks, oldest at the left. A null share draws an empty outlined
   slot rather than a bar at zero - the same "unknown is a hole, not a floor"
   rule the weather timeline keeps for a missing reading. */
.pat-rut-buckshare {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 60px;
  margin-top: 0.4rem;
}
.pat-rut-bar {
  flex: 1;
  min-width: 0;
  background: var(--accent);
  border-radius: 2px 2px 0 0;
}
/* A short stub, not the full 60px box: `align-self: stretch` used to draw
   every unmeasured week taller than a measured one short of 100% share (28px
   for a real 47%, say), which reads backwards - "no data" outweighing "some
   data". The parent's own `align-items: flex-end` already bottom-aligns a
   shorter item, so this only needs a fixed height. `--hairline` is a
   background/divider token, tuned to be barely there against its own
   theme's page colour - which is exactly wrong for a shape meant to be seen;
   `--ink-2` (this stylesheet's secondary-text token, readable on both
   grounds) keeps the slot visible in light and dark alike. */
.pat-rut-bar-empty {
  height: 8px;
  background: none;
  border: 1px dashed var(--ink-2);
  border-radius: 2px;
}

/* The herd survey: the fourth section in the same stack `.pat-weather` and
   `.pat-rut` are, so it gets their border-top treatment to read as one more
   card in the stack rather than a section that forgot to. The tile row
   reuses `.patw-days`/`.patw-day-card` (the weather panel's own daily-outlook
   cards) and the per-camera table reuses `.help-table` - this is the one rule
   this section needed that neither of those already provides. */
.pat-survey {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}

/* The sit log: fifth in the same stack, same border-top treatment. The
   table reuses `.help-table` like the survey's per-camera one does; the
   form is the one new layout this section needs - a wrapping row of
   labelled inputs, narrow enough that the number fields don't dominate it. */
.pat-sits {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}
.sit-form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 0.5rem;
}
.sit-form label { display: flex; flex-direction: column; font-size: 12px;
  color: var(--ink-2); gap: 2px; }
.sit-form input[type="number"] { width: 4.5em; }
#sit-notes { flex: 1 1 160px; }

/* The camera scorecard: sixth in the same stack, same border-top treatment
   as `.pat-survey`/`.pat-sits` above it. The table itself reuses
   `.help-table`, unchanged, but this panel's own columns (a rate, a trend
   phrase, a hint bar) are wider than the survey's four - `overflow-x: auto`
   on the wrapper is what keeps a narrow window scrolling the TABLE rather
   than the page (AGENTS.md's own render-and-look rule caught two of these
   the hard way before). The bar reuses `.meter`/`.meter > i` (the battery
   and quota gauges' own horizontal bar), scaled per row in app.js rather
   than getting a second copy of the same two rules here. */
.pat-scorecard {
  margin-top: 0.5rem;
  border-top: 1px solid var(--hairline);
  padding-top: 0.5rem;
}
.pat-scorecard-scroll { overflow-x: auto; }
/* `.meter`'s `height` is otherwise a no-op: every existing use sits inside
   `.meter-row`, a grid container, whose children are blockified for free
   regardless of their own `display` - a bare `<span class="meter">` in a
   table cell has no such parent, stays inline, and draws no bar at all
   (caught by rendering this panel and looking, per AGENTS.md's own rule -
   the table's numbers were right and the bar was invisible). */
#pat-scorecard-table .meter { display: inline-block; width: 72px; vertical-align: middle; }
#pat-scorecard-table td { white-space: nowrap; }

/* The login page: its own tiny layout rather than reusing `.settings-panel`,
   which assumes it is inside `.settings-overlay` and never appears alone. */
.login-page { min-height: 100vh; display: flex; align-items: center;
  justify-content: center; background: var(--page); }
.login-card { width: min(320px, calc(100vw - 32px)); background: var(--surface);
  border: 1px solid var(--border); border-radius: 12px; padding: 24px;
  box-shadow: var(--shadow); }
.login-card h1 { margin: 0 0 16px; font-size: 18px; color: var(--ink); }
.login-card label { display: block; font-size: 13px; color: var(--ink-2); margin: 12px 0 0; }
.login-card input[type="password"] { width: 100%; margin-top: 4px; padding: 8px;
  border: 1px solid var(--border); border-radius: 6px; background: var(--page);
  color: var(--ink); font: inherit; }
.login-remember { display: flex; align-items: center; gap: 8px; }
.login-remember input { margin: 0; }
.login-card .btn { width: 100%; margin-top: 16px; }
.login-card .test-result { margin-top: 10px; }

/* Same red as every other "this is not safe" line on the page (compare
   `.animal-error`) - the settings dialog's Login tab shows this only while
   `GET /api/auth/status` says the gate is off. */
.login-off-notice { color: var(--status-critical); font-size: 13px;
  margin: 0 0 14px; padding: 8px 10px; border: 1px solid var(--status-critical);
  border-radius: 6px; }

/* The two-factor enrollment QR is rendered at a fixed internal scale by
   `segno`; capping its display size is what keeps a high-error-correction
   code from filling the whole settings panel. */
#totp-qr { display: block; width: 200px; height: 200px; margin: 8px 0; }
#totp-backup-codes-list { white-space: pre-wrap; word-break: break-all;
  background: var(--page); border: 1px solid var(--border); border-radius: 6px;
  padding: 10px; }

/* --- phone overrides (must stay last) -------------------------------------

   This block must be the last thing in the sheet. Every rule here overrides
   an unconditional rule declared earlier at equal specificity, and equal
   specificity resolves by source order - a rule placed after this block
   would beat its own phone override on every screen size, not just narrow
   ones. `test_the_phone_overrides_are_last` pins that nothing follows it. */
@media (max-width: 600px) {
  .topbar { align-items: flex-start; gap: 8px; padding: 10px 12px; flex-wrap: wrap; }
  .primary-nav { order: 3; width: 100%; margin: 0; overflow-x: auto; }
  .primary-nav-link { flex: 0 0 auto; padding-inline: 9px; }
  .topbar-right { margin-left: auto; gap: 4px; }
  /* The words ("checking...", "no credentials") would wrap the topbar or need
     truncating at this width, so only the text goes - the dot is the one
     health signal that still fits, and it is the only thing left on screen
     saying the poller exists at all. */
  .poll-status #poll-text { display: none; }
  main { padding: 14px 12px; }
  .view-heading { flex-direction: column; }
  .camera-view-heading { gap: 12px; margin-bottom: 14px; }
  .camera-view-actions { width: 100%; }
  .camera-view-actions .btn { flex: 1 1 auto; }
  .camera-row { grid-template-columns: minmax(0, 1fr); }
  .camera-card .cam-edit { min-height: 44px; padding-inline: 12px; }
  .wildlife-actions { grid-template-columns: 1fr; }
  .camera-health-summary { flex-wrap: wrap; }
  .camera-health-open { margin-left: 0; }
  .lb-review-row, .lb-review-columns { grid-template-columns: 1fr 64px 100px auto; }
  .lb-nav { display: none; }
  /* The phone pinches, so it needs no buttons. Written beside `.lb-nav` rather
     than in a second block of the same width: one breakpoint decides what
     "desktop" means here, and two copies of 600px is two places to change it. */
  .lb-zoom-controls { display: none; }
  /* On a phone this is an in-the-woods instrument, not a desktop tooltip. It
     takes almost the map width, and the arrow and distance scale with the
     screen's height rather than sitting at one fixed size - a short portrait
     phone gets the 72px/32px floors below, and a tall one gets a panel
     closer to two thirds of the map. A landscape phone is wider than 600px,
     so it never enters this block and keeps the desktop sizes. The 28px floor preserves
     Leaflet's bottom-right attribution; growing upward from that fixed floor
     is also what keeps the panel off the zoom and locate controls, since it
     never needs a `top:` to grow. */
  .map-navigate { left: 10px; right: 10px; bottom: 28px; min-width: 0;
    padding: 4px 12px 4px; border-radius: 16px; }
  /* The arrow's budget is the 60vh map minus the 28px floor minus the 118px
     top-left control stack (Leaflet's 30x30 touch zoom buttons plus the
     30x30 locate button) minus the fixed parts below it (padding, distance,
     name, note) - so it takes what is left of that budget, not a raw share
     of the screen. */
  .nav-arrow    { font-size: clamp(72px, min(22.5vh, 60vh - 278px), 190px); }
  .nav-distance { font-size: clamp(32px, 4.6vh, 58px); margin-top: 6px; }
  .nav-to       { font-size: clamp(17px, 2vh, 22px); max-width: 100%; padding: 0 28px; }
  .nav-note { font-size: 13px; line-height: 1.2; max-width: none; margin-top: 0; }
  .nav-close { top: 8px; right: 10px; font-size: 30px; padding: 6px 10px; }
}
