/* radioctl web UI - dark theme, no framework */
:root {
  --bg: #14171c;
  --bg2: #1c2027;
  --bg3: #242a33;
  --line: #323a46;
  --fg: #d7dce3;
  --muted: #8a93a0;
  --accent: #4d9de0;
  --good: #3fb950;
  --warn: #e3a13c;
  --bad: #e5534b;
  --gray: #8a93a0;
  --dn: #4d7ce5;
  --cn: #16b8cf;
  /* HIT-TARGET FLOOR (operator 2026-07-26: "make those buttons bigger
   * across the whole nav, not just the little bubbles"). Everything
   * clickable in the topbar and the side nav is sized off THIS rather
   * than off its glyph, because the reported failure is a MIS-CLICK, not
   * a squint: a missed "+" on a community row lands on the row itself,
   * which loads that park ALONE and unloads every other one. 28 px is the
   * practical floor for a mouse on a dense list - the 44 px touch
   * guideline would cost this desktop-first list more rows than it buys. */
  --tap: 28px;
  /* ICON-BUTTON FLOOR (operator 2026-07-26: "we should have a minimum size
   * for the 4 buttons on the nav bar. at certain widths it's impossible to
   * see the icons"). --tap is a HIT-TARGET floor and it is NOT enough here,
   * because an .iconbtn spends most of its box on chrome: a 15 px glyph +
   * 8 px side padding + 1 px border = 33 px of border-box before the GLYPH
   * loses a single pixel. At --tap (28 px) the target would be legal and
   * the content box would be 10 px - the icon rendered at two thirds size.
   * 34 px is that 33 plus 1 px of slack, so sub-pixel layout rounding (and
   * the next padding tweak) cannot shave the glyph. It is also > --tap, so
   * the hit-target floor is satisfied by construction.
   * THE FLOOR IS ONLY HALF THE FIX - see `.iconbtn svg` below: the glyph is
   * a flex item and shrinks on its own, so a floor on the button alone
   * leaves an illegible icon inside a legal target. */
  --tap-icon: 34px;
}

* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  font: 14px/1.45 "Segoe UI", system-ui, -apple-system, sans-serif;
  background: var(--bg);
  color: var(--fg);
  display: flex;
  flex-direction: column;
  /* THE DOCUMENT NEVER SCROLLS SIDEWAYS. This is a full-viewport SPA -
   * everything that legitimately scrolls (#panel, the metrics tables,
   * the map) owns its own scroll box, so a horizontal document scrollbar
   * only ever means something LEAKED.
   * Measured leak this closes (found 2026-07-26 at 375 px, PRE-EXISTING):
   * opening the GEAR popover pushed scrollWidth 375 -> 423. Source is the
   * `.ihint` info tips inside it - 11 px spans whose absolutely
   * positioned ::after is ~136 px wide - i.e. the third instance of the
   * same trap this project has hit twice before: a tip that is invisible
   * but STILL LAID OUT and still counted. The tips must stay readable, so
   * this clamps the document rather than clipping them. */
  overflow-x: hidden;
}
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.hidden { display: none !important; }

/* ---------- header ---------- */
#topbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px;
  background: var(--bg2);
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
  /* WRAP RATHER THAN SHRINK PAST THE FLOOR. This used to be switched on
   * only under 860px, which made "the controls have a minimum" true at
   * some widths and false at others. The icon buttons now carry a hard
   * --tap-icon floor, so the bar needs a legal way to run out of room at
   * EVERY width: a control that moved to a second row can still be found
   * and clicked, one squeezed under its floor cannot. Above 860px nothing
   * actually wraps - #searchwrap and #loadbars both shrink to 0 and absorb
   * the whole range (verified by width sweep) - so this is a guarantee,
   * not a layout change. */
  flex-wrap: wrap;
}
#brand {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.5px;
  color: var(--muted);
  margin-right: 6px;
  user-select: none;
}
#brandlogo {
  height: 26px;
  display: block;
}
#search, #navfilter {
  background: var(--bg3);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 6px 10px;
  font: inherit;
  outline: none;
}
#searchwrap {
  flex: 1 1 180px;
  max-width: 420px;
  position: relative;
  display: flex;
}
#search { flex: 1 1 auto; width: 100%; min-width: 0; }
#search:focus, #navfilter:focus { border-color: var(--accent); }

/* community-list filter (side nav, above the list) */
#navfilterwrap { margin: 0 0 8px; }
#navfilter { width: 100%; padding: 5px 9px; font-size: 12.5px; }

/* ONE button for add AND unload: "+" that rotates 45° into an "x" while
 * the park is loaded (CSS transform transition on the same element).
 * THE most mis-clicked target in the app: missing it hits the row behind,
 * which loads that community ALONE and unloads every other one - so it is
 * sized at the tap floor even though the glyph is tiny. */
.parkaddbtn {
  flex: 0 0 auto;
  margin-left: auto;
  width: var(--tap); height: var(--tap);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--bg3);
  color: var(--muted);
  font: inherit;
  font-size: 17px;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  cursor: pointer;
  transition: transform 0.25s ease, color 0.15s ease,
              border-color 0.15s ease;
}
.parkaddbtn:hover { border-color: var(--accent); color: var(--accent); }
.parkaddbtn.isloaded {
  transform: rotate(45deg);
  color: var(--bad);
  border-color: var(--bad);
}

/* NAV-SEARCH typeahead dropdown - FIVE KINDS of result, grouped under a
 * labelled heading each (CNs, DNs, poles, communities, addresses) so a
 * community result can never be mistaken for a device. It scrolls now:
 * five groups of up to five rows plus the address attribution is taller
 * than the old flat five-row list ever was. */
#searchsug {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 1600;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 8px;
  box-shadow: 0 8px 30px rgba(0,0,0,0.55);
  overflow-x: hidden;
  overflow-y: auto;
  max-height: min(70vh, 460px);
}
.sugrow {
  padding: 6px 10px;
  cursor: pointer;
  font-size: 12.5px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* hover AND the keyboard cursor land on the same visual state: arrows and
 * the mouse move one cursor, so they must not be able to show two */
.sugrow:hover,
.sugrow.sugsel { background: var(--bg3); }
.sughead {
  padding: 5px 10px 2px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  border-top: 1px solid var(--line);
}
#searchsug > .sughead:first-child { border-top: 0; }
/* the address tier's non-pickable rows: the "looking up…" / "unavailable"
 * sentence (a silent empty list would read as "no such address") and the
 * OpenStreetMap attribution the terms require wherever results appear */
.sugnote {
  padding: 5px 10px;
  font-size: 11.5px;
  color: var(--muted);
  white-space: normal;
}
/* STICKY, because #searchsug scrolls. The attribution is a single trailing
 * div after the address rows and `address` sorts last, so at full caps it
 * is the last thing in an ~820 px flow inside a 460 px box - and
 * searchSelect's scrollIntoView({block:"nearest"}) parks a selected row
 * flush against the bottom edge, which pushes the notice out of view while
 * its results are on screen. A scroll position must not be able to satisfy
 * the code and not the requirement. */
.sugattr {
  padding: 2px 10px 6px;
  font-size: 10px;
  color: var(--muted);
  position: sticky;
  bottom: 0;
  background: var(--bg2);        /* matches #searchsug: no bleed-through */
}
.sugtag {
  font-size: 10px;
  font-weight: 700;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 999px;
  padding: 0 6px;
}

.chip {
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 5px 14px;
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  color: var(--muted);
  white-space: nowrap;
}
.chip.busy { color: var(--accent); border-color: var(--accent); }

/* METRICS-MODE CONTROL. Green = LIVE ("now": lastvalues, the authoritative
 * health view); blue = AVERAGE ("history": hourly-trend medians over the
 * window). Deliberately the same --accent blue the app already uses for
 * "the thing you are looking at" rather than a fifth new hue - but see the
 * caveat: --accent is ALSO the channel-4 swatch and --good the channel-3
 * swatch on the map legend. The collision is tolerable only because this
 * chip lives in the topbar and never on the map; do not carry this pair
 * onto a glyph. */
#livechip {
  color: var(--good);
  border-color: var(--good);
  font-weight: 700;
  cursor: pointer;
  position: relative;
  font-family: inherit;
}
#livechip.avg { color: var(--accent); border-color: var(--accent); }
#livechip:hover { background: var(--bg2); }
/* connection down: neither mode can be refreshed, so it dims until we
 * recover (it still switches - the setting is local) */
#livechip.dim { opacity: 0.35; filter: grayscale(1); }
/* hover hint. Three deliberate differences from the .ihint tips, all of
 * them about NOT PUSHING THE PAGE SIDEWAYS - this chip lives in the
 * topbar's right-hand cluster, which is the worst place on the page to
 * hang a 260 px box off:
 *
 *  1. DISPLAY:NONE when idle, not visibility/opacity. A visibility:hidden
 *     box is still LAID OUT and still counts toward scrollWidth, so the
 *     idle tip alone gave a 375 px phone 24 px of horizontal scroll
 *     (measured: documentElement.scrollWidth 399 -> 375 with the data-tip
 *     removed) - which on a field-facing app puts the right end of the
 *     topbar outside the reachable area. Cost: no fade-in on this one tip.
 *     Worth it; an unreachable settings button is not a styling nit.
 *  2. RIGHT-ANCHORED rather than centred on the chip, so it grows INWARD
 *     from the chip's right edge. A centred 260 px tip simply cannot fit
 *     beside a chip that sits ~90 px from the right edge of a 375 px
 *     screen. Left overflow, unlike right overflow, does not create a
 *     scrollbar in LTR - so this is the safe direction to spill.
 *  3. Opens DOWNWARD unconditionally: an upward tip (the .ihint default)
 *     would render above the top of the window.
 * NO vw UNITS anywhere here on purpose: 100vw counts the scrollbar
 * gutter, so a vw-based clamp is wider than the client width exactly when
 * a scrollbar exists - i.e. it re-creates the bug it was meant to fix. */
#livechip[data-tip]::after {
  content: attr(data-tip);
  display: none;
  position: absolute;
  top: calc(100% + 7px);
  right: 0;
  width: max-content;
  max-width: 260px;
  white-space: normal;
  text-align: left;
  background: var(--bg3);
  color: var(--fg);
  border: 1px solid var(--accent);
  border-radius: 7px;
  padding: 6px 9px;
  font: 400 11.5px/1.45 "Segoe UI", system-ui, sans-serif;
  box-shadow: 0 6px 20px rgba(0,0,0,0.5);
  pointer-events: none;
  z-index: 3000;
}
#livechip:hover::after { display: block; }

/* ---------- park-load progress bars ----------
 * One pill per LOADING community (name + n/total DNs only - the fun
 * loading lines live on the over-map overlay now). When the pills would
 * overflow, app.js collapses them into ONE combined bar (.combined /
 * .combobar); on small screens the bottom-edge #loadstripe replaces the
 * pills entirely. A finished bar lingers ~30 s then shrinks/slides out
 * (.gone).
 * flex-basis 0 (not auto): the container's width must come from the
 * topbar layout ONLY, never from its own children - syncBars() measures
 * clientWidth to decide pills-vs-combined, and a content-driven basis
 * would feed the measurement back into itself. */
#loadbars {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 10px;   /* PILL_GAP_PX in app.js must match */
  overflow: hidden;
}
/* width-collapse regime: individual pills hide, the one combined bar
 * ("Loading Communities (N)") stands in */
#loadbars.combined .loadbar { display: none; }
#loadbars.combined .loadbar.combobar { display: flex; }
.loadbar {
  flex: 1 1 190px;
  min-width: 0;
  max-width: 420px;
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 3px 10px;
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 11.5px;
  white-space: nowrap;
  overflow: hidden;
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.4s ease, transform 0.4s ease,
              max-width 0.4s ease, padding 0.4s ease,
              margin 0.4s ease, flex-basis 0.4s ease;
}
/* mount + dismiss states (BAR_EXIT_MS in app.js matches the 0.4s above) */
.loadbar.enter, .loadbar.gone {
  opacity: 0;
  transform: translateY(-10px);
  max-width: 0;
  flex-basis: 0;
  padding-left: 0;
  padding-right: 0;
  margin-left: -10px;
  border-width: 0;
}
.loadbar .lbname {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-weight: 700;
  color: var(--fg);
}
.loadbar .lbtrack {
  flex: 1 1 46px;
  min-width: 34px;
  height: 5px;
  border-radius: 999px;
  background: #10131a;
  overflow: hidden;
}
.loadbar .lbfill {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: linear-gradient(90deg, #2f7fd0, var(--accent) 60%, #7dc3ff);
  transition: width 0.35s ease;
}
/* indeterminate (a device search has no DN count): sweep the fill */
.loadbar.indet .lbfill {
  width: 45% !important;
  animation: lbsweep 1.15s ease-in-out infinite;
}
@keyframes lbsweep {
  0%   { transform: translateX(-105%); }
  100% { transform: translateX(230%); }
}
.loadbar .lbcount { flex: 0 0 auto; color: var(--muted); }
.loadbar.done { border-color: var(--good); }
.loadbar.done .lbfill { background: var(--good); }
.loadbar.done .lbcount { color: var(--good); }

/* ---------- small-screen loading stripe ----------
 * Full-width, 3px, along the topbar's BOTTOM edge: the aggregate
 * progress of every loading park. Zero horizontal cost; JS hides it
 * (.hidden) whenever nothing is loading; the media block below is what
 * turns it on for small screens. */
#loadstripe {
  display: none;
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 3px;
  background: rgba(16, 19, 26, 0.9);
  overflow: hidden;
  pointer-events: none;
}
#loadstripe .lsfill {
  display: block;
  height: 100%;
  width: 0;
  background: linear-gradient(90deg, #2f7fd0, var(--accent) 60%, #7dc3ff);
  transition: width 0.35s ease;
}
/* totals unknown yet: indeterminate shimmer sweep */
#loadstripe.indet .lsfill {
  width: 40% !important;
  animation: lbsweep 1.3s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .loadbar, .loadbar .lbfill, #loadstripe .lsfill { transition: none; }
  .loadbar.indet .lbfill,
  #loadstripe.indet .lsfill { animation: none; width: 100% !important; }
}

.iconbtn {
  background: var(--bg3);
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 6px;
  /* sized by the ICON FLOOR, which is the tap floor widened to what the
   * glyph needs - see --tap-icon in :root. Height stays on --tap: nothing
   * squeezes the topbar vertically. */
  min-width: var(--tap-icon);
  min-height: var(--tap);
  padding: 3px 8px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: inherit;
  font-size: 16px;
  cursor: pointer;
}
/* THE GLYPH IS NOT A SHRINK BUDGET. An inline SVG is a flex item, and a
 * flex item shrinks by default - so as the bar tightened the icon
 * collapsed INSIDE its button, independently of the button's own floor.
 * Measured on dev at 593 px: button 18 px, glyph 0.00 px - three of the
 * four controls rendered as empty boxes. This is the half of the bug a
 * min-width could never fix, so it is pinned here rather than left to
 * whatever the button happens to leave over. */
.iconbtn svg { flex: 0 0 auto; }
.iconbtn:hover { border-color: var(--accent); color: var(--accent); }
/* the live-view entry point is an <a>, so it needs the two things a
 * <button> gave the rest of the cluster for free */
#livelink { text-decoration: none; }
#livelink svg { display: block; }
/* the about button used to be painted a brighter blue than its
 * neighbours; the operator (2026-07-26) wants it to read as one of the
 * icon row, so it now inherits .iconbtn like #acct and #gear. */
#about svg { display: block; }

