/* Fixed 1920x1080 broadcast canvas internally - every panel/font/gap below
   is still a real px value against that design resolution, same assumption
   every GD-rendered scene image already makes, so none of it needed to
   change for this to scale. #app itself is a fixed-size stage that gets
   uniformly scaled (transform: scale(), see fitStage() in channel.js) to
   whatever the real viewport turns out to be, locked to 16:9 and letter/
   pillarboxed (html/body's own background shows through) rather than
   stretched - the capture pipeline's Chrome window is launched at exactly
   1920x1080 (run_capture.sh's --window-size), so that case computes
   scale=1 and looks pixel-identical to before this existed; a browser
   window at any other size (e.g. someone watching channel.thunderpup.net
   in a small desktop window) is the actual new case this serves. */

* { box-sizing: border-box; }

/* Nothing on this page is ever meant to be selected/copied/dragged out -
   it's a passive on-air display, not an interactive page (no inputs, no
   links). user-select:none on html/body is inherited by every descendant
   unless something further down opts back in (nothing does), so this
   alone covers the whole page; -webkit-user-drag/-webkit-touch-callout
   handle the two things user-select doesn't (image drag-out, iOS's
   long-press "Copy Image"/"Share" menu). Belt-and-suspenders JS event
   guards (contextmenu/copy/dragstart) are in channel.js - CSS alone
   doesn't stop a right-click "Copy image" or Ctrl+C on already-selected
   text in some browsers. */
html, body {
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  background: #060b12;
  font-family: 'Arial Narrow', Arial, 'Helvetica Neue', sans-serif;
  color: #f2f5f9;
  user-select: none;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-touch-callout: none;
}

img, video {
  -webkit-user-drag: none;
  user-drag: none;
}

#app {
  position: absolute;
  top: 0;
  left: 0;
  width: 1920px;
  height: 1080px;
  transform-origin: top left;
  /* transform (translate to center + scale to fit) is set inline by
     fitStage() in channel.js, recomputed on every resize. */
}

/* Konami-code easter egg canvas (see channel.js's "fireworks celebration"
   section) - highest z-index on the page on purpose, sits above .frame
   entirely rather than being scoped to one panel, and pointer-events:none
   so it never blocks the (already keyboard-only, no click targets)
   display underneath. Hidden via opacity rather than display:none/hidden
   so launchFireworks() only has one class to toggle, with a quick fade
   for the on/off edges instead of a hard cut. */
#fireworksCanvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 1920px;
  height: 1080px;
  z-index: 9999;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}

#fireworksCanvas.active {
  opacity: 1;
}

/* ---- background video layer ------------------------------------------ */

/* Two stacked elements, not one <video loop> - a native loop restart is an
   instant frame-0 jump-cut, and there's no attribute/CSS that softens that
   on a single element. channel.js keeps a hidden standby video preloaded
   and seeked to 0, then crossfades opacity into it a couple seconds before
   the visible one reaches its end (see crossfadeBackgroundTo()) - same
   "two independent layers" idea the Yellow/Red theme tint above already
   uses for the same reason, just applied to loop *and* state-change
   transitions here instead of only state changes. */
.bg-video {
  position: absolute;
  inset: 0;
  width: 1920px;
  height: 1080px;
  object-fit: cover;
  z-index: 0;
  opacity: 0;
  transition: opacity 1.4s ease;
}
.bg-video.bg-video-active { opacity: 1; }

#bg-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(4,10,20,0.55) 0%, rgba(4,10,20,0.72) 100%);
  z-index: 1;
}

/* Yellow/Red/Purple Mode background wash - see channel.js's
   applyAlertMode(). Independent layers (not one that swaps color) so a
   direct transition between any two crossfades cleanly instead of
   jump-cutting the color the instant a single shared layer's background
   value changes. Purple (Tornado Emergency - see computeAlertMode()'s own
   doc comment for why it outranks Red) reuses this same pattern rather
   than repurposing Red's own layer, since a Yellow<->Purple transition
   (a Tornado Watch upgraded straight to a Tornado Emergency) needs to
   crossfade too, not just Red<->Purple. */
#bg-theme-tint-yellow,
#bg-theme-tint-red,
#bg-theme-tint-purple {
  position: absolute;
  inset: 0;
  z-index: 2;
  opacity: 0;
  transition: opacity 1.4s ease;
  pointer-events: none;
}
#bg-theme-tint-yellow { background: linear-gradient(180deg, rgba(255,196,0,0.22), rgba(150,110,0,0.34)); }
#bg-theme-tint-purple { background: linear-gradient(180deg, rgba(200,38,211,0.24), rgba(120,20,130,0.36)); }
#bg-theme-tint-red    { background: linear-gradient(180deg, rgba(255,40,40,0.20), rgba(120,10,10,0.34)); }
body.theme-yellow #bg-theme-tint-yellow { opacity: 1; }
body.theme-red    #bg-theme-tint-red    { opacity: 1; }
body.theme-purple #bg-theme-tint-purple { opacity: 1; }

/* Frosted rain-on-glass look, toggled on during active severe weather -
   see channel.js's applyBackgroundState(). A blurred, darkened wash over
   the whole frame rather than per-panel, matching the second mockup's
   overall moodier look during the tornado warning rather than an isolated
   panel effect. */
#bg-glass {
  position: absolute;
  inset: 0;
  z-index: 3;
  opacity: 0;
  background: radial-gradient(ellipse at 30% 20%, rgba(120,150,190,0.10), transparent 60%);
  backdrop-filter: blur(1.5px) brightness(0.9);
  transition: opacity 1.2s ease;
  pointer-events: none;
}
body.severe #bg-glass { opacity: 1; }

/* Admin panel's Holiday Theme toggle (channel.holiday_theme, see
   applyBackgroundState() in channel.js) - a light drifting snowfall over
   the whole frame, above the background video/tints but below the real
   panels. Pure CSS (two radial-gradient dot patterns animated via
   background-position on this one element's own ::before/::after, at
   different sizes/speeds for a cheap sense of depth) rather than a JS
   canvas particle system - this runs 24/7 in an unattended kiosk that
   also gets screen-captured by ffmpeg at 30fps, so a GPU-composited
   background-position animation (no per-frame JS/layout work) is the
   safe choice over anything that could add up over weeks of uptime. */
#bg-snow {
  position: absolute;
  inset: 0;
  z-index: 3;
  opacity: 0;
  overflow: hidden;
  transition: opacity 1.4s ease;
  pointer-events: none;
}
body.theme-holiday #bg-snow { opacity: 1; }

#bg-snow::before,
#bg-snow::after {
  content: '';
  position: absolute;
  inset: -60px; /* overscan so the drift never reveals a hard edge */
  background-repeat: repeat;
  pointer-events: none;
}
#bg-snow::before {
  background-image: radial-gradient(circle, rgba(255,255,255,0.9) 1.6px, transparent 1.7px);
  background-size: 64px 88px;
  opacity: 0.55;
  animation: bg-snow-drift-1 11s linear infinite;
}
#bg-snow::after {
  background-image: radial-gradient(circle, rgba(255,255,255,0.75) 1.1px, transparent 1.2px);
  background-size: 96px 130px;
  opacity: 0.32;
  animation: bg-snow-drift-2 17s linear infinite;
}
@keyframes bg-snow-drift-1 {
  from { background-position: 0 0; }
  to   { background-position: 90px 900px; }
}
@keyframes bg-snow-drift-2 {
  from { background-position: 0 0; }
  to   { background-position: -130px 900px; }
}

/* Holiday Theme accent recolor - a couple of the light-blue accent labels
   shift to a pine green, pairing with the badge/tagline's existing gold
   (left as-is - already reads as festive, no change needed there).
   Deliberately not using red anywhere here: this station's own red
   already means "Red Mode/active danger" (see #bg-theme-tint-red above),
   and a decorative "Christmas red" risks reading as that at a glance.
   Every rule below is guarded off the instant a real alert-severity
   theme is active (:not(.theme-yellow):not(.theme-red):not(.theme-purple))
   so severity color-coding always wins outright, never just visually
   competes with the seasonal accent. */
body.theme-holiday:not(.theme-yellow):not(.theme-red):not(.theme-purple) .currently-label,
body.theme-holiday:not(.theme-yellow):not(.theme-red):not(.theme-purple) .forecast-city-label {
  color: #4caf7d;
}

.frame {
  position: relative;
  z-index: 4;
  width: 1920px;
  height: 1080px;
  padding: 20px;
  display: grid;
  grid-template-columns: 460px 1fr;
  /* Last row (.bottom-panel) is 234.25px, not a round 268px - solved so
     .map-panel's own content box (column 2, rows 1-3, with the grid's
     20px padding and 16px row/column gaps already subtracted out) comes
     out to *exactly* 16:9 - the same aspect every map/radar scene is
     rendered at (engine.canvas.width/height, confirmed 1920x1080 on
     every PNG this project generates). Every one of those images is
     shown via .map-body img's object-fit:cover, which crops whichever
     axis the container is proportionally wider/taller on to make the
     image fully cover it; at the old 268px row4, the panel's real box
     came out to 1404x756 (aspect 1.857), noticeably wider than the
     image's own 1.778 - confirmed live (2026-08-06) as a real,
     measurable ~34px total vertical crop (object-fit:cover trims it
     symmetrically top and bottom), matching the exact "a bit of the top
     and a bit of the bottom gets cut off, sides are fine" the user
     reported. Solving col2Width/(row1+gap+row2+gap+row3) = 16/9 for
     row4 (the only free variable once column widths and the two other
     fixed rows are held constant - row3's own 1fr makes row1+row2+row3's
     total depend on row4 alone: 1040 - 148 - 92 - 268 - 48 = 484 before,
     517.75 after) gives 234.25 - confirmed live afterward: .map-panel's
     own getBoundingClientRect() aspect now reads 1.7777... to 6+ decimal
     places, an exact match, zero crop needed on either axis. .bottom-
     panel's own contents (.bottom-forecast/.bottom-warning) are already
     fully relatively/flex-sized against their parent's real height, not
     hardcoded against the old 268px, so this ~34px reduction (~13%)
     costs no content - confirmed live, nothing inside either box
     overflows or visibly compresses at the new height. */
  grid-template-rows: 148px 92px 1fr 234.25px;
  gap: 16px;
}

.panel {
  background: rgba(12, 24, 40, 0.62);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: 10px;
  backdrop-filter: blur(6px);
  box-shadow: 0 6px 24px rgba(0,0,0,0.35);
  overflow: hidden;
}

/* ---- logo -------------------------------------------------------------- */

/* Bold condensed face for the station name - bundled (Ubuntu Sans, variable width+weight, Ubuntu Font
   Licence) so every browser/TV renders the same lettering instead of whatever 'Arial Narrow' resolves to. */
@font-face {
  font-family: 'BWE Broadcast';
  src: url('../assets/fonts/UbuntuSans-wdth-wght.ttf') format('truetype');
  font-weight: 100 800;
  font-stretch: 75% 100%;
  font-display: swap;
}