/* settings popover (anchored under the header's right edge) */
#topbar { position: relative; }
.popover {
  position: absolute;
  top: calc(100% + 6px);
  right: 12px;
  z-index: 1500;
  width: 250px;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 12px 14px;
  box-shadow: 0 8px 30px rgba(0,0,0,0.55);
}
.popover label {
  display: block;
  font-size: 12px;
  color: var(--muted);
  margin: 8px 0 4px;
}
.popover input {
  width: 100%;
  background: var(--bg3);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 5px 8px;
  font: inherit;
}
.popover input:focus { border-color: var(--accent); outline: none; }
.modes { display: flex; gap: 6px; }
.modebtn {
  flex: 1 1 0;
  background: var(--bg3);
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 6px 0;
  min-height: var(--tap);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}
.modebtn:hover:not(:disabled) { border-color: var(--accent); }
.modebtn.on { color: var(--accent); border-color: var(--accent); font-weight: 700; }
/* #fleetload is the first .modebtn the JS ever disables (three dead ends:
 * not in fleet mode / no bubbles in view / everything already loaded), and
 * .modebtn author-sets background, colour, border AND cursor - so without
 * this rule a disabled one renders pixel-identical to a live one, keeps
 * the pointer cursor and still lights on hover. Same idiom, same numbers
 * as .navpage:disabled and .hbbtn:disabled below - the house pattern, not
 * a fourth one. The .pophint under the control says WHY; this says THAT. */
.modebtn:disabled { opacity: 0.4; cursor: default; }
.chkrow {
  display: flex !important;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}
.chkrow input { width: auto !important; }

/* a small explanatory line UNDER a control - why it is disabled, what a
 * run is doing. Muted and wrapping, because it carries sentences: a
 * control that silently no-ops is worse than none. */
.popover .pophint {
  font-size: 11px;
  line-height: 1.35;
  color: var(--muted);
  margin: 5px 0 0;
}
.popover .pophint:empty { display: none; }

.popover .pophead {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--muted);
  border-bottom: 1px solid var(--line);
  padding-bottom: 4px;
  margin-bottom: 8px;
}

/* ---------- account popover ---------- */
/* the account popover grew a SHARE MY LOCATION card, whose off-state
 * copy is deliberately long (it is what a person reads before deciding
 * to broadcast their position). Cap it against the viewport and scroll
 * INSIDE rather than shortening that copy or letting sign out fall off
 * the bottom of a short window / a phone in landscape. */
#acctpop {
  width: 210px;
  max-height: calc(100vh - 64px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* SHARE MY LOCATION mount (geoshare.js appends #rgs-card in here). The
 * module's card is namespaced .rgs-* and brings its own styling; all
 * this does is give it the popover's width and a separator from the
 * sign-out button under it. :empty collapses to nothing when the module
 * is absent or the signed-in role cannot share. */
#acctgeo { margin: 10px 0; }
#acctgeo:empty { display: none; margin: 0; }
#acctgeo .rgs-card { padding: 9px 10px 10px; font-size: 13px; }
/* THE ON-STATE, VISIBLE WITH THE POPOVER CLOSED. The control now lives
 * behind the account button, so that button has to carry the fact -
 * a technician must never have to open a popover to find out whether
 * they are broadcasting. Same pink as the tech pin they would be
 * appearing as (app.js TECH_COLOR / geoshare's --rgs-person). */
#acct.sharing {
  border-color: #ff4fa3;
  color: #ff4fa3;
  box-shadow: 0 0 0 1px rgba(255, 79, 163, 0.45);
}
#acct.sharing::after {
  content: "";
  position: absolute;
  top: 2px; right: 2px;
  width: 7px; height: 7px;
  border-radius: 50%;
  background: #ff4fa3;
  animation: acctshare 1.6s ease-in-out infinite;
}
#acct { position: relative; }
@keyframes acctshare {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
  #acct.sharing::after { animation: none; }
}
.acctname { font-weight: 700; font-size: 14px; }
.acctlink {
  display: block;
  margin-top: 10px;
  color: var(--accent);
  text-decoration: none;
  font-size: 12.5px;
}
.acctlink:hover { text-decoration: underline; }
.acctbtn {
  width: 100%;
  margin-top: 12px;
  background: var(--bg3);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 7px 0;
  min-height: var(--tap);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}
.acctbtn:hover { border-color: var(--bad); color: var(--bad); }
.acctlink { padding: 4px 0; min-height: var(--tap); line-height: 20px; }
#acct svg { display: block; }

/* ---------- connection status dot + its hover popover ---------- */
#statuswrap {
  position: relative;
  display: flex;
  align-items: center;
  padding: 4px 6px;
  cursor: help;
}
#statuspop { width: 290px; line-height: 1.65; }
#statuspop b { color: var(--muted); font-weight: 600; }
#statusbody .bad-txt { color: var(--bad); font-weight: 700; }

.dot {
  width: 11px; height: 11px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--gray);
  box-shadow: 0 0 0 2px var(--bg3);
}
.dot.ok   { background: var(--good); }
.dot.err  { background: var(--bad); }
.dot.unknown { background: var(--gray); }

/* ---------- info icon + hover popover (dewording pass) ----------
 * Standing UI principle: labels stay terse, the sentence moves in here. */
.ihint {
  display: inline-block;
  position: relative;
  width: 13px; height: 13px;
  line-height: 13px;
  margin-left: 5px;
  border-radius: 50%;
  border: 1px solid var(--muted);
  color: var(--muted);
  background: transparent;
  text-align: center;
  font: 700 9px/12px "Segoe UI", system-ui, sans-serif;
  font-style: normal;
  vertical-align: middle;
  cursor: help;
  user-select: none;
}
.ihint:hover { border-color: var(--accent); color: var(--accent); }
.ihint::after {
  content: attr(data-tip);
  position: absolute;
  left: 50%;
  bottom: calc(100% + 7px);
  transform: translateX(-50%);
  width: max-content;
  max-width: 240px;
  white-space: normal;
  text-align: left;
  background: var(--bg3);
  color: var(--fg);
  border: 1px solid var(--accent);
  border-radius: 7px;
  padding: 6px 9px;
  font: 400 11.5px/1.45 "Segoe UI", system-ui, sans-serif;
  box-shadow: 0 8px 26px rgba(0,0,0,0.6);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.12s ease;
  z-index: 3000;
}
.ihint:hover::after { opacity: 1; visibility: visible; }
/* FLIP-DOWN (set by the #panel hover handler in app.js). #panel is a
 * scroll container (overflow-y:auto, so overflow-x computes to auto too),
 * and a scroll container CLIPS anything above/left of its content box -
 * that overflow is not even scrollable. An upward tip on an icon near the
 * top of the panel lost its first lines (measured: the ovhint tip was cut
 * mid-sentence). Downward tips are never lost: below/right overflow
 * extends the scrollable area instead of vanishing. */
/* LEFT-ANCHOR (set by the same #panel hover handler): a centered tip on
 * an icon near the panel's LEFT edge clips off-panel - left overflow of
 * a scroll container is unreachable, exactly like the upward case. */
.ihint.tipleft::after {
  left: -8px;
  transform: none;
}
.ihint.tipdown::after {
  bottom: auto;
  top: calc(100% + 7px);
}
/* legend sits at the map's bottom-left: its tips must open UPWARD-RIGHT
 * so they are never clipped by the viewport edge */
.legend .ihint::after { left: 0; transform: none; }

/* ...and a POPOVER is the mirror image: 250 px wide, pinned 12 px from
 * the viewport's RIGHT edge, so a 240 px tip centred on an 11 px icon
 * hangs off both the popover and the screen. Measured at 375 px: opening
 * the gear popover pushed documentElement.scrollWidth 375 -> 423, purely
 * from these tips - the third outing for the "visibility:hidden is still
 * LAID OUT and still counted" trap.
 * Anchored to the icon's RIGHT edge so it opens LEFTWARD, which is the
 * structural half of the fix: right-side overflow grows scrollWidth,
 * left-side overflow does not. `body { overflow-x: hidden }` is the
 * backstop under it, not the fix. */
.popover .ihint::after {
  left: auto;
  right: 0;
  transform: none;
  max-width: 190px;
}

/* ---------- layout ---------- */
main {
  flex: 1 1 auto;
  display: flex;
  min-height: 0;
}
#mapwrap {
  flex: 1 1 auto;
  min-width: 0;
  position: relative;
  display: flex;
}
#map {
  flex: 1 1 auto;
  background: #0d0f12;
}

/* full-map overlay over a park load's blank/positioning phase; the click
 * (anywhere on it) lifts it instantly */
#maploading {
  position: absolute;
  inset: 0;
  z-index: 1400;
  background: rgba(13, 15, 18, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
#maploading .mlbox {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  background: var(--bg2);
  border: 1px solid var(--accent);
  border-radius: 14px;
  padding: 10px 26px;
  color: var(--accent);
  font-weight: 600;
}
/* MULTI-COMMUNITY overlay: a second park joining a still-blank load turns
 * the wording into "Loading Communities…" and reveals this bar */
#maploading .mlbar {
  display: block;
  width: 190px;
  height: 5px;
  border-radius: 999px;
  background: #10131a;
  overflow: hidden;
}
#maploading .mlbar.hidden { display: none !important; }
#maploading .mlbarfill {
  display: block;
  width: 45%;
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, #2f7fd0, var(--accent) 60%, #7dc3ff);
  animation: lbsweep 1.15s ease-in-out infinite;
}
#maploading .mltext { animation: offline-flash 1.6s ease-in-out infinite; }
/* rotating WISP-flavored loading line - overlay only, never the pills */
#maploading .mlmsg {
  color: var(--accent);
  font-style: italic;
  font-weight: 400;
  font-size: 12.5px;
  opacity: 0.9;
  max-width: 340px;
  text-align: center;
}

/* ---------- connection-lost banner (top of the map) ---------- */
#connbanner {
  position: absolute;
  top: 10px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1450;
  padding: 7px 20px;
  border-radius: 999px;
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: 0.3px;
  white-space: nowrap;
  box-shadow: 0 8px 26px rgba(0,0,0,0.55);
  pointer-events: none;
}
#connbanner.down {
  background: #b5211a;
  color: #fff;
  border: 1px solid #7f1d18;
  animation: offline-flash 1.4s ease-in-out infinite;
}
#connbanner.upflash {
  background: #1f7a34;
  color: #fff;
  border: 1px solid #135c27;
}

/* collapsible side panel: slim always-visible tab + width transition */
#paneltab {
  flex: 0 0 20px;
  padding: 0;
  background: var(--bg2);
  color: var(--muted);
  border: none;
  border-left: 1px solid var(--line);
  font: inherit;
  font-size: 11px;
  cursor: pointer;
}
#paneltab:hover { color: var(--accent); }
#panel {
  flex: 0 0 380px;
  max-width: 45vw;
  overflow-y: auto;
  background: var(--bg2);
  border-left: 1px solid var(--line);
  padding: 14px 16px;
  transition: flex-basis 0.25s ease, padding 0.25s ease;
}
#panel.collapsed {
  flex-basis: 0;
  max-width: 0;
  padding: 0;
  border-left: none;
  overflow: hidden;
}
#panel h2 { margin: 0 0 8px; font-size: 16px; }
#panel h3 {
  margin: 16px 0 6px;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--muted);
  border-bottom: 1px solid var(--line);
  padding-bottom: 3px;
}
#panel p { margin: 6px 0; }
/* SECTION META: the subordinate line under an <h3> carrying identity +
 * provenance + freshness. Explicitly NOT uppercase - the h3 above it is
 * the caps element, and stacking caps on a 60-character line is what made
 * the old one-line metrics caption unreadable. Wraps rather than
 * ellipsising: every fact on it matters. */
.secmeta {
  margin: -2px 0 8px;
  font-size: 11.5px;
  line-height: 1.6;
  color: var(--muted);
}
.secmeta .mono { color: var(--fg); }

/* endpoint cards */
.ep {
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 8px 10px;
  margin: 6px 0;
}
.ep .role {
  display: inline-block;
  font-size: 11px;
  font-weight: 700;
  padding: 1px 7px;
  border-radius: 4px;
  margin-right: 6px;
  color: #10131a;
}
.ep .role.dn { background: var(--dn); }
.ep .role.cn { background: var(--cn); }
.ep .role.other { background: var(--gray); }
.ep .name { font-weight: 600; }
.ep .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.ep .coords { font-size: 12px; margin-top: 4px; }

button.copy, .ep a, #panel a {
  color: var(--accent);
  text-decoration: none;
}
#panel a:hover { text-decoration: underline; }
button.copy {
  background: none;
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 1px 8px;
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}
button.copy:hover { border-color: var(--accent); }
button.copy.copied { color: var(--good); border-color: var(--good); }