.logo-panel {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 16px;
  padding: 16px 18px;
  background: linear-gradient(135deg, #0d3a66, #133c5c 60%, #0a2b4a);
}

/* 1:1 logo, inset from the panel's edges rather than filling its full height. */
.station-logo {
  height: 100%;
  aspect-ratio: 1 / 1;
  width: auto;
  flex: 0 0 auto;
  object-fit: contain;
}

.station-name {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
  font-family: 'BWE Broadcast', 'Arial Narrow', Arial, sans-serif;
  font-stretch: 75%;
  font-weight: 800;
  text-transform: uppercase;
  line-height: 1.02;
  letter-spacing: 0.02em;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
  white-space: nowrap;
}

.station-name-1 { font-size: 39px; color: #f4f7fb; }
.station-name-2 { font-size: 39px; color: #f0c04a; } /* gold, picks up the logo's ring */

/* ---- Yellow/Red Mode "blue block" tinting -------------------------------
   Applies to the two blue panels (.logo-panel, .currently-panel) - see
   channel.js's applyAlertMode(). No longer includes .map-header, which
   is gone entirely now (2026-08-06) - every image/page shown in
   .map-body bakes in its own title bar instead of relying on this
   shared HTML one, so there's nothing left here for Yellow/Red Mode to
   tint. Two stacked overlay layers per element (::before for yellow,
   ::after for red), each independently opacity-faded, so the transition
   is a real crossfade in every direction (normal<->yellow, normal<->red,
   and a direct yellow<->red) rather than an abrupt color swap. Real
   content inside these two elements gets position:relative + z-index so
   it stays readable above the overlays instead of being painted under
   them the way an absolutely-positioned pseudo-element normally would
   sit atop any non-positioned sibling regardless of DOM order. */
.logo-panel, .currently-panel {
  position: relative;
}

.logo-panel > *, .currently-panel > * {
  position: relative;
  z-index: 1;
}

.logo-panel::before, .currently-panel::before,
.logo-panel::after,  .currently-panel::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: 0;
  transition: opacity 1.4s ease;
  pointer-events: none;
}

.logo-panel::before      { background: linear-gradient(135deg, #6b5108, #7c600e 60%, #57420a); }
.currently-panel::before { background: linear-gradient(180deg, rgba(150,120,20,0.60), rgba(80,62,10,0.80)); }

.logo-panel::after      { background: linear-gradient(135deg, #6b1210, #7c1614 60%, #500e0c); }
.currently-panel::after { background: linear-gradient(180deg, rgba(150,25,20,0.60), rgba(80,12,10,0.80)); }

body.theme-yellow .logo-panel::before,
body.theme-yellow .currently-panel::before { opacity: 1; }

body.theme-red .logo-panel::after,
body.theme-red .currently-panel::after { opacity: 1; }

/* Purple (Tornado Emergency) reuses the ::after slot Red's rules already
   define above rather than adding a fourth pseudo-element (CSS only gives
   an element ::before and ::after) - safe since Red and Purple are
   mutually exclusive states (see computeAlertMode()), so this can never
   fight with Red's own ::after opacity rule for the same element at the
   same time. */
body.theme-purple .logo-panel::after      { background: linear-gradient(135deg, #5a0f66, #6c1478 60%, #3f0a4a); opacity: 1; }
body.theme-purple .currently-panel::after { background: linear-gradient(180deg, rgba(180,20,190,0.55), rgba(90,12,100,0.80)); opacity: 1; }

/* ---- sponsor ad slot ---------------------------------------------------- */
/* Named .sponsor-panel, not .ad-panel - confirmed live 2026-08-05 that
   "ad-panel" is a generic cosmetic-filter match in Brave Shields/uBlock/
   EasyList (anything that looks like "ad-*" gets hidden client-side by
   default, regardless of whether it's a real third-party ad), silently
   dropping this panel on any viewer with an ad blocker even though it's
   entirely first-party sponsor content this station controls. */

.sponsor-panel {
  grid-column: 1;
  grid-row: 2;
  position: relative;
  /* Fallback fill, visible only behind a transparent banner PNG or before
     scheduleSponsorBannerRotation() (channel.js) has set a src at all
     (empty channel/assets/sponsors/, or the very first paint) - every
     real banner currently in that folder is opaque and exactly fills this
     panel (460x92, this grid cell's own real size), so this almost never
     actually shows through. */
  background: linear-gradient(180deg, #f4d23a, #e0b520);
  border-color: rgba(0,0,0,0.15);
}

.sponsor-banner {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- currently panel ---------------------------------------------------- */

.currently-panel {
  grid-column: 1;
  grid-row: 3;
  background: linear-gradient(180deg, rgba(40,90,150,0.55), rgba(15,40,75,0.75));
  padding: 20px 26px;
  display: flex;
  flex-direction: column;
}

/* Tropical System Layout only - its own top-level grid item (NOT nested
   inside .currently-panel - per the user's own explicit correction,
   2026-08-09: "The map shouldn't be inside the current conditions
   container... a whole new container should be created below it"), same
   grid-column as .alerts-mini-panel/.currently-panel so it's automatically
   the same width, locked to a 16:9 ratio via aspect-ratio rather than a
   fixed px height so it stays correct regardless of column width.
   grid-row itself (and .currently-panel's own shrink) is set below by
   .frame.tropical-active - see that rule's own comment for the row math. */
.tropical-map-panel {
  grid-column: 1;
  position: relative;
  aspect-ratio: 16 / 9;
  background: #04101f;
}

.tropical-map-panel[hidden] { display: none; }

.tropical-map-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Same semi-transparent/white-text caption-bar treatment as
   .mini-map-caption further down this file - that one sits at the bottom
   of the mini warning map since it names an alert; this one sits at the
   TOP per the user's own explicit spec (2026-08-09), naming whichever
   storm is currently showing since this map (like that one) has no title
   bar of its own. Text set by renderTropicalMapImage() in channel.js. */
.tropical-map-caption {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  background: rgba(0,0,0,0.55);
  color: #fff;
  font-size: 16px;
  font-weight: 900;
  letter-spacing: 1.5px;
  line-height: 1.1;
  text-align: center;
  padding: 8px 0;
}

/* Inserts .tropical-map-panel's own grid row between .currently-panel and
   .alerts-mini-panel, toggled in channel.js alongside every other
   tropical-mode state class. Column 1's rows go from 4 to 5; .currently-
   panel's own row (the 1fr row, index 3) shrinks by exactly the new
   row's own height to make room, per the user's own explicit spec
   (2026-08-09) - and since .map-panel (column 2) already spans that same
   1fr row as part of its own rows-1-to-3 span, it's widened to span the
   new row too (1/span 4) so its OWN total height - and therefore its
   carefully-tuned exact-16:9 content aspect, see .frame's own doc
   comment on why row 4's 234.25px value is load-bearing for that - comes
   out completely unchanged: row3 shrinks by the new row's height, but
   map-panel now captures that same height back via the wider span, a net
   wash. .bottom-panel/.alerts-mini-panel (both real, fixed-height rows
   that were "row 4") shift down to "row 5" alongside it. */
.frame.tropical-active {
  grid-template-rows: 148px 92px 1fr 258.75px 234.25px;
}

.frame.tropical-active .map-panel { grid-row: 1 / span 4; }
.frame.tropical-active .tropical-map-panel { grid-row: 4; }
.frame.tropical-active .alerts-mini-panel { grid-row: 5; }
.frame.tropical-active .bottom-panel { grid-row: 5; }

.currently-label-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.currently-label {
  font-size: 18px;
  font-weight: 700;
  letter-spacing: 3px;
  color: #8fc4f5;
}

.currently-obs-time {
  font-size: 14px;
  color: #9aa5b0;
  text-align: right;
  white-space: nowrap;
}

.currently-city {
  font-size: 40px;
  font-weight: 800;
  margin-top: 4px;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-main {
  display: flex;
  align-items: center;
  gap: 18px;
  margin-top: 6px;
}

/* Tropical System Layout only - the forecast rotation frame hides the
   normal icon/temp/wind row entirely, per the user's own explicit
   correction (2026-08-09): the moon icon/temperature/wind box shouldn't
   be showing on the forecast slide at all, only currentlyLabel's own
   "[CITY], [ST] FORECAST" text and the 3 forecast rows below. Class-
   scoped override (not the [hidden] attribute), so no UA-vs-author
   precedence gotcha here - see currently-label-row's own history further
   up this file for why that gotcha matters when it DOES apply. */
.currently-panel.forecast-phase .currently-main { display: none; }

/* Airport-status rotation frame (see channel.js's own
   currentlyShowingAirports) - same "hide the normal icon/temp/wind/aqi/
   stats block entirely, only the header row and this frame's own content
   show" treatment forecast-phase above already established for the
   tropical forecast frame, applied to every one of that block's own
   siblings rather than just currently-main, since this frame also
   replaces currently-city (the panel reads "AIRPORT STATUS" in
   currentlyLabel with no city name under it, not one specific city). */
.currently-panel.airport-phase .currently-city,
.currently-panel.airport-phase .currently-main,
.currently-panel.airport-phase .currently-aqi,
.currently-panel.airport-phase .currently-stats,
.currently-panel.airport-phase .currently-tropical-forecast {
  display: none;
}

.condition-icon {
  width: 96px;
  height: 96px;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-temp {
  /* 88px -> 80px (2026-08-14) - per the user's own explicit request: at
     88px, even an ordinary 2-digit reading (e.g. "77°") already ran the
     .currently-wind box 1px past .currently-main's own right edge inside
     this fixed 460px-wide panel (confirmed live) - not visible as a hard
     clip most of the time since flex's default nowrap just lets it spill
     a hair past the container, but enough to read as "pressed out" for
     wider glyphs/other fonts. 80px leaves a comfortable ~11px of slack
     instead of running right up against the edge.
  */
  font-size: 80px;
  font-weight: 800;
  line-height: 1;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease, font-size 0.2s ease;
}

/* 3+ digit (100°+) or negative temps - see the .is-long toggle in
   renderCurrently()'s own comment for why these need to shrink at all.
   Scaled down by the same ratio as the base size above (62/88 ~= 0.70,
   80*0.70 ~= 56) so the 3-digit case keeps the same proportional
   headroom relative to the wind box, not just the 2-digit case. */
.currently-temp.is-long {
  font-size: 56px;
}

.currently-panel.fade-in .currently-city,
.currently-panel.fade-in .condition-icon,
.currently-panel.fade-in .currently-temp,
.currently-panel.fade-in .currently-wind,
.currently-panel.fade-in .currently-aqi,
.currently-panel.fade-in .currently-stats,
.currently-panel.fade-in .currently-tropical-forecast,
.currently-panel.fade-in .currently-airport-status {
  opacity: 1;
  transform: none;
}

/* Own boxed area beside the condition icon/temp (not a stat-row line) -
   modeled directly on a real broadcast wind graphic: big MPH number on the
   left, a circular compass with a direction needle (or "CLM" when calm)
   on the right, both inside one dark panel set off from the temperature. */
.currently-wind {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px 16px;
  margin-left: 4px;
  border-radius: 10px;
  background: rgba(6,14,26,0.55);
  border-left: 2px solid rgba(255,255,255,0.18);
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-wind[hidden] { display: none; }

.currently-wind-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2px;
  color: #9fbedd;
}

.currently-wind-row {
  display: flex;
  align-items: center;
  gap: 14px;
}

.currently-wind-speed-block,
.currently-wind-compass-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  line-height: 1.1;
}

.currently-wind-speed {
  font-size: 34px;
  font-weight: 800;
}

.currently-wind-unit {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1.5px;
  color: #9fbedd;
  margin-top: 2px;
}

.currently-wind-compass {
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: rgba(0,0,0,0.35);
  display: flex;
  align-items: center;
  justify-content: center;
}

.currently-wind-compass svg { display: block; color: #d9b23c; }

.currently-wind-compass-calm {
  font-size: 12px;
  font-weight: 800;
  color: #eaf2fb;
}

.currently-wind-dir {
  font-size: 14px;
  font-weight: 800;
  margin-top: 4px;
}

/* Own full-width boxed row below the temp/wind line (not part of that
   flex row, and not a .currently-stats text line either) - a colorful EPA
   AQI scale bar with a marker showing where the current reading falls, per
   the user's own explicit spec (2026-08-07), same boxed treatment as
   .currently-wind above for visual consistency. */
.currently-aqi {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 16px;
  margin-top: 10px;
  border-radius: 10px;
  background: rgba(6,14,26,0.55);
  border-left: 2px solid rgba(255,255,255,0.18);
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-aqi[hidden] { display: none; }

.currently-aqi-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2px;
  color: #9fbedd;
}

.currently-aqi-bar {
  position: relative;
  height: 14px;
  border-radius: 7px;
  background: linear-gradient(
    90deg,
    #00e400 0%, #00e400 16.6%,
    #ffff00 16.6%, #ffff00 33.3%,
    #ff7e00 33.3%, #ff7e00 50%,
    #ff0000 50%, #ff0000 66.6%,
    #8f3f97 66.6%, #8f3f97 83.3%,
    #7e0023 83.3%, #7e0023 100%
  );
}

.currently-aqi-marker {
  position: absolute;
  top: -5px;
  width: 3px;
  height: 24px;
  background: #fff;
  border-radius: 2px;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.5);
  transform: translateX(-50%);
  transition: left 0.4s ease;
}

.currently-aqi-marker::before {
  content: '';
  position: absolute;
  top: -7px;
  left: 50%;
  transform: translateX(-50%);
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 6px solid #fff;
}

.currently-aqi-readout {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-size: 15px;
}

.currently-aqi-value { font-size: 22px; font-weight: 800; }
.currently-aqi-category { font-weight: 700; }

.currently-stats {
  margin-top: auto;
  font-size: 18px;
  line-height: 1.85;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-stats .stat-row {
  display: flex;
  justify-content: space-between;
  border-bottom: 1px solid rgba(255,255,255,0.08);
  padding: 2px 0;
}

.currently-stats .stat-label { color: #9fbedd; }
.currently-stats .stat-value { font-weight: 700; }

/* Tropical System Layout only (channel.js's own applyTropicalMode()) -
   takes over .currently-stats/.currently-aqi's own spot (both hidden
   instead while this is up), same "label above content" shape as
   .currently-stats but showing the city's own upcoming periods (name +
   icon + temp per row) instead of feels-like/dewpoint/humidity/pressure -
   per the user's own explicit spec (2026-08-09): "removes all but the
   temperature and wind from the current conditions [and] places the
   forecast in the bottom of that panel." */
.currently-tropical-forecast {
  margin-top: auto;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-tropical-forecast[hidden] { display: none; }

.currently-tropical-forecast-rows {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.ctf-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: rgba(60,100,170,0.22);
  border-radius: 8px;
  padding: 6px 14px;
}

.ctf-label {
  font-size: 17px;
  font-weight: 800;
}

.ctf-icon-temp {
  display: flex;
  align-items: center;
  gap: 10px;
}

.ctf-icon-temp img {
  width: 34px;
  height: 34px;
}

.ctf-temp {
  font-size: 26px;
  font-weight: 800;
}

.pressure-trend {
  display: inline-block;
  font-size: 20px;
  line-height: 1;
  vertical-align: middle;
  margin-left: 3px;
}

/* One extra frame in the Currently rotation (see channel.js's own
   currentlyShowingAirports) - takes over currently-stats/currently-aqi/
   currently-tropical-forecast's own spot exactly like that tropical
   frame already does, per the user's own explicit spec (2026-08-11).
   currentlyLabel itself is repurposed to read "AIRPORT STATUS" for this
   frame (see renderCurrently()) rather than this block carrying its own
   duplicate header - the same "reuse the shared label row" convention
   the tropical-forecast frame already established for "[CITY] FORECAST". */
.currently-airport-status {
  margin-top: auto;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.currently-airport-status[hidden] { display: none; }

/* 2-column grid, not one row each - National Mode's own default preset
   (20 major hubs, see config/national_mode.php) needs to fit this same
   panel a single market's 1-3 airports do, so each entry has to stay
   compact rather than assuming a short list. align-content:start keeps a
   short list pinned to the top instead of centering/stretching into the
   panel's own free space below it. */
.currently-airport-status-rows {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px 10px;
  align-content: start;
  max-height: 100%;
  overflow: hidden;
}

.airport-row {
  display: flex;
  align-items: center;
  gap: 8px;
  background: rgba(10,20,35,0.55);
  border-radius: 8px;
  padding: 6px 10px;
  border-left: 3px solid var(--airport-severity-color, #6c8199);
}

.airport-row-dot {
  flex: 0 0 auto;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--airport-severity-color, #6c8199);
  box-shadow: 0 0 6px var(--airport-severity-color, transparent);
}

.airport-row-text {
  min-width: 0;
  flex: 1 1 auto;
}

.airport-row-code {
  font-size: 16px;
  font-weight: 800;
  letter-spacing: 0.5px;
}

.airport-row-status {
  font-size: 11px;
  font-weight: 600;
  color: #9fbedd;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ---- mini alerts panel --------------------------------------------------- */

/* No more has-alerts red/green tint (removed 2026-08-13 per the user's
   own explicit request: "the red box behind it should be removed") - and,
   per the user's own immediate follow-up ("The blue container box around
   it shouldn't be visible"), .panel's own shared background/border/
   shadow/blur (the rounded dark-blue card look every OTHER panel on the
   page keeps) is overridden away here too, back to fully transparent -
   this section reads as sitting directly on the channel's own background,
   not inside a card, matching the same "no rectangular window" look the
   county-cutout map itself already has. */
.alerts-mini-panel {
  grid-column: 1;
  grid-row: 4;
  position: relative;
  display: grid;
  /* Fixed 2:1 ratio, not a flex-basis percentage - a hard cap guaranteed
     by construction (map column can never exceed 2/3) rather than
     something a future flex-grow/shrink tweak could accidentally erode,
     per the user's own explicit request (2026-08-13): "the map container
     is never more than 2/3 of the space available... so the legend never
     gets really squished." */
  grid-template-columns: 2fr 1fr;
  align-items: stretch;
  background: transparent;
  border: none;
  box-shadow: none;
  backdrop-filter: none;
}

/* Two-column split (2026-08-13, per the user's own explicit request,
   replacing the map-fills-the-whole-panel/legend-as-a-bottom-overlay
   layout above's own doc comments once described): the combined "every
   active alert at once" county-cutout map (ActiveAlertsMapScene) on the
   left, its own legend on the right. object-fit:contain, not cover -
   unlike the full-bleed .mini-map-image below, most of this image's own
   frame is genuinely transparent by design (everywhere outside the
   market's own county cutout, see that scene's own doc comment), so
   there's no "photo" content to crop into place the way cover would.
   ActiveAlertsMapScene's own fitByWidth() already fills the PNG's own
   west-to-east extent edge-to-edge within its fixed 640x400 canvas (see
   that method's own doc comment) - contain then just needs to not add a
   SECOND round of side letterboxing on top of that, which it won't as
   long as this column stays narrower (width/height) than the source
   canvas's own 640/400 aspect, true for both the previous 68/32 flex
   split and this grid's own fixed 2:1 ratio. Only one of
   .combined-alerts-map/.mini-map-image is ever visible at a time (see
   channel.js's showMiniMap()/showCombinedAlertsDisplay()) - .mini-map-
   image's own position:absolute;inset:0 still fills the WHOLE panel
   correctly whenever it's the one showing, regardless of these two grid
   columns sitting beside each other in the DOM (an absolutely positioned
   element ignores its siblings' own grid placement). */
.combined-alerts-map {
  min-width: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Right column - a left-aligned, vertically-stacked list (not the
   horizontally-wrapped, centered row the map-overlay version above used)
   rather than the wide single-row-page-at-a-time approach; channel.js's
   renderAlertsLegend() measures how many chips actually fit this
   column's own real rendered height and pages that many at a time when
   there isn't room for all of them - same real-DOM-measurement idea
   .warning-area-list below uses for its own row paging, just column-
   oriented here. min-height:0 + overflow:hidden are load-bearing, not
   decorative: a grid item's default min-height is 'auto' (content-based),
   which otherwise lets a long chip list silently grow this column (and,
   via .panel's own overflow:hidden, get invisibly clipped) TALLER than
   its real available height - channel.js's own clientHeight-based paging
   math would then measure that inflated, never-actually-visible height
   and under-paginate, showing more chips than actually fit (confirmed
   live 2026-08-13: a 5-entry list with several multi-line labels
   overflowed the bottom of the panel with pagination never kicking in
   at all). Pinning min-height to 0 forces this column to actually respect
   align-items:stretch's real constrained height instead. */
.alerts-legend {
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 8px;
  padding: 10px 14px;
  text-align: left;
  opacity: 1;
  transition: opacity 0.4s ease;
}

/* Applied briefly by channel.js's renderAlertsLegend() around swapping
   which page's chips are hidden, so paging between pages reads as a
   gentle cross-fade rather than an instant hard cut - per the user's own
   explicit request (2026-08-13). */
.alerts-legend.legend-transitioning {
  opacity: 0;
}

/* white-space:normal (not nowrap) + a real width - a long label
   ("TROPICAL CYCLONE LOCAL STATEMENT") wraps onto multiple lines within
   the legend column's own width instead of overflowing past it, per the
   user's own explicit request. width:100%/min-width:0 are what actually
   let that wrapping happen at all: a flex column's children otherwise
   size to their own max-content width (i.e. the whole label on one line)
   regardless of white-space, unless explicitly constrained to the
   parent's own width. channel.js's renderAlertsLegend() measures each
   chip's real (possibly multi-line) rendered height when deciding how
   many fit per page, so variable wrapped heights are handled correctly,
   not assumed uniform. */
.legend-chip {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  width: 100%;
  min-width: 0;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 0.5px;
  line-height: 1.25;
  color: #fff;
  white-space: normal;
  overflow-wrap: break-word;
  text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}

/* .legend-chip's own 'display:flex' above and the browser's built-in
   '[hidden] { display: none }' rule carry EQUAL specificity (one class,
   one attribute selector) - without this, the later-in-cascade class rule
   silently wins, so channel.js's own renderAlertsLegend() setting
   chip.hidden=true on an off-page chip does nothing visually: it stays a
   flex item, still taking up its own real height in the column, pushing
   the genuinely-visible page's chips down and off the bottom of the
   panel. Confirmed live (2026-08-13) as the actual root cause of a
   pagination page appearing to badly overflow even though its own
   chips' combined height was correctly calculated to fit - the "hidden"
   chips from every OTHER page were still silently occupying space above
   them the whole time. A class+attribute selector here (specificity 0-2-0)
   reliably outranks the plain class rule regardless of source order.
   .area-chip has no equivalent issue - it never sets its own 'display',
   so the UA stylesheet's un-competed-with rule already hides it correctly. */
.legend-chip[hidden] {
  display: none;
}

.legend-swatch {
  width: 12px;
  height: 12px;
  margin-top: 3px;
  border-radius: 3px;
  flex: 0 0 auto;
  box-shadow: 0 0 0 1px rgba(255,255,255,0.4);
}

.legend-empty {
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 1.5px;
  color: #cfe8dd;
  text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}

/* The per-alert mini map (AlertMiniMapScene's output) fills the whole
   panel; a thin caption strip along the bottom names which alert is
   currently shown, since the map itself has no title bar by design. */
.mini-map-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Same visual family as .warning-banner below (bold weight, letter-
   spacing, centered) - deliberately NOT the shared class, since the
   banner's per-alert gradient/pulse animation must never carry over
   here: this bar is always solid black and always static, regardless of
   how severe the alert is, per exact spec. */
.mini-map-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* Semi-transparent now, not solid - per the user's own explicit
     request (2026-08-07) to let the map show through underneath.
     Sized on its own terms now (2026-08-13) - this used to be tuned to
     match .warning-banner's own total height exactly, but that banner
     was cut to about half its old height (in favor of a much taller
     .warning-ticker - see both rules' own comments below) for a reason
     specific to that box's own at-a-distance legibility goals, not
     anything this caption bar needs to track. */
  background: rgba(0,0,0,0.55);
  color: #fff;
  font-size: 16px;
  font-weight: 900;
  letter-spacing: 1.5px;
  line-height: 1.1;
  text-align: center;
  padding: 8px 0;
}

/* ---- main map panel ------------------------------------------------------ */

.map-panel {
  grid-column: 2;
  grid-row: 1 / span 3;
  position: relative;
  background: #0a1826;
}

/* .map-body no longer *always* bakes in its own title bar (reversed
   2026-08-07, see .title-bar-overlay below for why) - it just fills the
   whole panel either way, whether the current image is a fully-baked PNG
   (no overlay shown) or a bare one with .title-bar-overlay drawn on top. */
.map-body {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* Query container for every cqw unit .title-bar-overlay's rules use
     below - 1cqw = 1% of .map-body's own real rendered width. Since
     #app is a fixed 1920x1080 stage (see this file's own header
     comment) and .map-panel's content box is confirmed exactly 16:9
     (same aspect as every source PNG), .map-body's real width is
     whatever uniform scale factor k the object-fit:cover image itself
     is already shown at - k = mapBodyWidthPx / 1920, same basis
     Theme::px()/SceneContext::withSize() use server-side
     (scale = renderWidth / engine.canvas.width). Expressing every
     title-bar measurement below as (designPx / 1920 * 100)cqw instead
     of a plain px value means the overlay tracks that same k
     automatically, with no JS-computed scale variable needed anywhere -
     it lines up with the displayed image at any window size, not just
     the capture pipeline's fixed 1920x1080 case. */
  container-type: inline-size;
}


/* #radarImage specifically, NOT a blanket ".map-body img" - the
   title-bar-overlay's own .tb-logo is also an <img> nested inside
   .map-body (via .title-bar-overlay > .tb-logo-panel), and a blanket
   selector here stretched it to fill this entire absolute/inset:0/100%
   box too, covering the whole map with a giant station logo (confirmed
   live 2026-08-07, right after this overlay first shipped). */
#radarImage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- HTML title bar overlay (2026-08-07) ----------------------------------
   Shown over a *bare* map image (no baked-in bar) for the scenes channel.js
   fetches a {scene}.titlebar.json sidecar for - see showTitleBar() in
   channel.js. Every measurement below is (designPx / 1920 * 100)cqw, where
   designPx is the exact same number RadarScene::drawBar() computes with at
   its own 1920-wide reference canvas (barMarginX=48, barMarginTop=28,
   barHeight=150, the 34px shared skew, the 190px logo panel, etc. - see
   that method's own doc comments for each). cqw ties these to .map-body's
   real rendered width (see its own container-type:inline-size comment
   above), so this overlay tracks the displayed image's own scale exactly,
   not just at the capture pipeline's fixed 1920x1080 window size.
   Colors are NOT hardcoded here - every color below is a CSS custom
   property channel.js sets per scene from that scene's own titlebar.json
   'colors' block (already broadcast-safe-clamped/darken()/lighten()-
   derived server-side by Theme::color()/Color::darken()/lighten() - see
   RadarScene::exportTitleBarSpec()), so a palette change in config/
   theme.php never needs a matching edit here. */

/* The browser's own UA stylesheet has a `[hidden] { display: none }`
   rule, but author stylesheets always win over UA ones regardless of
   specificity - so .title-bar-overlay/.tb-time-panel's own `display:
   flex` below silently overrode it, and setting .hidden = true in JS
   had NO visual effect on either one (confirmed live 2026-08-07: the
   whole overlay could double up over an already-baked bar, and
   showTimePanel()=false scenes like the SPC/QPF/HeatRisk/Drought
   Monitor family kept showing a time panel that should have been
   entirely absent). This rule's own specificity (class + attribute)
   beats the plain class selector below, so it correctly wins only when
   the hidden attribute is actually present. */
.title-bar-overlay[hidden],
.tb-time-panel[hidden] {
  display: none;
}

.title-bar-overlay {
  position: absolute;
  left: 2.5cqw;    /* barMarginX  (48/1920)  */
  top: 1.4583cqw;  /* barMarginTop (28/1920) */
  width: 95cqw;    /* barWidth   (1824/1920) */
  height: 7.8125cqw; /* barHeight (150/1920) */
  display: flex;
  align-items: stretch;
  pointer-events: none; /* passive on-air display, same as the rest of this page - see html/body's own user-select:none comment */
  /* 190deg, not a rounder-looking 180deg - matches the baked-in bar's own
     RadarScene::drawBar() gradientRect() angle (100deg in that method's own
     right-handed/y-down convention, which is CSS-angle-equivalent to
     Canvas-angle+90 - confirmed 2026-08-10 by walking Canvas::gradientRect()'s
     own dx/dy/origin math). Previously a hardcoded 135deg here (a full
     diagonal sweep) had drifted away from the near-vertical top-to-bottom
     sweep the baked PNG bar actually uses. */
  background: linear-gradient(190deg,
    color-mix(in srgb, var(--tb-panel-center, #d7d9dc) 100%, white 15%),
    color-mix(in srgb, var(--tb-panel-center, #d7d9dc) 100%, black 15%));
  box-shadow:
    inset 0 0.15625cqw 0 0 color-mix(in srgb, var(--tb-panel-center, #d7d9dc) 100%, white 75%),
    inset 0 -0.1042cqw 0 0 color-mix(in srgb, var(--tb-panel-center, #d7d9dc) 100%, black 40%);
}

.tb-logo-panel {
  flex: 0 0 11.6667cqw; /* panelWidth + skew ((190+34)/1920) */
  position: relative;
  background: var(--tb-panel-dark35, #121f2e);
  /* Right edge slants in from top (0,0)-(100%,0) down to
     (panelWidth/(panelWidth+skew), 100%)-(0,100%) - percentages here are
     safe (unlike .tb-time-panel's skew below) since this panel's own
     width is fixed, not content-driven. */
  clip-path: polygon(0 0, 100% 0, 84.8214% 100%, 0 100%);
  display: flex;
  align-items: center;
  justify-content: center;
}

.tb-logo-panel::after {
  /* Bright edge along the slanted cut - base->lighten(0.6) in drawBar().
     A parallelogram running exactly parallel to the panel's own clip-path
     edge (100%,0)-(84.8214%,100%), offset 1% inward - NOT a plain
     vertical band at a fixed x, which would cross that diagonal edge at
     only one point and read as a stray line near the cut rather than a
     highlight sitting on it (confirmed live 2026-08-07: that's exactly
     what a vertical-band version looked like). */
  content: '';
  position: absolute;
  inset: 0;
  clip-path: polygon(99% 0, 100% 0, 84.8214% 100%, 83.8214% 100%);
  background: var(--tb-panel-light60, #a4acb5);
  opacity: 0.9;
}

.tb-logo {
  height: 60%; /* (barHeight - 60) / barHeight, matching drawBar()'s diameter */
  width: auto;
  object-fit: contain;
}

.tb-center {
  flex: 1 1 auto;
  min-width: 0;
  position: relative;
  /* NOT padding - an absolutely positioned child's containing block is
     the padding EDGE of its nearest positioned ancestor, so padding here
     would have zero effect on .tb-title/.tb-legend's own left:N below
     (confirmed live: a padding-left here left both sitting flush with
     this element's un-padded edge). Each of those two supplies its own
     explicit left offset instead - see their own comments for the exact
     design-px math each one uses. */
}

/* Wraps .tb-title + .tb-legend (2026-08-08, per the user's own explicit
   request) - both keep positioning themselves exactly as before (against
   this element, which contributes the same 0,0-origin positioning context
   .tb-center used to provide directly), but as a GROUP they can now be
   shifted as one unit via a single JS-computed transform:translateY() -
   see tbCenterContent() in channel.js. That fixes titles/legends sitting
   inconsistently high or low across different scenes: a scene with a
   short single-row legend and one with a taller gradient+ticks legend
   both keep the exact same title-to-legend spacing they already had
   (nothing about their relative layout changes), but the whole block now
   centers as a group within the bar's real height instead of each piece
   anchoring independently to its own baked-in y coordinate regardless of
   how much total vertical space the pair actually uses. */
.tb-content-group {
  position: absolute;
  inset: 0;
}

.tb-title {
  position: absolute;
  left: 1.1458cqw; /* pad (22px) - cursorX (246px) minus the logo panel's own 224px flex-basis */
  top: 1.25cqw; /* px(24), local to the bar */
  right: 0;
  font-weight: 700;
  color: var(--tb-text-on-silver, #14171a);
  text-shadow: var(--tb-text-shadow, none);
  white-space: nowrap;
  overflow: hidden;
  transform-origin: left top;
  /* font-size and an optional transform:scale() (for the rare title too
     long even at the floor size) are set inline by channel.js's own
     shrink-to-fit pass - see fitTitleBarTitle(). */
}

.tb-legend {
  position: absolute;
  /* left:0 here sits at design-local x=224 (the logo panel's own
     panelWidth+skew flex-basis, .tb-center's own left edge) - NOT at
     cursorX (246px, cursorX = 224 + pad). channel.js's own per-item
     placement (see TB_LEGEND_ORIGIN_X) accounts for that directly, so
     nothing here needs an extra offset the way .tb-title's left above
     does. */
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  color: var(--tb-text-on-silver, #14171a);
  font-size: 0.9375cqw; /* type_scale.micro (18/1920) */
}

/* Populated per item by channel.js's tbRenderLegend() - kept generic here
   since a scene's legend can be a flow row, a wrap/shrink grid, or a
   single gradient ramp (see LegendRenderer's own PHP-side doc comment for
   the same three families), each positioned differently:
   - flow_row (the common case - swatches/lines/text in one row): a real
     .tb-legend-row flex container (below), one consistent CSS gap between
     items - per the user's own explicit request (2026-08-08) that the
     gap between legend items read as evenly spaced, which copying each
     item's own GD-measured x position couldn't fully guarantee (the
     browser doesn't measure text identically to GD, so the *visual* gap
     could come out slightly uneven even though the x's themselves were
     exactly what PHP computed).
   - flow_wrap_shrink (the SPC/QPF family's two-row wrap-and-shrink): each
     item individually absolutely-positioned from its own exported
     legend_layout x/y, unchanged - CSS flexbox's own native wrap
     wouldn't reliably make the same row-break decisions PHP's hand-
     rolled algorithm does, so this one still needs exact replication.
   - gradient: handled entirely by tbRenderGradientItem(), not this
     element at all. */
.tb-legend-row {
  position: absolute;
  display: flex;
  align-items: center;
  gap: 0.7292cqw; /* px(14) - LegendRenderer::layout()'s own flow_row $gap */
}

.tb-legend-item {
  position: absolute;
  display: inline-flex;
  align-items: center;
  gap: 0.3125cqw; /* px(6) */
  white-space: nowrap;
}

/* Inside .tb-legend-row, items are real flex children (the row's own
   flex gap above is what spaces them) - overrides the plain
   position:absolute every flow_wrap_shrink item still uses. */
.tb-legend-row .tb-legend-item {
  position: static;
}

.tb-legend-item.muted {
  color: var(--tb-text-on-silver-muted, #4a4d52);
}

.tb-legend-swatch {
  width: 0.8333cqw; /* px(16) */
  height: 0.8333cqw;
  flex: 0 0 auto;
  display: inline-block;
}

.tb-legend-swatch.round { border-radius: 50%; }

.tb-legend-line {
  width: 0.9375cqw; /* px(18) */
  height: 0.2083cqw;
  flex: 0 0 auto;
  display: inline-block;
}

/* This element IS the gradient bar itself (background set inline per
   scene) - not a wrapper around a separate bar child. Its own top (set
   inline to geo.y, the exact y LegendRenderer::drawGradient() draws the
   bar at) is the real anchor every other piece measures from: the label
   sits ABOVE this box (bottom:100%) and the tick marks/labels sit BELOW
   it (top:100%) - mirroring drawGradient()'s own layout exactly ($canvas
   ->text() draws the label at $y - size*1.6, i.e. above $y; the bar
   itself fills $y..$y+height; tick marks/labels draw at $y+height
   onward). The previous version stacked label->bar->ticks in a flex
   column all growing downward from geo.y instead, which put everything
   roughly one label-height too low and pushed the tick labels below the
   bar's own bottom edge - confirmed live 2026-08-07 (the reflectivity
   legend visibly hanging off the bottom of the bar onto the map). */
.tb-legend-gradient {
  position: absolute;
  left: 0;
  top: 0; /* overridden inline to geo.y */
  width: 18.75cqw; /* px(360), matches LegendRenderer's own default width_px */
  height: 0.625cqw; /* px(12) */
  border-radius: 2px;
}

.tb-legend-gradient-label {
  position: absolute;
  left: 0;
  bottom: 100%;
  margin-bottom: 0.15cqw; /* ~ size(18px)*1.6 minus this label's own line height, so its baseline lands close to drawGradient()'s y - size*1.6 */
  font-size: 0.75cqw;
  color: var(--tb-text-on-silver-muted, #4a4d52);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

.tb-legend-gradient-ticks {
  position: absolute;
  left: 0;
  top: 100%;
  margin-top: 0.3125cqw; /* px(6) tick-mark height below the bar, before tick labels */
  width: 18.75cqw;
  height: 0.9cqw;
  font-size: 0.6cqw;
  color: var(--tb-text-on-silver-muted, #4a4d52);
}

.tb-legend-gradient-ticks span {
  position: absolute;
  transform: translateX(-50%);
  white-space: nowrap;
}

.tb-time-panel {
  flex: 0 0 auto;
  position: relative;
  background: var(--tb-panel-dark22, #162537);
  padding: 0 1.1458cqw 0 2.9167cqw; /* pad ... (pad+skew) */
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  justify-content: center;
  gap: 0.1042cqw; /* smallGap, px(2) */
  /* Skew on the LEFT edge only, at a fixed 34px width regardless of this
     panel's own (content-driven) total width - mixing a cqw length for
     just the first point with %-of-own-box for the rest is valid CSS
     (each polygon() coordinate resolves independently), and is exactly
     what's needed here: a %-based point would make the skew's real
     width balloon or shrink with the time/date text length, which
     drawBar()'s own fixed-px skew never does. */
  clip-path: polygon(1.7708cqw 0, 100% 0, 100% 100%, 0 100%);
}

.tb-time-panel::before {
  /* Bright edge along the slanted cut - base->lighten(0.6), mirroring
     .tb-logo-panel::after, same fix for the same reason: this needs to
     be a parallelogram running exactly parallel to the panel's own
     clip-path edge (1.7708cqw,0)-(0,100%), not a vertical band at some
     guessed fixed x - a plain vertical line only crosses that diagonal
     at one single point, which is what read as a stray gray line near
     the cut instead of sitting on it. Every coordinate here uses the
     same absolute-cqw-length trick the parent's own clip-path does (see
     that rule's own comment) so the highlight tracks the real edge
     regardless of this panel's own dynamic, content-driven width. */
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  clip-path: polygon(1.7708cqw 0, 1.9791cqw 0, 0.2083cqw 100%, 0 100%);
  background: var(--tb-panel-light60, #a4acb5);
  opacity: 0.9;
}

.tb-top-label {
  font-size: 0.6771cqw; /* px(13) */
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--tb-text-muted, #9fb6cf);
}

.tb-time {
  font-size: 1.5625cqw; /* px(30) */
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--tb-text, #ebebeb);
  line-height: 1.1;
}

.tb-date {
  font-size: 0.8854cqw; /* px(17) */
  color: var(--tb-text-muted, #9fb6cf);
  letter-spacing: 0.02em;
}

/* ---- bottom rotator: forecast strip / warnings ---------------------------- */

.bottom-panel {
  grid-column: 2;
  grid-row: 4;
  position: relative;
  padding: 0;

  /* Bare layout wrapper only - NOT a .panel-styled box itself, even
     though it carries the .panel class (for the grid-area sizing/
     positioning rules that live there). Confirmed live (2026-08-06):
     .panel's own background/border/shadow used to paint across this
     entire grid cell, so the gap between .bottom-forecast and
     .bottom-warning below showed .panel's dark navy background through
     it - reading as one shared outer box with two regions inside,
     not two independent boxes. .bottom-forecast and .bottom-warning
     each carry their own full panel-style chrome now (see below), so
     nothing here should paint anything of its own.
     overflow MUST stay hidden (not visible) - confirmed live
     (2026-08-06) as a real regression, not just a cosmetic one:
     .warning-ticker's text needs white-space:nowrap to hold its full
     un-scrolled width before channel.js's translateX takes over, and
     overflow:hidden is what gives an element an automatic min-width of
     0 for grid/flex sizing purposes (CSS spec behavior, not specific to
     this codebase). Without it, that nowrap text's real intrinsic width
     propagated all the way up into the page grid's own 1fr column,
     stretching the whole column - including .map-panel above, which
     shares that same column - to ~6589px instead of the intended
     ~1424px. The very slight box-shadow clipping this reintroduces at
     .bottom-panel's own outer edge is the same clipping the single
     shared .panel box already had here before any of today's changes -
     not a new cosmetic cost. */
  background: none;
  border: none;
  border-radius: 0;
  backdrop-filter: none;
  box-shadow: none;
  overflow: hidden;
}

/* Warning-active mode: the forecast strip stays visible (a viewer should
   be able to check their own forecast whether or not they're affected by
   whatever's currently on screen - see channel.js's own comment on this),
   just shrunk and stacked above a shrunk warning box, both real flex
   children of .bottom-panel instead of absolutely-stacked full-size
   siblings covering the same box. Toggled by channel.js alongside
   whether any alert exists at all - the two rotations underneath run on
   fully independent timers (see channel.js), this is purely the layout
   switch between "forecast alone, full size" and "both, split". */
.bottom-panel.warning-active {
  display: flex;
  flex-direction: column;
  /* Wide enough to read as real empty space between two floating cards,
     not just a hairline seam - 8px plus each box's own near-invisible
     0.08-alpha border wasn't enough to visually separate them even
     though they were already two independent boxes underneath (reported
     live, 2026-08-06, after the first pass at this fix). */
  gap: 18px;
}

/* Carries the full .panel look itself (background/border/radius/blur/
   shadow) rather than relying on .bottom-panel for it - see
   .bottom-panel's own comment for why. Same values .panel itself uses,
   so a solo forecast (no warnings active) reads identically to every
   other panel on the page. */
.bottom-forecast {
  position: absolute;
  inset: 0;
  padding: 14px 20px;
  display: flex;
  flex-direction: column;
  opacity: 1;
  transition: opacity 0.5s ease, background 1.2s ease;
  background: rgba(12, 24, 40, 0.62);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: 10px;
  backdrop-filter: blur(6px);
  box-shadow: 0 6px 24px rgba(0,0,0,0.35);
  overflow: hidden;
}

/* Tropical System Layout only (state class toggled in channel.js's
   renderForecastCity(), same pattern as .bottom-panel.warning-active) -
   .tropical-bar draws its own full edge-to-edge card look instead, so
   strip this box's own padding/background/border/shadow out from under
   it here rather than have two nested rounded boxes visible at once.
   border-radius/overflow:hidden stay (still clip .tropical-bar's own
   corners to match), so this reads as ONE card, not a smaller one
   floating inside another. */
.bottom-forecast.tropical-active {
  padding: 0;
  /* Explicit longhand, not the "background: none" shorthand this rule
     used before (2026-08-09) - confirmed live that the shorthand form
     was NOT actually overriding the base .bottom-forecast rule's own
     background-color in this environment (getComputedStyle kept
     reporting the base rule's navy, not transparent, despite this rule's
     own higher 2-class specificity) - background-color set directly here
     sidesteps whatever shorthand-expansion quirk caused that regardless
     of root cause. */
  background-color: transparent;
  border: none;
  backdrop-filter: none;
  box-shadow: none;
}

/* Tropical System Layout, no local warning active - per the user's own
   explicit spec (2026-08-09): "the hurricane bar [should] stay on the
   top and put the forecast bar on the bottom" instead of the tropical
   bar replacing the forecast strip outright (the base .tropical-active
   rule just above, still applied whenever ANY storm is active
   regardless of this split). .tropical-split is toggled in channel.js's
   renderForecastCity() alongside .tropical-active, only when there's
   also no local warning to share the panel with instead (see
   .bottom-panel.warning-active further below for THAT split, which
   divides .bottom-panel itself into two boxes rather than sharing one).
   Both classes combined for specificity - needs to win over the plain
   .tropical-active rule's own padding:0/background:none, not to gate on
   it separately, since both are always set together whenever this one
   applies.

   #bottomForecast itself stays chromeless here (inherits .tropical-
   active's own padding:0/background:none/border:none/box-shadow:none -
   nothing new to override), just a bare flex-column layout slot with a
   real gap - #tropicalBar and #forecastStrip below EACH draw their own
   independent card (background/border/radius) instead of sharing one,
   per the user's own explicit correction (2026-08-09): "the hurricane
   bar moved up, but it's still in the same container as the forecast.
   They need to be in separate containers." */
.bottom-forecast.tropical-active.tropical-split {
  gap: 14px;
}

/* .tropical-bar normally stretches to its parent's full height (see its
   own base rule) since it's usually the ONLY visible child - shrunk to a
   fixed share of the split slot instead, keeping its own already-
   independent card look (background/border-radius from its own base
   rule) - just a smaller one now that it's sharing the slot with
   #forecastStrip's own separate card below it. Reuses that same compact
   title/badge/tag sizing (see the selector lists further below) rather
   than a third, separate size scale - the tropical bar should look the
   same "compact" way whichever box it's sharing space with. */
.tropical-split .tropical-bar {
  height: auto;
  /* Same 45% .bottom-panel.warning-active already uses for its own
     compact .bottom-forecast, not an arbitrary independent number -
     confirmed live (2026-08-09) as the actual reason the icon/"Category
     Four" text rendered visibly BIGGER here than the warning-active
     compact look despite reusing that same font-size rule: the icon's
     own pixel size is computed at render time from the badge's real
     measured height (renderTropicalBar(), see .tropical-category-badge's
     own doc comment on why), not fixed by CSS alone, so the two modes'
     boxes need to resolve to the SAME actual height, not just look
     "compact" by the same font-size number. The math: warning-active's
     tropicalBar height = 100% of .bottom-forecast = 45% of the shared
     234.25px row (.bottom-forecast itself is flex:0 0 45% there).
     .tropical-split's own .bottom-forecast is NOT shrunk at the
     .bottom-panel level (no separate .bottom-warning box splitting it) -
     it's the full 234.25px row - so tropicalBar needs that SAME 45%
     fraction of THIS box specifically (not 56%, which was sized by eye
     rather than solved against the reference) to land on the identical
     pixel height, and therefore the identical measured icon size. */
  flex: 0 0 45%;
  border-radius: 8px;
  /* #tropicalBar actually comes AFTER #forecastStrip in index.html's own
     source order (unrelated - that order only ever mattered before now,
     when just one of the two was ever visible at once) - without this,
     the flex column stacks them in that source order instead, putting
     the forecast strip on TOP and the tropical bar on the BOTTOM,
     backwards from the user's own explicit spec (2026-08-09: "the
     hurricane bar [should] stay on the top"). Confirmed live as a real
     bug, not a hypothetical - screenshotted exactly backwards on first
     pass. Negative order sorts before every other child's default
     order:0, without needing to touch the HTML itself. */
  order: -1;
}

/* #forecastStrip's own independent card (2026-08-09) - background/
   border/radius, same values .bottom-panel.warning-active .bottom-
   forecast already uses for ITS OWN compact split-mode box further below
   (same "sharing space, needs its own visible card" reasoning, just a
   different split axis) - not just a bare region, a genuinely separate
   container from #tropical-bar's own, per the user's own explicit
   correction quoted above. */
.tropical-split .forecast-strip {
  flex: 1 1 auto;
  min-height: 0;
  padding: 6px 16px;
  background: rgba(20, 40, 74, 0.5);
  border: 1px solid rgba(255,255,255,0.22);
  border-radius: 8px;
}

/* Warning-active mode: shrunk to share the panel with the warning box
   below it, now with a visible gap (.bottom-panel's own 8px flex gap)
   between two fully independent boxes rather than one shared outer
   card - each still carries its own border/blur/shadow from the base
   rule above, only background/radius/sizing change here. */
.bottom-panel.warning-active .bottom-forecast {
  position: static;
  flex: 0 0 45%;
  padding: 6px 16px;
  background: rgba(20, 40, 74, 0.5);
  border-radius: 8px;
  /* Stronger than the base rule's near-invisible 0.08 alpha - split mode
     needs a border a viewer can actually see to read this as its own
     card rather than the top half of one bigger box. */
  border-color: rgba(255,255,255,0.22);
}

/* Same "let .tropical-bar draw the only card" override as the base
   .bottom-forecast.tropical-active rule above, but needs its own higher-
   specificity copy here - the compact rule just above it (3 classes)
   would otherwise beat that 2-class rule and bring padding/background
   back, since CSS specificity doesn't care which rule is written later. */
.bottom-panel.warning-active .bottom-forecast.tropical-active {
  padding: 0;
  background-color: transparent;
  border: none;
}

/* Matches the compact box's own 8px radius (line above) rather than the
   full-size 10px .tropical-bar normally uses, so the two side-by-side
   halves (this and .bottom-warning's own compact box) read as a matched
   pair, not two different corner curvatures. */
.bottom-panel.warning-active .tropical-bar {
  border-radius: 8px;
}

/* Matches whichever Yellow/Red/Purple Mode theme is currently active
   (body.theme-* toggled by channel.js's applyAlertMode()) instead of
   staying a fixed navy blue regardless of the alert's own severity - same
   color language as the full-page #bg-theme-tint-* wash above, just
   scoped to this one panel so it doesn't fight that wash's own tint.
   .tropical-split .forecast-strip (2026-08-09) reuses these exact same
   three colors - same reasoning, just the split's OTHER compact box (see
   that rule's own comment for why it needed its own background at all) -
   per the user's own explicit request: "The background needs to follow
   the color theme of the channel. Currently the normal blue." */
body.theme-yellow .bottom-panel.warning-active .bottom-forecast,
body.theme-yellow .tropical-split .forecast-strip { background: rgba(150, 110, 0, 0.5); }
body.theme-red    .bottom-panel.warning-active .bottom-forecast,
body.theme-red    .tropical-split .forecast-strip { background: rgba(120, 10, 10, 0.5); }
body.theme-purple .bottom-panel.warning-active .bottom-forecast,
body.theme-purple .tropical-split .forecast-strip { background: rgba(120, 20, 130, 0.5); }

/* Its own independent panel-style box too (border/shadow/background),
   same as .bottom-forecast above - not just a bare color region, so it
   reads as a second distinct card rather than a continuation of the
   forecast box above it. overflow:hidden so warning-top/ticker/
   banner's own edge-to-edge backgrounds get clipped to this box's
   rounded corners instead of squaring off past them; the background
   set here rarely shows through those opaque children, it's there for
   the same-as-forecast fallback/consistency. */
.bottom-panel.warning-active .bottom-warning {
  position: static;
  flex: 1 1 auto;
  opacity: 1;
  border-radius: 8px;
  overflow: hidden;
  /* Same stronger, actually-visible border as the split-mode forecast
     box above it - see that rule's own comment. */
  border: 1px solid rgba(255,255,255,0.22);
  box-shadow: 0 6px 24px rgba(0,0,0,0.35);
  background: rgba(12, 24, 40, 0.62);
}

/* Compact sizing for the forecast strip while it's sharing the panel
   with a warning box - smaller label/icon/temp, and the condition text
   dropped entirely - doesn't need a text descriptor to still be useful
   at a glance, and there isn't room for it here. .fc-icon-temp already
   lays icon/temp out as a horizontal row in both modes (see the
   full-size rules below), so this only needs to override its sizing,
   not reimplement the row itself. Same compact treatment reused for
   .tropical-split (2026-08-09) - sharing the card with the tropical bar
   instead of a warning box, same lack of vertical room either way. */
.bottom-panel.warning-active .forecast-city-label,
.tropical-split .forecast-city-label {
  font-size: 14px;
  margin-bottom: 3px;
}

.bottom-panel.warning-active .forecast-columns,
.tropical-split .forecast-columns {
  gap: 6px;
  /* Same real flexbox gotcha .tropical-bar-row's own min-height:0 already
     documents further up this file - a flex item's content doesn't
     shrink below its own natural minimum size by default, which could
     otherwise overflow this shrunk box regardless of the compact icon/
     font sizing just below. */
  min-height: 0;
}

.bottom-panel.warning-active .forecast-col,
.tropical-split .forecast-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 4px 10px;
}

.bottom-panel.warning-active .forecast-col .fc-label,
.tropical-split .forecast-col .fc-label {
  font-size: 10px;
  text-align: center;
}

.bottom-panel.warning-active .forecast-col .fc-icon-temp,
.tropical-split .forecast-col .fc-icon-temp {
  margin: 2px 0;
  gap: 6px;
}

.bottom-panel.warning-active .forecast-col img,
.tropical-split .forecast-col img {
  width: 54px;
  height: 54px;
}

.bottom-panel.warning-active .forecast-col .fc-temp,
.tropical-split .forecast-col .fc-temp {
  font-size: 32px;
}

.bottom-panel.warning-active .forecast-col .fc-condition,
.tropical-split .forecast-col .fc-condition {
  display: none;
}

/* Plain pass-through wrapper around .forecast-city-label/.forecast-columns
   (added 2026-08-09 so .tropical-split, further up this file, can give it
   its own independent card - see that rule's own comment) - no chrome of
   its own here, just enough flex-column plumbing that its two children
   lay out exactly as they did as #bottomForecast's own direct children
   before this wrapper existed (flex:1 1 auto so it still fills #bottom
   Forecast's own remaining height in every non-split mode). Tropical
   System Layout toggles this element's own [hidden] attribute
   (renderForecastCity()) when the tropical bar takes its place - needs
   an explicit override here since author CSS (this class's own
   display:flex below) always wins over the browser's built-in
   [hidden]{display:none} rule regardless of selector order/specificity,
   the same real conflict channel.css already documents on several other
   elements that set their own explicit display value (e.g. .currently-
   aqi[hidden], .currently-wind[hidden]). */
.forecast-strip {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.forecast-strip[hidden] { display: none; }

.forecast-city-label {
  font-size: 23px;
  font-weight: 700;
  letter-spacing: 2px;
  color: #9fbedd;
  margin-bottom: 6px;
  text-align: center;
}

.forecast-columns {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 12px;
}

/* Same reflow-retriggered fade-in convention as .currently-panel's own
   city/temp/icon rules above - renderForecastCity() (channel.js) toggles
   .bottom-forecast's own .fade-in class every time the shared city rotator
   advances, in lockstep with the Currently panel's own fade, so both
   halves of a city change read as one deliberate transition rather than
   Currently easing in while the forecast strip pops instantly beside it. */
.forecast-city-label,
.forecast-columns,
.tropical-bar {
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}

.bottom-forecast.fade-in .forecast-city-label,
.bottom-forecast.fade-in .forecast-columns,
.bottom-forecast.fade-in .tropical-bar {
  opacity: 1;
  transform: none;
}

.tropical-bar[hidden] { display: none; }

/* Tropical System Layout only - takes over #bottomForecast's own box
   (see index.html's own comment on why it lives here rather than a
   separate panel) whenever channel.js's applyTropicalMode() shows it;
   .forecast-city-label/.forecast-columns hide in its place. Background
   set inline per-storm by renderTropicalBar() (a translucent tint of
   that storm's own triggering alert color - config/warnings.php,
   admin-editable) via --tropical-color, not a fixed color, matching the
   "let admin-configured warning colors drive alert visuals" convention
   already used everywhere else on this page (AlertFullscreenLoopScene's
   own storm-info box, .bottom-panel's own theme-yellow/red/purple
   tints, ...) - falls back to a plain dark red if that custom property
   is never set for some reason (JS always sets it before un-hiding this
   element, this is a defensive-only fallback). */
.tropical-bar {
  --tropical-color: #7a1010;
  display: flex;
  flex-direction: column;
  gap: 10px;
  height: 100%;
  background: color-mix(in srgb, var(--tropical-color) 45%, rgba(12,24,40,0.75));
  border-radius: 10px;
  overflow: hidden;
}

/* Full-width bar at the TOP of .tropical-bar, mirroring .warning-banner's
   own full-width-at-bottom treatment (see that rule's own comment) - as
   the FIRST flex child it gets its own edge-to-edge background/padding
   and only the container's TOP corners (.tropical-bar's own
   border-radius/overflow:hidden clips it, same mechanism .bottom-warning
   uses for .warning-banner's bottom corners), rather than being inset
   text centered inside the shared box padding like before. */
.tropical-bar-title {
  flex: 0 0 auto;
  padding: 6px 18px;
  font-size: 26px;
  font-weight: 800;
  letter-spacing: 1px;
  text-align: center;
  /* Pinned rather than the browser default 'normal' - 'normal' line-height
     is baked into each FONT's own metrics, not a fixed ratio, so the same
     rule renders with a different amount (and a different above/below
     split) of leading depending on which actual font a given OS/browser
     substitutes for 'Arial Narrow'. That's invisible in any same-machine
     DOM measurement (it only shifts where the glyph ink sits INSIDE an
     already-correctly-centered line box, not the box's own edges) but
     reads as real vertical drift once viewed in a browser using a
     different substitute font - confirmed against the user's own report
     (2026-08-09) that the row was still visibly low after a hard refresh,
     despite this row's own padding already being symmetric and every box
     measuring centered in this environment's renderer. */
  line-height: 1.1;
  background: linear-gradient(180deg, var(--tropical-color), color-mix(in srgb, var(--tropical-color) 70%, black));
  text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}

/* Grid, not flex, for an exact 1/5 - 4/5 split (per the user's own explicit
   spec, 2026-08-09) - a grid's fr-unit columns divide the row's own space
   AFTER subtracting the gap, so 1fr/4fr is genuinely 20%/80% of the
   available width; flex-basis percentages don't subtract the gap the same
   way and would overflow by that same 20px. align-items:start (not
   center) per the user's own explicit call (2026-08-09), after DevTools
   confirmed the row's own content box and .tropical-tags's own height
   leave under 2.5px of slack either way - true vertical centering here
   was already correct to within a pixel, but reads wrong regardless
   because of how each pill's bold value line outweighs its label line
   above it, so top-aligning instead of chasing an optical correction. */
.tropical-bar-row {
  flex: 1 1 auto;
  /* Flex items don't shrink below their own content's natural minimum
     size by default (min-height:auto's real initial behavior, a well-
     known flexbox gotcha) - without this override, .tropical-category-
     icon's own height:100% below drives this row's "natural" height
     bigger than the compact split-with-a-warning-box mode's actual
     available space, so it overflows .tropical-bar's own overflow:hidden
     instead of actually shrinking to fit it. Confirmed live (2026-08-09):
     without this, the icon measured 114px tall inside only a 105px bar. */
  min-height: 0;
  display: grid;
  grid-template-columns: 1fr 4fr;
  align-items: start;
  gap: 20px;
  /* Top padding cut to 2px (was 6px, matching the sides/bottom) - the
     row's own total height is fixed by the flex layout above it
     regardless of how this padding is split, so trimming padding-top
     specifically pulls the icon/label/pills up within that fixed box
     rather than changing how tall the row is. Per the user's own
     explicit call (2026-08-09), after DevTools confirmed there's no
     real alignment bug left to fix here - this is a direct, deliberate
     "sit higher" adjustment, not a centering correction. */
  padding: 2px 18px 6px;
}

.tropical-category-badge {
  /* Stretched to the row's own full height so channel.js's own
     renderTropicalBar() (see .tropical-category-icon's own comment) has
     an accurate, already-resolved height to measure and size the icon
     against - not used for the icon's own CSS sizing directly, pure-CSS
     stretch/percentage-height didn't reliably resolve this many flex/
     grid layers deep (confirmed live, 2026-08-09). */
  align-self: stretch;
  min-width: 0;
  display: flex;
  /* Back to center (start overcorrected - confirmed live 2026-08-09
     that the icon has essentially zero slack against the badge's own
     stretched height either way, so align-items only ever moved the
     much-shorter category-label text, and start put it flush against
     the badge's own top edge instead of centered against the icon it
     sits beside). The icon/label pairing being visually low was
     already fixed by .tropical-bar-row's own reduced padding-top above
     - this rule only needs to keep the label centered next to the icon
     it's paired with, not re-anchored against the badge's own (much
     taller than the icon actually needs) stretched box. */
  align-items: center;
  gap: 10px;
  /* Deliberate manual nudge, not an alignment fix - per the user's own
     explicit request (2026-08-09) to move the icon (and its paired
     label) up a bit further once the two were already correctly
     centered on each other. transform (not padding/margin) so it's a
     pure visual offset that doesn't touch the badge's own stretched
     layout box or the row's column sizing. */
  transform: translateY(-3px);
}

/* Same depression/tropical_storm/hurricane.png marker (+ hurricane_number_N
   overlay) TropicalBasinScene::paintStormIcon() plots on the actual maps -
   see index.html's own comment on this element for the full story. Plain
   square wrapper (the source PNGs are already circular marker art with
   their own transparent background/border baked in - no extra circle
   chrome needed here, unlike the old plain-div badge this replaced).
   Sized off the badge's own (now stretched, definite) height rather than
   a fixed pixel size - per the user's own explicit spec (2026-08-09), it
   should fill as much of its 1/5-width column as the row's own height
   allows, in both full-size and the compact split-with-a-warning-box mode
   alike, with no separate override needed for either (this scales itself
   automatically as the row shrinks). */
/* width/height set inline by channel.js's own renderTropicalBar() (measured
   from the already-laid-out row, not CSS) - confirmed live (2026-08-09)
   that percentage/stretch-based auto-sizing this many flex/grid layers
   deep (row -> badge -> icon -> its own absolutely-positioned <img>
   children) doesn't reliably resolve across engines: the icon kept
   measuring 114-204px tall inside a row only 66-181px tall, well past
   .tropical-bar's own overflow:hidden, no matter which of align-items:
   stretch/height:100% carried the sizing. Reading the row's own real,
   already-resolved clientHeight in JS after layout sidesteps the whole
   circular-auto-sizing problem outright. */
.tropical-category-icon {
  position: relative;
  flex: 0 0 auto;
}

.tropical-category-icon-shape,
.tropical-category-icon-number {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Continuous smooth spin, per the user's own explicit request (2026-08-09)
   - linear (not ease) so the rotation reads as a constant steady spin with
   no speed-up/slow-down per lap. Depression is never given this class
   (see channel.js's own renderTropicalBar()) - same "rotationally
   symmetric ring, spinning it would be visually indistinguishable from
   not spinning it" reasoning paintStormIcon()'s own comment already
   documents for the baked map graphics. */
.tropical-category-icon-shape.spin {
  animation: tropicalIconSpin 6s linear infinite;
}

/* Counter-clockwise - matches real Northern Hemisphere tropical cyclone
   rotation, per the user's own explicit correction (2026-08-09). */
@keyframes tropicalIconSpin {
  to { transform: rotate(-360deg); }
}

.tropical-category-icon-number[hidden] { display: none; }

.tropical-category-label {
  font-size: 38px;
  font-weight: 800;
  white-space: nowrap;
  /* Pinned - see .tropical-bar-title's own comment on why 'normal' isn't
     safe to leave here. */
  line-height: 1.1;
}

/* Fills the grid's own 4fr column exactly (.tropical-bar-row above) - a
   plain grid cell, min-width:0 so its .tag children's own flex-shrink
   below can actually shrink them below their content width instead of
   this column's min-content size forcing an overflow past the 4/5
   boundary. */
/* --tag-scale: set by channel.js's own fitTropicalTags() after every
   render - shrinks all pill text uniformly (not per-tag) so a much
   longer value like LOCATION's "485 mi S of Panama City Florida" is
   fully readable rather than truncated, per the user's own explicit
   spec (2026-08-09). Uniform across every tag (not just the long one)
   so the row reads as one consistent type size, matching how a real
   broadcast lower-third keeps every data point in a row the same size
   rather than each at its own independent scale. */
.tropical-tags {
  --tag-scale: 1;
  display: flex;
  justify-content: flex-end;
  min-width: 0;
  gap: 10px;
}

/* Same black-pill "label above value" look .warning-tags .tag already
   established for this page's other alert-info boxes, but sized up in
   its own right rather than sharing its exact declarations. Per the
   user's own explicit follow-up spec (2026-08-09), each pill sizes to
   fit its own content (flex: 0 1 auto - no forced equal grow) rather
   than all splitting the row evenly - a short value like PRESSURE's
   "970 MB" only claims what it needs, freeing more of the row for a
   much longer one like LOCATION, instead of every pill being stuck at
   the same width regardless of how little (or how much) text it
   actually holds. Still allowed to shrink (not flex:0 0 auto) as a
   backstop for when the pills' own combined natural width still doesn't
   fit - fitTropicalTags() (channel.js) shrinks --tag-scale uniformly in
   that case, same mechanism as before, just needing far less shrinkage
   now that short pills aren't wasting width they don't need. */
.tropical-tags .tag {
  flex: 0 1 auto;
  min-width: 0;
  background: #101010;
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 8px;
  padding: 10px 16px;
  text-align: center;
}

/* nowrap + ellipsis, not free-wrapping text - confirmed live (2026-08-09)
   that LOCATION's own much-longer value ("485 mi S of Panama City
   Florida") wrapping to a second line grows this pill (and so the whole
   row/bar, a fixed height) taller than .tropical-bar's own overflow:
   hidden allows, clipping the icon and everything else. Truncating with
   an ellipsis instead keeps every pill's height identical and
   predictable regardless of how long any one value's text happens to
   be - the same reason .tropical-tags .tag already has min-width:0
   (required for text-overflow:ellipsis to actually work on a flex
   child, not just cosmetic). */
.tropical-tags .tag .tag-label,
.tropical-tags .tag .tag-value {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tropical-tags .tag .tag-label {
  font-size: calc(17px * var(--tag-scale));
  font-weight: 700;
  letter-spacing: 1px;
  color: #8ecdf8;
  /* Pinned - see .tropical-bar-title's own comment on why 'normal' isn't
     safe here. Matters even more for this label+value pair specifically:
     .tag's own outer box is centered correctly by the grid regardless of
     line-height (confirmed by measurement), but the label/value SPLIT
     inside that box - where the shared gap between the two stacked lines
     actually falls - is governed entirely by each line's own leading, so
     an unpinned 'normal' here shifts the visible text within an
     already-centered box rather than the box itself. */
  line-height: 1.1;
}

.tropical-tags .tag .tag-value {
  font-size: calc(34px * var(--tag-scale));
  font-weight: 800;
  color: #fff;
  line-height: 1.1;
}

/* Compact sizing while sharing the panel with a warning box (45% split,
   same trigger .bottom-panel.warning-active already uses for the plain
   forecast strip) - smaller title/badge/tag text so the whole bar still
   fits comfortably in the shrunk box. Same compact sizing reused for
   .tropical-split (2026-08-09, see that class's own comment further up)
   - the tropical bar is sharing space either way, just with the forecast
   strip instead of a warning box. */
.bottom-panel.warning-active .tropical-bar-title,
.tropical-split .tropical-bar-title {
  font-size: 18px;
  padding: 4px 18px;
}

.bottom-panel.warning-active .tropical-category-label,
.tropical-split .tropical-category-label {
  font-size: 28px;
}

.bottom-panel.warning-active .tropical-tags .tag,
.tropical-split .tropical-tags .tag {
  padding: 2px 8px;
  min-width: 0;
}

.bottom-panel.warning-active .tropical-tags .tag .tag-label,
.tropical-split .tropical-tags .tag .tag-label {
  font-size: calc(13px * var(--tag-scale));
}

.bottom-panel.warning-active .tropical-tags .tag .tag-value,
.tropical-split .tropical-tags .tag .tag-value {
  font-size: calc(25px * var(--tag-scale));
}

/* Top-anchored, not centered - label/icon+temp stack down from the top
   in a fixed position; .fc-condition below them just takes whatever
   space is left over. Confirmed live (2026-08-06) as a real bug the
   centered way: justify-content:center re-centers the *whole* column
   every time the condition text's own height changes (a 1-line vs.
   2-line forecast phrase), which visibly shifted the icon/temp up and
   down as the rotation cycled through different periods/cities instead
   of holding still. */
.forecast-col {
  background: rgba(60,100,170,0.28);
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 8px 4px;
  transition: background 1.4s ease;
}

/* Nighttime periods (p.is_daytime false - Tonight, tomorrow night, ...)
   get a slightly darkened box, distinguishing them from daytime boxes at
   a glance the same way a real broadcast forecast strip would - on top
   of the existing .fc-temp color split (warm/cool), not instead of it. */
.forecast-col.is-night {
  background: rgba(18,28,48,0.55);
}
body.theme-yellow .forecast-col.is-night { background: rgba(90,68,14,0.5); }
body.theme-red    .forecast-col.is-night { background: rgba(85,16,14,0.5); }
body.theme-purple .forecast-col.is-night { background: rgba(85,14,92,0.5); }

/* Yellow/Red Mode also tints the forecast boxes, per the user's explicit
   call - even though they're unlikely to show alongside a real Watch/
   Warning in production, the admin panel's manual override can force one
   on while forecast boxes are on screen, so they need to react too. Same
   color language as the "blue block" panels elsewhere on the page. */
body.theme-yellow .forecast-col { background: rgba(150,120,20,0.38); }
body.theme-red    .forecast-col { background: rgba(150,25,20,0.38); }
body.theme-purple .forecast-col { background: rgba(150,20,160,0.38); }

.forecast-col .fc-label {
  font-size: 14px;
  font-weight: 800;
  letter-spacing: 1px;
  text-align: center;
}

/* Icon and temp side by side, not stacked - a fixed-height row that
   never grows/shrinks with anything else in the box (see .forecast-col's
   own comment on why that matters), so it holds its position exactly
   whether the condition text below is one line or three. */
.forecast-col .fc-icon-temp {
  flex: 0 0 auto;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 8px 0;
}

.forecast-col img {
  width: 66px;
  height: 66px;
}

.forecast-col .fc-temp {
  font-size: 40px;
  font-weight: 800;
}

/* Daytime high vs nighttime low - same pastel red/blue tokens as the GD
   scenes' own theme.palette.temp_high/temp_low (config/theme.php), kept
   as literal hex here since this stylesheet doesn't read that PHP config
   directly. Applied via .is-day/.is-night, set per period in
   renderForecastCity() from the period's own is_daytime flag. */
.forecast-col .fc-temp.is-day {
  color: #ff9e91;
}
.forecast-col .fc-temp.is-night {
  color: #8ecbff;
}

/* Short text descriptor ("Sunny", "Chance Showers And Thunderstorms",
   ...) - replaces the wind reading that used to sit here (per the user's
   own call, 2026-08-06: wind out, a plain-language condition in). Same
   ForecastPeriod::shortForecast text the hourly graphic page already
   shows (see forecast-hourly.js's .hour-condition) - reused, not a new
   field. flex:1 1 auto claims whatever height is left under the fixed
   icon/temp row above and lets this text grow into *that* reserved
   space - 1 line or 3, it never pushes .fc-icon-temp around, it just has
   more or less room of its own to wrap into. Top-aligned within that
   space (not centered) so a short 1-line phrase sits right under the
   icon/temp row instead of drifting to the middle of the leftover area. */
.forecast-col .fc-condition {
  flex: 1 1 auto;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
  color: #cfe0f0;
  margin-top: 2px;
  text-align: center;
  line-height: 1.15;
}

.bottom-warning {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  opacity: 0;
  transition: opacity 0.5s ease;
}

/* Lives on #alertsMiniPanel now (moved there 2026-08-06, per the user's
   own explicit call - the earlier placement floating over the seam
   between #bottomForecast/#bottomWarning wasn't where they wanted it
   either). Overlays the top edge of that panel - #alertsMiniPanel is
   already position:relative and its .mini-map-image already fills it
   edge to edge (position:absolute;inset:0) with no title bar of its own
   (see that rule's own comment), so this reads as a title-bar-like strip
   sitting on top of the map rather than displacing it - same "float over
   existing content, never resize/push it" approach the original seam
   placement used, just anchored to this panel's own top edge instead of
   a boundary between two other boxes. z-index needed since this is now
   the *first* child in DOM order (before .combined-alerts-map/.mini-map-
   image), which would otherwise paint it underneath both by default
   stacking order. */
.new-alert-bar {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  z-index: 5;
  background: #f4d23a;
  color: #1a1a1a;
  font-size: 13px;
  font-weight: 900;
  letter-spacing: 1.5px;
  text-align: center;
  padding: 6px 0;
  box-shadow: 0 2px 10px rgba(0,0,0,0.45);
}

.warning-top {
  /* flex-grows to fill whatever's left in .bottom-warning above the
     fixed-height ticker and banner - needs to, since .warning-ticker
     (47px) and .warning-banner (21px) don't grow themselves; without
     this, their combined height fell short of .bottom-warning's own
     flex-basis and left a bare dark gap below the banner instead of the
     banner sitting flush with the box's bottom edge. The one-line
     county/zone content itself still reads as compact regardless of this
     box's height, since it's vertically centered (align-items below). */
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  gap: 24px;
  padding: 4px 24px;
  background: rgba(20,10,10,0.55);
}

.warning-areas {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: 10px;
}

.warning-area-label {
  flex: 0 0 auto;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: #ffd9d9;
  white-space: nowrap;
}

/* Still needs its own display:flex;flex-wrap:wrap;gap - NOT just a plain
   viewport - because channel.js briefly inserts every chip flat as this
   element's own direct children first, specifically so the browser's real
   flex-wrap decides how many fit per row (same technique as before the
   2026-08-13 odometer-roll change) before regrouping them into
   .area-list-page rows. Dropping these in an earlier pass of that change
   was a real regression (confirmed live: every chip measured as its own
   one-item row, since block-stacked divs never share an offsetTop) -
   flex-wrap here is only ever active for that one transient measurement
   moment; once rebuilt, this element holds a single child (.area-list-
   track) that wrap doesn't affect. overflow:hidden + the fixed height
   channel.js sets afterward (from a real chip's own rendered height) are
   what actually clip everything but the current row once that rebuild
   happens - see startAreaListCycle()'s own doc comment for the full
   odometer-roll technique this supports. */
.warning-area-list {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 6px 16px;
  overflow: hidden;
}

/* The part that actually moves - a plain vertical stack of page rows,
   translated up by exactly one row's height per page change. transform,
   not top/margin, so this animates on the GPU compositor without
   triggering layout on every frame. */
.area-list-track {
  display: flex;
  flex-direction: column;
  transition: transform 0.5s ease;
}

/* One page's worth of chips, laid out exactly like the old flat
   .warning-area-list used to (flex-wrap was only ever needed for the
   MEASUREMENT pass that decides where a page boundary falls - a page's
   own chips, once grouped, never need to wrap again within their own
   row). flex:0 0 auto so each page keeps its own natural height inside
   the track regardless of how many chips it holds. */
.area-list-page {
  display: flex;
  flex-wrap: nowrap;
  gap: 6px 16px;
  flex: 0 0 auto;
}

/* Each county/zone its own pill, not flowing text - per the user's own
   call (2026-08-06), matching the same dark-box-behind-white-text look
   the GD-rendered scenes' own city labels already use (see CityLabels.php's
   label_fill/label_border/label_radius) rather than inventing a new
   visual language for this one element. channel.js's own row-grouping
   math (startAreaListCycle()) measures each chip's real offsetTop to
   page whole rows at a time - unaffected by adding padding/background
   here, since it just reads laid-out positions, not any assumption
   about a chip's own box model. */
.warning-area-list .area-chip {
  font-size: 15px;
  font-weight: 700;
  white-space: nowrap;
  color: #f2f2ef;
  background: rgba(10, 18, 30, 0.72);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 6px;
  padding: 4px 10px;
}

.warning-tags {
  flex: 0 0 auto;
  display: flex;
  gap: 10px;
}

/* Same reflow-retriggered fade-in convention as .currently-panel's own
   rules - applyWarningContent() (channel.js) toggles #warningTop's own
   .fade-in class every time the warning rotator (re)shows an alert, so a
   switch to a different alert's counties/tags eases in rather than
   popping. Deliberately NOT #bottomWarning itself or .warning-banner/
   .warning-ticker - #bottomWarning's own opacity is already a separate,
   precious whole-panel show/hide transition (see
   scheduleVisibilityWatchdog()'s own doc comment), and the banner/ticker
   both have their own established animations (elevated pulse, marquee
   scroll) this must not disturb. */
.warning-areas,
.warning-tags {
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.4s ease, transform 0.4s ease;
}

#warningTop.fade-in .warning-areas,
#warningTop.fade-in .warning-tags {
  opacity: 1;
  transform: none;
}

.warning-tags .tag {
  background: #101010;
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 6px;
  padding: 3px 12px;
  text-align: center;
  min-width: 96px;
}

.warning-tags .tag .tag-label {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1px;
  color: #b9c6d6;
}

.warning-tags .tag .tag-value {
  font-size: 14px;
  font-weight: 800;
}

/* Damage-threat/tornado-detection flash/blink treatments - which class
   channel.js's tagFlashClass() applies is driven entirely by the tag's
   own text (buildTags()'s IMPACT/TORNADO values), not by alert type, so
   these are plain class-triggered animations rather than anything tied
   to alert color/severity. Per the user's own explicit spec (2026-08-06):
   Considerable/Destructive/Catastrophic (IMPACT tag) flash yellow/red/
   purple; a confirmed ("Observed") tornado's own TORNADO tag blinks red.
   step-end (not ease) so each color is a hard on/off flash, matching a
   real broadcast alert box rather than a soft pulse. */
.warning-tags .tag.tag-flash-yellow {
  animation: tagFlashYellow 1s step-end infinite;
}

.warning-tags .tag.tag-flash-red {
  animation: tagFlashRed 1s step-end infinite;
}

.warning-tags .tag.tag-flash-purple {
  animation: tagFlashPurple 1s step-end infinite;
}

.warning-tags .tag.tag-blink-red {
  animation: tagFlashRed 0.6s step-end infinite;
}

@keyframes tagFlashYellow {
  0%, 100% { background: #101010; border-color: rgba(255, 255, 255, 0.15); }
  50%      { background: #d9b23c; border-color: #d9b23c; }
}

@keyframes tagFlashRed {
  0%, 100% { background: #101010; border-color: rgba(255, 255, 255, 0.15); }
  50%      { background: #c0392b; border-color: #c0392b; }
}

@keyframes tagFlashPurple {
  0%, 100% { background: #101010; border-color: rgba(255, 255, 255, 0.15); }
  50%      { background: #9b30ff; border-color: #9b30ff; }
}

/* The full-screen preempting alert's storm-info box (counties/zones +
   warning tags) used to live here as an HTML overlay
   (.storm-info-panel/#stormInfoPanel) - removed 2026-08-06 per the
   user's own call to bake it directly into the animation instead (see
   AlertFullscreenLoopScene::paintStormInfoBox()), since it's redundant
   with the animation's own baked-in copy now. */

.warning-ticker {
  /* 47px, up from 30px (2026-08-13) - the exact ~17px cut from
     .warning-banner's own height below, moved here rather than lost, per
     the user's own explicit request: the scrolling ticker text needs to
     read at a distance on a large screen, so it gets the space the
     (less critical at a glance) title bar gives up. .ticker-track's own
     font-size is scaled up to match, not just this box padded taller
     around the old small text - see that rule's own comment. */
  height: 47px;
  /* Explicit flex-shrink:0 (flex-basis matching height above) - a flex
     item's automatic minimum size defaults to 0 once it has
     overflow:hidden (unlike .warning-top below, which keeps a
     content-based floor), so without this .warning-ticker was the only
     row in .bottom-warning with no shrink floor of its own: whenever a
     tag-heavy .warning-top demanded more height than .bottom-warning
     actually had, the flex algorithm dumped the *entire* deficit onto
     this row, squeezing it to a real 0px - confirmed live (an active
     tag-bearing alert's ticker text was present in the DOM with a real
     scrollWidth, just rendered in a 0-height box). flex-shrink:0 makes
     that structurally impossible now - .warning-top is the one row
     designed to absorb overflow (see its own comment), so it's the one
     that gives if space ever runs short again. (.new-alert-bar used to
     live in this same box and could compound this same failure - it's
     been moved to #alertsMiniPanel instead, see its own current CSS
     comment, so it's no longer part of this box's height budget at
     all.) */
  flex: 0 0 47px;
  /* Set per-alert by channel.js from config/warnings.php's official NWS
     color for that event type (via AlertResolver::styleFor()) - the
     fallback here only shows before the first alert has rendered. */
  background: var(--alert-color-dark, #7a0f14);
  overflow: hidden;
  position: relative;
  display: flex;
  align-items: center;
}

/* font-size scaled up from 16px to 25px alongside .warning-ticker's own
   30px->47px height increase above (same ~1.57x ratio) - per the user's
   own explicit request (2026-08-13): the freed height needs to actually
   make the scrolling text itself more legible at a distance, not just
   pad more empty space around the old small size. */
.ticker-track {
  white-space: nowrap;
  font-size: 25px;
  font-weight: 700;
  padding-left: 1920px;
  /* Position is driven directly by JS (channel.js's startTickerScroll(),
     via requestAnimationFrame) rather than a CSS @keyframes animation -
     under Xvfb, Chrome's CSS-animation compositor scheduling was only
     actually repainting the scroll position a few times per second
     (confirmed via frame-by-frame capture analysis) regardless of
     display/encoder fixes elsewhere in the pipeline. Driving the
     transform directly on every rAF tick bypasses whatever throttling
     heuristic that scheduler was applying. */
  transform: translateX(0);
}

/* Cut to about half its old (auto/content-driven, ~34px) height -
   explicit 17px now, not auto - per the user's own explicit request
   (2026-08-13): the scrolling ticker below needs to be "more visible on
   a large screen at distance" than this title bar, so this gives up
   height in its favor (see .warning-ticker's own comment for where that
   ~17px went) rather than the two competing for space on equal terms.
   font-size/padding/letter-spacing all cut roughly in proportion so the
   text actually fits the smaller box instead of just getting clipped by
   this rule's own overflow:hidden below - channel.js's own
   BANNER_MAX_FONT_PX/BANNER_MIN_FONT_PX (fitBannerText()) were lowered
   to match, so a long event name still shrinks further from here exactly
   like before, just starting from this new smaller ceiling. */
.warning-banner {
  flex: 0 0 21px;
  /* --alert-color/--alert-color-text set per-alert by channel.js - see the
     .warning-ticker comment above. Text color is picked for contrast
     against whatever the event's official color happens to be (e.g. a
     Tornado Watch's yellow needs dark text, a Flash Flood Warning's dark
     red needs light text). */
  background: linear-gradient(180deg, var(--alert-color, #d81c1c), var(--alert-color-dark, #a11010));
  color: var(--alert-color-text, #fff);
  /* Max/default size - channel.js's fitBannerText() shrinks this (inline
     style, overriding the value here) for a long event name like "SPECIAL
     WEATHER STATEMENT" or "EXTREME WIND WARNING" that would otherwise
     overflow or wrap. Bumped 12px -> 15px (2026-08-13, ~25% bigger) per
     the user's own explicit request for better at-a-distance legibility;
     see fitBannerText()'s own BANNER_MAX_FONT_PX for the matching JS-side
     ceiling. */
  font-size: 15px;
  font-weight: 900;
  letter-spacing: 1.2px;
  line-height: 1.1;
  text-align: center;
  padding: 1px 16px;
  /* display:flex + centering on both axes, rather than relying on equal
     top/bottom padding alone - confirmed live the text was sitting
     noticeably off-center vertically with just padding, this guarantees
     it regardless of the box's actual rendered height. */
  display: flex;
  align-items: center;
  justify-content: center;
  /* Without this, long text (a Special Weather Statement's full title,
     say) just wraps to a second line instead of overflowing
     horizontally - which both looks wrong for a one-line banner AND
     silently breaks fitBannerText()'s own overflow check (scrollWidth
     can only exceed clientWidth if the text isn't allowed to wrap). One
     line, shrunk to fit, always. */
  white-space: nowrap;
  overflow: hidden;
  animation: banner-pulse 1.6s ease-in-out infinite;
  /* Matches .bottom-warning's own 8px radius (channel.css) on this,
     its last child - without this, .warning-banner's own overflow:
     hidden clips its animated chevron stripe layer (.elevated::before)
     to a plain sharp-cornered rectangle, and only the *ancestor*
     .bottom-warning's rounded mask, one level further out, was left to
     round off the visible corners. Confirmed live (2026-08-06,
     screenshot): that two-level setup leaves a visible seam/tear right
     at the bottom-right corner on an elevated alert - a real browser
     compositing quirk where overflow:hidden+border-radius doesn't
     always reliably re-mask an *animated* descendant layer on every
     frame, especially through an intermediate unrounded box. Rounding
     the clip at the same element as the animation itself (rather than
     depending on a grandparent's separate compositing layer) avoids
     that class of artifact entirely. Only the bottom two corners need
     it - this is the last item in .bottom-warning's own stack, so its
     top edge never touches that parent's own rounded corners. */
  border-radius: 0 0 8px 8px;
}

@keyframes banner-pulse {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.18); }
}

/* Elevated-warning treatment (Tornado Emergency / Confirmed Tornado
   Warning / Flash Flood Emergency / Destructive Severe Thunderstorm
   Warning - see ChannelDataBuilder::detectElevatedAlert()) - a
   continuously-scrolling diagonal chevron/hazard-stripe texture over the
   banner's own solid color, plus a brighter/faster pulse than every other
   warning gets. Modeled on the constant-motion "alert tape" look real
   broadcast lower-thirds use for their highest-tier warnings (the user's
   own reference: a Fox Weather Alert bar) - built from a description and
   a still image, not a frame-by-frame copy of any particular graphic, so
   treat the exact stripe angle/speed as a starting point to refine.
   z-index:-1 on the stripe layer (not 0) is deliberate: .warning-banner's
   text is a plain, unwrapped text node (channel.js just sets
   .textContent), and per the normal CSS stacking order an in-flow text
   node paints *above* a positioned z-index:0 sibling regardless of DOM
   order - a positive/zero z-index here would have sat over the text, not
   under it. -1 keeps the stripes between the banner's own solid-color
   background and the text, exactly like a texture on the background
   rather than a layer over the words, without needing to wrap the text
   in its own element just to give it a z-index to win with. */
.warning-banner.elevated {
  position: relative;
  overflow: hidden;
}

.warning-banner.elevated::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  /* Matches .warning-banner's own bottom-corner radius directly, rather
     than relying solely on that parent's overflow:hidden to mask this
     layer from the outside. Confirmed live (2026-08-06, phone video):
     without its own matching radius, this pseudo-element's animated
     background gets promoted to its own compositor layer, and the
     *separately applied* parent clip mask rasterizes the curve as a
     jagged/faceted notch right at the corner rather than a smooth arc -
     a real (if subtle) artifact, made much more visible by a phone
     camera's own moire interference with the screen's pixel grid.
     Giving this element the same radius directly lets the browser
     rasterize its own shape correctly from the start instead of
     compositing a rectangular layer through a separate rounded mask. */
  border-radius: inherit;
  /* Switched from a CSS repeating-linear-gradient to a self-authored
     bitmap tile (chevron-tile.png) 2026-08-06, after finally isolating
     the real "one seam that moves then jumps back" bug via a direct,
     static side-by-side pixel comparison of two consecutive video frames
     (not a derived metric - actually looking at the two frames): a real,
     fixed-position diagonal discontinuity, present identically in every
     frame, sitting a consistent ~43px in from the banner's right edge -
     nowhere near the rounded corner (8px radius) and unrelated to the
     text (an earlier read of a spacetime plot that pointed at the text
     turned out to be a methodology mistake - text has enough spatial
     detail along one scanline to look "chaotic" in that kind of plot even
     when it never changes over time at all). Every previous attempt in
     this file's history assumed the CSS gradient itself was mathematically
     seamless when unsized (true in theory - an analytic gradient function
     has no tile boundary to be inconsistent at) and looked for the bug in
     the animation math instead. The actual browser apparently doesn't
     evaluate it as one continuous analytic function in practice - it
     rasterizes *something* internally (almost certainly a GPU texture, to
     avoid recomputing the gradient per pixel every animation frame) with
     an internal tile size this file never had any control over or
     visibility into. A bitmap tile sidesteps that uncertainty completely:
     chevron-tile.png is generated from pure integer pixel math (the
     pattern depends only on (x+y) mod 62, never on CSS gradient-axis
     units), so its own tile size is provably, exactly self-seamless -
     verified directly by tiling it 6x2 and visually confirming zero seams
     at any boundary before this ever went live - not a CSS engine's
     internal, unverifiable implementation detail. */
  background-image: url('../assets/chevron-tile.png');
  background-repeat: repeat;
  animation: chevron-scroll 1.1s linear infinite;
  animation-delay: var(--chevron-delay, 0s);
  pointer-events: none;
}

@keyframes chevron-scroll {
  from { background-position: 0 0; }
  /* chevron-tile.png is 124x124px (2 full stripe periods) - sliding by
     exactly that many pixels is a full loop with zero remainder, by the
     same tile-identity logic as the Bootstrap progress-bar-striped
     technique, except this time the tile itself is actually guaranteed
     seamless (see the ::before comment above) rather than assumed to be. */
  to   { background-position: -124px 0; }
}

/* Was `.warning-banner.elevated`'s own `filter: brightness()` animation
   (see git history / this comment's own former self for the old
   @keyframes banner-pulse-elevated). Moved off filter and onto this
   dedicated overlay layer's opacity instead - confirmed via frame-by-
   frame video analysis (2026-08-06) that animating `filter` directly on
   the element containing the "TORNADO EMERGENCY" text was the actual
   cause of a real, localized flicker that only ever showed up in motion
   capture, never a static screenshot: CSS `filter` forces the whole
   element - background, ::before's independently-scrolling chevron
   layer, AND the text's own anti-aliasing - through an offscreen
   re-composite on every frame the filter value changes. That was
   running at 0.9s while ::before's chevron-scroll runs at 1.1s - two
   animations on nested layers with incommensurate periods, both forcing
   repaints of the same region, drifting in and out of phase with each
   other on a multi-second beat (LCM-ish ~4.95s) rather than every single
   1.1s loop - which is exactly the "moves, then jumps back" symptom
   reported live, and exactly why it was only ever visible in a screen
   recording of the actual text, never a still frame. Animating opacity
   on a separate positioned layer instead only touches that layer's own
   compositing - it doesn't force the text (or the chevron layer) through
   any repaint at all. Sits above ::before's z-index:-1 but still below
   the text for the same reason noted on ::before above: an in-flow text
   node paints above any positioned z-index sibling regardless of DOM
   order, so z-index:0 here is enough to stay under the words without
   needing to wrap them in their own element. */
.warning-banner.elevated::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  background: #fff;
  opacity: 0;
  animation: banner-pulse-elevated 0.9s ease-in-out infinite;
  animation-delay: var(--pulse-delay, 0s);
  pointer-events: none;
}

@keyframes banner-pulse-elevated {
  0%, 100% { opacity: 0; }
  50%      { opacity: 0.22; }
}

/* ---- map-panel forecast/map-rotation iframe ---------------------------- */

/* Sits in the same .map-body container as #radarImage (see
   scheduleMapRotation() in channel.js) - shown instead of the radar image
   during the forecast-graphic portion of the rotation, never as a
   separate full-screen layer. */
#mapFrame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