/* distance block */
.distbox { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.distval { font-size: 26px; font-weight: 700; }
.badge {
  font-size: 11px;
  font-weight: 700;
  padding: 2px 8px;
  border-radius: 4px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}
.badge.gps   { background: var(--good); color: #10131a; }
.badge.model { background: var(--accent); color: #10131a; }
.badge.none  { background: var(--bg3); color: var(--muted); border: 1px solid var(--line); }
.badge.dead  { background: var(--gray); color: #10131a; }
.badge.stpblk { background: #000; color: #fff; border: 1px solid var(--gray); }
.flag {
  display: inline-block;
  font-size: 11px;
  padding: 1px 8px;
  border-radius: 999px;
  margin: 2px 4px 2px 0;
  border: 1px solid var(--warn);
  color: var(--warn);
}
.flag.red { border-color: var(--bad); color: var(--bad); font-weight: 700; }

/* metrics table */
table.metrics {
  width: 100%;
  border-collapse: collapse;
  font-size: 12.5px;
}
table.metrics th, table.metrics td {
  text-align: right;
  padding: 3px 8px;
  border-bottom: 1px solid var(--line);
}
table.metrics th:first-child, table.metrics td:first-child {
  text-align: left;
  color: var(--muted);
}
table.metrics th { color: var(--muted); font-weight: 600; }
/* the end's own short name under its column head - see metricsHdr: the
 * metrics table now sits ABOVE the endpoint cards, so it has to name its
 * two columns itself. Wraps rather than ellipsises: a truncated radio
 * name is worse than a two-line header on a 375 px panel. */
table.metrics th .thsub {
  font-weight: 400;
  font-size: 11px;
  color: var(--muted);
  opacity: 0.85;
  overflow-wrap: anywhere;
}
td.good { color: var(--good); }
td.warn { color: var(--warn); }
td.bad  { color: var(--bad); font-weight: 700; }

/* warnings + lists */
ul.warns, ul.plain {
  margin: 6px 0;
  padding-left: 18px;
}
ul.warns li { color: var(--warn); margin: 3px 0; font-size: 13px; }
ul.plain li { margin: 3px 0; }
.linkref, .cand { cursor: pointer; color: var(--accent); }
.linkref:hover, .cand:hover { text-decoration: underline; }

.wbadge {
  display: inline-block;
  width: 15px; height: 15px;
  line-height: 15px;
  text-align: center;
  border-radius: 50%;
  background: var(--warn);
  color: #10131a;
  font-size: 11px;
  font-weight: 800;
  margin-right: 6px;
}

.kv { font-size: 13px; margin: 3px 0; }
.kv b { color: var(--muted); font-weight: 600; }

/* park overview: breadcrumb + DN / CN-link rows */
#crumb {
  display: inline-block;
  margin-bottom: 10px;
  cursor: pointer;
}
#crumb:hover { border-color: var(--accent); color: var(--accent); }
.backrow { margin-bottom: 6px; }
.backlink { color: var(--accent); cursor: pointer; font-size: 12px; }
.backlink:hover { text-decoration: underline; }

/* unified community list: one accordion row per park (the old topbar
 * picker rows and the per-park overview sections are the SAME element
 * now - unloaded rows are header-only, loaded rows carry the nav tree) */
/* one-line nav hint above the community list (the explainer paragraph it
 * replaced lives in its info icon now) */
.ovhint { margin: 2px 0 8px; }

.parksec {
  margin: 5px 0;
  border: 1px solid var(--line);
  border-radius: 10px;
  overflow: hidden;
}
.parkhead {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  min-height: var(--tap);
  background: var(--bg2);
  cursor: pointer;
  user-select: none;
  font-size: 12.5px;
}
.parkhead.loaded { background: var(--bg3); padding: 8px 10px; }
.parkhead:hover { background: #2b323d; }
.parkhead.active { box-shadow: inset 3px 0 0 var(--accent); }
.parkhead .parkname { flex: 0 1 auto; overflow: hidden;
                      text-overflow: ellipsis; white-space: nowrap; }
.parkhead .parktitle { font-weight: 700; font-size: 14px; }
/* counts/offline badges never wrap: the name is the element that
 * ellipsizes when a long community name meets a busy row */
.parkhead .parkcounts { white-space: nowrap; }
.parkhead.loaded .parkname { color: var(--accent); }
/* park currently has OFFLINE radio(s): red header highlight (composes
 * with .active - the accent inset bar stays) */
.parkhead.offline { background: rgba(229, 83, 75, 0.16); }
.parkhead.offline:hover { background: rgba(229, 83, 75, 0.26); }
.parkhead.offline .parktitle { color: #f0928c; }
.parkhead .offcount {
  color: var(--bad);
  font-weight: 700;
  white-space: nowrap;
}
.parkcaret {
  color: var(--muted);
  font-size: 14px;
  flex: 0 0 auto;
  width: 18px;
  text-align: center;
  padding: 0 2px;
}
.parkcaret:hover { color: var(--accent); }
.parkload { color: var(--accent); }
/* a collapsed row renders NO body at all (parkSectionHtml skips the whole
 * nav tree) - with ~66 rows that is what keeps the list cheap */
.parkbody { padding: 0 10px 8px; }

/* management-page icon link (pole-panel member rows) */
.mgmticon {
  font-size: 12px;
  margin-left: 6px;
  text-decoration: none;
}

ul.ovlist { list-style: none; margin: 6px 0; padding: 0; }
.dnrow, .cnrow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  padding: 7px 9px;
  min-height: var(--tap);
  margin: 5px 0;
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
}
.dnrow:hover, .cnrow:hover { border-color: var(--accent); }
/* live-aiming icon on a CN nav row: reachable WITHOUT selecting the
 * radio first. Square, last in the row, and it never grows - the
 * customer name keeps the slack (.rowmain flexes, this does not).
 * pointer-events:none on the glyph so a tap always lands on the BUTTON:
 * the panel's single closest() dispatcher reads .uiact off e.target's
 * nearest match, and a hit on the bare <path> would resolve to the row
 * instead and fire focusLink as well. */
.cnrow .navlive {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap);
  padding: 0;
  color: var(--muted);
}
.cnrow .navlive:hover { color: var(--accent); border-color: var(--accent); }
.cnrow .navlive svg { pointer-events: none; }
.rowmain { flex: 1 1 auto; min-width: 0; }
.hbadge {
  font-size: 11px;
  padding: 1px 8px;
  border-radius: 999px;
  border: 1px solid var(--dn);
  color: #9db7f5;
  white-space: nowrap;
}
.hbadge.none { border-color: var(--line); color: var(--muted); }
.cncount { font-size: 11px; color: var(--muted); white-space: nowrap; }
.distsm { font-weight: 700; font-size: 13px; }
/* small black STP dot on affected park-overview DN and link rows */
.stpdot {
  display: inline-block;
  width: 9px; height: 9px;
  flex: 0 0 auto;
  border-radius: 50%;
  background: #000;
  border: 1px solid var(--gray);
}
.stptext { color: var(--warn); }

/* ---------- nav accordion tree (park overview) ----------
 * Ring -> Spoke -> Pole -> WLAN -> CNs; indented kid containers with a
 * guide line, chip-like pole rows, small polarity/channel dots. */
.navtree { margin: 8px 0 4px; }
.navnode { margin: 2px 0; }
.navkids {
  margin: 2px 0 4px 9px;
  padding-left: 8px;
  border-left: 1px solid var(--line);
}
/* every nav row IS a click target (it opens/focuses its node), so it gets
 * the tap floor too - the caret inside it is a hint, not the hit area */
.navrow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px 8px;
  min-height: var(--tap);
  border: 1px solid transparent;
  border-radius: 8px;
  cursor: pointer;
  user-select: none;
  font-size: 13px;
}
.navrow:hover { border-color: var(--accent); }
.navrow .navsum { margin-left: auto; white-space: nowrap; }
.navring { font-size: 14px; }
.navpole { background: var(--bg3); border-color: var(--line); }
.navpole:hover { border-color: var(--accent); }
/* A WLAN ROW WEARS ITS CHANNEL as a left edge, set INLINE by navWlanHtml
 * from the map's own chanColor() - never restated here, or the row and the
 * pole glyph's rectangle could drift apart. The edge is 3px where the row's
 * own border is 1px, so the extra 2px comes back out of the padding and
 * wlan rows stay aligned with their siblings. Deliberately the ROW only:
 * .navkids underneath keeps the ordinary background. */
.navwlan { padding-left: 6px; border-radius: 4px 8px 8px 4px; }
.ncar, .navcaret {
  color: var(--muted);
  font-size: 13px;
  width: 16px;
  flex: 0 0 auto;
  text-align: center;
}
.navcaret:hover { color: var(--accent); }
.navpol {
  display: inline-block;
  width: 15px; height: 15px;
  line-height: 15px;
  border-radius: 50%;
  color: #fff;
  text-align: center;
  font-size: 10px;
  font-weight: 800;
  flex: 0 0 auto;
}
.chdot {
  display: inline-block;
  width: 14px; height: 14px;
  line-height: 14px;
  border-radius: 3px;
  color: #10131a;
  text-align: center;
  font-size: 10px;
  font-weight: 800;
  flex: 0 0 auto;
}
.navleaf { cursor: default; }
.navleaf:hover { border-color: transparent; }
/* inline dn-dn backhaul indicator ON the wlan row ("wlan1 · 318° ·
 * ⇆SP8 · ch3"): a small pill between the azimuth and the channel;
 * clicking it selects the link (focusLink), never toggles the row */
.navdnlnk {
  display: inline-block;
  font-size: 11.5px;
  font-weight: 700;
  color: var(--dn);
  border: 1px solid var(--dn);
  border-radius: 999px;
  padding: 0 7px;
  white-space: nowrap;
  cursor: pointer;
}
.navdnlnk:hover { color: var(--accent); border-color: var(--accent); }
/* down/poor backhaul: same burnt-orange family as the map alert lines */
.navdnlnk.alert { color: #e08a4a; border-color: #c2540c; }
.navempty { padding: 2px 8px; }
.navpager {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 0 6px;
}
.navpage {
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--fg);
  font: inherit;
  font-size: 12px;
  padding: 4px 12px;
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  cursor: pointer;
}
.navpage:hover:not(:disabled) { border-color: var(--accent); }
.navpage:disabled { opacity: 0.4; cursor: default; }

/* no-position list: one COLLAPSIBLE group per reason, shut by default
 * (operator 2026-07-26). The <details> carries the state, so there is no
 * class for JS to get out of step with; the count lives in the <summary>
 * so a shut group still says how many radios are inside it. */
.nogrp { margin: 6px 0 0; }
.nogroup {
  margin: 10px 0 2px;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  user-select: none;
  /* NO `display:` HERE ON PURPOSE. A <summary> is display:list-item, and
     that is what draws the disclosure triangle - setting flex (or block)
     silently removes the only affordance that says the row opens. The tap
     target is bought with padding instead, which keeps the marker. */
  padding: 6px 0;
  min-height: var(--tap);
  box-sizing: border-box;
}
.nogroup:hover { color: var(--accent); }
/* the rows themselves sit in from the summary so the grouping is readable
   at a glance once open */
.nogrp > ul.plain { margin: 2px 0 6px 16px; }

/* REVEAL flash: focusing a radio that has no map position scrolls its row
 * here and pulses it, because there is nowhere on the map to pan to. The
 * highlight is short-lived - the resting row style must come back exactly
 * as it was, so only background/outline animate. */
/* The 0% stop is TINTED, not transparent: during a park load the list
 * re-renders many times a second and each re-render is a brand-new <li>,
 * i.e. the animation restarts from 0% over and over - a transparent 0%
 * left the "highlighted" row looking untouched for the whole load. */
@keyframes rowflash {
  0%, 100% { background: rgba(77,157,224,0.14); }
  20%, 60% { background: rgba(77,157,224,0.32); }
}
.rowflash {
  /* background + outline ONLY: neither affects layout, so a row that
     flashes must not nudge its neighbours (the <li> is a block box, its
     background already spans the full row without padding) */
  animation: rowflash 2.6s ease-in-out 1;
  border-radius: 4px;
  outline: 1px solid var(--accent);
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  /* no pulse, but the row must still be findable: steady tint */
  .rowflash {
    animation: none;
    background: rgba(77,157,224,0.32);
  }
}

/* dead-links footer chip (panel footer; sticks to the bottom edge while
 * the panel content scrolls) */
#deadchip {
  display: inline-block;
  position: sticky;
  bottom: 8px;
  margin-top: 12px;
  cursor: pointer;
  background: var(--bg2);
}
#deadchip:hover { border-color: var(--accent); color: var(--accent); }
#deadchip.on { color: var(--warn); border-color: var(--warn); }

/* ---------- map markers / legend ---------- */
.dn-marker {
  background: var(--dn);
  border: 2px solid #dfe8ff;
  border-radius: 3px;
  box-shadow: 0 0 5px rgba(0,0,0,0.7);
}
/* Non-D621 DN (K60DN) with an STP-blocked ethernet port: black square
 * (border kept for visibility against the dark tiles) */
.dn-marker.stpblk { background: #000; }
/* eth-blocked D621: default square + a black INSET STRIKE across the
 * middle of the long axis (~2px thick, inset 2px from each end) - same
 * mark the pole-glyph member rects draw. Shared with the legend swatches. */
.dn-marker.ethstrike,
.legend .mk.ethstrike,
.legend .legrect.ethstrike { position: relative; }
.dn-marker.ethstrike::after,
.legend .mk.ethstrike::after,
.legend .legrect.ethstrike::after {
  content: "";
  position: absolute;
  left: 2px; right: 2px;
  top: 50%; height: 2px;
  margin-top: -1px;
  background: #000;
  border-radius: 1px;
}

/* ---------- offline radios: soft red pulse ----------
 * CONTRAST (2026-07-25): the floor was 0.3, which on a bright OSM tile
 * left the marker essentially invisible for half of every cycle. 0.5
 * still reads unmistakably as "flashing" while staying legible on light
 * tiles; the markers additionally carry a dark casing stroke. */
@keyframes offline-flash {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}
/* CONTRAST (2026-07-25): a map mark must never pulse its WHOLE self
 * towards transparent - measured on live OSM tiles, red-at-0.3 over a
 * green tile scored 1.21-1.33 WCAG, i.e. gone. Red on green is also
 * luminance-poor even at FULL opacity (~1.5), so the thing that carries
 * the mark is a permanently opaque DARK casing: the fill pulses, the
 * casing does not. Measured after: 3.9-4.3 at the flash floor. */
@keyframes offline-flash-bg {
  0%, 100% { background: #e5534b; }
  50% { background: #8f1f19; }
}
@keyframes offline-flash-fill {
  0%, 100% { fill-opacity: 0.9; }
  50% { fill-opacity: 0.2; }
}
/* offline DN square (search view) - offline wins over stpblk (declared
 * after it). Solid dark border + a color pulse, never an opacity pulse. */
.dn-marker.offline {
  background: #e5534b;
  border-color: #7f1d18;
  animation: offline-flash-bg 1.2s ease-in-out infinite;
}
/* offline CN circleMarker (Leaflet puts the className on the SVG path):
 * the #7f1d18 stroke set in makeMarker stays fully opaque */
.offline-flash { animation: offline-flash-fill 1.2s ease-in-out infinite; }
/* offline pole member rectangle: red overrides the inline channel fill */
.pole-marker .polemember.offline rect {
  fill: #e5534b;
  stroke: #7f1d18;
  stroke-width: 1.6;
  animation: offline-flash-fill 1.2s ease-in-out infinite;
}
/* red "offline" text in panels / popups / list rows */
.offline-txt { color: #e5534b; font-weight: 600; }
/* legend swatch */
.legend .mk.offline-leg {
  background: #e5534b;
  border-radius: 50%;
  animation: offline-flash 1.2s ease-in-out infinite;
}
/* alert dn-dn backhaul lines (down or poor): opacity-only pulse on the
 * SVG path so it composes with any color mode (orange in health, the
 * channel color in channel mode).
 * CONTRAST FIX (2026-07-25, user report): #e3a13c pulsing to 0.25 was
 * invisible over light OSM tiles. Three changes: the health-mode alert
 * color is now the darker/more saturated #c2540c (ALERT_ORANGE in
 * app.js), the pulse FLOOR is 0.55 instead of 0.25, and a thin dark
 * casing polyline (casingpane) is drawn underneath. */
@keyframes lnk-alert {
  0%, 100% { stroke-opacity: 1; }
  50% { stroke-opacity: 0.55; }
}
path.lnk-alert { animation: lnk-alert 1.2s ease-in-out infinite; }
/* legend swatch for the alert backhaul row */
.legend .sw.alert-leg {
  border-top-color: #c2540c;
  animation: offline-flash 1.2s ease-in-out infinite;
}

/* reduced motion: steady red/orange instead of the pulse */
@media (prefers-reduced-motion: reduce) {
  .dn-marker.offline,
  .offline-flash,
  .pole-marker .polemember.offline rect,
  path.lnk-alert,
  .legend .sw.alert-leg,
  .legend .mk.offline-leg,
  #connbanner.down,
  #maploading .mltext,
  #maploading .mlbarfill { animation: none; }
  /* steady state must still be the LEGIBLE one */
  .offline-flash,
  .pole-marker .polemember.offline rect { fill-opacity: 0.9; }
  #maploading .mlbarfill { width: 100%; }
}
/* eth-blocked-D621 arrow: sits ON the solid black link line ~70% toward
 * the blocked DN, rotated (inline style) to the segment bearing. The
 * wrap is the 16x16 divIcon box; the triangle points up before rotate. */
.stp-arrow-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
}
.stp-arrow {
  width: 0; height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-bottom: 11px solid #000;
  filter: drop-shadow(0 0 1.5px #dfe8ff);
}
/* pole glyphs (park view): fixed-size SVG divIcon, zoom-stable, touch-sized.
 * The clickable member rectangles carry data-host; the ring/center opens the
 * pole panel. */
/* overflow visible is LOAD-BEARING on both of the next two rules: the
 * pole-name badge hangs OUTSIDE the 76 px icon box on purpose - in
 * whichever direction app.js found room, so it can overhang any edge - and
 * that is what lets the ring stay pinned to the pole's lat/lon with
 * iconSize/iconAnchor untouched. (The drop-shadow already paints outside
 * the box, which is also why the CSS filter region does not clip it.) */
.pole-marker { background: none; border: none; overflow: visible; }
/* .fleetlabel-wrap rides this rule rather than repeating it: the fleet
 * map's park nametag IS the pole name badge (app.js fleetLabelIcon), so it
 * wants the same overflow-visible box and the same drop shadow that makes
 * a badge legible over bright and dark tiles alike. One declaration, two
 * hosts, no duplicated literal. */
.pole-marker svg,
.fleetlabel-wrap svg { display: block; overflow: visible; filter: drop-shadow(0 0 3px rgba(0,0,0,0.8)); }
.pole-marker .polemember { cursor: pointer; }
.pole-marker .polebody { cursor: pointer; }
/* pole/wlan labels (gear "pole names" + the selected pole's members) are
 * BADGES: an opaque box carries its own contrast, so one rule reads over
 * BOTH light and dark tiles - the same contrast the golay badge buys with
 * its white disc. NEVER a click target: the member rectangle underneath
 * keeps its hit even where a badge overlaps it.
 * NO font-size here on purpose - app.js sets it inline (11) and sizes the
 * box from it; a size in CSS would win over the attribute and desync the
 * text from its box.
 * THE SELECTORS ARE CLASS-ONLY, NOT `.pole-marker .polelabel` (2026-07-29):
 * the FLEET MAP's park nametag is the same badge hosted in a Leaflet
 * divIcon rather than in the pole pane, and the alternative was a second
 * block repeating this font stack and the three .polename colours - i.e.
 * exactly the duplicated literals that let two labels drift apart. Nothing
 * else in this file styles a bare SVG rect/text, so dropping the prefix
 * competes with nothing. */
.polelabel {
  pointer-events: none;
  font-family: Consolas, "Cascadia Mono", monospace;
  font-weight: 700;
  text-anchor: middle;
}
/* WLAN label: white box, black border, flush on the member rect's front
 * edge (app.js rotates it with the radio and flips it when it would read
 * upside down). */
.pole-marker .wlanlabel rect { fill: #fff; stroke: #10131a; stroke-width: 1; }
.pole-marker .wlanlabel text { fill: #10131a; }
/* pole NAME: filled blue badge, bold white text, detached from the glyph
 * at whatever bearing app.js found clearest. Deeper than the odd-polarity
 * blue (#4d7ce5) so white text clears 6:1 and the badge never reads as a
 * polarity dot. SHARED with the fleet map's park nametag (see above) - so
 * a park tag and a pole tag read as the same kind of object because they
 * are literally the same three declarations. */
.polename rect { fill: #1f5fbf; stroke: #0b2545; stroke-width: 1; }
.polename text { fill: #fff; }
/* THERE IS NO `.badgesub` RULE ANY MORE (2026-07-30). It styled a SECONDARY
 * RUN inside a name badge, and its one user was the fleet map's zoom
 * hand-off tag ("<park> - loading…"). That state is a RING around the mark
 * now (`.flring.flhand::after`, operator's call), so the tspan, its CSS and
 * `nameBadgeParts`' `sub` parameter are all gone rather than left as an
 * unused API for the next person to find a use for. */
/* LEADER LINE glyph -> badge. A detached badge has to be unambiguously
 * tied to its own glyph, but it must never be mistaken for a link line, so
 * it stays deliberately subordinate: a 1.2 px hairline in the badge's own
 * blue, on a dark casing so it survives bright OSM tiles, with a small dot
 * pinning it to the ring. Link lines by contrast are 3-5 px, coloured by
 * health/channel, and run pole-to-pole.
 * CLASS-ONLY SELECTORS, not `.pole-marker .poleleader` (2026-07-30), for
 * exactly the reason `.polelabel`/`.polename` above lost the same prefix:
 * the FLEET MAP's park nametag is the same badge in a Leaflet divIcon and
 * needs the same leader (operator: "Where is the line that connects
 * them?"). One declaration, two hosts - the alternative was a second block
 * restating the badge blue, the casing ink and three stroke widths, i.e.
 * two leaders that drift apart on the next tweak. (A test counts how many
 * times the badge blue appears in this file, so do not spell it out in
 * prose here either.) */
.poleleader .lead-base {
  fill: none; stroke: #0b2545; stroke-width: 2.6; stroke-linecap: round;
}
.poleleader .lead-ink {
  fill: none; stroke: #1f5fbf; stroke-width: 1.2; stroke-linecap: round;
}
.poleleader .lead-dot {
  fill: #1f5fbf; stroke: #0b2545; stroke-width: 1;
}
/* STACKED-DEVICE CUE: the little "3 of 6 here" chip app.js parks next to
 * the cursor when a click lands on a pile of co-located radios. It exists
 * because the SECOND click on the same spot selects something else, and
 * without a cue that reads as the map changing its mind rather than as
 * cycling. Created and positioned from JS (index.html is not app.js's to
 * edit) inside the Leaflet container, hence the absolute positioning.
 * pointer-events off: the chip sits over the very markers it is talking
 * about and must never eat the next click of the cycle. */
#stackcue {
  position: absolute;
  z-index: 1100;             /* over every Leaflet pane, under the controls */
  pointer-events: none;
  max-width: 300px;
  padding: 3px 8px;
  border-radius: 5px;
  background: rgba(16, 19, 26, 0.94);
  color: #dfe8ff;
  border: 1px solid var(--accent);
  box-shadow: 0 2px 8px rgba(0,0,0,0.6);
  font: 600 11px/1.45 Consolas, "Cascadia Mono", monospace;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#stackcue.hidden { display: none; }
/* golay badge at a DN-DN link midpoint: white disc, black digit, zoom-stable */
.golay-badge { background: none; border: none; }
.golay-badge .gb {
  width: 20px; height: 20px; line-height: 18px;
  border-radius: 50%;
  background: #fff; color: #10131a;
  border: 2px solid #000;
  text-align: center;
  font: 700 11px/18px Consolas, monospace;
  box-shadow: 0 0 3px rgba(0,0,0,0.7);
}
/* mismatch keeps the red ring + red text but grows so the "a/b" text
 * doesn't crowd the ring (user feedback: same-color text on ring was
 * hard to read at 20px) */
.golay-badge .gb.mismatch {
  border-color: var(--bad); color: var(--bad);
  width: 26px; height: 26px;
  line-height: 22px;
  font-size: 12px;
  margin: -3px 0 0 -3px;   /* keep the 20px icon anchor centered */
}
/* HEALTH-mode sibling of .gb: the same white/black chip family, but a
 * ROUNDED RECT carrying the measured link distance ("172 m"). Sized by
 * the icon (width computed from the text in badgeContent), so it fills
 * whatever box Leaflet anchored. */
.golay-badge .db {
  width: 100%; height: 100%;
  box-sizing: border-box;
  border-radius: 6px;
  background: #fff; color: #10131a;
  border: 2px solid #000;
  text-align: center;
  font: 700 11px/14px Consolas, monospace;
  box-shadow: 0 0 3px rgba(0,0,0,0.7);
  white-space: nowrap;
}
/* SURVEY-CANDIDATE range badge: the same .db chip, filled with the
 * candidate's RATING colour (set inline from RATING_COLORS, so the map
 * and the panel's .rating chip can never drift apart).
 *
 * FILL, NOT BORDER: a 2 px border on a ~30x18 chip is invisible at map
 * scale, and fill costs nothing in legibility here because all four
 * rating colours are mid-to-light - ONE dark ink clears 6:1 against every
 * one of them, which is why .rating above already does exactly this in
 * the panel. The black casing and the drop shadow stay: they are what
 * keeps the chip readable over both light and dark tiles, and they also
 * separate it from the translucent channel-coloured petals it necessarily
 * shares a palette with. It is never coloured by an UNKNOWN rating - the
 * JS falls back to a neutral gray rather than asserting a quality. */
.golay-badge .db.svyb { color: #10131a; border-color: #10131a; }

.leaflet-container { font: inherit; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
  background: var(--bg3);
  color: var(--fg);
}
.leaflet-popup-content { margin: 10px 14px; font-size: 13px; }

.legend {
  background: rgba(20, 23, 28, 0.88);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 8px 12px;
  color: var(--fg);
  font-size: 12px;
  line-height: 1.7;
}

/* ---------- the legend scrolls when the map pane cannot hold it ----------
 * Operator 2026-07-30: "when the map is compressed the legend should become
 * scrollable if expanded". The CAP is derived per render from the live pane
 * (legendMaxHeight/sizeLegend in app.js, where the measurement is written
 * down); this block is only what a capped box does with the overflow.
 *
 * THE CLASS IS THE POINT - it is applied ONLY while the cap actually bites,
 * so a legend that fits is byte-identical to before, and the who-is-here
 * pill (also a .legend) is never touched. An overflow container CLIPS, and
 * two things here get clipped the moment these rules go unconditional:
 *
 *  - overflow-x HAS TO BE SPELLED OUT. An idle .ihint tip is
 *    visibility:hidden, which is STILL LAID OUT and still counts in
 *    scrollWidth - the same trap this stylesheet already documents in
 *    five other places. Measured on the legend: scrollWidth 512 against a
 *    276 px client box, so `overflow-y: auto` alone computes overflow-x to
 *    `auto` and hangs a PERMANENT horizontal scrollbar on the box
 *    (verified live: it also ate 15 px of the visible height).
 *    `overflow-x: clip` + overflow-clip-margin is not an escape - with the
 *    other axis scrolling, clip computes to hidden, confirmed live in the
 *    browser as well as by the spec.
 *  - THE HOVER TIPS ARE CUT WHILE IT SCROLLS, and that is the accepted
 *    cost, not an oversight. They are 215-240 px wide and anchored at
 *    their icon, and the icons sit 91-252 px into a 252 px content box:
 *    measured, 15 of the legend's 16 tips hang past the right edge, by 12
 *    to 199 px. Nothing in CSS can reel that back in - the icons are
 *    spread across the row width, so neither edge to anchor to fits every
 *    row - and a legend that covers the map is the worse failure. On a
 *    tall viewport the class is absent and the tips escape exactly as they
 *    always have.
 *    THE CUT IS ON BOTH AXES, and the second half is easy to miss because
 *    only the first shows up in a screenshot of an idle legend.
 *    `.ihint::after` opens UPWARD (`bottom: calc(100% + 7px)`), and a
 *    scroll container clips everything above its content box - the same
 *    fact the .tipdown comment up at .ihint records for #panel. So while
 *    it scrolls, the tips of the rows sitting at the TOP of the scrollport
 *    lose their heads as well: any row within (tip height + 7 px) of the
 *    scrollport top. Observed in a harness driving THIS stylesheet with
 *    legend-shaped rows (240 px tips, 64 px tall): first row cut 35 px,
 *    second 15 px, third and below clean.
 *  - ...AND DO NOT "FIX" THAT WITH .tipdown. Flipping these tips downward
 *    is the obvious reach - the rule already exists 40 lines up - and it
 *    breaks the feature, because a DOWNWARD tip is laid out BELOW the last
 *    row and an idle tip is `visibility: hidden`, which is still laid out
 *    and still counted. So an idle tip adds a fixed slab to scrollHeight
 *    with nothing hovered: adding exactly that rule in the same harness
 *    moved scrollHeight 954 -> 1014 at 45 rows and 363 -> 423 at 16 -
 *    +60 px at both, i.e. a constant, not a per-row cost, and it does not
 *    shrink with the legend. That is not a cosmetic 60 px -
 *    scrollHeight is the input to BOTH `sizeLegend`'s class toggle AND the
 *    wheel handler's `scrollHeight > clientHeight`, so the legend would
 *    latch into scroll mode a tip-height early and take the wheel with
 *    only empty space to scroll: the dead zone the conditional capture
 *    exists to avoid. Pinned by tests/test_legend_scroll.py.
 *
 * THE HEADING IS STICKY because it IS the collapse toggle: an operator who
 * scrolls past it cannot uncover the map. It has to cover the box's own
 * padding or rows slide through the gap, and the TOP half of that is not a
 * negative margin: A STICKY CHILD IS CLAMPED TO ITS CONTAINING BLOCK'S
 * CONTENT BOX, so it can never rise into the parent's padding. Measured -
 * with `margin-top: -8px` the pinned heading still sat 9 px down (1 border
 * + the 8 px padding) at EVERY scroll position, leaving an 8 px strip of
 * scrolled rows above it. So the container gives its top padding up while
 * it scrolls and the heading carries it instead: the heading pins at 1 px,
 * flush with the scrollport, and nothing has moved. Left/right ARE margins
 * (no clamp applies across the inline axis) and cancel with the matching
 * padding, as does the 2 px at the bottom. The background is the box's own
 * colour, which doubles to ~0.99 alpha where the two overlap - exactly what
 * a pinned header needs. */
.legend.legscroll {
  overflow-y: auto;
  overflow-x: hidden;
  padding-top: 0;          /* moved onto .leghead, which now pins at the top */
}
.legend.legscroll .leghead {
  position: sticky;
  top: 0;
  z-index: 1;
  margin: 0 -12px -2px;
  padding: 8px 12px 2px;
  background: rgba(20, 23, 28, 0.88);
}

/* map-floating control button (clear map, bottom-right - across from the
 * bottom-left legend, same visual family). The attribution shares the
 * bottom-right corner, so the corner stacks its children in a column and
 * the button sits UNDER the attribution line, flush with the map edge. */
.leaflet-bottom.leaflet-right {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  /* ...AND THE STACK MAY NEVER OUTGROW THE MAP. This corner is anchored
   * at the BOTTOM inside a `.leaflet-container` that is `overflow:
   * hidden`, so a stack taller than the map is clipped off the TOP -
   * silently, and top-first, which is the end the toolbox keeps its
   * collapse head at. MEASURED 2026-07-30 at 375x812 with the ruler
   * armed on a 5-point measurement: 462 px of controls inside a 358 px
   * map put the head at y -27.5 (elementFromPoint: nothing), i.e. the
   * only control that can reopen a shut toolbox was painted outside the
   * map. The percentage resolves against `.leaflet-container` (the
   * nearest positioned ancestor, and its height is definite), and this
   * corner is ALREADY a flex column, so capping it makes its children
   * shrink rather than overflow - no measuring, no observer, and correct
   * in the states JS timing gets wrong (a background tab delivers no
   * ResizeObserver callbacks at all - driven, 2026-07-30). A box that
   * can shrink says so with `min-height: 0` and owns its own scroll;
   * the attribution keeps its size. */
  max-height: 100%;
}
.leaflet-bottom.leaflet-right > .leaflet-control-attribution { flex-shrink: 0; }
.mapctl button {
  background: rgba(20, 23, 28, 0.88);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 5px 12px;
  color: var(--fg);
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}
.mapctl button:hover { border-color: var(--bad); color: var(--bad); }

.legend .sw {
  display: inline-block;
  width: 20px;
  height: 0;
  border-top: 3px solid;
  vertical-align: middle;
  margin-right: 7px;
}
.legend .sw.dash { border-top-style: dashed; }
/* CHANNEL link-color mode: the cn-dn / dn-dn swatches slowly and softly
 * cycle through all four channel colors (1 yellow, 2 purple, 3 green,
 * 4 blue) - the on-map color depends on each link's serving channel. */
@keyframes chancycle {
  0%, 100% { border-top-color: #e3c93c; }
  25%      { border-top-color: #9d6cff; }
  50%      { border-top-color: #3fb950; }
  75%      { border-top-color: #4d9de0; }
}
.legend .sw.chancycle {
  border-top-color: #e3c93c;
  animation: chancycle 16s ease-in-out infinite;
}
/* black STP swatches: thin gray halo so they read on the dark legend.
 * stpblk = DOTTED (STP-blocked RF link); stpsolid = SOLID (link of a
 * D621 with an STP-blocked eth port, drawn with an arrow on the map) */
.legend .sw.stpblk {
  border-top-style: dotted;
  border-top-color: #000;
  box-shadow: 0 0 0 1px #6e7681;
}
.legend .sw.stpsolid { border-top-color: #000; box-shadow: 0 0 0 1px #6e7681; }
/* small triangle overlapping the stpsolid swatch: mirrors the on-line
 * arrow that points at the eth-blocked D621 end */
.legend .legtri {
  display: inline-block;
  width: 0; height: 0;
  margin: 0 7px 0 -16px;
  vertical-align: middle;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  border-left: 7px solid #000;
  filter: drop-shadow(0 0 1px #9aa4b1);
}
.legend .mk.dnsq.stpblk { background: #000; }
.legend .mk {
  display: inline-block;
  width: 11px; height: 11px;
  vertical-align: middle;
  margin-right: 7px;
  margin-left: 4px;
}
.legend .mk.dnsq { background: var(--dn); border: 1px solid #dfe8ff; border-radius: 2px; }
/* A CN WITH A REAL FIX IS A RING, NOT A DOT (operator 2026-07-26), and
 * the swatch has to say the same thing the map does or the legend stops
 * being a key. WHITE centre, matching makeMarker's fillColor exactly -
 * the map marker sits on OSM tiles and the legend sits on the panel, so
 * the centre is a LITERAL white in both rather than a theme variable
 * that would drift to the panel background in one of them. */
.legend .mk.cnre { background: #fff; border: 2px solid var(--cn);
  border-radius: 50%; }
.legend .mk.cnsy { background: none; border: 2px dashed var(--cn); border-radius: 50%; }
.legend .leghead {
  cursor: pointer;
  font-weight: 600;
  color: var(--muted);
  user-select: none;
}
.legend .leghead:hover { color: var(--fg); }
.legend.min { padding: 4px 10px; }
.legend .legsep {
  margin-top: 6px;
  padding-top: 5px;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-weight: 600;
}
/* collapsible section headings (feat/position-lifecycle): same
 * affordance the whole-legend head already has - pointer + hover - so a
 * clickable heading looks clickable. The caret is part of the heading
 * text, not layout, so a collapsed section costs one line. */
.legend .legsechead { cursor: pointer; user-select: none; }
.legend .legsechead:hover { color: var(--fg); }
.legend .legcaret { font-size: 10px; }
.legend .legdot {
  display: inline-block;
  width: 14px; height: 14px;
  line-height: 14px;
  text-align: center;
  border-radius: 50%;
  color: #fff;
  font-weight: 800;
  font-size: 11px;
  vertical-align: middle;
}
/* pole-member rectangle swatch (gray = channel-agnostic; used with
 * .ethstrike for the eth-blocked-D621 strike row) */
.legend .legrect {
  display: inline-block;
  width: 20px; height: 9px;
  vertical-align: middle;
  margin-right: 7px;
  margin-left: 2px;
  background: var(--gray);
  border: 1px solid #10131a;
  border-radius: 2px;
}
/* the AIM-UNKNOWN member: mirrors memberRect's parked branch - shorter
 * than a real panel (PARK_W 9 vs MEM_W 15, scaled to the swatch's 20 px)
 * and the same light dashed stroke. The whole-element `opacity` stands in
 * for the map rect's fill-opacity 0.55; on a flat swatch there is no
 * separate fill to fade, and matching the map's exact value would leave
 * the dashes too faint to read at this size. The extra left margin keeps
 * this swatch right-aligned with the full-length one above it, so the
 * difference in LENGTH is what the eye picks up - the point of the row. */
.legend .legrect.parked {
  width: 12px;
  margin-left: 10px;
  border: 1px dashed #dbe2ea;
  opacity: 0.75;
}
/* channel swatches mirror the pole-glyph sector rectangles (rounded,
 * dark stroke) with the channel number inside */
.legend .legch {
  display: inline-block;
  width: 26px; height: 15px;
  line-height: 15px;
  vertical-align: middle;
  border: 1px solid #10131a;
  border-radius: 3px;
  margin: 0 2px;
  text-align: center;
  font: 700 11px/15px Consolas, monospace;
  color: #10131a;
}

/* ---------- fleet legend section swatches ----------
 * .legbub = a health bubble (filled disc); .prring = a presence border
 * (white disc, coloured ring); split + pulse mirror the map. */
.legend .legsub { margin-top: 3px; }
.legend .legbub {
  display: inline-block;
  width: 13px; height: 13px;
  border-radius: 50%;
  vertical-align: middle;
  margin: 0 7px 0 4px;
  box-sizing: border-box;
}
/* the "number in the bubble" rows: a NEUTRAL disc carrying the token
 * itself (a sample digit, "?", "+"), so the legend can say what the count
 * currently is without asserting a health colour on the sample. Sized a
 * touch bigger than the plain swatch because it has to carry glyphs.
 * .flmergedleg mirrors the map's merged mark - the inset ring, so the
 * legend and the glyph cannot drift about what "merged" looks like. */
.legend .legbub.legbubnum,
.legend .legbub.flmergedleg {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px; height: 16px;
  background: var(--bg3);
  border: 1px solid var(--line);
  color: var(--fg);
  font-size: 9px;
  font-weight: 800;
  line-height: 1;
}
.legend .legbub.flmergedleg { box-shadow: inset 0 0 0 2px var(--muted); }
.legend .legbub.prring { background: #fff; border: 3px solid; }
.legend .legbub.prringsplit {
  background: conic-gradient(#4d9de0 0 120deg, #ff4fa3 120deg 240deg,
                            #581c87 240deg 360deg);
}
.legend .legbub.prringpulse {
  background: #fff;
  border: 3px solid #581c87;
  animation: fleet-pulse 1.3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .legend .legbub.prringpulse {
    animation: none;
    filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.85));
  }
}

/* ---------- who-is-here control (top right, the legend's box) -------
 * It IS a .legend (shared box family + collapse affordance); these
 * rules are only what differs: the dots, the rows, and a width cap so
 * a long focus detail cannot push a phone viewport sideways - the
 * documented .ihint/overflow trap, pre-empted rather than refound. */
.presencectl { max-width: 240px; }
.presencectl .prhead { white-space: nowrap; }
.presencectl .prdot {
  display: inline-block;
  width: 9px; height: 9px;
  border-radius: 50%;
  margin: 0 3px 0 1px;
  vertical-align: middle;
  border: 1px solid #10131a;
}
/* B1: the collapsed-pill count group - a white disc, the group colour on
 * the ring AND the bold number (both set inline). A zero-count group emits
 * no element at all, so the pill never shows an empty ring. */
.presencectl .prcount {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px; height: 18px;
  padding: 0 3px;
  margin: 0 3px 0 1px;
  border-radius: 9px;
  background: #fff;
  border: 2px solid;
  font-weight: 800;
  font-size: 11px;
  line-height: 1;
  vertical-align: middle;
}
.presencectl .prrow {
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.presencectl .prrow .prsub {
  margin-left: 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.presencectl .prshare { cursor: pointer; }
.presencectl .prshare:hover { text-decoration: underline; }
.badge.you { background: transparent; color: var(--accent);
  border: 1px dashed var(--accent); }

/* ---------- the toolbox (bottom right, the legend's box) ------------
 * The four map TOOLS - measure, drop pin, place me, clear map - gathered
 * into ONE collapsible box (operator 2026-07-30). Like the who-is-here
 * control above it IS a .legend; these rules are only what differs.
 *
 * The body holds the tools' OWN control divs, ADOPTED intact by app.js.
 * Nothing here restyles a tool - but two things reach them anyway, and
 * BOTH had to be undone by hand for "the stack looks as it did loose in
 * the corner" to be true rather than merely intended (measured
 * 2026-07-30 at 1257x737, branch vs the same four moved back):
 *   1. THE CORNER'S OWN MARGINS FOLLOW THEM IN. `.leaflet-bottom
 *      .leaflet-control { margin-bottom: 10px }` and `.leaflet-right
 *      .leaflet-control { margin-right: 10px }` are DESCENDANT selectors
 *      and the box is still inside that corner, so they kept matching:
 *      tool-to-tool spacing read 16 px (10 + the new gap) against 10 px
 *      loose, every tool sat 19 px inside the box's right border against
 *      the head's 9, and 10 px of dead space hung under the last one.
 *      Zeroed here, with the gap restored to the corner's own 10 px,
 *      which also gives back 22 px of the height that pushes the head
 *      off the top of a short map (two 6 px gaps and the 10 px tail, on
 *      the three tools that were visible).
 *   2. LINE-HEIGHT IS INHERITED THROUGH `font: inherit`. `.mapctl
 *      button` takes the font shorthand from its parent, which in the
 *      corner was the document's `font: 14px/1.45` and inside a .legend
 *      is 1.7 - every tool button grew from 29.4 px to 32.4 px. The body
 *      restates the document's own 1.45 so the buttons are the size they
 *      have always been; that is the other 9 px, and with (1) the whole
 *      box measured 180.6 px before against 149.6 after.
 * The box itself is CAPPED to the map's height by the corner rule above,
 * and what absorbs the cap is the BODY: the head keeps its size and the
 * tools scroll, so the collapse control can never be the part that gets
 * squeezed out of the map. */
.toolbox {
  padding: 6px 8px 7px;
  /* head fixed, body flexible - so a capped box scrolls its TOOLS and
   * never its own collapse control */
  display: flex;
  flex-direction: column;
  min-height: 0;              /* the corner's cap can only shrink this
                               * box if the box admits it can shrink */
}
.toolbox .tbbody {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 10px;
  margin-top: 6px;
  line-height: 1.45;          /* the document's own, see (2) above */
  overflow-y: auto;
  min-height: 0;              /* or a flex item refuses to shrink */
}
.toolbox .tbbody > .mapctl { margin: 0; }   /* see (1) above */
.toolbox.min { padding: 4px 10px; }
.toolbox.min .tbbody { margin-top: 0; gap: 0; }
.toolbox.min .tbbody > .mapctl { display: none; }
/* A TOOL WITH LIVE STATE IS NEVER HIDDEN BY THE COLLAPSE. Two states
 * qualify and each is a trap in its own way: a tool ARMED for the next
 * map click (its own button is the only way back out, and until then the
 * map silently stops selecting anything), and a tool that has left
 * something ON THE MAP whose only removal control is inside the box -
 * a placed `place me` pin, a parked ruler measurement.
 * app.js marks the two pin-style tools (.tbpin / .tbsynth) from the sync
 * functions that already own that state; the ruler is exempted by the
 * classes measure.js already puts on the map container - `msr-arm` for
 * its crosshair cursor, and `msr-drawn` for a measurement that is still
 * drawn after `done` parks it (operator 2026-07-30: exiting deliberately
 * LEAVES the line, and `clear`, inside the card, is the only thing that
 * removes it). Both are that module's own state published in its own
 * file, so no armed flag here can fall out of step with it.
 * Plain `display`, never !important: `.hidden` (display:none !important)
 * is how "clear map" hides itself while nothing is loaded and how "place
 * me" hides itself from an account that may not share a position, and
 * neither may be overridden by being armed-adjacent. */
.toolbox.min.tbpin .tbbody > .mapctl.pinctl,
.toolbox.min.tbsynth .tbbody > .mapctl.synthctl { display: block; }
.leaflet-container.msr-arm .toolbox.min .tbbody > .mapctl.msrctl,
.leaflet-container.msr-drawn .toolbox.min .tbbody > .mapctl.msrctl {
  display: flex;
}
/* ---------- time scrubber (top right, under the who-is-here pill) ----
 * The THIRD member of the .legend box family - same background, same
 * collapse affordance, its own persisted key. These rules are only what
 * differs: the range input, the readout and the count line. The width is
 * capped for the documented overflow trap (a long community list in
 * "covering ..." would otherwise push a phone viewport sideways). */
.scrubctl { max-width: 250px; }
.scrubctl .scrhead { white-space: nowrap; }
/* the selected instant, in the COLLAPSED head: it is the one thing that
 * must be readable without expanding - a map painted three hours ago
 * with a pill that just says "time" is the whole failure mode. */
.scrubctl .scrpast { color: var(--accent); font-weight: 700; }
/* an instant is SELECTED but not PAINTING (reading, or the read failed):
 * the collapsed pill is the only thing on screen, so it must not print a
 * past timestamp with nothing to say the colours are still live. */
.scrubctl .scrpend {
  color: var(--bad);
  font-weight: 700;
  font-size: 11px;
}
.scrubctl .scrrow { margin-top: 6px; }
.scrubctl .scrrange { width: 100%; margin: 0; accent-color: var(--accent); }
.scrubctl .scrends {
  display: flex;
  justify-content: space-between;
  margin-top: -2px;
}
.scrubctl .scrnow { font-weight: 600; margin-top: 2px; }
.scrubctl .scrline { margin-top: 2px; }
.scrubctl .scrcount { font-weight: 800; }
/* a failed read is NOT styled as a muted footnote: while it is on screen
 * the map's colours are LIVE under a past timestamp, which is the one
 * thing a reader must not skim past. */
.scrubctl .screrr {
  margin-top: 4px;
  color: var(--bad);
  font-size: 11.5px;
  line-height: 1.45;
  white-space: normal;
}
/* the radar frame line. ALWAYS in the DOM (empty while the layer is off)
 * so the drag-safe readout patch has something to write into - a
 * container that only exists in one state cannot be patched into
 * existence. `:empty` keeps it from costing a blank line. */
.scrubctl .scrframe { margin-top: 3px; }
.scrubctl .scrframe:empty { display: none; }
.scrubctl .scracts { margin-top: 5px; }
.scrubctl .scracts button {
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 2px 8px;
  color: var(--fg);
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
}
.scrubctl .scracts button:hover:not(:disabled) { border-color: var(--accent); }
.scrubctl .scracts button:disabled { opacity: 0.45; cursor: default; }
.scrubctl .scrradar {
  display: block;
  margin-top: 5px;
  cursor: pointer;
  white-space: normal;
}
.scrubctl .scrradar input { margin-right: 5px; vertical-align: middle; }

/* ---------- fleet map bubbles (the zoomed-out view) ----------
 * One bubble per community, coloured by its worst device state. The
 * divIcon className REPLACES leaflet-div-icon, so there is no default
 * white box to undo - the .flbub span is the whole visible mark.
 *
 * ================= THE MARK'S GEOMETRY, DERIVED ONCE =================
 * FOUR CONCENTRIC BANDS, and every number in this block, in app.js's
 * FLEET_* constants and in FLEET_CLUSTER_PX comes from this one stack.
 * Radii from the centre outward, for the LARGER (merged) bubble:
 *
 *     0    -> 17     the coloured health disc          .flbub / .flmerged
 *     17   -> 20     3 px WHITE outline                .flbub box-shadow
 *     20   -> 21.5   1.5 px dark ink hairline          .flbub box-shadow
 *     21.5 -> 26.5   >= 5 px PRESENCE band             .flring background
 *     ------------------------------------------------ 53 px = the MARK
 *     (transiently, while a park loads: a spinner ring OUTSIDE all of it,
 *      .flring.flhand::after - see there for why it is not in the stack)
 *
 * WHY IT IS WRITTEN OUT: the outline and the presence band are drawn by
 * two DIFFERENT elements (a child's box-shadow over its parent's
 * background), so thickening one silently eats the other. That is exactly
 * what happened - the 3 px + 1.5 px outline below was added to a 40 px
 * .flring holding a 30 px .flbub, i.e. 4.5 px of OPAQUE ring painted into
 * a band that was only (40-30)/2 = 5 px wide, leaving 0.5 px of presence
 * ring. Operator: "The indicator border color for who is viewing around
 * the bubble is so much worse now, can't hardly see it, its like a pixel
 * thick." (That change's own comment claimed "no layout change", which is
 * true of the BOX and false of everything drawn inside it.)
 *
 * THE RING IS SIZED OFF THE MERGED BUBBLE, the larger of the two, so no
 * variant of the mark can fall below the 5 px minimum: a merged bubble
 * gets exactly 5 px of presence band and a lone one gets 7 px. Sizing it
 * off the 30 px bubble instead would have put merged bubbles back at 3 px.
 * ==================================================================== */
.fleetbub-wrap { background: transparent; border: 0; }
.flring {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  /* = 34 (merged bubble) + 2*(4.5 outline + 5 presence). Mirrored in
   * app.js as FLEET_MARK_PX, which is also the divIcon's iconSize - a
   * test pins the two together, because a ring wider than its icon box
   * would sit off-centre from the park it marks. */
  width: 53px; height: 53px;
  border-radius: 50%;
  transform-origin: center;
  /* the loading spinner (.flring.flhand::after) is absolutely positioned
   * against this box */
  position: relative;
}
.flbub {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 30px; height: 30px;
  border-radius: 50%;
  /* OUTLINE (operator 2026-07-29: "too hard to see. Too thin"). Three
   * stacked rings, not one thicker white one: white alone is what made it
   * faint, because a white ring on a bright OSM tile has almost no edge to
   * find. 3 px white for the thickness, then a dark hairline OUTSIDE it so
   * the mark keeps a visible border on pale tiles too, then the existing
   * drop shadow for lift. 4.5 px of OPAQUE ring, which is why .flring is
   * sized to clear it AND the presence band - see the derivation above. */
  box-shadow: 0 0 0 3px #fff,
              0 0 0 4.5px rgba(16, 19, 26, 0.6),
              0 1px 5px rgba(0, 0, 0, 0.55);
  cursor: pointer;
}
/* A MERGED BUBBLE IS IDENTIFIABLE WITH NO NUMBER IN IT (review fix
 * 2026-07-29). It used to print members.length UNCONDITIONALLY, so the
 * digit was also the "this dot is several parks" cue. The digit is the
 * operator's choice now, and the DEFAULT (CN + OFFLINE) SUPPRESSES a
 * complete zero - so a stack of clean communities drew as a bare dot whose
 * only tells were 4 px of extra diameter and a missing nametag, and it
 * becomes the COMMON case as the fleet gets healthier. Mergedness is a
 * property of the GLYPH now, not of the digit: a second dark ring INSIDE
 * the coloured disc reads as "more than one" at map scale and no display
 * choice can delete it. The outer three rings are .flbub's, restated here
 * because box-shadow does not cascade layer by layer.
 * THIS 34 IS THE NUMBER .flring IS DERIVED FROM (see the geometry block
 * above): the presence band is measured from the LARGER bubble so the
 * merged mark keeps the full 5 px minimum. Changing it changes the mark,
 * FLEET_MARK_PX and FLEET_CLUSTER_PX - all three are pinned together. */
.flbub.flmerged {
  width: 34px; height: 34px;
  box-shadow: inset 0 0 0 2px rgba(16, 19, 26, 0.55),
              0 0 0 3px #fff,
              0 0 0 4.5px rgba(16, 19, 26, 0.6),
              0 1px 5px rgba(0, 0, 0, 0.55);
}
/* A6: a guest/sharer alone in a park - pulse so support notices. Reduced
 * motion holds a steady white glow instead (same philosophy as
 * offline-flash), never a moving mark. */
@keyframes fleet-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.14); }
}
.flring.flpulse { animation: fleet-pulse 1.3s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .flring.flpulse {
    animation: none;
    filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.85));
  }
}
/* .flhand: the ZOOM HAND-OFF mark - a bubble whose load is in flight, held
 * across the fleet zoom threshold (in BOTH directions since
 * fix/per-park-load-state) until that community has a pole or a marker on
 * the map. ONE PER LOADING COMMUNITY, not one per map: this sentence used
 * to open "the one bubble", which was the singleton that made two
 * concurrent loads show a single spinner, and it sat 25 lines above the
 * paragraph below that now says the opposite.
 *
 * IT IS A RING, NOT A WORD (operator 2026-07-30: "The loading message is
 * nice but we can do better, drop it and show the loading by making a
 * circular spinning animation around the bubble, or maybe a circular
 * loading bar around it? Dealers choice."). The nametag's "- loading…" run
 * is gone, and `.polename .badgesub` went with it - nothing else used it.
 *
 * INDETERMINATE, over the determinate arc that was the other option, and
 * the reason is this mark's LIFETIME rather than a lack of data. The app
 * does track honest per-park progress (`loadBars`, done/total DNs, one bar
 * per loading community) - but a hand-off mark exists only from "the load
 * started" to "the FIRST pole or marker rendered": `fleetContentCheck`
 * releases it on the first `parkHasMapContent`. So an arc driven by
 * done/total would spend its whole visible life between 0/N and 1/N and
 * read as STALLED - a determinate ring that never advances is worse than an
 * honest spinner. The real progress signal outlives the indicator it would
 * drive, and the topbar bar is where it already lives.
 *
 * OUTSIDE THE PRESENCE BAND, NOT SHARING IT: two concentric meanings, and a
 * community being zoomed into may well have a viewer. 2 px of clear gap, then
 * a 3 px ring at radius 28.5-31.5 - the ONLY thing that ever reaches past
 * FLEET_MARK_PX, and deliberately EXEMPT from the FLEET_CLUSTER_PX
 * derivation: only a community whose load is IN FLIGHT with nothing drawn
 * yet carries this, so the marks that have it are bounded by the parks a
 * person is loading right now - transiently, and 1-3 in practice - and
 * sizing every bubble's clustering around a few seconds of them would cost
 * merges everywhere. (Until fix/per-park-load-state this paragraph said
 * "`FL.hand.park` is a single park name, so at most ONE mark", which was
 * the singleton that made two concurrent loads show one spinner. The
 * exemption's ARGUMENT is unchanged - transient and few, not exactly one.)
 * THOSE ARE RESTING RADII. `.flpulse` scales `.flring` and a pseudo-element
 * inherits its parent's transform, so on a park that is both lone-guest and
 * loading this ring reaches 32.5-35.9 - which is why the gap is 5 rather than
 * 6 (at 6 the peak was 37.05, i.e. past FLEET_NAME_GAP 36.5, and the ring
 * overlapped its own community's nametag). WHAT THE EXEMPTION NOW COSTS, out
 * loud: two adjacent loading communities peak at 35.91 each against a 67 px
 * merge threshold, so two rings just past that threshold CROSS by up to
 * 4.82 px of outer radius (2.02 px of annulus - each ring is 3.42 px thick
 * when pulsed, so they intersect rather than nest). Structurally
 * unavoidable while GAP >= RING: even the smallest legal gap of 3 peaks at
 * 33.63 and still pairs to 67.26. What holds is the pair that matters - a
 * loading ring never reaches a NEIGHBOUR'S MARK (35.91 + 30.21 = 66.12).
 * Both numbers are mirrored in app.js as FLEET_LOAD_GAP_PX /
 * FLEET_LOAD_RING_PX and pinned against this rule by
 * tests/test_fleet_mark_geometry.py, which also holds GAP >= RING - the
 * whole condition for not eating the presence band.
 * A dark FULL-CIRCLE track carries the contrast (same problem the bubble
 * outline has on pale tiles) and a white leading arc sweeps it, so the
 * indicator is legible even at the instant the arc is over a light tile.
 *
 * pointer-events: none, because it paints OUTSIDE the divIcon's box and
 * bubbles now coexist with park content: a 65 px disc swallowing clicks
 * aimed at a loaded park's own markers is exactly the regression the
 * hand-off's `interactive: false` exists to avoid.
 *
 * IT CLEARS BY DISAPPEARING, not by being reset: the class comes from
 * `fleetHandoffMark(park)` testing that park's membership of `FL.hand`, and
 * all three release paths (render signal, promise settle, watchdog) go
 * through fleetHandoffDrop -> renderFleet, which redraws the mark without
 * it. There is no animation to stop and no state to unwind - and because
 * the membership is PER PARK, retiring one community's ring redraws every
 * other loading community's unchanged. */
@keyframes fleet-spin {
  to { transform: rotate(360deg); }
}
.flring.flhand::after {
  content: "";
  position: absolute;
  /* = -FLEET_LOAD_GAP_PX (app.js). Width is auto and both insets are given,
   * so the used width absorbs the border and the border box spans -5..+5
   * around .flring: the ring itself lands at radius 28.5-31.5 with the
   * presence band ending at 26.5. GAP >= BORDER-WIDTH is what keeps it out
   * of that band; see app.js and test_fleet_mark_geometry.py. */
  inset: -5px;
  border-radius: 50%;
  /* = FLEET_LOAD_RING_PX (app.js) */
  border: 3px solid rgba(16, 19, 26, 0.55);   /* the track */
  border-top-color: #fff;                     /* the leading arc */
  pointer-events: none;
  animation: fleet-spin 0.9s linear infinite;
}
/* the placeholder is not a control (app.js passes interactive:false); the
 * cursor must not advertise a click it will not honour.
 * BELT AND BRACES, and knowingly so (2026-07-30): `interactive: false` means
 * Leaflet never adds `.leaflet-interactive`, so leaflet.css's
 * `pointer-events: none` on `.leaflet-marker-icon` stands and NEITHER this
 * rule nor the `.flbub { cursor: pointer }` it exists to cancel can apply.
 * Kept because the intent survives any future change that makes the mark a
 * hover target again. */
.flring.flhand .flbub { cursor: default; }
/* A MOVING MARK FOR SOMEONE WHO ASKED FOR NONE is the one thing this
 * file's pulse rules forbid (.flpulse, offline-flash). The ring STAYS -
 * dropping it would leave this mark with NO on-screen carrier for the
 * loading state at all: the word is gone, and the marker `title` cannot
 * stand in for it because a non-interactive icon is not a hover target (see
 * the note in app.js's drawFleetSingle). So it holds still and goes DASHED,
 * which reads as "in progress" without moving and cannot be confused with
 * the solid outline the bubble always has. */
@media (prefers-reduced-motion: reduce) {
  .flring.flhand::after {
    animation: none;
    border-style: dashed;
  }
}
/* dark ink on every health colour - all four are mid-to-light, so one
 * dark number clears 6:1 on red/orange/green/gray alike */
.flnum { color: #10131a; font-weight: 800; font-size: 12px; }
/* A SINGLE PARK'S NAME, to the RIGHT of its dot. There is no `.fllabel`
 * rule any more (2026-07-29, operator: "reuse the pole name label that we
 * built"): the tag is now the SHARED `.polename` badge, drawn as SVG in
 * the divIcon by app.js `fleetLabelIcon`, so its colours/font/box come
 * from the pole-label block above and nothing here restates them.
 * pointer-events:none rides `.polelabel`, so the click still lands on the
 * bubble. Merged bubbles carry no tag at all.
 * The ZOOM HAND-OFF no longer writes anything into this tag: "loading…" was
 * a `<tspan class="badgesub">` here and is a RING around the mark now
 * (.flring.flhand::after above). The tag says WHICH community; the ring
 * says WHAT IS HAPPENING to it. */

/* dark-ish map tiles: mild filter keeps OSM readable but on-theme */
#map .leaflet-tile-pane { filter: brightness(0.82) contrast(1.05) saturate(0.85); }

/* ---------- modal (about) ----------
 * The legacy API-key modal is GONE (session-cookie auth everywhere);
 * the .modal/.modal-box shell now hosts the about modal. Closes on the
 * × and on a backdrop click (app.js). */
.modal {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2000;
}
.modal-box {
  position: relative;
  background: var(--bg2);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 22px 26px;
  width: 380px;
  max-width: 92vw;
  box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
.modal-box h2 { margin: 0 0 8px; }
.modal-box .modalx {
  position: absolute;
  top: 6px; right: 8px;
  min-width: var(--tap);
  min-height: var(--tap);
  background: none;
  border: none;
  color: var(--muted);
  font: inherit;
  font-size: 22px;
  line-height: 1;
  padding: 2px 6px;
  cursor: pointer;
}
.modal-box .modalx:hover { color: var(--fg); }

/* ---------- "share your real location?" modal ----------
 * NO × and no backdrop dismissal on this one, unlike the about box:
 * decline is a first-class answer the sender is TOLD about, so a stray
 * click that closed the box would leave a colleague waiting on a reply
 * that never comes. The two buttons are the only exits. */
.locreqbox { width: 420px; }
.locreqbox .locreqwho { margin: 0 0 10px; font-weight: 600; }
.locreqbox .locreqnote {
  margin: 0 0 10px;
  padding: 6px 10px;
  border-left: 3px solid var(--line);
  font-style: italic;
  /* the sender's free text: it can be long and it can be one unbroken
   * token, and #panel's overflow history says both must be contained
   * rather than allowed to widen anything */
  overflow-wrap: anywhere;
}
.locreqbox .locreqmute { margin: 12px 0 4px; }
.locreqacts {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 14px;
}
.locreqacts .uiact { flex: 1 1 auto; min-height: var(--tap); }
.locreqacts .uiact.locreqok { color: var(--good); border-color: var(--good); }
.locreqacts .uiact.locreqok:hover { background: rgba(63, 185, 80, 0.12); }
.modal-box .aboutlogo {
  height: 30px;
  display: block;
  margin: 2px 0 14px;
}
/* build identity, bottom of the about box - deliberately quiet: it is the
 * answer to "which build am I looking at", not a headline */
.aboutver {
  margin-top: 14px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
  font-family: Consolas, "Cascadia Mono", monospace;
}
.aboutver .vernum { color: var(--fg); font-weight: 700; }
/* an unstamped build says so in the warn colour: a dev build must never
 * be mistaken for a release */
.aboutver .verdev { color: var(--warn); font-weight: 700; }

/* ---------- hidden-but-online safety rail ----------
 * Sits under #connbanner at the top of the map, full width, and there is
 * no close affordance ANYWHERE in this block on purpose: the banner ends
 * when the contradiction does. It is styled loud in the actionable case
 * and neutral in the role-suppressed one - a warning nobody on this
 * account can clear must still be legible without reading as an alarm
 * they are failing to answer. */
#hiddenbanner {
  position: absolute;
  top: 8px; left: 50%; transform: translateX(-50%);
  z-index: 1200;
  max-width: min(760px, calc(100% - 24px));
  display: flex; flex-direction: column; gap: 6px;
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 13px; line-height: 1.5;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
}
#hiddenbanner.alarm {
  background: #4a1512; border: 1px solid var(--bad); color: #ffd9d6;
}
#hiddenbanner.notice {
  background: #2c2417; border: 1px solid var(--warn); color: #f2e2c6;
}
.hbrow {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.hbmsg { flex: 1 1 220px; min-width: 0; }
.hbsub { color: inherit; opacity: 0.75; font-size: 12px; }
.hbbtn {
  flex: 0 0 auto;
  min-height: var(--tap);
  padding: 4px 12px;
  background: var(--bad); color: #fff;
  border: 1px solid #ff8b84; border-radius: 4px;
  font: inherit; font-weight: 700; cursor: pointer;
}
.hbbtn:hover { background: #c9403a; }
.hbbtn:disabled { opacity: 0.5; cursor: default; }

/* ---------- consolidated per-entity ACTIONS area ---------- */
.actions {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 8px 0 10px;
}
.uiact {
  min-height: var(--tap);
  padding: 4px 10px;
  background: var(--bg3); color: var(--accent);
  border: 1px solid var(--line); border-radius: 4px;
  font: inherit; font-size: 12.5px; cursor: pointer;
}
.uiact:hover { border-color: var(--accent); }
.uiact.danger { color: var(--bad); }
.uiact.danger:hover { border-color: var(--bad); }

/* ---------- technician pins + placeholders ---------- */
.tech-pin { background: none; border: none; }
.tech-pin svg { display: block; filter: drop-shadow(0 0 3px rgba(0,0,0,0.75)); }
#map.pindrop { cursor: crosshair; }
.mapctl.pinctl button { color: var(--fg); }
.mapctl.pinctl button:hover { border-color: var(--accent); color: var(--accent); }
.mapctl.pinctl button.on { border-color: #ff9f43; color: #ff9f43; }
/* "place me" - MY OWN synthetic position. Technician-hued, so the control
 * and the marker it creates are recognisably the same feature. */
.mapctl.synthctl button { color: var(--fg); }
.mapctl.synthctl button:hover { border-color: #ff4fa3; color: #ff4fa3; }
.mapctl.synthctl button.on { border-color: #ff4fa3; color: #ff4fa3; }
.badge.tech { background: #ff4fa3; color: #10131a; }
.badge.pin  { background: #ff9f43; color: #10131a; }
/* a GUEST is not staff: dark purple, and light ink + a light border so it
 * reads on this dark panel without borrowing either staff colour */
.badge.guest { background: #581c87; color: #f3e8ff; border: 1px solid #d9c7ff; }
/* a client-only survey point from a shared #survey= link - teal, so it is
 * neither a person (pink/purple) nor an editable pin (orange) */
.badge.svypt { background: #0f766e; color: #ccfbf1; border: 1px solid #5eead4; }
/* SYNTHETIC (hand-placed) position. It rides BESIDE the "technician"
 * badge, never instead of it, so it is deliberately not a second solid
 * pink block competing with it - hollow, dashed, technician-hued: the
 * same "asserted, not measured" ink as the map glyph and the legend. */
.badge.synth { background: transparent; color: #ff4fa3;
  border: 1px dashed #ff4fa3; }
/* the panel's synthetic disclosure. It is a WARNING-weight block rather
 * than another muted paragraph, because it changes the meaning of every
 * coordinate above it and a reader who misses it will drive somewhere. */
.synthnote {
  margin: 8px 0;
  padding: 7px 10px;
  border-left: 3px solid #ff4fa3;
  background: rgba(255, 79, 163, 0.09);
  border-radius: 0 4px 4px 0;
  font-size: 12.5px;
  line-height: 1.45;
}
.synthnote b { color: #ff85c0; }
.pinedit { margin: 8px 0; }
.pinedit input {
  width: 100%; padding: 5px 8px;
  background: var(--bg); color: var(--fg);
  border: 1px solid var(--line); border-radius: 4px; font: inherit;
}
.pinedit input:focus { border-color: var(--accent); outline: none; }
.pinrow { cursor: pointer; color: var(--accent); }
.pinrow:hover { text-decoration: underline; }
.pinlist { margin: 4px 0 8px; }
/* the disconnect countdown chip (people rows + the tech panel). It is a
 * WARNING tone, not an error: the position is still honest data, it is
 * just aging in the open. Ticked in place by techCountdownTick, so its
 * width is stabilised with tabular figures to stop a 1 Hz wiggle. */
.techcd {
  color: #e3a13c;
  font-variant-numeric: tabular-nums;
  font-size: 11px;
  white-space: nowrap;
}
/* the panel's disconnected disclosure: same block weight as .synthnote
 * (it changes what "updated N s ago" above it means), amber not pink */
.discnote {
  margin: 8px 0;
  padding: 7px 10px;
  border-left: 3px solid #e3a13c;
  background: rgba(227, 161, 60, 0.10);
  border-radius: 0 4px 4px 0;
  font-size: 12.5px;
  line-height: 1.45;
}
.discnote b { color: #e3a13c; }
.discnote .techcd { font-size: 12.5px; }

/* ---------- site survey ---------- */
.svyprog {
  display: flex; align-items: center; gap: 8px;
  margin: 8px 0; font-weight: 600;
}
.spin {
  width: 12px; height: 12px; border-radius: 50%;
  border: 2px solid var(--line); border-top-color: var(--accent);
  animation: spin 0.9s linear infinite;
  display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.svylist { margin-top: 6px; }
/* a candidate row opens the same detail panel its map ray does, so it
 * carries the same affordance every other clickable row here has */
.svycand {
  list-style: none;
  margin: 6px 0; padding: 8px 10px;
  background: var(--bg2); border: 1px solid var(--line); border-radius: 5px;
  cursor: pointer;
}
.svycand:hover { border-color: var(--accent); }
/* the FOCUSED candidate. --accent is the selection colour everywhere else
 * on this screen (halos, the map ray's glow), so the row and its ray agree
 * about which one is selected. */
.svycand.on {
  border-color: var(--accent);
  box-shadow: inset 3px 0 0 var(--accent);
}
/* the map is FILTERED while a pin is selected; this is the sentence that
 * says so, because an unexplained change to the map is indistinguishable
 * from a setting the user does not remember making */
.svyfocus {
  margin: 6px 0; padding: 6px 9px;
  border-left: 3px solid var(--accent);
  background: var(--bg2); border-radius: 0 4px 4px 0;
  font-size: 12px; color: var(--fg); line-height: 1.5;
}
/* WHICH POINT WAS SURVEYED. Same block shape as .svyfocus, warn-coloured:
 * a survey run from a SYNTHETIC CN position is a survey of an estimate,
 * and the reader has to know that before they read the ranking. */
.svywarn {
  margin: 6px 0; padding: 6px 9px;
  border-left: 3px solid var(--warn);
  background: var(--bg2); border-radius: 0 4px 4px 0;
  font-size: 12px; color: var(--fg); line-height: 1.5;
}
/* THE "THIS DOES NOT EXIST" BANNER. Every other panel in this app
 * describes measured gear; this one describes a link nobody has built, so
 * the disclaimer is a standing block at the TOP that cannot be scrolled
 * past, in the warning register rather than the error one - a proposal is
 * not a fault. */
.hypo {
  margin: 2px 0 10px; padding: 8px 10px;
  background: #2a2318; border: 1px solid var(--warn); border-radius: 5px;
  color: #f2e2c6; font-size: 12.5px; line-height: 1.5;
}
.hypo b { display: block; color: var(--warn); letter-spacing: 0.04em; }
/* THE AIM HEADING IS THE PRODUCT. It is the one number a technician
 * standing at the pin can act on without a laptop, so it gets the size
 * and the box; everything else on the panel is supporting evidence. */
.aimwrap {
  margin: 8px 0 12px; padding: 10px 12px;
  background: var(--bg2); border: 1px solid var(--accent); border-radius: 6px;
}
.aimlab {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted);
}
.aimbig {
  font-size: 30px; font-weight: 800; line-height: 1.15;
  color: var(--accent);
}
.aimsub { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
/* IDLE TIPS IN THIS PANEL ARE display:none, NOT visibility:hidden - the
 * same lesson the mode chip taught, re-measured here: with the shipped
 * visibility:hidden rule, the aim block's ONE idle tip pushed #panel's
 * scrollWidth from 364 to 387 px, i.e. 23 px of sideways scroll in a
 * field-facing panel. A visibility:hidden box is still laid out and still
 * counts; a display:none one does not. Scoped rather than global so the
 * fade on every other tip in the app is untouched. */
.aimwrap .ihint::after {
  display: none; visibility: visible; opacity: 1;
}
.aimwrap .ihint:hover::after { display: block; }

/* ---------- expected (predicted) metrics ---------- */
/* Collapsed by default via <details>: the rating and the aim heading are
 * what a tech acts on, and thirteen unpredicted cells above them would
 * bury both. No JS - a <details> cannot get out of sync with itself. */
.expwrap {
  margin: 8px 0; padding: 0;
  border: 1px solid var(--line); border-radius: 5px;
  background: var(--bg2);
}
.expwrap > summary {
  cursor: pointer; padding: 7px 10px;
  font-size: 12.5px; font-weight: 600;
  min-height: var(--tap); box-sizing: border-box;
}
.expwrap > summary:hover { color: var(--accent); }
.expwrap > table, .expwrap > p, .expwrap > div, .expwrap > ul {
  margin: 0 10px 8px;
  width: auto;
}
/* THE EXPECTED TABLE MUST NOT BE MISTAKEN FOR THE MEASURED ONE. It has
 * the same shape on purpose (one layout to learn), so the difference is
 * carried by the frame: dashed rules and a muted header, plus the
 * per-cell provenance chips below. */
table.metrics.expected { border: 1px dashed var(--line); }
table.metrics.expected th { color: var(--muted); font-style: italic; }
table.metrics.expected td { border-bottom: 1px dashed var(--line); }
.ecell { white-space: nowrap; }
.ecell .eval { font-weight: 600; }
/* not-modelled cells read as ABSENT, not as zero */
.ecell.e-none .eval { color: var(--muted); }
/* fleet constants are deliberately NOT quality-coloured (they would all
 * be green by construction, which would read as "this link will be
 * good"), so they are muted to say "this is not about your link" */
.ecell.e-median .eval { color: var(--muted); font-weight: 400; }
/* the provenance chip. It IS an .ihint, so it inherits the whole shipped
 * tooltip machinery including the panel's flip/left-anchor handler - only
 * the shape changes from a circle to a word-sized pill.
 * IDLE STATE IS display:none, NOT visibility:hidden. A visibility:hidden
 * tip is still LAID OUT and still counts toward scrollWidth - that is
 * exactly how one hidden tip cost a 375 px phone 24 px of horizontal
 * scroll - and this table hangs SIXTEEN of them inside a scroll
 * container. Cost: no fade on these. Worth it. */
.ihint.prov {
  width: auto; height: auto; line-height: 1.3;
  padding: 0 4px; border-radius: 3px;
  font-size: 9px; letter-spacing: 0.02em;
  margin-left: 5px; vertical-align: middle;
}
.ihint.prov::after { display: none; visibility: visible; opacity: 1; }
.ihint.prov:hover::after { display: block; }
.ecell.e-pred .prov { border-color: var(--accent); color: var(--accent); }
.svyhead { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.svyrow { font-size: 12.5px; margin-top: 4px; color: var(--fg); }
.svybig { font-size: 17px; font-weight: 700; color: var(--accent); }
/* the rating leads every candidate and colours it: it is the answer, and
 * a survey read row-by-row without it is just numbers */
.rating {
  display: inline-block; padding: 1px 8px; border-radius: 3px;
  color: #10131a; font-weight: 700; font-size: 11.5px;
  text-transform: uppercase; letter-spacing: 0.04em;
}
.ratesm { font-weight: 700; font-size: 11.5px; }
.band {
  display: inline-block; padding: 0 6px; border-radius: 3px;
  font-size: 11px; font-weight: 700; color: #10131a;
}
.band.green  { background: var(--good); }
.band.yellow { background: var(--warn); }
.band.red    { background: var(--bad); }
.svyskip li { list-style: none; margin: 2px 0; font-size: 12.5px; }

/* ---------- per-community extras (segments entry, hidden count, pins) --- */
.parkacts { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
.parkbusy {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 12.5px; color: var(--accent);
}
.parkhidden {
  font-size: 12px; color: var(--muted); margin: 4px 0 6px;
}
.parkhidden.warn { color: var(--warn); }

/* legend swatches for the new layers */
.legend .mk.techleg { background: #ff4fa3; border-radius: 50%; }
/* SYNTHETIC (hand-placed) technician position. Same hue as a technician -
 * it IS one - and a DASHED HOLLOW ring, which is this codebase's settled
 * ink for "asserted, not measured" (the guest accuracy ring, the
 * synthetic-CN marker). Hollow, so it never reads as the solid pin of a
 * real fix even in greyscale. */
.legend .mk.synthleg { background: none; border: 2px dashed #ff4fa3;
  border-radius: 50%; }
.legend .mk.pinleg { background: #ff9f43; border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg); }
/* SHAPE carries the meaning here too - the swatch is clipped to the same
 * flat-top hexagon the map glyph draws, so the legend row and the pin are
 * recognisably the same thing without reading the colour */
.legend .mk.guestleg { background: #581c87; border: 1px solid #d9c7ff;
  clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0 50%); }
/* the shared survey point: a diamond, dashed-teal like its map glyph */
.legend .mk.svyptleg { background: #0f766e; border: 1px solid #5eead4;
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }

@media (prefers-reduced-motion: reduce) {
  .spin { animation: none; border-top-color: var(--line); }
}

@media (max-width: 860px) {
  /* narrow topbar: search + account + gear already fill the row, so the
   * bars WRAP onto a full-width line of their own rather than squeezing
   * the park name and n/total into ~50 px. (An idle #loadbars is empty
   * and adds no height.) `#topbar { flex-wrap: wrap }` used to live here;
   * it is unconditional now - see the base rule. */
  #loadbars { flex: 1 0 100%; order: 9; }
  #loadbars:empty { display: none; }   /* idle: no wasted row */
  .loadbar { flex: 1 1 auto; max-width: none; }

  main { flex-direction: column; }
  #paneltab { display: none; }   /* stacked layout: no collapse tab */
  #panel, #panel.collapsed {
    flex: 0 0 45%;
    max-width: none;
    border-left: none;
    border-top: 1px solid var(--line);
    padding: 14px 16px;
    overflow-y: auto;
  }
}

/* ---------- small screens (MQ_SMALL in app.js mirrors 700px) ---------- */
@media (max-width: 700px) {
  /* the Kwikbit logo lives in the ABOUT modal on phones - the topbar
   * width goes to search + status icons */
  #brand { display: none; }
  /* NO pills at all: the thin bottom-edge stripe is the loading UI
   * (aggregate progress; .hidden from JS keeps it gone while idle) */
  #loadbars { display: none; }
  #loadstripe { display: block; }

  /* ---- COMPRESSED TOPBAR (operator 2026-07-26) ----
   * The search takes the whole first row; the controls form a second,
   * equally-spaced one that fills the width.
   *
   * THIS BREAKPOINT, NOT A NEW ONE. It is the same 700px that already
   * drops the loading pills to the bottom-edge stripe, hides the logo
   * and turns on keyboard-search mode - so the topbar degrades in ONE
   * place at ONE width instead of three, and "compressed" catches a
   * narrow DESKTOP window too, not only a phone.
   *
   * The wrap itself is free: #topbar is already flex-wrap:wrap from
   * 860px down, so a full-width #searchwrap is all it takes to push
   * everything after it onto row two. The popovers and #loadstripe are
   * absolutely positioned and take no part in the flow.
   * NOTE the two horizontal-overflow bugs this project has already had:
   * both were elements hidden with visibility:hidden, which is STILL
   * LAID OUT and still counts in scrollWidth. Everything hidden here
   * uses display:none for exactly that reason. */
  /* max-width:none IS WHAT MAKES THE SECOND ROW HAPPEN. The base rule caps
   * #searchwrap at 420px, and a cap beats flex-basis - so `flex: 1 1 100%`
   * silently resolved to 420px and the search STOPPED filling row one as
   * soon as the topbar's content box passed 420px (~448px of viewport).
   * From there up to 700px the controls fell back beside the search and
   * crammed: measured on dev, buttons 18px with 0px glyphs at 593px, and a
   * ragged 1-to-3-row bar between 509 and 592 as they wrapped one at a
   * time. Below ~448px the cap was never reached, which is exactly why the
   * compressed row looked correct on a phone and broke on a narrow
   * desktop window. */
  #searchwrap { flex: 1 1 100%; order: 1; max-width: none; }
  /* THE METRICS-MODE CHIP GOES, and it does not strand the setting: the
   * gear popover carries the same live|medians pair, both run through
   * setMode() so they cannot disagree, and the gear is one of the four
   * buttons on this very row. Hiding the chip would only be a problem
   * if the gear went with it - it does not. */
  #livechip { display: none; }
  #statuswrap { order: 2; flex: 0 0 auto; }
  #livelink, #about, #acct, #gear {
    order: 3;
    /* basis 0 + equal grow = the four controls share this row evenly.
     * `min-width: 0` USED TO SIT HERE and it was the second half of the
     * icon bug: an id selector outranks `.iconbtn`, so it deleted the
     * button floor for every width at or under 700px and let flex squeeze
     * each button to its padding+border (18px). The floor is restored by
     * simply not overriding it - --tap-icon now clamps the flexed size,
     * and because that clamp also sets the item's hypothetical size, a row
     * that cannot hold four of them WRAPS instead of squeezing. */
    flex: 1 1 0;
    /* a row that fills the width should make the TARGETS BIGGER, not
     * just spread them out. --tap (28px) is the floor everywhere else;
     * this is a field app, and that floor exists because a too-small
     * target was wiping loaded parks on a mis-click. */
    min-height: 38px;
  }
  /* keyboard-open search mode (body class from app.js): the map hides
   * so the filtered community list gets the full viewport height above
   * the phone keyboard; blur restores it (delayed so a result tap
   * lands first) */
  body.kbd-search #mapwrap { display: none; }
  body.kbd-search #panel,
  body.kbd-search #panel.collapsed { flex: 1 1 auto; min-height: 0; }

  /* the safety rail keeps its full width on a phone: it is the one thing
   * on this screen that must not be squeezed into an ellipsis */
  #hiddenbanner {
    left: 8px; right: 8px; transform: none;
    max-width: none; font-size: 12.5px;
  }
  .hbbtn { width: 100%; }
  /* field-facing panels: full-width tap targets, and the aim heading
   * stays large enough to read at arm's length on a pole */
  .actions .uiact { flex: 1 1 auto; }
  .svybig { font-size: 19px; }
  /* the aim heading is read at arm's length, standing at a pole, in
   * daylight - it gets BIGGER on a phone, not smaller */
  .aimbig { font-size: 34px; }
}

/* ================================================================
 * PATH CAPACITY BLOCK (/api/v0/impact) - CN panel, pole panel,
 * link panel. See the block comment in web/app.js.
 *
 * THE COLOURS CARRY THE HONESTY RULE, so they are not decoration:
 * a MEASURED figure, a CEILING and an UNMEASURABLE one must be
 * distinguishable at a glance, because all three would otherwise
 * read as the same kind of claim.
 *
 * NO .ihint IS ADDED TO A VALUE ROW HERE. Every explanation in this
 * block rides a native title= attribute (the fix this project has
 * now applied six times): an .ihint tip is position:absolute with
 * visibility:hidden, which is STILL LAID OUT and still counts in
 * scrollWidth, and #panel is overflow-x:auto. The one .ihint in the
 * block sits on a short HEADING, which is the shape that has never
 * overflowed. Measured at 375x812 after this landed: document
 * 375/375, #panel 375/375.
 * ================================================================ */
.caprow { margin: 5px 0; }
.caprow .sub { margin-top: 1px; }

/* the figure itself. min-width:0 + wrapping is what keeps a long value
 * ("at most 1000 Mb/s" beside its label) inside a 343 px column */
.capval {
  font-weight: 600;
  white-space: nowrap;
}

/* MEASURED: a real bottleneck, named by a real hop */
.capval.capexact { color: var(--good); }

/* A CEILING, NOT A READING. The true figure is this or lower, because
 * something on the path is unmeasured. Deliberately NOT green: it must
 * not read as a measurement, and it must not read as a fault either. */
.capval.capupper { color: var(--warn); }

/* NOTHING ON THE PATH WAS MEASURABLE. Muted and italic - it is an absence,
 * not a bad number, and above all it is not a zero. */
.capval.capnone {
  color: var(--muted);
  font-style: italic;
  font-weight: 500;
}

.caph {
  margin: 12px 0 4px;
  font-size: 11px;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
}

/* hop rows: a clickable one carries .linkref (which already styles the
 * cursor); a hop that cannot navigate is emitted with its own class only
 * and inherits the plain list style. */
li.capunk, li.capbind { line-height: 1.45; }

/* THE ONLY HAND-TYPED NUMBER IN THE SYSTEM. Purple, so it cannot be
 * confused with the good/warn/bad scale that every measured value uses -
 * this is a provenance statement, not a quality one. */
.badge.captyped {
  border-color: #8b5cf6;
  color: #a78bfa;
}

/* why a hop is unmeasured, coloured by whether anything can be done:
 * author-it and warm-a-cache are actionable, never/no-reading are not */
.badge[class*="capcls-"] {
  font-weight: 500;
  text-transform: none;
  letter-spacing: 0;
}
.badge.capcls-author-it { border-color: var(--accent); color: var(--accent); }
.badge.capcls-warm-a-cache { border-color: var(--warn); color: var(--warn); }
.badge.capcls-never,
.badge.capcls-no-reading { border-color: #3a4150; color: var(--muted); }

.capnotes { margin: 10px 0 2px; }
.capnotes > summary {
  cursor: pointer;
  font-size: 12px;
  color: var(--muted);
}
.capnotes ul { margin: 6px 0 0; padding-left: 16px; }
.capnotes li { margin: 4px 0; line-height: 1.45; }

/* ================================================================
 * THE RIGHT PAGE - ONE SHELL, TWO VIEWS (`sb*` = SideBar)
 *
 * Operator: the right-hand sidebar becomes ONE page with two views -
 * LINK (the shipped live viewer, web/live.html, embedded as an iframe)
 * and CONFIG (pole scope, built later). These are the styles for the
 * strip that switches them and the frame that holds the link view.
 * The markup is app.js's `sbViewsHtml` / `rightPageHtml`.
 *
 * THE TAB IDIOM IS CHAT'S, NOT A SECOND ONE. Every number below -
 * gap 4, padding 6px, radius 6, 11.5px type, the --tap floor, and the
 * bg3 + accent-border + bold ACTIVE state - is #chatviews/.chatview
 * from web/chat.css, so the app has ONE tab, worn twice. Only the
 * horizontal padding differs, and only because #chatpanel has no
 * padding of its own while #panel has 16px: the strip drops to
 * `padding: 6px 0` so the tabs line up with the h3s and cards under
 * them instead of being inset twice.
 *
 * NO NEGATIVE-MARGIN BLEED, deliberately. Running the strip and the
 * frame out to #panel's border edge would look tidier, and it is
 * exactly the shape of the horizontal-overflow bug this project has
 * now fixed three times (#panel is overflow-y:auto, which computes
 * overflow-x to auto, so anything reaching past the padding box can
 * arm a scrollbar). 348px of usable frame width at the default panel
 * size is inside live.css's mobile-first range; a scrollbar is not.
 * ================================================================ */

/* ---- the view toggle strip ---- */
.sbviews {
  display: flex;
  gap: 4px;
  padding: 6px 0;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.sbviews.hidden { display: none; }
.sbview {
  flex: 1 1 0;
  min-width: 0;
  min-height: var(--tap);
  padding: 4px 6px;
  background: transparent;
  color: var(--muted);
  border: 1px solid transparent;
  border-radius: 6px;
  font: inherit;
  font-size: 11.5px;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sbview:hover { color: var(--fg); border-color: var(--line); }
/* ACTIVE reads off EITHER signal. app.js sets both the class and
 * aria-selected; pinning the look to only one of them is how a future
 * view ends up looking active while the screen reader disagrees. */
.sbview.sbviewon,
.sbview[aria-selected="true"] {
  background: var(--bg3);
  color: var(--fg);
  border-color: var(--accent);
  font-weight: 700;
}
/* KEYBOARD FOCUS IS VISIBLE, and it is not the same ink as "active":
 * the toggle has a persistent selected state, so a focus ring that
 * reused the accent border would be unreadable against it. Inset, so
 * it cannot add to the box and reach the panel's padding edge. */
.sbview:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
/* CONFIG BEFORE IT EXISTS. app.js emits the tab with `disabled`, which
 * also takes it out of the tab order - correct: a destination that
 * cannot be reached should not be a keyboard stop. It still RENDERS,
 * because the second view is the thing being announced. */
.sbview[disabled] {
  opacity: 0.45;
  cursor: default;
}
.sbview[disabled]:hover { color: var(--muted); border-color: transparent; }

/* ---- the embedded live viewer ---- */
/* THE FALLBACK HEIGHT, not the real one. The fill path below needs
 * :has(); where that is unsupported the whole fill rule is dropped and
 * an iframe falls back to its 150px intrinsic height, so this gives the
 * frame a definite, usable box on its own. In the fill path it is
 * overridden back to auto. */
/* THE HOST'S OWN ACTIONS, above the frame - pop-out and share. They are
 * decisions about WHERE this view goes and WHO ELSE may see it, which are
 * questions about the panel rather than about the link's metrics, so they
 * live on the shell and the frame never grows a second copy. */
.sbacts {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  margin: 0 0 8px;
}

/* THE PUBLISHED URL IS SHOWN EXACTLY ONCE - the server keeps only a
 * SHA-256 of the secret. So this is deliberately loud: a secret that
 * scrolls past unnoticed is a share the operator has to revoke and
 * re-issue. */
.sbshareout { margin: 0 0 8px; }
.sbshareout.hidden { display: none; }
.sbsharebox {
  padding: 8px 10px;
  background: var(--bg3);
  border: 1px solid var(--accent);
  border-radius: 8px;
}
.sbsharewarn {
  font-size: 11.5px;
  font-weight: 700;
  color: var(--accent);
  margin-bottom: 6px;
}
/* the URL wraps rather than scrolling out of sight - it is long, and a
 * fragment that runs off the edge is the half nobody copies */
.sbshareurl {
  word-break: break-all;
  font-size: 11.5px;
  margin-bottom: 6px;
}

.sbframewrap {
  display: flex;
  height: 62vh;
  min-height: 300px;
  /* live.html's body is --bg, a shade darker than the panel's --bg2;
   * matching it here means the load has no pale flash before the
   * document paints */
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow: hidden;   /* the radius has to clip the frame's corners */
}
.sbframe {
  width: 100%;
  height: 100%;
  /* belt AND braces: the percentage above satisfies the literal spec,
   * the flex line makes the fill independent of whether the wrap's
   * flex-resolved height counts as definite for percentage resolution */
  flex: 1 1 auto;
  min-height: 0;
  display: block;
  border: 0;
}

/* ---- THE FILL PATH: the frame takes the rest of the panel ----
 * #panel is a plain overflow-y:auto block and #detail (which app.js
 * writes into) has auto height, so there is no height chain for the
 * frame to fill. :has() supplies the missing conditional - the panel
 * becomes a flex column ONLY while a frame is inside it, so every other
 * view (the community list, endpoint, pole and the no-live fallback
 * panels) lays out byte-identically to today. The alternative was a
 * host class, and that would have meant editing app.js, which another
 * stream owns this step.
 *
 * NO DOUBLE SCROLLBAR, AND NO UNREACHABLE CONTENT EITHER - the two ways
 * this can be got wrong, and they pull in opposite directions. The frame
 * is flex-sized to the space that is left, so in every real window it
 * fits EXACTLY: scrollHeight == clientHeight, `auto` shows no bar, and
 * the live page inside is the only thing scrolling (measured 1280x800:
 * panel 638/638, and 744x705 stacked: 399/399). The panel keeps `auto`
 * rather than `hidden` for the pathological case only - a window so
 * short that the frame hits its floor below - because a clipped panel
 * makes the chips under it UNREACHABLE, which is a worse failure than a
 * second scrollbar nobody will ever see. */
#panel:has(.sbframewrap) {
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}
#panel:has(.sbframewrap) > * { flex: 0 0 auto; }
/* the two chips are inline-flex pills; a flex column would stretch them
 * into full-width bars, so they keep hugging their text */
#panel:has(.sbframewrap) > #crumb,
#panel:has(.sbframewrap) > #deadchip { align-self: flex-start; }
/* #detail passes the height through to the frame rather than absorbing
 * it: it is the element app.js renders into, so it is the one link in
 * the chain that cannot be given a class from here */
#panel:has(.sbframewrap) > #detail {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
/* flex-basis 0 = "take the space that is left", not "content height then
 * grow" - the iframe's intrinsic 150px must not get a vote. The floor is
 * the counterpart to the panel's `auto` above: below it the frame stops
 * shrinking and the panel starts scrolling, so a very short window costs
 * a scrollbar instead of hiding the frame. It only engages under roughly
 * 430px of viewport height, where the free space runs out. */
#panel:has(.sbframewrap) > #detail > .sbframewrap {
  flex: 1 1 0;
  height: auto;
  min-height: 180px;
}

/* ---- COLLAPSED (#panel.collapsed: flex-basis 0, max-width 0) ----
 * chat.css's rule, for chat.css's reason: a zero-width flex item still
 * lays its children out. An iframe makes that worse than a div - it
 * stays LOADED, keeps polling, and stays in the tab order behind a
 * panel with no width. Hidden, not merely narrow.
 * display:none does not reload the frame (only re-parenting does), so
 * re-opening the panel resumes rather than restarts. */
#panel.collapsed .sbviews,
#panel.collapsed .sbframewrap { display: none; }

/* the placeholder that stands in for a view: "config is not built yet",
 * and the note above the fallback panel when a link has no live subject */
.sbnote {
  margin: 2px 0 10px;
  padding: 10px 12px;
  background: var(--bg3);
  border: 1px solid var(--line);
  border-radius: 8px;
  font-size: 12.5px;
  line-height: 1.5;
}

/* ---- stacked layout (mirrors the 860px block above) ----
 * DELIBERATELY LATER IN THE FILE than that block: these override it at
 * equal specificity, so the cascade order is doing the work. */
@media (max-width: 860px) {
  /* COLLAPSE IS A NO-OP AT THIS WIDTH - #paneltab is display:none and
   * `#panel, #panel.collapsed` are given the same 45% - so a .collapsed
   * left over from a wider window must not blank the shell. Without
   * this, dragging a desktop window narrow while the panel is collapsed
   * shows a panel with a hole in it. */
  #panel.collapsed .sbviews { display: flex; }
  #panel.collapsed .sbframewrap { display: flex; }

  /* THE FRAME VIEW GETS MORE OF THE COLUMN. The shared rule gives the
   * panel 45% of the viewport; minus padding, the strip and live.css's
   * 56px sticky header that leaves roughly 200px of actual link stats
   * on a phone - the numbers this whole page exists to put front and
   * centre. 60% still leaves the map a usable 40%, and it applies ONLY
   * while a frame is up. One line to revert if the map is worth more. */
  #panel:has(.sbframewrap),
  #panel.collapsed:has(.sbframewrap) { flex: 0 0 60%; }
}
