/* The `hidden` attribute is only a UA default of `display: none`, and any class
   here that sets `display` outranks it -- `.cta-button` is `inline-block`, so a
   hidden button would stay on screen. The JS hides things by setting `.hidden`,
   so that has to be true of every element without exception. Hence !important:
   the one place in this file where nothing may override the rule. */
[hidden] { display: none !important; }

/* Links.
 *
 * **There was no rule for these at all**, which meant every link outside the footer
 * fell through to the browser default -- a dark navy intended for white pages. On
 * this background that is roughly 1.2:1 against text needing 4.5:1, and the terms
 * page is almost entirely prose links: weather.gov, the licence, the support
 * address. They rendered as slightly-different-coloured dark text.
 *
 * The footer had its own colour, #0a84ff, which was better and still short of the
 * threshold at about 3.5:1. Both now use one light blue that clears it, and the
 * footer inherits rather than restating it, so the next surface to gain a link
 * cannot quietly repeat the original omission.
 *
 * `.cta-button` and `.link-button` set their own colour and are unaffected.
 *
 * Underline is left on deliberately in prose. Colour alone is not an accessible
 * signal for a link, and the pages that lean on this hardest are the legal ones,
 * where knowing what is clickable matters more than the page looking clean. */
a {
  color: #8ab4f8;
}

a:hover,
a:focus-visible {
  color: #b9d5fc;
}

body {
  background-color: #111;
  color: #fff;
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  margin: 0;
  padding: 2rem;
  line-height: 1.6;
  font-size: 1.2rem;
  background-image: url('/static/assets/mf_background.webp');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  min-height: 100vh;
  box-sizing: border-box;
}

.container {
  max-width: 700px;
  margin: 0 auto;
  text-align: center;
}

h1 {
  color: #e0e0e0;
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.tagline {
  font-size: 1.3rem;
  font-weight: 600;
  color: #e0e0e0;
  margin-bottom: 2rem;
}

/* The blue is a shade darker than the accent used everywhere else, and that is the
   one contrast fix #176 actually needed.
   --------------------------------------------------------------------------
   White on #0a84ff is 3.64:1. The label is 1.2rem at normal weight -- 14.4pt, which
   is under the 18pt that would let it be judged as large text -- so it needs 4.5:1
   and was the only colour-contrast failure Lighthouse could find on either public
   page. #006ae0 is 5.07:1 and is not a new colour: it is what this button already
   used on hover, so the rest state moves down onto it and hover takes a further step.

   **The issue expected the greys and they were not the problem.** #8e8e93 on the
   card measures 5.22:1 and #888 measures 4.80:1; both clear AA, and both were left
   alone rather than lightened on a guess.

   Deliberately not applied to #0a84ff wherever it is *not* behind text -- the grid
   blocks, the two-hour strip, focus rings, borders. Those are non-text and judged at
   3:1, which they already pass, and darkening a mark on a near-black track makes it
   harder to see rather than easier. One accent, two jobs, two thresholds. */
.cta-button {
  background-color: #006ae0;
  color: #fff;
  padding: 1em 2em;
  border-radius: 10px;
  border: none;
  font-size: 1.2rem;
  cursor: pointer;
  display: inline-block;
}

.cta-button:hover {
  background-color: #005ec4;
}

.cta-button:disabled {
  background-color: #34506e;
  cursor: default;
}

/* The weather button.
   ==========================================================================
   Two places, one construction: "Get started" on the public alert page, and "Create
   your first alert" on the empty dashboard (#200). Both are the single thing their
   screen wants the reader to do, so both are the single thing on it that moves. One
   is an <a> and the other a <button>; nothing below cares which, because the layers
   are absolutely positioned rather than laid out.

   Four scenes -- wind, rain, snow, sunshine -- three seconds each, cross-fading, on
   one twelve-second loop.

   **No JavaScript, and that is the constraint the whole design is shaped by.** The
   public alert page ships none: it is opened from a link in an email, often on a phone
   on a bad connection, and a script is the one part that can fail and leave the button
   looking broken instead of merely plain. Everything below is gradients and keyframes,
   so the worst case is a still blue button that still carries its label and still goes
   where it should -- which is also why it costs the dashboard nothing to reuse.

   Every layer runs the same `wxScene` keyframes on the same 12s clock, offset three
   seconds apiece. The fade edges are 3% of the cycle -- about a third of a second --
   and layer N is still fading out while N+1 is fading in, which is what makes it a
   cross-fade rather than a cut. */
.weather-cta {
  position: relative;
  overflow: hidden;
  /* The layers are clipped to the pill and cannot paint over anything around it. */
  isolation: isolate;
}

.wx {
  position: absolute;
  inset: 0;
  opacity: 0;
  /* Never intercepts the tap; the anchor is the whole target. */
  pointer-events: none;
  animation: wxScene 12s linear infinite;
}

/* Above the scenes, and shadowed so the word stays readable across all four -- the
   sun layer lightens the blue underneath it more than the other three. */
.wx-label {
  position: relative;
  z-index: 1;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}

/* Wind: autumn leaves blowing left to right.
   --------------------------------------------------------------------------
   It was wavy horizontal lines, which are the symbol for wind rather than a picture
   of it -- and on a blue pill they read as a stylised current, not as weather. Leaves
   say the same thing by showing what wind does to something.

   **An SVG tile rather than a gradient**, for the reason the waves needed one and one
   more besides. A `repeating-linear-gradient` only makes straight bands, and its bands
   run *perpendicular* to the angle given -- `100deg` reads as "almost horizontal" and
   draws almost vertical, which is how the first version of the waves became a picket
   fence sliding sideways. Leaves are scattered shapes, so they were never reachable
   that way at all.

   **Two tiles on one layer, not two layers**, the way .wx-snow already does its two
   sizes of flake: 100px of small leaves and 140px of larger ones, both travelling one
   whole tile width per cycle. The wider tile therefore moves further in the same time
   and reads as nearer. Every leaf is drawn at its own angle, including a few nearly
   edge-on, so the field looks like leaves caught mid-tumble rather than a pattern
   sliding.

   The path is one shape reused -- `M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z`, a leaf drawn around
   the origin so `rotate()` in each transform turns it about its own middle rather than
   swinging it across the tile. Nothing is drawn near an edge, so the seam has nothing
   to line up.

   Warm ambers and a rust rather than the white the other three scenes use. They are
   the .wx-sun palette, which is the only other warm thing on the button. */
.wx-wind {
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='40' viewBox='0 0 100 40'%3E%3Cg fill-opacity='.5'%3E%3Cpath fill='%23ffd166' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(12 9) rotate(-35) scale(.55)'/%3E%3Cpath fill='%23ff9f45' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(40 27) rotate(70) scale(.5)'/%3E%3Cpath fill='%23ffd166' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(64 12) rotate(20) scale(.6)'/%3E%3Cpath fill='%23e2703a' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(88 31) rotate(-65) scale(.5)'/%3E%3Cpath fill='%23ff9f45' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(30 36) rotate(115) scale(.45)'/%3E%3C/g%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='56' viewBox='0 0 140 56'%3E%3Cg fill-opacity='.7'%3E%3Cpath fill='%23ffb703' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(24 18) rotate(45)'/%3E%3Cpath fill='%23e2703a' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(78 40) rotate(-20) scale(1.1)'/%3E%3Cpath fill='%23ffd166' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(116 16) rotate(95) scale(.9)'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 100px 40px, 140px 56px;
  animation: wxScene 12s linear infinite, wxLeaves 4s linear infinite;
}

/* The one leaf that actually tumbles.
   --------------------------------------------------------------------------
   **A background tile cannot rotate its own contents.** Travel is a background-position
   shift, and that translates the whole field; the drawn angles above make it *look*
   tumbled but nothing in it turns. This is a real element, so it can, and one leaf
   spinning through two full turns is what reads as tumbling for the whole scene.

   `left` in percent, not a translate in pixels, because the two buttons are different
   widths -- "Get started" against "Create your first alert". `left: 100%` puts the
   leaf's own left edge on the button's right edge, so it exits exactly at the edge
   whatever the label says. A pixel distance would either stop short on the wide one or
   waste most of the pass on the narrow one.

   **On the scene's own 12s clock, crossing in the first quarter**, which is precisely
   the three seconds the wind scene is visible for. Given its own shorter period it
   would drift against the fade and spend most cycles crossing an invisible button.
   Parked off the right edge for the rest.

   No wxScene of its own: opacity applies to the whole subtree, so it fades in and out
   with its parent, exactly as .wx-rain::before does. */
.wx-wind::after {
  content: "";
  position: absolute;
  top: calc(50% - 8px);
  /* The same start the keyframe opens on, so the leaf is off the edge rather than at
     its static position when the animation is switched off for reduced motion. */
  left: -16%;
  width: 16px;
  height: 16px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23ffd166' fill-opacity='.85' d='M0 -5 Q4 0 0 5 Q-4 0 0 -5 Z' transform='translate(8 8) scale(1.5)'/%3E%3C/svg%3E");
  background-size: 16px 16px;
  animation: wxLeafTumble 12s linear infinite;
}

/* Rain: short slanted drops falling, and splashing at the bottom edge.
   --------------------------------------------------------------------------
   **Drops, not streaks.** A repeating gradient can only draw bands that run edge to
   edge, so rain built from one is always full-height diagonal stripes -- which reads
   as speed lines rather than as rain. These are individual dashes about nine pixels
   long, scattered through the tile, and the tilt is drawn into each dash.

   **The fall is straight down even though the drops lean**, because the loop has to
   land on a whole tile. Shifting sideways as well would need the x travel to be a
   multiple of the tile width too, and anything less makes the whole field jump
   sideways once per cycle. The lean in the artwork gives the diagonal; the motion
   does not have to.

   Nothing is drawn across the tile's edges, so the seam has nothing to line up. */
.wx-rain {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cg stroke='%23fff' stroke-width='1.6' stroke-linecap='round' stroke-opacity='.55'%3E%3Cpath d='M8 4 l-2 9'/%3E%3Cpath d='M24 16 l-2 9'/%3E%3Cpath d='M46 8 l-2 9'/%3E%3Cpath d='M14 34 l-2 9'/%3E%3Cpath d='M36 40 l-2 9'/%3E%3Cpath d='M54 30 l-2 9'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 60px 60px;
  animation: wxScene 12s linear infinite, wxRain 0.7s linear infinite;
}

/* The bounce, pinned to the bottom edge the drops arrive at.
   --------------------------------------------------------------------------
   Separate layers because this is the one part of the scene that does *not* travel --
   splashes happen where the button ends, and the drops fall past them. The arcs open
   upward, which is what makes it a bounce rather than a ripple. Both inherit the
   scene's opacity, so they fade in and out with the rest of the rain.

   **Two layers on periods that do not divide into each other, because one layer made
   every splash rise at the same instant.** A layer has one opacity, so a single
   animation pops every arc it draws together, however many the tile has and however
   they are spaced -- which reads as a row of interface flashing rather than as rain
   landing. That is what shipped, and it is the thing anyone notices first.

   Nothing here is random; it only has to stop being periodic while somebody is
   looking. 0.7s against 0.93s next lines up after about 9.8 seconds, and the scene is
   only visible for three -- so within any one appearance of the rain, no two splashes
   pop together twice. The tiles are 96px and 67px with arcs at irregular offsets
   inside them, so the two sets never sit above each other either.

   The 0.7s layer still matches the fall. Its partner does not, and that is not worth
   fixing: at nine pixels nobody follows one drop down to one splash. What they see is
   whether the bottom edge pulses in unison. */
.wx-rain::before,
.wx-rain::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 10px;
  background-repeat: repeat-x;
}

.wx-rain::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='10' viewBox='0 0 96 10'%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.4' stroke-linecap='round' stroke-opacity='.75'%3E%3Cpath d='M4 9 q4 -6 8 0'/%3E%3Cpath d='M38 9 q3 -4 6 0'/%3E%3Cpath d='M70 9 q5 -7 10 0'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 96px 10px;
  animation: wxSplash 0.7s ease-out infinite;
}

.wx-rain::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='67' height='10' viewBox='0 0 67 10'%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.4' stroke-linecap='round' stroke-opacity='.6'%3E%3Cpath d='M12 9 q3 -5 6 0'/%3E%3Cpath d='M44 9 q4.5 -6 9 0'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 67px 10px;
  /* The negative delay matters as much as the period: without it both layers start
     their pop on the first frame, so the very first splash of every scene -- the one
     that arrives as the rain fades up, and the one most likely to be watched -- is the
     synchronised one this exists to remove. */
  animation: wxSplash 0.93s ease-out -0.31s infinite;
}

/* Snow: two sizes of dot at two speeds, which is what reads as depth rather than as
   a moving texture. */
.wx-snow {
  background-image:
    radial-gradient(circle, rgba(255, 255, 255, 0.9) 1.4px, transparent 1.7px),
    radial-gradient(circle, rgba(255, 255, 255, 0.6) 1px, transparent 1.3px);
  background-size: 46px 46px, 31px 31px;
  animation: wxScene 12s linear infinite, wxSnow 5s linear infinite;
}

/* Sunshine: a warm glow that breathes, with rays turning behind it. */
.wx-sun {
  background-image: radial-gradient(circle at 50% 50%,
      rgba(255, 209, 102, 0.5),
      rgba(255, 138, 0, 0.14) 55%,
      transparent 75%);
  animation: wxScene 12s linear infinite, wxGlow 4s ease-in-out infinite;
}

/* The rays, on a pseudo-element sized far past the button so a rotation always has
   something to fill the corners with. Rotating .wx-sun itself would swing its own
   edges through the pill and leave wedges of bare blue. */
.wx-sun::before {
  content: "";
  position: absolute;
  inset: -150%;
  background-image: conic-gradient(from 0deg,
      rgba(255, 214, 102, 0) 0 9deg,
      rgba(255, 214, 102, 0.22) 9deg 16deg,
      rgba(255, 214, 102, 0) 16deg 32deg);
  animation: wxSpin 22s linear infinite;
}

/* One layer visible per quarter of the loop, fading at both ends.
   The percentages are of the whole 12s cycle, so 25% is one scene's three seconds and
   the 3% edges are a little over a third of a second each. */
@keyframes wxScene {
  0% { opacity: 0; }
  3% { opacity: 1; }
  22% { opacity: 1; }
  25% { opacity: 0; }
  100% { opacity: 0; }
}

/* Offsets, in the order the scenes are declared. Negative delays rather than
   positive: a positive delay leaves every layer invisible for its first pass, so the
   button sits plain for the first six seconds after the page loads -- which is
   exactly the moment somebody is looking at it. */
.wx-wind { animation-delay: 0s, 0s; }
.wx-rain { animation-delay: -3s, 0s; }
.wx-snow { animation-delay: -6s, 0s; }
.wx-sun { animation-delay: -9s, 0s; }

/* The leaves, both tiles at once -- the same shape wxSnow uses for its two sizes of
   flake. Each ends on one whole tile width, so both loop seamlessly; the 140px tile
   covers more ground in the same four seconds and reads as the nearer of the two.

   The intermediate stops are what make it wind rather than a conveyor belt. The two
   layers rise and fall in opposite phase, so the gap between them opens and closes.
   Small numbers on purpose: a tile is only 40 and 56 tall, and a vertical swing much
   past this starts to look like the whole field sliding rather than leaves lifting. */
@keyframes wxLeaves {
  0% { background-position: 0 0, 0 0; }
  25% { background-position: 25px -3px, 35px 4px; }
  50% { background-position: 50px 2px, 70px -4px; }
  75% { background-position: 75px -2px, 105px 3px; }
  100% { background-position: 100px 0, 140px 0; }
}

/* One leaf across the button and two full turns while it goes, then parked off the
   right edge for the nine seconds the scene is not on screen.

   Both ends are percentages, so nothing interpolates between a pixel and a percentage
   and the leaf clears the edge on a button of any width. The rotation is deliberately
   not linear against the travel -- it turns fastest through the middle, where the
   vertical swing is largest, which is what a leaf caught by a gust does. */
@keyframes wxLeafTumble {
  0% { left: -16%; transform: translateY(0) rotate(0deg); }
  8% { transform: translateY(-7px) rotate(240deg); }
  17% { transform: translateY(5px) rotate(480deg); }
  25%, 100% { left: 100%; transform: translateY(-2px) rotate(720deg); }
}

@keyframes wxRain { to { background-position: 0 60px; } }

/* The splash: nothing, then a quick pop, then gone -- flattened on the way out so it
   settles rather than fading in place. It peaks a little after the cycle starts, which
   is where a drop that began at the top of the tile arrives. */
@keyframes wxSplash {
  0%, 100% { opacity: 0; transform: translateY(2px) scaleY(0.4); }
  55% { opacity: 1; transform: none; }
  85% { opacity: 0; transform: translateY(-1px) scaleY(0.7); }
}
@keyframes wxSnow { to { background-position: 12px 46px, -9px 31px; } }
@keyframes wxSpin { to { transform: rotate(1turn); } }

@keyframes wxGlow {
  0%, 100% { background-size: 130% 130%; }
  50% { background-size: 165% 165%; }
}

/* **Motion off means off, not slower.** Somebody who has asked their system for
   reduced motion has usually asked because movement makes them ill, and a button that
   never stops moving is the worst offender on a page. One still scene keeps the
   button from looking like a plain rectangle where the animation failed. */
@media (prefers-reduced-motion: reduce) {
  .wx { animation: none; opacity: 0; }
  .wx-sun { opacity: 0.5; }
  /* Every pseudo-element that carries its own animation, not only the ones that were
     here when this block was written. They sit inside a layer .wx has already taken to
     opacity 0, so none of them is visible either way -- but an invisible element
     animating `left` is still work being done, forever, for somebody who asked the
     machine to stop moving. */
  .wx-sun::before,
  .wx-wind::after,
  .wx-rain::before,
  .wx-rain::after { animation: none; }
}

/* The invite banner above the sign-in form (#72). Same width as the form it explains,
   so the two read as one block rather than as a notice sitting over the page.

   A border and no fill, like the public page's Current Forecast block: a filled panel
   on this background reads as a hole cut in the page rather than a part of it. */
.claim-banner {
  max-width: 22rem;
  margin: 0 auto 1.25rem;
  padding: 0.75rem 1rem;
  border: 1px solid #0a84ff;
  border-radius: 0.5rem;
  font-size: 0.95rem;
  line-height: 1.45;
  text-align: left;
}

.login-form {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.75rem;
  max-width: 22rem;
  margin: 0 auto;
}

.email-input {
  background-color: #1c1c1e;
  color: #f5f5f5;
  border: 1px solid #3a3a3c;
  border-radius: 10px;
  padding: 1em;
  font-size: 1.1rem;
  text-align: center;
}

.email-input::placeholder {
  color: #7c7c80;
}

.email-input:focus {
  outline: none;
  border-color: #0a84ff;
}

/* Passkey sign-in, under the email form. Matches .login-form's width so the two
   read as one stack rather than two unrelated controls. */
.passkey-block {
  display: flex;
  flex-direction: column;
  max-width: 22rem;
  margin: 0 auto;
}

.passkey-block .cta-button {
  width: 100%;
}

/* The Turnstile widget, between the address field and the button.
   min-height reserves its space before Cloudflare's script has rendered anything, so
   the submit button does not jump down the page underneath a thumb already reaching
   for it. 65px is the widget's own height in flexible mode. */
.turnstile-block {
  min-height: 65px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.divider {
  color: #7c7c80;
  font-size: 0.95rem;
  margin: 0.75rem 0;
}

/* Wide tracking so six digits read as six digits and a transposed pair is
   obvious before it is submitted. */
.code-input {
  font-size: 1.6rem;
  letter-spacing: 0.4em;
  /* text-indent offsets letter-spacing's trailing gap after the last character,
     which otherwise leaves the digits looking off-centre. */
  text-indent: 0.4em;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

/* **The hint is prose and the value is six digits, so they cannot share a treatment.**
   Everything above is for the digits -- monospace at 1.6rem, spaced 0.4em apart so a
   code can be read back a character at a time. "Enter code" in that costs about twice
   the width of the field and reads as a headline rather than an instruction. The
   placeholder is put back to ordinary text; the moment somebody types, the rules above
   take over again. */
.code-input::placeholder {
  font-family: inherit;
  font-size: 1rem;
  letter-spacing: normal;
}

.subtext {
  font-size: 1rem;
  color: #aaa;
  margin-top: 0.5rem;
}




/* The price, above the sign-in form (#296).
   --------------------------------------------------------------------------
   **Quieter than the tagline and louder than the legal note**, which is exactly the
   weight it has in the decision: nobody arrives wanting to know the price, and
   everybody wants to know it before typing an address. Sized and coloured with
   .subtext, with only the amount lifted out -- three facts in one line where one
   of them is the number being looked for.

   Wraps to two lines on a phone, which is why the separators are middots rather
   than a border or a pipe: a broken line still reads as a list. */
.price-line {
  font-size: 1rem;
  color: #aaa;
  margin: 0 0 1rem;
  line-height: 1.5;
}

.price-line strong {
  color: #f5f5f5;
  font-weight: 600;
}

/* The 404 page (#177).
   --------------------------------------------------------------------------
   Sized in vw with a ceiling rather than at a fixed width, so the cloud is the
   same proportion of a phone as of a laptop. It is the page's only illustration
   and the thing that says "weather product" before any text is read. */
.cloud-404 {
  display: block;
  width: min(60vw, 260px);
  height: auto;
  margin: 1rem auto 0.5rem;
}

/* The number, dim and large. Loud enough to be seen at a glance and quiet enough
   that the sentence below it is still what gets read -- it is the label, not the
   message. Tabular figures so the three digits sit evenly rather than kerning
   into each other at this size. */
.error-code {
  font-size: 3rem;
  font-weight: 700;
  letter-spacing: 0.15em;
  /* The letter-spacing is applied after each glyph, including the last, which
     pushes the number off-centre by a fifth of a character. Put it back. */
  text-indent: 0.15em;
  font-variant-numeric: tabular-nums;
  color: #6b6b70;
  margin: 0;
  line-height: 1;
}

/* The footer sits on the photo background rather than on a card, so the grey it is
   read against is whatever the picture happens to be doing there. #888 disappeared
   into it -- most visibly on the legal entity line, which is the one row with no link
   colour or underline to give it an edge. Lightened for the whole footer rather than
   for that line alone: it is one block of small print, and one of its rows being a
   different grey from the two above it would read as a state rather than as a fix. */



.footer {
  margin-top: 3rem;
  font-size: 0.9rem;
  color: #b0b0b6;
}

.footer a {
  text-decoration: none;
}

.footer a:hover {
  text-decoration: underline;
}

/* .dashboard-header had a second, earlier rule here setting a row layout, which the
   one further down overrode in every property that mattered. Two rules for one
   selector, hundreds of lines apart, is a change made in the wrong one waiting to
   happen -- so the layout now lives in exactly one place. */

.logout-form {
  margin: 0;
}

.welcome-text {
  color: #aaa;
  margin-bottom: 2rem;
}

.alerts-section {
  text-align: left;
}

.alerts-section h2 {
  color: #e0e0e0;
  font-size: 1.4rem;
  margin-bottom: 1rem;
}

.empty-state {
  color: #888;
  margin-bottom: 1.5rem;
}

/* ==========================================================================
   Dashboard, alert builder, and public alert page.

   Mobile-first: most users will be doing this on a phone, so these rules assume
   a narrow screen and widen at a breakpoint rather than the reverse.
   ========================================================================== */

/* Full-bleed on a phone: the body's 2rem padding wastes a third of a small
   screen. Also respects the notch and home-bar safe areas. */
@media (max-width: 640px) {
  body {
    padding: 1rem max(0.75rem, env(safe-area-inset-left))
             max(1rem, env(safe-area-inset-bottom))
             max(0.75rem, env(safe-area-inset-right));
    font-size: 1.05rem;
  }
  h1 { font-size: 1.75rem; }
  .container { max-width: 100%; }
}

/* --- Dashboard ----------------------------------------------------------- */

/* A title and an email address do not fit one phone row, and they did not fit one
   desktop row either once the address was long. The title takes the top row and the
   account bar takes its own underneath, at every width -- not at one width and not
   the other, which is how the account pill ended up clipped by the viewport before.
   That is why this is a column with no breakpoint. */
.dashboard-header {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.6rem;
  margin-bottom: 0.5rem;
}

.dashboard-header h1 { margin: 0; font-size: 1.8rem; }

/* The address on the left, the way to the other page on the right.
   A div, so flex is safe here -- the rule is about <button>, which collapses to
   nothing in WebKit on iOS when it is made a flex container.

   flex-start plus margin-left: auto on the last icon, rather than space-between.
   space-between is only correct for exactly two children: add a third -- the operator
   link an admin sees -- and it spreads them evenly, stranding one in the middle of
   the row instead of keeping it beside the address it belongs to. */
.account-bar {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 0.75rem;
}

/* The navigation icon holds the right-hand end whatever else is on the row, so the
   way out of the page is always in the same place. Anything between it and the
   address sits next to the address. */
.account-bar > a:last-child { margin-left: auto; }

/* An address is long and a phone is narrow: ellipsise rather than wrap, so the icon
   beside it keeps its place on the row instead of being pushed off it.
   min-width: 0 is what lets a flex item shrink below its content at all.

   **Sized to its content, never stretched.** Giving it flex: 1 to absorb the row's
   slack looks equivalent and is not: .container sets text-align: center, so a
   stretched span centres its own text inside the space it just took, and the address
   drifts into the middle of the row while still being "left-aligned" in the markup.
   text-align is stated here too, so the row does not depend on that inheritance
   either way. */
.account-email {
  color: #e0e0e0;
  font-size: 0.95rem;
  min-width: 0;
  overflow: hidden;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A square tap target holding one glyph.

   **Needs a fill of its own**, because it sits directly on the background
   photograph: a transparent control with a dim border reads as invisible over a busy
   image, and the fill is what separates it from whatever happens to be behind it. It
   matches the alert cards, so the page has one surface colour rather than two. Other
   rules in this file point here for that reasoning.

   **Centred by position, not by flex and not by line-height.** It is an <a> here, so
   flex would in fact work -- but the two icon controls on these pages are built the
   same way, and the other one cannot be. See `.icon-button svg`, and the note on
   `.link-button svg` for what happens to a <button> laid out with flex. */
.icon-button {
  display: inline-block;
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  /* The glyph is positioned against this box rather than flowed inside it. See
     `.icon-button svg` for why, and for why it is not centred with flex. */
  position: relative;
  background-color: rgba(28, 28, 30, 0.9);
  border: 1px solid #3a3a3c;
  border-radius: 999px;
  color: #e0e0e0;
  text-decoration: none;
  box-sizing: border-box;
}

/* The legal pages: title left, way out right, at the top of the document.
   --------------------------------------------------------------------------
   Flex on a <header>, which is safe -- it is <button> as a flex container that
   collapses in WebKit, and the control inside here is an <a>. */
.legal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

/* And again at the end, beside the cross-link to the other document. */
.legal-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin: 1.5rem 0;
}

/* Alone in its own row on the public page, so a 44px circle does not sit hard against
   the left margin looking like something that came loose. */
.public-home {
  margin: 1.5rem 0;
  text-align: center;
}

/* Resets so the same class works on a <button> as well as an <a>.
   --------------------------------------------------------------------------
   A button carries user-agent padding, border and font, any of which would break the
   44px box. Harmless on the anchors, which have none of them to reset.

   Note there is no `display: flex` anywhere in this family, which is what makes it
   safe on a button at all -- the glyph is centred by absolute position against the
   circle. See `.icon-button svg` and the note on `.link-button svg`. */
.icon-button {
  padding: 0;
  font: inherit;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.icon-button:hover { border-color: #5a5a5e; color: #fff; }
.icon-button:active { background-color: #2c2c2e; }

/* The same surface with a word in it instead of a glyph.
   --------------------------------------------------------------------------
   For a destination whose icon cannot carry its own meaning. A shield said
   "protected", which is true of /account and does not say what is behind it -- and
   the two icons either side of it, a house and a person, are things everybody already
   reads. The word costs a little width and removes the guess.

   **Keeps the fill and the border**, for the reason in .icon-button above: a bare text
   link on the background photograph reads as invisible.

   **line-height rather than flex to centre it**, which is the documented way to place a
   text glyph in a fixed box -- see the note on .link-button svg. 42px is the 44px box
   less its two 1px borders, under box-sizing: border-box. This is an <a> rather than a
   <button>, so the WebKit collapse does not apply either way, but the family is built
   one way on purpose. */
.icon-button.labelled {
  width: auto;
  padding: 0 0.9rem;
  line-height: 42px;
  font-size: 0.9rem;
  white-space: nowrap;
}

/* The action variant: same circle, filled.
   --------------------------------------------------------------------------
   **Because it does something rather than goes somewhere.** The outlined ones are
   destinations -- home, profile, admin -- so an outlined "+" beside a heading reads as
   a fourth one. Filled in the accent blue, so the product has a single colour meaning
   "this is the action" at whatever size it is drawn. iOS makes exactly this
   distinction: the "+" in a navigation bar is tinted, the chevrons are not.

   It keeps #0a84ff where `.cta-button` moved to a darker one (#176), and that is not
   drift. The contents here are a glyph rather than a label, so this is judged at the
   3:1 non-text threshold and clears it at 3.65:1. Darkening it to match would trade
   a passing control for a dimmer one on no evidence.

   **Below `.icon-button:hover`, not above it.** Both are one class and one
   pseudo-class, so they carry identical specificity and the later one wins -- written
   above, the grey hover border would take the blue off this button on the one
   interaction that confirms it is a control. */
.icon-button-action {
  background-color: #0a84ff;
  border-color: #0a84ff;
  color: #fff;
}

.icon-button-action:hover {
  background-color: #006ae0;
  border-color: #006ae0;
  color: #fff;
}

.icon-button-action:active { background-color: #0059bb; }

/* The glyph, centred on the circle exactly.
   --------------------------------------------------------------------------
   **Positioned, not flowed.** This used to be `line-height: 44px` with
   `vertical-align: middle`, which is *not* centring: `middle` aligns the glyph's
   midpoint with the baseline plus half the parent font's x-height, and the baseline
   sits wherever the font's ascent and descent put it inside the line box. So the icon
   landed near the middle by way of two font metrics, and every icon in the product was
   a fraction off.

   **That is why it was worse on a phone.** iOS and the desktop resolve the system font
   stack differently, so the x-height and the baseline move, and the error moves with
   them -- a bug that changes size by platform and has nothing in the markup to explain
   it.

   top/left 50% with a -50% translate is the geometric centre of the padding box and
   depends on no font, no line box and no border width. It survives changing the glyph
   size, and it is the same answer at 44px as at any other.

   **Still no flex**, which is the point of the whole family: a <button> is not a
   reliable flex container in WebKit on iOS -- it collapses and takes its contents with
   it, with no error and nothing to search for. Absolute positioning inside a relative
   button is not that bug and is safe on both elements. See the note on
   `.link-button svg`. */
.icon-button svg {
  width: 22px;
  height: 22px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* .logout-button was the email pill in the header, and it is gone: the address is
   plain text now and the control beside it is .icon-button, which carries the same
   fill for the same reason. That rule is the worked example other comments here
   point at. */

.section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin: 1.5rem 0 0.75rem;
}

.section-head h2 { margin: 0; font-size: 1.3rem; }

/* One per line, each across the section.
   --------------------------------------------------------------------------
   For a head whose two halves are both too long to share a phone's width. Side by
   side, "Calendar links" and "Replace links" each wrapped to two lines because of the
   other; neither is the one that should give way, so neither does. **Opt-in rather
   than the default**: the three other section heads pair a short heading with a
   compact control, and stacking those would put a full-width button beside nothing. */
.section-head-stacked {
  flex-direction: column;
  align-items: stretch;
  gap: 0.6rem;
}

/* One column on a phone; two once there is room. */
.alert-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.9rem;
  /* **Each card keeps its own height.**
     A grid row is as tall as its tallest cell and stretches the others to match, so
     opening one alert on a two-column screen pulled its closed neighbour to the same
     height -- a card with three lines in it and a foot of empty glass below, which
     reads as an expanded card with nothing inside rather than as a collapsed one.
     Single-column phones never showed it: every row holds one card. */
  align-items: start;
}

@media (min-width: 641px) {
  /* **minmax(0, 1fr), not 1fr.** A bare `1fr` is `minmax(auto, 1fr)`, so a track
     never shrinks below its content's minimum -- and one wide line inside a card
     (a precipitation criterion listing five types) pushed that column past its half
     and squeezed the other one to a third of the width, wrapping a street address
     into four lines beside it. Zero as the minimum lets the track hold its share and
     the text wrap inside it, which is the way round it should be: the column decides
     the width, the content decides how many lines it takes. */
  .alert-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Frosted glass, carried over from v1's .glass3d (homestyle2.css): the background
   photograph shows through faintly rather than being blocked out.
   --------------------------------------------------------------------------
   The tint and filter are v1's values. The blur is lower because v1 applied it to
   a pseudo-element stacked over the card, and the same figure reads far stronger
   applied to the card itself.

   v1's noise texture is deliberately dropped -- it was loaded from
   mercuryfalling.net, and that is the domain v2 replaces. A cross-domain
   dependency on the site being retired is not worth a subtle grain. */
.alert-card,
.public-alert,
.first-run,
.prompt-band-viewport {
  background-color: hsl(189 80% 10% / 0.3);
  -webkit-backdrop-filter: blur(30px) brightness(0.84) saturate(2.7);
  backdrop-filter: blur(30px) brightness(0.84) saturate(2.7);
  /* A light hairline instead of a dark one -- against a blurred photo a #2c2c2e
     edge disappears, and the glass loses its shape. */
  border: 1px solid hsl(205 20% 90% / 0.14);
  /* The inset sheen from v1's ::after, trimmed from five layers to two. It is what
     reads as a lit edge rather than a flat translucent panel. */
  box-shadow:
    inset 2px 2px 1px -3px hsl(205 20% 90% / 0.8),
    inset 1.5px 1.5px 1.5px -0.75px hsl(205 20% 90% / 0.15),
    0 4px 14px hsl(205 20% 10% / 0.25);
}

/* Without backdrop-filter the tint alone is far too transparent to read against a
   photograph, so fall back to the opaque panel. Safari needs the prefixed name in
   the test as well as the rule. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .alert-card,
  .public-alert,
  .prompt-band-viewport {
    background-color: rgba(28, 28, 30, 0.9);
  }
}

.alert-card {
  border-radius: 14px;
  padding: 1rem;
  text-align: left;
}

/* Anything that can draw weather behind itself: the dashboard card, and the panel on
   the public page.
   --------------------------------------------------------------------------
   **A class of its own rather than two selectors per rule.** The scene block below is
   thirty-odd selectors; naming both containers in each would be sixty, and the day a
   third surface wants one it would be ninety. This is the one place that says which
   elements are eligible.

   The containing block and the stacking context for the scene. It sits at z-index -1,
   which is above the panel's own glass and below every line of text on it -- but only
   while the panel is a stacking context, and the backdrop-filter that would otherwise
   make it one has a @supports fallback that drops it. Stated here rather than
   inherited from a filter that might not apply. */
.wx-scene {
  position: relative;
  isolation: isolate;
}

.alert-card.is-paused { opacity: 0.55; }

/* The weather behind a matching card.
   ==========================================================================
   A cousin of the .weather-cta scenes at the top of this file, and deliberately not a
   reuse of them. That button cycles all four on a twelve-second loop because it is
   *about* weather in general; a card is about one alert, so it draws that alert's own
   sky and holds it. Which sky comes from alertname.Scenes, in Go, off the same criteria
   the card lists inside itself -- so the picture and the words cannot disagree.

   **Two layers, and the number is not a preference.** A card is a <details>, and a
   collapsed one renders nothing but its <summary> -- so the layers cannot be real
   elements, and ::before and ::after are the entire budget. The lead scene takes
   ::before through `wx-card-*`; anything layered over it takes ::after through
   `wx-card2-*`. Only four scenes are ever drawn in the second slot, which is why only
   those four carry a rule for it.

   **Only on the green group, and that is what keeps it from becoming wallpaper.** Most
   alerts are clear most of the time. Eight cards raining at once says nothing; the one
   card raining out of eight is the same signal the green line gives, in a form that is
   read before the words are. `is-firing` is added by loadAlertStatus when the forecast
   answers, so a card that starts matching gains its scene without being rebuilt.

   **Nothing here may cost a word of legibility.** Every layer is at a negative z-index:
   above the card's own glass, below every line of text on it -- which is painting
   order, not a guess. The card sets `isolation` so that stays true even where
   backdrop-filter is unsupported and the card would otherwise not be a stacking context
   at all. The marks themselves are the faintest thing on the card by a wide margin: the
   effective alpha of a raindrop is about 0.13 against text at 1, which is also the
   budget the two-layer cap is protecting.

   Sized to the card, not copied from the pill. The rain tile up there is 60px with six
   drops in it; repeated across a 340px card that is a downpour. These tiles are wider
   and sparser for the same apparent density. */
.wx-scene.is-firing.wx-card-snow::before,
.wx-scene.is-firing.wx-card-rain::before,
.wx-scene.is-firing.wx-card-haze::before,
.wx-scene.is-firing.wx-card-wind::before,
.wx-scene.is-firing.wx-card-heat::before,
.wx-scene.is-firing.wx-card-sun::before,
.wx-scene.is-firing.wx-card2-storm::after,
.wx-scene.is-firing.wx-card2-wind::after,
.wx-scene.is-firing.wx-card2-heat::after,
.wx-scene.is-firing.wx-card2-sun::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Follows the card's corners. Backgrounds are clipped by the border radius, so this
     is the whole of the clipping -- no overflow:hidden on the card, which would cut
     off any popup opened inside it. */
  border-radius: inherit;
  z-index: -1;
  pointer-events: none;
}

/* **The light goes behind the weather.** A sun layered under bad air is the sun seen
   through it, which is the thing being described; drawn over the smoke it is a lamp in
   front of it. ::after paints above ::before at equal z-index, so the second slot has
   to say otherwise when what it holds is a light source. */
.wx-scene.is-firing.wx-card2-heat::after,
.wx-scene.is-firing.wx-card2-sun::after {
  z-index: -2;
}

/* Rain: drops falling, and nothing else.
   --------------------------------------------------------------------------
   The pill's version has splashes along its bottom edge. A card has a foot of text
   above that edge and the splashes would pulse under the criteria list, which is the
   one thing on here that must not move. */
.wx-scene.is-firing.wx-card-rain::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90' viewBox='0 0 90 90'%3E%3Cg stroke='%23cfe6ff' stroke-width='1.5' stroke-linecap='round' stroke-opacity='.5'%3E%3Cpath d='M12 6 l-2 10'/%3E%3Cpath d='M52 22 l-2 10'/%3E%3Cpath d='M30 48 l-2 10'/%3E%3Cpath d='M72 62 l-2 10'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 90px 90px;
  opacity: 0.26;
  animation: wxCardRain 0.85s linear infinite;
}

/* The storm: the sky lighting up, over the rain that is already falling.
   --------------------------------------------------------------------------
   **Never a scene on its own** -- alertname.Scenes will not return it as a lead, and
   there is no `wx-card-storm` rule to draw one. A thunderstorm is rain plus this, so
   what it layers over is always there underneath.

   Brightest at the top and gone by three quarters down, because the light comes from
   the sky. Nothing is drawn: a flash has no shape, it is the whole scene getting
   brighter for a moment.

   **Two flickers, then ten and a half seconds of nothing.** A single fade reads as the
   card pulsing; the second, dimmer flick a fifth of a second later is what makes it
   lightning. The whole event is under half a second, and at 0.14 peak it is a lift in
   the card's own brightness rather than a white frame -- this sits on a dashboard
   somebody scrolls, and a hard strobe would be both unpleasant and a genuine
   photosensitivity question.

   The resting opacity is 0, so with motion turned off there is simply no layer.
   Lightning that does not flash is not lightning, and a permanent pale wash over the
   top of the card is the one version of this that would hurt the text. */
.wx-scene.is-firing.wx-card2-storm::after {
  background-image: linear-gradient(180deg,
      rgba(226, 240, 255, 0.9),
      rgba(198, 222, 255, 0.3) 38%,
      transparent 72%);
  opacity: 0;
  animation: wxCardFlash 11s linear infinite;
}

/* Snow: two sizes of flake at two speeds, which is what reads as depth rather than as
   a texture sliding down. Gradients rather than a tile -- a dot is a circle, and a
   radial-gradient is already one. */
.wx-scene.is-firing.wx-card-snow::before {
  background-image:
    radial-gradient(circle, rgba(255, 255, 255, 0.9) 1.5px, transparent 1.8px),
    radial-gradient(circle, rgba(255, 255, 255, 0.6) 1px, transparent 1.3px);
  background-size: 68px 68px, 44px 44px;
  opacity: 0.3;
  animation: wxCardSnow 9s linear infinite;
}

/* Wind: streaks crossing left to right, on two tiles at two speeds.
   --------------------------------------------------------------------------
   Not the pill's tumbling leaves. Those are warm ambers picked to sit on a blue
   button, and eight of them scattered across a card of body text reads as confetti
   over the address. A streak is the same statement with nothing in it to look at.

   Drawn identically in both slots. Wind blowing rain and wind on a hot day are the
   same wind, so the second selector here is the rule rather than a variant of it. */
.wx-scene.is-firing.wx-card-wind::before,
.wx-scene.is-firing.wx-card2-wind::after {
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='58' viewBox='0 0 170 58'%3E%3Cg fill='none' stroke='%23dceaf7' stroke-width='1.4' stroke-linecap='round' stroke-opacity='.55'%3E%3Cpath d='M14 16 h40 q8 0 8 -5'/%3E%3Cpath d='M86 40 h44 q8 0 8 5'/%3E%3C/g%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='44' viewBox='0 0 120 44'%3E%3Cg fill='none' stroke='%23dceaf7' stroke-width='1.1' stroke-linecap='round' stroke-opacity='.4'%3E%3Cpath d='M10 30 h30 q6 0 6 -4'/%3E%3C/g%3E%3C/svg%3E");
  background-size: 170px 58px, 120px 44px;
  opacity: 0.3;
  animation: wxCardWind 5.5s linear infinite;
}

/* Bad air: smoke, which is the absence of an edge.
   --------------------------------------------------------------------------
   Three soft brown-grey masses that drift across each other and breathe. Nothing here
   has a shape, on purpose: haze is the one condition on this list you recognise by
   things becoming *less* distinct, so a drawn symbol for it would be a contradiction.

   **One layer holding all three, rather than a layer each.** The second slot belongs
   to whatever is layered over this, so smoke gets exactly one -- and three background
   layers on one element cost nothing, since a single keyframe set can give each of
   them its own path. What it gives up is what two elements had: animations on periods
   that do not divide into each other, so no arrangement ever repeated. Three
   intermediate stops on a nineteen-second loop is the replacement, and it is enough
   that nothing in it is recognisable as a cycle. */
.wx-scene.is-firing.wx-card-haze::before {
  background-image:
    radial-gradient(ellipse 60% 80% at 50% 50%, rgba(190, 160, 120, 0.55), transparent 70%),
    radial-gradient(ellipse 50% 70% at 50% 50%, rgba(160, 150, 140, 0.45), transparent 70%),
    radial-gradient(ellipse 55% 75% at 50% 50%, rgba(175, 155, 130, 0.4), transparent 70%);
  background-size: 70% 100%, 55% 100%, 60% 100%;
  background-repeat: no-repeat;
  opacity: 0.34;
  filter: blur(9px);
  animation: wxCardSmoke 19s ease-in-out infinite;
}

/* Sunshine: a warm glow that breathes, in the top right.
   --------------------------------------------------------------------------
   **Off to one side rather than centred, and the side the text is not on.** Every line
   on this card is left-aligned and none of them reaches the right edge, so the corner
   opposite the heading is the one place a light can sit without ever being behind a
   word.

   **Anchored to the corner in pixels, not positioned in percent.** A percentage
   position aligns that point of the *image* with the same point of the box, so with a
   gradient larger than the card it does not mean "near the top right" at all -- which
   is why the first version drifted toward the middle and grew a foot taller on an
   opened card. Offsetting the image's own right and top edges past the card's puts the
   centre of the glow a fixed 50px in and 30px down, on a card of any height.

   **No turning rays, unlike the pill.** Those live on a pseudo-element inset far past
   the button so a rotation always has something to fill the corners with, and the
   button clips it. Clipping the card would cut off any popup opened inside it, and an
   unclipped rotation swings its own corners out past the card's edge. The glow alone is
   the scene here; the rays were never the part that read as sun at this size. */
.wx-scene.is-firing.wx-card-sun::before,
.wx-scene.is-firing.wx-card2-sun::after {
  background-image: radial-gradient(circle,
      rgba(255, 214, 120, 0.9),
      rgba(255, 168, 48, 0.28) 45%,
      transparent 70%);
  background-repeat: no-repeat;
  background-size: 210px 210px;
  background-position: right -55px top -75px;
  opacity: 0.37;
  animation: wxCardSunGlow 7s ease-in-out infinite;
}

/* Hot and muggy: the same sun, seen through the air above a road.
   --------------------------------------------------------------------------
   Brighter, larger and blurred past having an edge at all -- that is the whole
   difference between a bright day and an oppressive one, and it is why this is the sun
   scene with a filter rather than a second drawing. A blur spreads light and dims it,
   so the higher opacity is what the filter costs rather than a second decision: the two
   move together.

   The mirage rides on the same layer, as a second background: faint horizontal bands
   that drift and squash. Bands, because heat shimmer is a *horizontal* distortion of a
   vertical view, and the 9px blur leaves them as an undulation rather than as stripes.

   **Two animations on one element, which is only possible because they touch different
   properties.** The glow breathes in `opacity` and the bands travel in
   `background-position`; had both wanted the same property the later one would simply
   have won. The glow's own position is repeated in the shimmer's keyframes for that
   reason -- a `background-position` animation moves every layer it does not pin. */
.wx-scene.is-firing.wx-card-heat::before,
.wx-scene.is-firing.wx-card2-heat::after {
  background-image:
    radial-gradient(circle,
      rgba(255, 232, 168, 1),
      rgba(255, 146, 46, 0.45) 45%,
      transparent 74%),
    repeating-linear-gradient(180deg,
      rgba(255, 206, 150, 0) 0 6px,
      rgba(255, 206, 150, 0.14) 12px 14px,
      rgba(255, 206, 150, 0) 20px 24px);
  background-repeat: no-repeat, repeat;
  background-size: 240px 240px, 100% 100%;
  background-position: right -60px top -85px, 0 0;
  opacity: 0.62;
  filter: blur(9px);
  animation:
    wxCardHeatGlow 9s ease-in-out infinite,
    wxCardHeatShimmer 6s ease-in-out infinite;
}

/* Each ends on a whole tile, so the loop has no seam.
   The rain tile is 90 tall; snow's two are 68 and 44, and both travel their own
   height in the same nine seconds, which is what makes the near flakes faster. */
@keyframes wxCardRain { to { background-position: 0 90px; } }
@keyframes wxCardSnow { to { background-position: 14px 68px, -10px 44px; } }
@keyframes wxCardWind { to { background-position: 170px 0, 120px 0; } }

/* The glow breathes in brightness rather than in size.
   --------------------------------------------------------------------------
   **Resizing moves it.** The gradient is anchored by its own top and right edges, so
   growing it walks the centre down and to the left -- a sun that slides away from its
   corner every few seconds, which is what the first version did. Brightness leaves the
   geometry alone, and it is the property a light source actually varies.

   Two sets rather than one shared with a multiplier: they are four numbers, and a
   variable holding a base opacity for two rules to scale is more machinery than the
   duplication costs. The declared opacity on each rule is the resting value, so a card
   with motion turned off sits at the dimmer end rather than at the peak. */
@keyframes wxCardSunGlow {
  0%, 100% { opacity: 0.37; }
  50% { opacity: 0.52; }
}

@keyframes wxCardHeatGlow {
  0%, 100% { opacity: 0.62; }
  50% { opacity: 0.85; }
}

/* The bands rise and settle by one whole period, so there is nothing to see restart.
   The glow's position is repeated on both stops to hold it still. */
@keyframes wxCardHeatShimmer {
  0%, 100% { background-position: right -60px top -85px, 0 0; }
  50% { background-position: right -60px top -85px, 0 -24px; }
}

/* Smoke: a slow wander with three stops, so the arrangement never obviously repeats
   inside a loop. Each mass takes its own path across the card. */
@keyframes wxCardSmoke {
  0%, 100% { background-position: 0% 30%, 100% 70%, 60% 20%; }
  33% { background-position: 45% 65%, 55% 25%, 10% 75%; }
  66% { background-position: 20% 20%, 80% 55%, 95% 40%; }
}

/* Lightning: two flickers inside four tenths of a second, then nothing for the rest of
   the eleven. Linear rather than eased -- a flash has no ramp. */
@keyframes wxCardFlash {
  0%, 100% { opacity: 0; }
  1.1% { opacity: 0.14; }
  1.7% { opacity: 0.02; }
  2.3% { opacity: 0.1; }
  3.6% { opacity: 0; }
}

/* **Motion off means off, not slower** -- the same rule the button scenes carry, and
   it matters more here: a dashboard can hold eight of these at once, where the button
   is one.

   The scene is left on screen, still. Every one of them is a texture rather than a
   sequence, so a frozen card still reads as rain or as haze; what goes is the movement
   somebody asked their machine to stop making. The exception is the lightning, whose
   resting opacity is 0 -- see the note on it.

   **Every slot, not only the ones that were here when this was written.** The button
   scenes above carry a comment about having made exactly that mistake, and there is a
   test holding this list against alertname.Themes and alertname.SecondThemes. */
@media (prefers-reduced-motion: reduce) {
  .wx-scene.is-firing.wx-card-snow::before,
  .wx-scene.is-firing.wx-card-rain::before,
  .wx-scene.is-firing.wx-card-haze::before,
  .wx-scene.is-firing.wx-card-wind::before,
  .wx-scene.is-firing.wx-card-heat::before,
  .wx-scene.is-firing.wx-card-sun::before,
  .wx-scene.is-firing.wx-card2-storm::after,
  .wx-scene.is-firing.wx-card2-wind::after,
  .wx-scene.is-firing.wx-card2-heat::after,
  .wx-scene.is-firing.wx-card2-sun::after {
    animation: none;
  }
}

/* Pause and delete, behind the collapsed row.
   ==========================================================================
   Swipe a collapsed card to the left and two tiles come out from under it. On a desktop
   the same panel opens from the handle at the row's top right, which is also the only
   way to it from a keyboard.

   **The wrapper exists because a <details> cannot hold them itself.** A collapsed one
   renders nothing but its <summary>, and a button inside a summary is part of that
   summary's activation -- it would open the card on the way to being pressed. So the
   panel is a sibling of the card and this is their common parent.

   `position: relative` is what the panel and the handle are placed against. */
.alert-swipe {
  position: relative;
  /* **The vertical half of every gesture stays with the browser.** A list of cards is
     scrolled far more often than a card is swiped, and a page that waits to find out
     which is happening feels like it is catching on every touch. Horizontal is left to
     us, which is what delivers the pointermove events swipeRow reads. */
  touch-action: pan-y;
}

/* The card itself is the thing that moves; the panel behind it never does. Sliding the
   card is one transform on one element, and it takes the glass, the weather scene and
   every line of text with it without any of them being laid out again.

   The transition is for the snap at the end of a gesture and for the handle's click.
   swipeRow turns it off while a finger is down -- see .is-dragging below -- because a
   card easing 0.22s behind the finger dragging it is a card that feels broken. */
.alert-swipe > .alert-card {
  transition: transform 0.22s ease;
}

/* The two tiles, the height of the row, at its right edge.
   --------------------------------------------------------------------------
   **Clipped to the strip the card has stopped covering, not merely stacked underneath
   it.** A card is frosted glass: its backdrop-filter blurs whatever is painted behind
   it, so a tile sitting under a half-swiped card would smear red across the row for the
   length of the gesture. Clipping means there is nothing behind the glass to blur.
   swipeRow rewrites the inset every frame; this is the closed state and the one it
   returns to. (Once the panel is fully out the glass does pick up a little of its
   colour along that edge, because a 30px blur samples past the element's own bounds.
   That one is the glass working.)

   **`visibility` as well as the clip**, because a clip-path only stops it being
   painted. Hidden, the two buttons are out of the tab order -- so a keyboard walking a
   list of ten alerts does not pass through twenty controls nobody can see. It is
   delayed by the length of the close so the tiles do not vanish out from under a card
   still sliding back over them.

   The padding is a gutter between the card's edge and the first tile. It is inside the
   panel's own width, so it is revealed last and the card travels exactly as far as
   there is something to show. */
.alert-swipe-actions {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  display: flex;
  gap: 8px;
  padding-left: 8px;
  box-sizing: border-box;
  visibility: hidden;
  clip-path: inset(0 0 0 100%);
  transition: clip-path 0.22s ease, visibility 0s linear 0.22s;
}

.alert-swipe.is-open .alert-swipe-actions {
  visibility: visible;
  transition: clip-path 0.22s ease, visibility 0s;
}

/* While a finger is down: no easing anywhere, and the tiles show from the first pixel
   of travel rather than after the close delay above. */
.alert-swipe.is-dragging > .alert-card { transition: none; }

.alert-swipe.is-dragging .alert-swipe-actions {
  visibility: visible;
  transition: none;
}

/* The snap at the end of a gesture, and the handle's click, arrive instantly instead.
   **`visibility` keeps its delay**, because that one is not motion -- it is what stops
   the tiles disappearing out from under a card still on its way back over them, and
   with the slide instant there is nothing left for it to wait for either. Stated as 0s
   rather than dropped, so the closed rule's 0.22s delay does not apply here. */
@media (prefers-reduced-motion: reduce) {
  .alert-swipe > .alert-card { transition: none; }
  .alert-swipe-actions,
  .alert-swipe.is-open .alert-swipe-actions { transition: visibility 0s; }
  .alert-swipe-handle { transition: none; }
}

/* An expanded card does not swipe -- swipeRow refuses the gesture, and these two go
   with it. `display: none` rather than hiding, so neither is reachable by a keyboard
   walking an opened card either. */
.alert-card[open] ~ .alert-swipe-actions,
.alert-card[open] ~ .alert-swipe-handle { display: none; }

/* A tile: glyph over a word.
   --------------------------------------------------------------------------
   **No flex on the button, and the pair inside it is centred by absolute position.** A
   <button> is not a reliable flex container in WebKit on iOS -- it collapses and takes
   its contents with it, with no error and nothing to search for, on the platform this
   gesture is mostly for. Same construction and same reasoning as `.icon-button svg`.

   Stretched to the row's height by the panel's flex, so the tiles are as tall as
   whatever the card's three lines come to. */
.swipe-action {
  position: relative;
  display: block;
  width: 68px;
  flex-shrink: 0;
  padding: 0;
  border: 0;
  border-radius: 12px;
  font: inherit;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.swipe-action-inner {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  text-align: center;
}

.swipe-action-inner svg {
  display: block;
  width: 22px;
  height: 22px;
  margin: 0 auto 0.3rem;
}

.swipe-action-label {
  display: block;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1;
}

/* Filled tiles with dark or white text, whichever clears 4.5:1 on the fill -- these are
   words at 0.75rem, not glyphs, so the text threshold is the one that applies.

   Green for resume is the colour the switch these replaced turned when the alert was
   on, and amber is the same amber .alert-status.is-unknown already uses. Red is
   .cta-button.danger's fill, which is the product's one destructive colour. */
.swipe-action-pause { background-color: #ffb340; color: #1c1c1e; }
.swipe-action-resume { background-color: #30d158; color: #1c1c1e; }
.swipe-action-delete { background-color: #c9463f; color: #fff; }

.swipe-action:active { filter: brightness(0.88); }

/* The way in that is not a gesture.
   --------------------------------------------------------------------------
   **A real button on every device, not only where there is a mouse.** A swipe cannot be
   tabbed to and cannot be spoken to a screen reader, so gesture-only would put pause and
   delete out of reach of anyone not using a finger or a pointer.

   Invisible until the row is hovered or the button is focused, which is what keeps the
   collapsed row down to the three things #169 left on it. It stays *pressable* while
   invisible rather than being turned off with pointer-events, because a screen reader
   activating a control hit-tests the point it sits at -- switched off, the tap would
   fall through to the card underneath and open it instead. The cost is that a tap on an
   otherwise empty corner slides the panel out; the panel is not destructive by itself
   and a tap anywhere puts it back. */
.alert-swipe-handle {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: none;
  /* **The alert name's own white, not the quiet grey the place and status lines use.**
     It was #8e8e93, which is right for text that is always on screen and wrong for
     this: it appears only once someone is already pointing at the row, so it has one
     moment to read as a control rather than as a smudge -- and it is three small dots
     on frosted glass over a photograph, which is the least legible mark on the card. */
  color: #fff;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  opacity: 0;
  transition: opacity 0.15s ease;
}

.alert-swipe-handle svg {
  width: 20px;
  height: 20px;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

@media (hover: hover) and (pointer: fine) {
  .alert-swipe:hover .alert-swipe-handle { opacity: 1; }
}

/* Gone once what it opens is open: it is a control for reaching the panel, and it would
   be sitting on a card that has slid out from under it. */
.alert-swipe.is-open .alert-swipe-handle,
.alert-swipe.is-dragging .alert-swipe-handle {
  opacity: 0;
  pointer-events: none;
}

/* **Focus outranks the rule above, which is why it is written a second time here.** A
   keyboard can Shift+Tab from a tile back to the handle while the panel is out, and the
   rule above would leave it standing on a control with nothing drawn -- focus somewhere
   invisible is a keyboard user lost on the page. Four classes to that rule's three, and
   later besides. */
.alert-swipe.is-open .alert-swipe-handle:focus-visible,
.alert-swipe-handle:focus-visible {
  opacity: 1;
  pointer-events: auto;
}

/* The collapsed row (#169).
   --------------------------------------------------------------------------
   list-style on the <summary> hides the default triangle; ::-webkit-details-marker
   is the same job for older WebKit, which ignores the standard property. The marker
   is drawn by .alert-summary-head::before instead, so it can be sized against the
   text rather than against whatever the platform picks. */
.alert-summary {
  list-style: none;
  cursor: pointer;
  /* The tap target is the row. A summary that only responds on its text is a control
     that misses on a phone. */
  padding: 0.15rem 0;
}

.alert-summary::-webkit-details-marker { display: none; }

/* **A span inside the summary, never the summary itself.** A <summary> given a
   display other than list-item misbehaves in WebKit on iOS -- the same family as the
   <button>-as-flex-container collapse in CLAUDE.md. An inner span cannot be caught
   by it and costs one element. */
/* Three columns and two rows, every child placed explicitly.
   --------------------------------------------------------------------------
   **Auto-placement is what got this wrong first**: the marker spanned both rows, the
   place claimed row 1, and the name was pushed down into row 2 -- so the place
   floated above the name it belongs to. Naming every cell costs four lines and
   removes the class of bug entirely. */
.alert-summary-head {
  display: grid;
  /* One text column, three stacked rows: name, place, status.
     **The place had been beside the name, and it wrapped badly** -- an auto column
     holding "Boathouse Row, Philadelphia, PA" either squeezed the alert's own name or
     broke across two lines mid-address. Stacking costs one line and never wraps
     against the name. */
  grid-template-columns: 1.25rem 1fr;
  gap: 0.1rem 0.5rem;
  align-items: baseline;
}

/* The disclosure marker. Drawn by the shared plus/minus rule at the end of this file,
   which is also what the day rows and the Current Forecast control use -- so all
   three are the same size by construction rather than by three numbers agreeing.
   Everything here is where it sits, not what it looks like. */
.alert-summary-head::before {
  grid-column: 1;
  grid-row: 1 / span 3;
  align-self: start;
  color: #aeaeb2;
  /* Centred against the *first line* of the name, not against the three-row column it
     spans. The h3 is 1.05rem at line-height 1.35, so its line box is ~22.7px and the
     marker is 14px: half the difference is the offset. `align-self: start` alone puts
     it flush with the top of the line box, which reads as sitting above the name. */
  margin-top: 0.27rem;
}

.alert-summary-head h3 {
  grid-column: 2;
  grid-row: 1;
  margin: 0;
  font-size: 1.05rem;
  line-height: 1.35;
  overflow-wrap: anywhere;
  /* The gutter .alert-swipe-handle sits in. Reserved on every device rather than only
     where there is a hover to reveal it: the handle is pressable whether or not it is
     painted, so a name running under it would be a name with a button on top of it. Only
     the first line needs it -- the place and the status lines are below the handle's
     30px box. */
  padding-right: 1.6rem;
}

.alert-summary-place {
  grid-column: 2;
  grid-row: 2;
  color: #8e8e93;
  font-size: 0.9rem;
  min-width: 0;
  /* break-word, not anywhere. `anywhere` breaks mid-word whenever it helps the line,
     which split a postcode as "191 / 19" -- a number cut in half reads as two numbers.
     break-word only breaks a word that cannot fit on a line of its own, so an address
     wraps at its spaces and a genuinely long token still cannot overflow. */
  overflow-wrap: break-word;
}

/* The line the row exists for, on its own under the place. Empty until the status
   pass answers, so it takes no height and the row does not jump when it arrives. */
.alert-status {
  grid-column: 2;
  grid-row: 3;
  font-size: 0.9rem;
  color: #8e8e93;
}

.alert-status:empty { display: none; }

/* Green only when something is actually expected. Bright enough to be the thing the
   eye lands on scanning a list of ten, since that is the whole point of the row. */
.alert-status.is-firing { color: #4ade80; font-weight: 600; }

/* A forecast that could not be read is amber, not grey: it is a different statement
   from "nothing expected" and must not be mistaken for it (#156). */
.alert-status.is-unknown { color: #ffb340; }

/* The expected-conditions picture inside an opened card (#169).
   --------------------------------------------------------------------------
   position: relative only so the expand button has something to sit in the corner
   of. The grid inside is the public page's markup, rendered by the same template,
   and it carries its own styles -- nothing here restyles it, because the two views
   are meant to draw the same picture. */
.alert-card-conditions {
  position: relative;
  margin-top: 0.75rem;
  color: #8e8e93;
  font-size: 0.9rem;
  text-align: left;
}

.alert-card-conditions:empty { display: none; }

/* On the "Expected conditions" heading's line, at the far right.
   --------------------------------------------------------------------------
   It was `position: absolute; top: 0; right: 0` once, which landed on the last letters
   of a long heading like "NOT CURRENTLY EXPECTED"; then in a row of its own below the
   grid. It sits in the heading's flex row now (see .windows-head and placeExpandButton),
   pushed right by that row's `justify-content: space-between` -- in flow, colliding with
   nothing, and costing no vertical space because the heading's right half was empty. */
.conditions-expand {
  display: block;
  margin: 0;
  width: 44px;
  height: 44px;
  /* Centred with text-align and line-height, **not flex** -- a <button> is not a
     reliable flex container in WebKit on iOS. See .link-button svg. */
  text-align: center;
  line-height: 44px;
  background: none;
  border: none;
  color: #8e8e93;
  cursor: pointer;
  padding: 0;
}

.conditions-expand:hover { color: #f5f5f5; }
.conditions-expand svg { width: 18px; height: 18px; vertical-align: middle; }

/* The heading and its enlarge control on one line, the control at the far right.
   space-between rather than a margin so it holds whether or not the button is present:
   the enlarged view clones this row with the button stripped, and a lone heading then
   simply sits left. The row's height is the 44px button, which centres the small
   heading in a comfortable tap target and costs no space the heading's own row did not. */
.windows-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
}

/* The title carried the row's bottom margin; the row owns it now, so the title inside
   sheds it rather than making the flex line taller than the button.

   **It used to be `white-space: nowrap`, and that was what widened the whole
   dashboard.** The intent was to keep the expand control on the heading's own row
   rather than under a wrapped heading -- but nowrap does not merely prefer one line,
   it makes the row's *minimum* width the whole string: "NOT CURRENTLY EXPECTED" plus
   the gap and the 44px button measures 302px, so a card could never be narrower than
   about 336px however little room there was. A card is a grid item with the automatic
   minimum size, so it refused to shrink to its track and stuck out past the container
   -- and since every card in the list is the same width, one alert with that heading
   dragged the whole page wider than the viewport. Reported on an iPhone, where a
   zoomed-in page and a 320px device both land well under 336.

   Wrapping costs nothing in the case nowrap was written for: at any ordinary phone
   width the heading is 250px against 283px of room, so it stays on one line and the
   control stays beside it. It wraps only where the alternative was a horizontal
   scroll, and `align-items: center` on the row keeps the button beside a two-line
   heading rather than under it.

   min-width: 0 for the same reason it appears on every other flex item here: a flex
   item's minimum is its own content, so a long word would put the overflow back. */
.windows-head .windows-title {
  margin: 0;
  min-width: 0;
  overflow-wrap: break-word;
}

/* The enlarged view. Wider than the alert modal, because the thing it exists to show
   is a chart of eight days against twenty-four hours -- the whole point is width. */
.conditions-panel {
  background-color: #1c1c1e;
  border: 1px solid #2c2c2e;
  border-radius: 14px;
  padding: 1rem 1.25rem;
  width: min(1100px, 100%);
  max-height: 90vh;
  overflow-y: auto;
  text-align: left;
}

/* A div laid out with flex holding a button that is not one. */
.conditions-panel-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 0.5rem;
}

.conditions-panel-head h2 {
  margin: 0;
  font-size: 1.2rem;
  color: #f5f5f5;
  overflow-wrap: anywhere;
}

.alert-card-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
}

.alert-card h3 {
  margin: 0;
  font-size: 1.05rem;
  line-height: 1.35;
  /* break-word, not anywhere: only split a word that genuinely cannot fit, rather
     than mid-word whenever it is convenient. Places are held together by
     non-breaking spaces in the generated name itself. */
  overflow-wrap: break-word;
}

/* The generated name, under a private one that has taken the heading.

   Quieter than the place line below it and directly beneath the title, so the pair
   reads as one heading with its subtitle rather than as two facts about the alert.
   It is only ever present on a renamed alert, so the untouched card is unchanged. */
.alert-card-generated {
  font-size: 0.85rem;
  color: #8e8e93;
  margin: 0.15rem 0 0;
  overflow-wrap: break-word;
}

.alert-card-place {
  font-size: 0.9rem;
  color: #8e8e93;
  margin: 0.2rem 0 0.7rem;
}

/* Three columns: metric, operator, value.

   **The grid is on the list, not on the row.** A grid per <li> gives every row its own
   column widths, so each one sizes to its own words and the eye zig-zags down a column
   that is not a column. One grid over the whole list is what makes "below" start at the
   same x on every line -- which is the entire point of leaving prose behind.

   auto/auto/1fr: the first two columns take exactly what their *widest* member needs
   and the value column absorbs the rest. So "Air quality" sets the first column and
   everything aligns to it, rather than each row being padded independently. */
/* Each criterion is one sentence that wraps, not a row of three columns.
   --------------------------------------------------------------------------
   It was a three-column grid -- metric, operator, value -- which aligned nicely down
   a list and fell apart on the row that needed it least. "is any of" in an auto
   column is squeezed by a long value list until it stacks as "is / any / of", three
   words down the page next to a single line of types. A precipitation criterion is
   the widest row there is and the one where the columns bought nothing.

   Reading it as prose also matches how it is said everywhere else: the email, the
   push and the public page all quote the criteria as sentences. */
.criteria-list {
  display: block;
  list-style: none;
  padding: 0;
  margin: 0 0 0.8rem;
  font-size: 0.95rem;
}

/* One criterion, one wrapping line.
   --------------------------------------------------------------------------
   This was `display: contents`, which dissolved the row so its three spans became
   items of the list's grid -- and took the list-item role out of the accessibility
   tree with it. As a real block the row is a list item again, which it always should
   have been: the number of criteria is information, and every one of them is ANDed. */
.criterion-row {
  display: block;
  margin-bottom: 0.3rem;
}

.criterion-row:last-child { margin-bottom: 0; }

/* On the dashboard card only: the criteria wrap across the line instead of taking one
   line each.
   --------------------------------------------------------------------------
   Six criteria were six full-width rows, and "UV below 6" held a line that could carry
   three of them -- about a fifth of the card's height spent on the part of it that
   changes least. Wrapped, the same six come to two lines.

   **Scoped to the card, not applied to `.criteria-list` everywhere.** The same class is
   the public page's criteria block and the builder's name preview, and neither is short
   of room: the public page gives one alert a whole page, and the preview is three rows
   in a panel. Compressing them would be a change made for a constraint they do not have.

   **The separator is drawn rather than spaced.** A gap alone left "Temp between
   50-80°F" and "Precip chance below 30%" abutting in two whites -- the value and the
   next metric are both #f5f5f5 -- so "80°F Precip chance" read as one phrase for a
   moment. The dot costs nothing and removes the ambiguity. It can be orphaned at the
   end of a wrapped line, which is the price of packing them tightest; the alternative
   is a fixed column grid, and these rows vary from "UV below 6" to "Air quality below
   100", so fixed columns would give the height back in white space.

   The rule above the block comes with it. The section is the alert's own definition --
   criteria, then place and schedule -- and it needs one line above it, not one above
   each half, now that the grid above ends with its own. */
.alert-card .criteria-list {
  display: flex;
  flex-wrap: wrap;
  column-gap: 0.55rem;
  row-gap: 0.25rem;
  border-top: 1px solid #2c2c2e;
  margin: 1rem 0 0;
  padding-top: 0.9rem;
}

/* An alert always has at least one criterion, so this is a guard rather than a case:
   an empty list would otherwise draw the section rule above nothing at all. */
.alert-card .criteria-list:empty { display: none; }

.alert-card .criterion-row { margin-bottom: 0; }

.alert-card .criterion-row:not(:last-child)::after {
  content: "·";
  /* Dimmer than the operator, which is already the quiet part of a row. This separates
     criteria; it should not compete with anything inside one. */
  color: #636366;
  margin-left: 0.55rem;
}

/* **The spacing is a margin, not a space in the markup.** These rows are built in two
   places -- Go for the public page, JavaScript for the dashboard card -- and the
   JavaScript one appends spans with no text nodes between them, so relying on
   whitespace collapsing would run "Precipabove" together on one of the two and not
   the other. */
.criterion-metric {
  color: #f5f5f5;
  margin-right: 0.35em;
}

/* Quieter than the two it joins. The operator is the least surprising part of a row --
   almost every criterion is above or below -- and at full weight a column of them is
   the loudest thing in the block.
   nowrap so "is any of" is never broken across a line: it is one phrase, and split it
   reads as three words rather than as an operator. */
.criterion-op {
  color: #8e8e93;
  margin-right: 0.35em;
  white-space: nowrap;
}

/* Tabular figures so a column of thresholds lines up as a column. */
.criterion-value {
  color: #f5f5f5;
  font-variant-numeric: tabular-nums;
}

/* **A place the row is allowed to break, which the margins above do not provide.**
   --------------------------------------------------------------------------
   The three spans carry no whitespace between them -- the gaps are margins, for the
   reason given on .criterion-metric -- so the browser sees "Precipis any ofFreezing
   rain" as one unbroken run, and a row's minimum width is the whole of it. That
   minimum is the card's minimum, and the card is a grid item: it keeps it rather than
   shrinking to its track, so the card grows past the container and takes the page's
   width with it, exactly as the conditions heading's nowrap did.

   It only shows up when text is large against the box around it -- an iPhone with a
   larger system font size, where WebKit boosts the text and leaves the padding and
   the 44px controls where they were. Measured against the real stylesheet: at 2x text
   the widest row is 281px unbroken and 129px broken.

   **A zero-width space, and `white-space: normal` is what makes it count** -- the same
   pair, for the same reason, as the generated comma on .criterion-line below: without
   it the character inherits the operator's nowrap and creates no break at all.

   Not a real space with the margins removed: the margins are a spacing decision taken
   in one place for two builders, and this is a line-breaking one. Nothing moves. */
.criterion-op::before,
.criterion-value::before {
  content: "\200B";
  white-space: normal;
}

/* What a threshold means on a published scale: "Air quality above 90 · Moderate".
   --------------------------------------------------------------------------
   Quieter than the value it explains and no louder than the operator -- it is the
   gloss on the row, not a third fact in it. The separator is drawn here rather than
   written into the markup for the same reason .criterion-line's comma is: the rows
   are built in Go for the public page and in JavaScript for the card, and a character
   typed into either would have to be typed into the other and kept there.

   Only UV and air quality ever carry one, so this styles a span that is absent from
   every other row. */
.criterion-band {
  color: #8e8e93;
  margin-left: 0.45em;
  /* The whole phrase or none of it: "Sensitive" on one line and "groups" on the next
     reads as two bands rather than one. It is a short string in a row that already
     wraps between its parts, so this costs no width -- the break simply happens
     before the band instead of inside it. */
  white-space: nowrap;
}

/* **Parentheses, not the middle dot this first used.** The dot is already the
   separator *between* criteria (see .criterion-row::after), so a band introduced with
   one produced "Air quality above 90 · Moderate ·" -- the same glyph dividing a row
   from its own gloss and from the next row, which reads as three conditions where
   there are two. Brackets can only be read as an aside, and they match the band beside
   the reading in the block above.

   Drawn here rather than written into the markup for the same reason .criterion-line's
   comma is: these rows are built in Go for the public page and in JavaScript for the
   card, and a character typed into either would have to be typed into the other. */
.criterion-band::before { content: "("; }
.criterion-band::after { content: ")"; }

/* The same thing beside a reading rather than a threshold -- "Air quality (Moderate)"
   in the Current Forecast block. Dimmer than the metric name it follows, because the
   name is what the row is about and this is what it means. */
.reading-band {
  color: #8e8e93;
  font-weight: 400;
  white-space: nowrap;
}

/* The severity ramp the UV and air quality bands are coloured on.
   ==========================================================================
   **Only these two metrics are coloured, because only these two have a published
   scale to take a colour from.** EPA runs the AQI green, yellow, orange, red, purple,
   maroon; WHO runs UV green, yellow, orange, red, violet. They are the same ramp, so
   Low and Good share a level and the two never disagree on one card.

   **The word is the signal and this is the reinforcement**, never the other way round.
   Colour alone fails a colour-blind reader -- the rule that keeps the two-hour strip
   naming its precipitation types in words above the bars -- so every one of these
   tints a word that already says the same thing.

   **This is only possible because the dot took green back.** While a reading that
   cleared its threshold was itself green, a green here for "Good" was a second meaning
   for one colour in one row, and the two can disagree: an alert watching for bad air
   makes Good the case that does *not* match. The dot answers "does this meet your
   condition" and the ramp answers "how bad is it", in different columns.

   **The published values are for white paper and are not used verbatim.** EPA's
   Moderate is #FFFF00, which on this card's dark glass is a glare rather than a
   warning, and its Hazardous maroon #7E0023 is close to invisible on it. The hues are
   theirs and the lightness is chosen for the background these actually sit on -- the
   same correction the grid's own labels needed when #8e8e93 measured 3.3:1 over a
   photograph.

   **These must stay below .reading-band and .criterion-band.** All three are single
   class selectors, so specificity is equal and source order is the whole mechanism --
   written above them, the grey default wins and the ramp silently does nothing. That
   is exactly what happened on the first attempt, and it looked like the classes were
   not being emitted. Same arrangement as the now-grid palette over .match-grid. */
.band-1 { color: #5fd97a; }
.band-2 { color: #ffd60a; }
.band-3 { color: #ff9f0a; }
.band-4 { color: #ff6961; }
/* Violet and then magenta, matching EPA's purple and maroon in hue order. Maroon
   itself cannot be used: dark red on a dark card is unreadable, and lightening it
   toward pink makes the worst band look gentler than the one below it. Magenta keeps
   it legible and keeps the ramp ascending. */
.band-5 { color: #d0a2f7; }
.band-6 { color: #ff4d8d; }

/* Comma-separated, wrapping.
   --------------------------------------------------------------------------
   This was one value per line, to keep a precipitation criterion from pushing a
   narrow screen into a horizontal scroll. It does not scroll now for a better reason:
   the values are inline and the line simply wraps, so five accepted types cost two
   lines instead of five. On a card that leads with what the weather is doing, five
   lines of vocabulary was the tallest thing in the expansion.

   **The comma is drawn by CSS, not written into the markup.** These spans are built
   in two places -- Go for the public page, JavaScript for the dashboard card -- and a
   separator added in either would have to be added identically in the other, then
   kept that way. A rule here reaches both and cannot drift.

   :not(:last-child) rather than ::after on every one and a negative margin to hide the
   last: a trailing comma that is merely invisible is still read aloud. */
.criterion-line {
  /* **Each value stays whole.** Left to ordinary wrapping, "Freezing rain" broke
     after "Freezing", and that is the one class here that must never be mistaken for
     another -- a fragment at the end of a line invites exactly that. */
  display: inline;
  white-space: nowrap;
}

/* **The separator must escape the nowrap above, or the whole line stops wrapping.**
   The generated ", " is a child of the span, so it inherited nowrap -- which made the
   space after each comma non-breaking and left the entire list with no break
   opportunity anywhere in it. Five types then ran straight out of the card.
   white-space: normal here restores the one break the line needs, between values,
   while each value itself stays unbreakable. */
.criterion-line:not(:last-child)::after {
  content: ", ";
  white-space: normal;
}

/* Place and schedule, under the criteria and inside the same section.
   --------------------------------------------------------------------------
   **The rule above this one has gone**, and moved to .criteria-list. The two blocks are
   now adjacent halves of one thing -- what this alert is -- so a line between them drew
   a boundary where there is no change of subject, and a second hairline within a few
   lines of the grid's own. */
.alert-card-meta {
  font-size: 0.85rem;
  color: #8e8e93;
  margin-top: 0.55rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.9rem;
}

/* The rest of the address, on a line of its own at the head of the block.
   --------------------------------------------------------------------------
   `flex-basis: 100%` rather than a separate element outside the flex row, because it
   belongs to this block and everything here is one wrapping row of facts. It is much
   the longest of them, and left to flow it would drag "daylight" and "next 48h" onto a
   second line behind it, which reads as the schedule having wrapped rather than as an
   address ending. */
.alert-card-place-full { flex-basis: 100%; }

/* The card's action row: View, Edit, Rename, Share and, on a scheduled alert,
   Calendar -- all five across one line.
   --------------------------------------------------------------------------
   The pause switch and Delete are not here any more; they are behind the collapsed row,
   in .alert-swipe-actions. What went with them is the reason this was ever a layout: a
   column for the switch on the left, a 2x2 square of the ordinary actions, and Delete
   pushed to the opposite corner so a destructive control was never a thumb's width from
   Share. None of those three things needs a place any more, so the square is a row and
   the container it needed to hold its shape is gone with it.

   **Five is where wrapping stopped being the right answer.** At their natural size the
   five come to 266.5px, against 247.3px of card at the narrowest the two-column
   breakpoint produces -- measured, not estimated -- so `flex-wrap: wrap` put one button
   alone on a second line at that width. A row of five that is sometimes a row of four
   and a straggler is worse than five slightly smaller ones.

   So the row does not wrap and the buttons give up width instead: `min-width: 0` lets a
   flex item shrink below its own content, and the icon inside follows (see the svg rule
   at the foot of this file). At 247.3px each button lands at 43px with a 15px glyph
   rather than 47px with a 19px one -- a difference nobody reads, where the alternative
   is a visibly broken row.

   **It shrinks for any reason, which is the point of doing it this way.** A narrow
   window, a small phone, or a larger system font size inflating the em padding all
   arrive as the same fact -- the row needs more room than it has -- and none of them
   needs its own breakpoint. The 44px tap height is untouched throughout. */
.alert-card-actions {
  display: flex;
  flex-wrap: nowrap;
  align-items: flex-start;
  gap: 0.5rem;
  margin-top: 0.8rem;
}

/* A flex item's automatic minimum is its own content, so without this the buttons
   refuse to shrink and overflow the card instead.

   **The side padding is pinned in pixels, and that is what keeps the icons full size.**
   `.link-button` sets it in em, so a larger system font size inflates the gap either
   side of a glyph that is fixed at 19px -- the row then runs out of width and the icons
   shrink to pay for padding nobody asked for. Measured at 2x text: 6.8px icons with em
   padding, 19px with this. 13px is 0.9em at the button's own 0.9rem, so nothing moves at
   the default size. The vertical padding stays in em, where growing with the text is
   exactly right for a tap target.

   **There is no floor under the shrink, and it cannot have one.** A `min-width` here
   would need `flex-wrap` to catch the overflow, and flexbox breaks lines using each
   item's *unshrunk* size -- so wrapping always wins over shrinking, which is the
   behaviour being replaced. The floor is the geometry instead: the narrowest card this
   layout can produce is 247.3px, where the icons land at 15.2px. */
.alert-card-actions .link-button {
  min-width: 0;
  padding-left: 13px;
  padding-right: 13px;
}

/* Needs a fill for the same reason .icon-button does, and it was missed here.
   `background: none` over the card's frosted glass leaves only a #3a3a3c hairline
   between the button and a photograph, which is close to invisible -- worst on a
   phone, where the backdrop is brighter and the buttons are smaller.

   Deliberately dimmer than .icon-button's fill: that one sits directly on the
   photo, these sit on glass that has already knocked the background back. */
.link-button {
  background-color: rgba(28, 28, 30, 0.92);
  border: 1px solid hsl(205 20% 90% / 0.45);
  color: #fff;
  border-radius: 8px;
  padding: 0.5em 0.9em;
  font-size: 0.9rem;
  min-height: 44px;
  cursor: pointer;
}

.link-button:active { background-color: #2c2c2e; }
.link-button.danger { color: #ff8a8a; border-color: hsl(0 60% 72% / 0.5); }

/* The dashboard's one-time offer. Quieter than a section: it is an aside above the
   alerts, not a peer of them. */
.passkey-nudge {
  text-align: left;
  background-color: #1c1c1e;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  padding: 0.9rem 1rem;
  margin-bottom: 1.5rem;
}

.passkey-nudge-text {
  color: #aaa;
  font-size: 0.95rem;
  margin: 0 0 0.75rem;
}

/* An opaque panel for text sitting directly on the background photograph.
   --------------------------------------------------------------------------
   The dashboard's text is nearly all inside .alert-card, which has the frosted
   glass to sit on. The profile page had no equivalent: its headings and hints were
   painted straight onto the photo, and #8e8e93 hint text over the lighter part of
   that image toward the bottom of the page was close to unreadable.

   Opaque rather than frosted, and matching .passkey-item, so a section and the rows
   inside it are one surface rather than a panel on a panel. This is the same fill
   .icon-button and .passkey-nudge already use for the same reason. */
.panel {
  background-color: #1c1c1e;
  border: 1px solid #2c2c2e;
  border-radius: 12px;
  padding: 1rem 1.15rem;
  margin-bottom: 1.25rem;
}

/* The rows inside a panel already sit on it, so repeating the fill draws a box
   inside a box of the same colour, visible only as a border. Keep the border for
   the row's shape and drop the fill. */
.panel .passkey-item,
.panel .place-item { background-color: transparent; }

/* The step-up panel (#165), which had no styling of its own at all.
   --------------------------------------------------------------------------
   It relied on default block flow, and on two class names that were never defined,
   which on a phone produced: two bordered buttons with their edges touching, a code
   field the browser drew at its own default height, a Confirm button sitting beside
   that field rather than under it, and the hint below overlapping the button's border.

   The overlap was `.step-hint`, whose `margin-top: -0.5rem` exists to pull a hint
   tight under a heading. Against a 44px control it pulls the text *into* it, so the
   rule is cancelled inside this panel rather than changed for everywhere it works.

   Flex on the section, never on the buttons themselves -- a <button> is not a
   reliable flex container in WebKit on iOS, which is the failure with no symptom. */
#reauthPanel {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.9rem;
}

#reauthPanel h2,
#reauthPanel p { margin: 0; }

/* The gap above owns the spacing, so the hint cannot claw any of it back. */
#reauthPanel .step-hint { margin: 0; }

/* **The field is full width and Confirm sits under it.** Side by side, the field had
   whatever width was left over after a button, which on a phone is not enough room
   for six digits at a comfortable size -- and it made the two look like one control.
   Stacked, the field is the thing being filled in and Confirm is what finishes it.

   max-width so this does not become a full-bleed field on a desktop, where the panel
   is much wider than six characters ever need. */
#reauthCodeRow {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6rem;
  width: 100%;
  max-width: 18rem;
}

#reauthCodeRow .field {
  width: 100%;
  font-size: 1.15rem;
  letter-spacing: 0.25em;
}

/* The profile page. */
.profile-section { text-align: left; }
.profile-section h2 { color: #e0e0e0; font-size: 1.3rem; }
.profile-billing-state {
  margin: 0 0 0.9rem;
  color: #e0e0e0;
  line-height: 1.5;
}

.profile-email { color: #f5f5f5; font-weight: 600; margin: 0.25rem 0; overflow-wrap: anywhere; }

/* The Account heading and Log out on one row, button hard right.
   --------------------------------------------------------------------------
   **Paired with the heading rather than with the address**, which is where it was.
   An email address has no bound on its length, so sharing a line with one meant the
   two collided for anybody whose address was long -- the layout worked only for
   short addresses, which is to say it worked until it was someone else's account.
   A heading is a fixed string, so the button has a stable place to sit.

   align-items: center, since the two are of genuinely different heights here and a
   button hung off the heading's baseline sits noticeably high. The h2 keeps its own
   margins, so the row occupies exactly the space the heading did on its own. */
.profile-account-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

/* The address now has the line to itself and can wrap on a narrow phone rather than
   being squeezed by whatever shares it. */
.profile-account-head + .profile-email { margin-top: 0; }

.profile-link { color: #aaa; text-decoration: none; }
.profile-link:hover { color: #f5f5f5; }

/* Passkey management, on the profile page. */
.passkey-section { text-align: left; }
.passkey-section h2 { color: #e0e0e0; font-size: 1.3rem; }

/* The list itself is shared with saved places (#159) -- a column of rows with a
   gap, which is the same thing in both cases. The rows are not; see below. */
.passkey-list,
.place-list {
  list-style: none;
  padding: 0;
  margin: 0.75rem 0 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.passkey-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  /* Wraps rather than squeezing: on a narrow phone the buttons drop to their own
     line instead of shrinking below a thumb's worth of target. */
  flex-wrap: wrap;
  background-color: #1c1c1e;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  /* Tight vertically: the 44px buttons already set the row's height, so padding
     on top of them only adds dead space. */
  padding: 0.4rem 0.75rem;
}

.passkey-name { font-weight: 600; color: #f5f5f5; }

/* Takes the slack, so the buttons sit at the right edge until the row wraps. */
.passkey-meta { color: #888; font-size: 0.9rem; margin-right: auto; }

/* Saved places (#159): the passkey row, with the one difference that decides its
   layout.
   --------------------------------------------------------------------------
   A passkey's name is short and typed -- "Jon's iPhone". A place's is a full
   geocoded label, "244 West Hortter Street, Philadelphia, PA 19119", which is
   longer than a phone is wide. Wrapping *the row* therefore put Remove alone on a
   line under a full-width address, where it reads as a control belonging to the
   next place rather than to this one.
   So this row does not wrap. The label wraps inside its own column and the buttons
   keep their line, which is the only arrangement where a button's meaning stays
   attached to the thing it acts on. */
/* A column, not a row.
   --------------------------------------------------------------------------
   **The label and the buttons never share a line.** A geocoded place label has no
   bound on its length -- "244 West Hortter Street, Philadelphia, PA 19119" is an
   ordinary one -- so beside a button it wrapped four times around a control floating
   at its middle. Giving up the row costs one line per place and buys a label that is
   never squeezed and buttons that are always in the same place. */
.place-item {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  background-color: #1c1c1e;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  padding: 0.4rem 0.75rem;
}

/* Top right, above the label they act on. */
.place-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.6rem;
}

/* **min-width: 0 is what lets it wrap at all**: a flex item's minimum size is its own
   content by default, so without this the text refuses to break and widens the row
   instead -- the same bug the column layout is here to prevent, wearing a different
   shape. */
.place-text {
  min-width: 0;
  overflow-wrap: anywhere;
}

.place-name { font-weight: 600; color: #f5f5f5; }
.place-meta { color: #888; font-size: 0.9rem; margin-left: 0.4rem; }

/* Fixed at their natural width: the label gives up room, never the tap target. */
.place-item .link-button { flex-shrink: 0; }

/* The green switch that used to pause an alert has gone with the card's action row.
   Pausing is a tile in the swipe panel now -- see .alert-swipe-actions. It was the only
   thing in the product wearing this control, so the rules went with it rather than
   staying as a component nothing renders. */

/* Centred, which is only about the button: it is the one control on the screen, and
   left-aligned under a full-width card it sat off to one side of the thing it belongs
   to. The card resets itself to the left below, because two sentences of prose read as
   prose and a centred paragraph reads as a banner. */
.empty-state {
  color: #8e8e93;
  text-align: center;
}

/* It carries a button now, not just a sentence. The gap keeps the two from reading as
   one block of interface. */
.empty-state p { margin: 0 0 1rem; }

/* The first-run explainer (#200).
   --------------------------------------------------------------------------
   **The same glass as .alert-card**, because it stands exactly where the reader's
   first card is about to. A different surface here would read as a notice *about* the
   page rather than as part of it -- the same mistake the public page's "Current
   Forecast" block made with its own background, which looked like a hole cut in the
   card.

   **Left-aligned back out of the centred empty state**, which is centred for the
   button. Two sentences set centre-aligned read as a banner rather than as something
   to be read, and the second one is long enough to wrap twice on a phone.

   The rules for the glass itself live with .alert-card; only the box is here. */
.first-run {
  border-radius: 14px;
  padding: 1rem;
  text-align: left;
  margin-bottom: 1.25rem;
}

.first-run h3 {
  margin: 0 0 0.6rem;
  font-size: 1.05rem;
  line-height: 1.35;
  color: #f5f5f5;
}

/* Brighter than .empty-state's grey, which was chosen for a sentence stating an
   absence. This is the copy somebody is meant to read. */
.first-run p {
  margin: 0 0 0.75rem;
  color: #c7c7cc;
  line-height: 1.5;
}

.first-run p:last-child { margin-bottom: 0; }

/* --- Alert modal ---------------------------------------------------------
   Opens, closes, and slides between three steps. The steps themselves are
   placeholders; each gets built and confirmed before the next is started.

   No media queries here. The last attempt at this UI was invisible on phones and
   fine on desktop, so the modal is deliberately written once, for every screen:
   full-bleed with a max-width, so a phone gets a near-full-screen sheet and a
   desktop gets a centred box from the same rules.
   ------------------------------------------------------------------------- */

.modal-overlay {
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.7);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Padding respects the notch and home bar; the modal never runs under either. */
  padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
           max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
  box-sizing: border-box;
}

.modal-head {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}

.modal h2 { margin: 0; font-size: 1.3rem; }
.modal h3 { margin: 0 0 0.5rem; font-size: 1.1rem; }

.modal-step-count {
  margin: 0;
  font-size: 0.85rem;
  color: #8e8e93;
  flex-shrink: 0;
}

/* The window. Everything outside it is clipped, which is what makes the strip
   behind it read as a slide rather than three panels in a row.

   This clips in both directions and never scrolls: the vertical scrolling belongs
   to .modal-step below, so each panel scrolls on its own and the strip's height
   stays the height of the window rather than the tallest panel.

   Worth knowing before building the steps: anything that needs to escape a panel
   -- a suggestions list under a search box, say -- has to be drawn some other way,
   because it will be cut off here. */
.modal-steps {
  flex: 1 1 auto;
  /* Without this a flex child refuses to shrink below its content height, the
     modal grows past the viewport, and the button bar leaves the screen again. */
  min-height: 0;
  overflow: hidden;
  /* A flex container so the strip inside is a flex item and stretches to this
     element's final height. That is what gives each panel a real height to scroll
     inside -- see the note on .modal-track. */
  display: flex;
}

/* The strip. Its own width stays one panel wide -- the panels overflow it rather
   than widening it -- so translateX(-100%) is exactly one panel, whatever the
   screen size and however many steps there end up being. Nothing here counts to
   three; only the JS does. */
/* `flex: 1 1 100%` rather than `height: 100%`. A percentage height resolved
   against a parent whose own height comes from the flex algorithm is the case
   browsers handle inconsistently: when it fails the strip falls back to its
   content height, the panels size to their content, nothing overflows, and
   .modal-steps silently clips the bottom with no way to scroll to it -- which is
   exactly how panel 3 behaved. As a flex item it stretches to the container's
   settled height instead, with no percentage involved. */
.modal-track {
  display: flex;
  flex: 1 1 100%;
  /* Same trap as on .modal-step, one level up and easy to miss. The strip is a
     flex item too, so it also defaults to min-width:auto -- and its content is
     three panels each asking for the full width, so it sized itself to fit them
     instead of to the window. Everything past the modal's right edge was then
     clipped, including full-width paragraphs that had nothing to do with the
     two-column rows. Both this and .modal-step need it; neither alone is enough. */
  min-width: 0;
  transition: transform 0.3s ease;
}

.modal-step {
  /* 0 0 100%: never grow, never shrink, exactly the width of the window. The
     no-shrink half is what matters -- without it all three squeeze in side by
     side and the slide does nothing. */
  flex: 0 0 100%;
  /* Load-bearing. A flex item defaults to min-width:auto, so it refuses to shrink
     below its content's minimum -- and a <select> reports its longest option as
     that minimum. Panel 3 was therefore sized to "Hawaii-Aleutian — Adak (DST)"
     rather than to the track, and everything past the modal's edge was clipped:
     the right-hand column of every row, and the tail of every hint. */
  min-width: 0;
  /* Each panel scrolls itself. Putting the scroll here rather than on the strip
     means a short panel does not inherit the tallest panel's scroll range. */
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* Stops a flick that reaches the end of the panel from carrying on into the
     page behind the modal. */
  overscroll-behavior: contain;
  /* Room for a focus ring on the last control, which would otherwise be clipped
     flush against the button bar. */
  padding-bottom: 0.25rem;
}

@media (prefers-reduced-motion: reduce) {
  .modal-track { transition: none; }
}

.step-blocked {
  flex: 0 0 auto;
  font-size: 0.85rem;
  color: #8e8e93;
  margin: 0.7rem 0 0;
}

/* Pinned. The top border is not decoration -- it is the only cue that the panel
   above continues past the fold, since iOS draws no persistent scrollbar. */
.modal-actions {
  flex: 0 0 auto;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0;
  padding-top: 0.9rem;
  border-top: 1px solid #2c2c2e;
}

/* Cancel sits apart from the navigation, so a misfire on Back or Next cannot
   land on it. */
.modal-cancel { margin-right: auto; }

.modal-advance {
  padding: 0.55em 1.3em;
  font-size: 0.95rem;
  min-height: 44px;
}

/* The confirmation dialog. Geometry only -- it takes the surface, the safe-area
   padding and the pinned button bar from .modal and .modal-actions above, so it
   cannot drift away from the alert modal it sits beside.

   Narrower than the builder because it holds a sentence, not a form; a question
   stretched to 34rem reads as a page rather than as a prompt. */
.confirm-modal { max-width: 26rem; }

/* The alert name goes in the heading and can be long, so it wraps rather than
   pushing the dialog wider than the screen. */
.confirm-modal h2 { overflow-wrap: anywhere; }

.confirm-body {
  margin: 0 0 1.1rem;
  line-height: 1.5;
  color: #d1d1d6;
}

/* The dialog's one field: a name being edited, or a URL to copy out of.
   Full width, because the thing it most often holds is a URL, and a field narrower
   than its contents makes the user scroll a text box to read what they came for. */
.dialog-field {
  width: 100%;
  box-sizing: border-box;
  margin: 0 0 1rem;
  font-size: 0.95rem;
}

/* Read-only is a different thing from disabled: still selectable, still copyable,
   just not editable. Dimmed slightly so it does not invite typing. */
.dialog-field[readonly] { color: #d1d1d6; }

.dialog-note { margin: -0.6rem 0 1rem; }

/* The irreversible answer, in the red .link-button.danger already uses for Delete --
   a solid fill rather than that rule's text colour, because this one is the filled
   confirm button rather than a card action.

   It carries the weight `window.confirm` gave to nothing: the system dialog painted
   Delete in the same OS blue as Cancel, so the only thing separating "stop sending
   this" from "destroy it" was reading the sentence. Colour is not the safeguard --
   Cancel holds the focus, so Return is always the safe answer -- but the two should
   not look identical either. */
.cta-button.danger { background-color: #c9463f; }
.cta-button.danger:hover { background-color: #a93a34; }

/* --- Panel 1: location --------------------------------------------------- */

.location-list {
  list-style: none;
  padding: 0;
  margin: 0 0 1.25rem;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  overflow: hidden;
}

.location-list:empty { display: none; }
.location-list li + li { border-top: 1px solid #2c2c2e; }

/* The row holds the label and, on the pending place only, a discard button beside
   it. Flex on the <li> so the label takes the width and the button keeps its size;
   the button itself is not a flex container -- see .location-discard. */
.location-item { display: flex; align-items: center; }
.location-item .location-row { flex: 1 1 auto; min-width: 0; }

/* A label wrapping a radio: the whole row is the tap target, and selection is
   native form behaviour rather than anything of ours that could misfire. */
.location-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.7rem 0.9rem;
  min-height: 44px;
  cursor: pointer;
}

.location-row:active { background-color: #2c2c2e; }

.location-row input {
  /* accent-color themes the native control, so there is no custom radio to get
     wrong on a phone. */
  accent-color: #0a84ff;
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  margin: 0;
}

.location-row-body { display: flex; flex-direction: column; min-width: 0; }

.location-row-name {
  font-size: 1rem;
  line-height: 1.35;
  /* No overflow-wrap here on purpose. PlaceName joins city, state and ZIP with
     non-breaking spaces so they behave as one word -- break-word would split that
     word anyway and drop the state onto its own line, which is the exact thing the
     non-breaking spaces exist to prevent. Wrapping still happens at the real space
     after a street address. */
}

.location-row-note {
  font-size: 0.8rem;
  color: #8e8e93;
}

.location-row-note.is-flagged { color: #ffb340; }

/* Discards the looked-up place, and appears on that row only.
   --------------------------------------------------------------------------
   Saved places are managed on /profile, deliberately -- a control here that could
   delete other alerts is the thing #159 kept out of the builder. This one deletes
   nothing: the pending place has never been written, so the button throws away a
   lookup rather than a row.

   Centred with text-align and line-height, **not flex**. A <button> is not a
   reliable flex container in WebKit on iOS: it collapses and renders as nothing at
   all, which is how the Share and Calendar buttons went invisible on iPhone while
   being perfect in desktop Safari. Same construction as .criterion-remove. */
.location-discard {
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  text-align: center;
  line-height: 44px;
  /* The glyph is small at button size; this sizes the × itself, not the control. */
  font-size: 1.5rem;
  background: none;
  border: none;
  color: #8e8e93;
  cursor: pointer;
  padding: 0;
}

.location-discard:hover { color: #ff6b6b; }

/* Field and button share a line. The label spans the full width above them, and
   the field grows into whatever is left rather than taking the whole modal for a
   five-character ZIP. */
.location-search {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.location-search label {
  flex: 1 0 100%;
  font-size: 0.9rem;
  color: #8e8e93;
}

.location-search input {
  flex: 1 1 11rem;
  max-width: 22rem;
  min-width: 0;
}

.location-search button { flex: 0 0 auto; }

/* Search results and the map that confirms one (#60).
   -----------------------------------------------------------------------------
   Bordered and inset, so the rows below the form read as that search's answer
   rather than as more saved places. Only a border, no fill: a second background
   inside the modal panel reads as a hole cut in it, which is the correction the
   public page's forecast block had to be given. */
.location-results {
  margin-top: 0.75rem;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  padding: 0.5rem 0.75rem 0.75rem;
}

/* The hint takes the line and the × sits on its end, matching the row-plus-control
   shape of .location-item. Flex on a div, never on the button itself. */
.location-results-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.location-results-head .step-hint { flex: 1 1 auto; margin: 0; min-width: 0; }

/* A fixed height, because the box is measured to decide what size image to ask for
   and a box with no height asks for nothing. Tall enough to show which block a pin
   is on, short enough to leave the list above it visible on a phone.

   position: relative is what the pin is absolutely positioned against, so its
   coordinates are offsets within the picture rather than within the page. */
.location-map {
  position: relative;
  height: 220px;
  margin-top: 0.5rem;
  border-radius: 10px;
  overflow: hidden;
  /* Something to look at while the image arrives, and what remains if it never
     does. The panel does not report that: the list is still a choice. */
  background-color: #1c1c1e;
}

.location-map-image {
  display: block;
  width: 100%;
  height: 100%;
  /* The image is requested at the box's measured size, so this crops rather than
     scales in the ordinary case. It matters when it does not: a scaled image would
     put a pixel of screen at a different distance than a pixel of map, and every
     drag would land short or long by that ratio. */
  object-fit: cover;
}

/* The classic teardrop: a circle with one corner squared off, turned 45 degrees.
   Its point is the bottom-left corner before rotation, which after it is bottom
   centre -- so the pin marks the coordinate under its tip, which is where the
   script positions it. */
.location-map-pin {
  position: absolute;
  width: 24px;
  height: 32px;
  cursor: grab;
  /* **Required for the drag to work on a phone.** Without it the browser claims the
     gesture as a scroll before pointermove is ever delivered, and the pin simply
     does not move while the panel behind it does. */
  touch-action: none;
}

/* The shape is a child of the box rather than the box itself, so the rotation
   cannot interfere with the positioning arithmetic above it. */
.location-map-pin::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 2px;
  width: 20px;
  height: 20px;
  background-color: #ff9f0a;
  border: 2px solid #1c1c1e;
  border-radius: 50% 50% 50% 0;
  transform: rotate(-45deg);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}

.location-map-pin.is-dragging { cursor: grabbing; }
.location-map-pin.is-dragging::before { transform: rotate(-45deg) scale(1.15); }

/* It is reachable by keyboard, so it has to say so when it is focused. */
.location-map-pin:focus-visible { outline: 2px solid #0a84ff; outline-offset: 2px; }

.location-pin-note { margin: 0.5rem 0 0; }

/* The candidate list is the last thing in the box, so its usual 1.25rem tail would
   stack on the container's own padding and leave a gap wider than a row. */
.location-results .location-list { margin: 0.5rem 0 0; }

/* The plain-English box (#138), on the conditions panel. Same construction as
   .location-search -- full-width label, field, button on the end -- because it is
   the same kind of thing: type something, press a button, get rows you can edit.
   The rule above it separates the two ways in without a heading claiming they are
   different sections. */
.describe-box {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  border-top: 1px solid #2c2c2e;
  margin-top: 1.25rem;
  padding-top: 1rem;
}

.describe-label {
  flex: 1 0 100%;
  font-size: 0.9rem;
  color: #8e8e93;
}

/* Full width and its own line: this takes a sentence, not a ZIP code, and a field
   sharing a line with the button would show about four words of it.
   resize:vertical rather than the default `both`, which lets a drag push the
   textarea wider than the modal. */
.describe-text {
  flex: 1 0 100%;
  min-width: 0;
  resize: vertical;
  line-height: 1.35;
  font-family: inherit;
}

/* Both take the full width, so the hint sits under the button rather than beside
   it -- and the error, when there is one, under that. */
.describe-hint { flex: 1 0 100%; margin: 0; }

/* Said when the description moved the place chosen on the previous panel. Not an
   error -- it is the box doing what was asked -- but the one thing it changes that
   is not on screen, so it is coloured to be read rather than skimmed past. */
.describe-note {
  flex: 1 0 100%;
  margin: 0;
  color: #ffb340;
}

.describe-box .form-error { flex: 1 0 100%; margin: 0; }

.form-error {
  color: #ff6b6b;
  font-size: 0.9rem;
  margin: 0.75rem 0 0;
}

/* The operator's send-a-push form. Left-aligned and stacked: it is a form on a page
   of read-outs, and centring it would make it look like a call to action. */
.admin-push { text-align: left; margin-top: 1.25rem; }
.admin-push h2 { margin: 0 0 0.35rem; font-size: 1.2rem; color: #e0e0e0; }

.admin-push-form {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin-top: 0.9rem;
}

/* The button sits at its own width rather than stretching across the panel: it is one
   action on a narrow form, and a full-width button on an operator page reads as the
   page's purpose rather than as one control on it. */
.admin-push-form .cta-button { align-self: flex-start; margin-top: 0.4rem; }

/* The form's gap owns the spacing, so the label's own bottom margin is cancelled.
   Left on, it stacks with the gap and puts 0.8rem between a label and the field it
   names while leaving 0.4rem above it -- which reads as the label belonging to the
   field above rather than the one below. */
.admin-push-form .field-group-label { margin-bottom: 0; }

/* Green, and the only green on the page. What happened is the thing the operator came
   back to read, and it has to be distinguishable at a glance from the red failures
   above it. */
.admin-push-result {
  color: #4ade80;
  font-size: 0.95rem;
  font-weight: 600;
  margin: 0.6rem 0 0;
}

/* The JavaScript-off notice on the landing page.
   --------------------------------------------------------------------------
   Amber rather than red: nothing has gone wrong and nothing the visitor did caused
   it. It is the same colour every other "this needs your attention but is not an
   error" state on the site uses.

   Panelled, because it sits over the background photograph where ordinary body text
   is hard to read -- and because a visitor who has scripts off is the one person here
   who will actually read three sentences. */
.noscript-note {
  background-color: #1c1c1e;
  border: 1px solid #ffb340;
  border-radius: 12px;
  padding: 0.9rem 1.1rem;
  margin: 0 0 1.25rem;
  text-align: left;
  color: #e0e0e0;
  font-size: 0.95rem;
}

/* The band of example prompts, above the sign-in form (#296).
   --------------------------------------------------------------------------
   **One prompt at a time, and the timing is the whole design.** The first version was
   a continuous marquee of all of them, which is a moving wall the reader has to pick a
   sentence out of -- nothing holds still long enough to finish. Each prompt now arrives
   in 0.4s, drifts while it is read for 6s, and leaves in 0.6s. Six seconds is set by
   the longest prompt rather than the average: at about twenty words it needs roughly
   that to be read once at an ordinary pace, and a band nobody finishes reading is a
   band that says nothing.

   **The drift is what stops this being a slideshow.** Held perfectly still between two
   fast transitions it reads as a panel that swaps when you look away; still moving, it
   reads as a live band. Two percent is small on purpose -- it should be noticed only
   if you look for it.

   **One animation, staggered by delay, no script.** Every item runs the same 56s
   timeline and is offset by 7s from the one before, so the sequence is expressed as
   arithmetic rather than as state somebody has to advance. `both` fill is what keeps
   items 2-7 offscreen during their delay instead of stacked on the first.

   **The keyframes carry their own easing.** A single timing function across the whole
   timeline would make the arrival and the drift the same kind of motion; the fast
   halves ease and the drift is linear, which is what makes one read as a movement and
   the other as a rest. */
.prompt-band {
  margin: 0 0 1.75rem;
}

/* A <p> rather than a heading, so a band of examples does not put a section in the
   document outline between the h1 and the sign-in form. Carried over from the v1
   notice that used to sit here, which made the same choice for the same reason. */
.prompt-band-head {
  color: #0a84ff;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: 0.8rem;
  margin: 0 0 0.6rem;
  text-align: left;
}

/* **The height is in em and holds two lines**, because the items are positioned and
   take the container's height with them when they leave the flow. Two lines is what
   the camping prompt needs on a phone; a value in pixels would be a number that
   disagrees with the font size the first time either moves. */
/* **The panel is the thing that holds still.** The glass lives here, not on the items,
   so one container stays put and the sentences travel through it -- a panel that slid
   in and out with its own text read as eight cards taking turns rather than as one
   window onto a list.

   That is also why the glass is on the viewport rather than on .prompt-band: this is
   the element that clips, and a translucent panel whose blur extended past its own
   overflow edge would show the arriving line through its border. */
.prompt-band-viewport {
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  border-radius: 12px;
  padding: 0.65rem 1rem;
}

/* **The stack is a grid cell, not absolute positioning, and that is what removed the
   fixed height.** Every prompt sits in row 1 column 1, so they overlap -- but they are
   still in flow, so the panel is exactly as tall as the tallest one and works out that
   height per screen width. A `min-height` could not: the longest prompt is one line on
   a desktop and two on a phone, so any number is wrong on one of them. It was 3.4em,
   which fit the phone and left a desktop line sitting in the top half of an empty box.
   Absolute children take no height with them, which is why that number had to exist at
   all. */
.prompt-list {
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Just the text now -- no panel of its own. It shares row 1 column 1 with every other
   prompt, so they stack without leaving the flow and the panel keeps its own height.
   **Centred on both axes, and neither is decoration.** Pinned to the top it sat against
   the panel's upper edge with the rest of the height empty below, and pinned left it
   came to rest hard against the corner with most of the panel unused to its right --
   which is what the arrival read as overshooting. Centring is what makes a one-word
   prompt and a two-line one look like the same component.

   Flex rather than a translate for the vertical, because the animation owns `transform`
   outright: a translateY written here would be overwritten by the first keyframe and
   the centring would silently stop happening. */
.prompt-list li {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  color: #e0e0e0;
  font-size: 0.95rem;
  text-align: center;
  animation: prompt-cycle 56s linear infinite both;
}

.prompt-list li:nth-child(1) { animation-delay: 0s; }
.prompt-list li:nth-child(2) { animation-delay: 7s; }
.prompt-list li:nth-child(3) { animation-delay: 14s; }
.prompt-list li:nth-child(4) { animation-delay: 21s; }
.prompt-list li:nth-child(5) { animation-delay: 28s; }
.prompt-list li:nth-child(6) { animation-delay: 35s; }
.prompt-list li:nth-child(7) { animation-delay: 42s; }
.prompt-list li:nth-child(8) { animation-delay: 49s; }

.prompt-band-viewport:hover .prompt-list li,
.prompt-band-viewport:focus-within .prompt-list li {
  animation-play-state: paused;
}

/* 7s of a 56s cycle is 12.5%. In: 0-0.71%. Drift: 0.71-11.43%. Out: 11.43-12.5%,
   landing exactly where the next item begins its own arrival, so the handoff has
   neither a gap nor an overlap. The rest of the timeline is spent offscreen.

   **The drift settles rightward-to-flush, never past it, and that is the fix rather
   than a preference.** It used to arrive flush and drift on to -5%, which walked the
   box's left edge out of a viewport that clips -- so the first character of every
   prompt was eaten, and the longest ones lost a whole word. The reading edge is the
   one thing that must not move: the line now lands 3% in and closes that gap while it
   is read, which is the same slow leftward creep with the start pinned. Two percent of
   a centred line, so a long one cannot reach the clipping edge either.

   The only leftward travel past zero is the exit, where leaving is the point. */
@keyframes prompt-cycle {
  0% {
    transform: translateX(105%);
    opacity: 0;
    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
  }
  0.71% {
    transform: translateX(2%);
    opacity: 1;
    animation-timing-function: linear;
  }
  11.43% {
    transform: translateX(0);
    opacity: 1;
    animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
  }
  12.5% {
    transform: translateX(-105%);
    opacity: 0;
  }
  100% {
    transform: translateX(-105%);
    opacity: 0;
  }
}

/* **Everything is shown at once, and nothing moves.** With the cycle off, a grid
   stack would be eight sentences drawn on top of each other in one cell; they return to
   ordinary rows instead, and the panel grows to fit the whole list. */
@media (prefers-reduced-motion: reduce) {
  .prompt-band-viewport {
    overflow: visible;
  }

  .prompt-list {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
  }

  .prompt-list li {
    grid-area: auto;
    opacity: 1;
    animation: none;
    transform: none;
  }
}

.noscript-note p { margin: 0 0 0.5rem; }
.noscript-note p:last-child { margin-bottom: 0; }
.noscript-note strong { color: #ffb340; }

/* --- Form controls, shared ------------------------------------------------
   font-size is 1rem and never smaller: below 16px, iOS zooms the page in when a
   field takes focus and does not zoom back out afterwards.
   ------------------------------------------------------------------------- */

.field {
  background-color: #111;
  color: #f5f5f5;
  border: 1px solid #3a3a3c;
  border-radius: 10px;
  padding: 0.5em 0.6em;
  font-size: 1rem;
  min-height: 44px;
  box-sizing: border-box;
}

.field:focus {
  outline: none;
  border-color: #0a84ff;
}

/* Selects size to their widest option rather than stretching, so a row of them
   uses only the width it needs. */
select.field {
  width: auto;
  max-width: 100%;
  /* The platform arrow, kept -- a native select is the one control iOS has never
     had trouble with, and restyling it is how that gets thrown away. */
  padding-right: 0.4em;
}

/* --- The next two hours, on the public page (#145) ------------------------- */

/* Three columns that line up down the whole strip, which is what makes the bars
   comparable at a glance.

   **The grid is on the list, not on each row.** It used to be per-row with the outer
   columns at fixed widths -- 5.5rem for the time and 7rem for the type -- because
   fixed was the only way to make independent grids agree on where a bar starts. That
   reserved about 13.7rem before the track got anything, which is most of a portrait
   phone: the bars came out a hundred-odd pixels wide there and fine in landscape,
   because landscape had width to spare.

   One grid over the list lets both outer columns be `auto` and still align: they size
   to their widest member *across every row*, which is what per-row grids could not do.
   The type column then costs only what some row actually says, and **collapses to
   nothing when no row says anything** -- the common case, since most steps are dry. */
/* The next two hours, horizontal (#169).
   --------------------------------------------------------------------------
   **Time runs left to right now.** It was a vertical list of clock times, which asked
   the reader to convert "4:15 PM" into "about an hour" before they could use it --
   and "how soon" is the only question this chart answers. The bars grow from a
   baseline rather than rightward: same fixed intensity bands, value in the height. */
.nowstrip {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  list-style: none;
  margin: 0.6rem 0 0;
  padding: 0;
}

/* Equal columns. min-width: 0 so eight of them share a narrow card rather than
   overflowing it.

   text-align: left because the label is positioned off this box's left edge -- the
   line between two blocks -- rather than centred under the block. See .nowstrip-when. */
.nowstrip-step {
  flex: 1 1 0;
  min-width: 0;
  text-align: left;
}

/* The closing tick, at the right-hand end of the axis.
   --------------------------------------------------------------------------
   Zero basis, so it marks the edge the last bar already ends at instead of taking a
   column. It carries no track: the list aligns on flex-end, so an invisible
   full-height one would push this label below the others by exactly its own height. */
.nowstrip-edge {
  flex: 0 0 0;
  min-width: 0;
}

/* The <li> steps aside so its three spans become items of the list's grid. Same
   trade as .criterion-row: `display: contents` removes the box from the accessibility
   tree along with its list-item role. The three spans still read in order as "Now,
   Rain", so what is lost is the item count rather than the content. */
/* **On the line between two blocks, not centred under one.**
   --------------------------------------------------------------------------
   Every label here names an *instant* -- the moment its block begins -- so the honest
   place for it is the boundary, and that is what makes the row read as an axis. Centred
   under the blocks they were marks of a different kind from the closing "2h", which is
   why that one read as a step with a missing bar wherever it was put: it was the only
   boundary label in a row of block labels. Nine lines, nine labels, one grammar.

   inline-block so the box hugs the text and the -50% shifts it by its own width rather
   than by the column's, landing its centre on the block's left edge.

   The two ends are justified instead, so nothing overhangs the card: "Now" sits flush
   with the first bar's left edge and the closing tick flush with the last bar's right.

   Tabular figures so "15" and "105" keep the same width. */
.nowstrip-when {
  display: inline-block;
  margin-top: 0.25rem;
  font-size: 0.68rem;
  color: #8e8e93;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transform: translateX(-50%);
}

.nowstrip-step:first-child .nowstrip-when {
  transform: none;
}

.nowstrip-edge .nowstrip-when {
  transform: translateX(-100%);
}

/* The classes, named once above the chart.
   --------------------------------------------------------------------------
   **The words cannot be dropped when the chart turns sideways.** Colour alone fails a
   colour-blind reader, and freezing rain is the one class that must never be mistaken
   for another. They do not fit under a column either -- eight columns on a phone are
   about forty pixels each, and "Freezing rain" is not a forty-pixel word.

   Each takes its own class's colour, so the sentence and the bars read as the same
   thing without a legend. */
.nowstrip-types {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: #8e8e93;
}

.nowstrip-types .nowstrip-liquid { background: none; color: #0a84ff; }
.nowstrip-types .nowstrip-frozen { background: none; color: #e5e5ea; }
.nowstrip-types .nowstrip-thunderstorm { background: none; color: #bf5af2; }
.nowstrip-types .nowstrip-freezing { background: none; color: #ffb340; }
.nowstrip-types .nowstrip-radar { background: none; color: #64d2ff; }

/* Quieter than the sentence it qualifies, but present. It is the difference between
   the page claiming the sky is clear and the page saying what a forecast said, and
   when. */
.windows-age {
  font-size: 0.8rem;
  margin-bottom: 0.25rem;
}

/* Radar saw precipitation here (#156). Brighter than the notes around it, because it
   is the one line on this panel that is an *observation* rather than a forecast, and
   it is on screen precisely when it contradicts everything below it. A reader who
   skims it and believes the model instead is back to being told it is dry while
   standing in rain.

   The rule beneath separates it from the forecast's own account, so the two are not
   read as one paragraph disagreeing with itself. */
.windows-radar {
  color: #e0e0e0;
  font-weight: 600;
  margin-bottom: 0.5rem;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid #2c2c2e;
}

/* The zone, said once next to the heading. Quieter than the heading it qualifies. */
.windows-zone {
  font-size: 0.8rem;
  font-weight: 400;
  color: #8e8e93;
  margin-left: 0.4rem;
}

/* The empty track is drawn for every row, including dry ones. A strip of only the wet
   rows loses the shape -- a gap is information, and it is what makes "starting in
   forty minutes" legible at a glance. */
/* The full two-hour scale for this step; the bar fills the part of it the intensity
   band calls for. Short on purpose -- this sits inside a card, and eight tall columns
   would push the buttons below the fold. */
.nowstrip-track {
  display: block;
  position: relative;
  height: 44px;
  border-radius: 4px;
  background-color: #2c2c2e;
  overflow: hidden;
}

/* Anchored to the bottom: a bar growing downward from the top would read as something
   falling *out* of the chart rather than as an amount. */
.nowstrip-bar {
  display: block;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  border-radius: 4px;
}

/* Fixed bands, never scaled to the busiest block in the window -- see intensityOf.
   Colour carries the class; the word beside it carries it again, because colour alone
   fails for a colour-blind reader. */
.nowstrip-liquid { background-color: #0a84ff; }
.nowstrip-frozen { background-color: #e5e5ea; }
.nowstrip-thunderstorm { background-color: #bf5af2; }

/* Freezing rain is the one row that must never be mistaken for another, so it gets
   the warning colour every other flagged thing on the site uses. */
.nowstrip-freezing { background-color: #ffb340; }

/* Observed, not forecast (#156). Its own colour because it is a different kind of
   claim from the rows beneath it -- those say what the model expects, this says what
   the radar saw -- and because it carries no precipitation type, so nothing else on
   the row distinguishes it. Cyan reads as "water" beside the forecast blue without
   competing with it. */
.nowstrip-radar { background-color: #64d2ff; }

/* Sized to whatever the widest row actually says, and gone entirely when no row says
   anything. Never wraps: a two-line "Freezing rain" would make one row taller than its
   neighbours and the bars would stop reading as a single column. */
.nowstrip-type {
  font-size: 0.85rem;
  font-weight: 600;
  color: #8e8e93;
  white-space: nowrap;
}

.nowstrip-type.is-freezing {
  color: #ffb340;
  font-weight: 600;
}

/* --- The kind chooser (#145) ----------------------------------------------- */

.kind-chooser {
  padding: 0.5rem 0 0.25rem;
}

/* **Never `display: flex` on this.** It is a <button>, and a <button> is not a
   reliable flex container in WebKit on iOS -- it collapses and renders nothing at all
   while being perfect in desktop Safari and Chrome. The two spans inside are block
   level, which stacks them with no flex involved. See the note on .link-button svg. */
.kind-card {
  display: block;
  width: 100%;
  text-align: left;
  background-color: #1c1c1e;
  border: 1px solid #3a3a3c;
  border-radius: 12px;
  padding: 0.9rem 1rem;
  margin-bottom: 0.75rem;
  color: #f5f5f5;
  font-size: 1rem;
  font-family: inherit;
  cursor: pointer;
  min-height: 44px;
}

.kind-card:active { background-color: #2c2c2e; }

.kind-card-title {
  display: block;
  font-weight: 600;
  margin-bottom: 0.3rem;
}

.kind-card-body {
  display: block;
  font-size: 0.9rem;
  color: #8e8e93;
  line-height: 1.35;
}

/* --- Panel 2: conditions -------------------------------------------------- */

.step-hint {
  font-size: 0.9rem;
  color: #8e8e93;
  margin: -0.5rem 0 1rem;
}

.criteria-rows {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-bottom: 0.9rem;
}

/* Wrapping row: everything sits on one line where there is room, and folds onto a
   second line on a narrow phone, without a media query deciding which. */
.criterion {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  padding: 0.6rem;
  /* Room reserved on the right for the remove button, which is positioned rather
     than laid out -- see .criterion-remove. */
  padding-right: 3rem;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
  position: relative;
}

.criterion-values {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

/* A number is a few characters wide. Sizing these to the container is what makes a
   form look like it was never laid out. */
.num-input {
  width: 4.5rem;
  text-align: right;
}

.value-unit {
  font-size: 0.9rem;
  color: #8e8e93;
}

.value-join {
  font-size: 0.9rem;
  color: #8e8e93;
}

/* iOS numeric keypads have no minus key, so a below-zero temperature cannot be
   typed at all. Only the three metrics that can go negative get this. */
.sign-toggle {
  padding: 0.5em 0.6em;
  min-width: 2.9rem;
}

/* Pinned to the row's top-right rather than laid out with everything else. As a
   flex item pushed over with margin-left:auto it competed with the selects and
   value fields for width and lost, ending up past the right edge with only its
   first letter visible. Positioned, it cannot be pushed anywhere. */
.criterion-remove {
  position: absolute;
  top: 0.25rem;
  right: 0.25rem;
  width: 44px;
  height: 44px;
  /* Centred with text-align and line-height, **not flex**. A <button> is not a
     reliable flex container in WebKit on iOS -- it collapses and renders nothing --
     which is what made the Share and Calendar buttons invisible on iPhone and iPad
     while working in desktop Safari. This one had the same flaw and had not been
     noticed yet, because the builder it lives in is still being rebuilt. */
  text-align: center;
  background: none;
  border: none;
  color: #ff6b6b;
  /* The glyph is small at button size; this is the × itself, not the control. */
  font-size: 1.5rem;
  line-height: 44px;
  cursor: pointer;
  padding: 0;
}

.criterion-remove:active { color: #fff; }

/* The words a proposed row came from, on its own line under the controls it
   explains (#138). Full basis inside the wrapping .criterion, like .precip-block,
   so it never tries to share a line with a select. Italic because it is a quote of
   what the user typed rather than anything the product is asserting. */
.criterion-note {
  flex: 1 1 100%;
  min-width: 0;
  margin: 0.15rem 0 0;
  font-style: italic;
  overflow-wrap: break-word;
}

/* Takes the whole row inside .criterion; the chips and their hint sit inside. */
.precip-block {
  flex: 1 1 100%;
  min-width: 0;
}

.precip-hint { margin: 0.45rem 0 0.1rem; }

/* Toggle chips. Checkboxes were the wrong control here: a checkbox plus its label
   is wide and mostly empty, eight of them overran the row, and which were ticked
   took reading eight small marks. A chip is the width of its own text, wraps
   cleanly, and its state is the whole control. Used for precipitation types, wind
   directions and delivery channels -- the same kind of choice, so the same
   control. */
.choice-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  min-width: 0;
}

.chip {
  background-color: #111;
  border: 1px solid #3a3a3c;
  color: #e0e0e0;
  border-radius: 999px;
  padding: 0.5em 0.95em;
  font-size: 0.95rem;
  min-height: 44px;
  cursor: pointer;
  /* Removes the 300ms double-tap-zoom delay, so a chip responds immediately. */
  touch-action: manipulation;
}

.chip:hover { border-color: #5a5a5e; }

.chip.is-selected {
  background-color: #0a84ff;
  border-color: #0a84ff;
  color: #fff;
  font-weight: 600;
}

/* Compass points are one or two characters. A shared minimum width keeps N the
   same size as NE so they read as one set rather than eight odd shapes. */
.choice-chips.is-compact .chip {
  min-width: 3.4rem;
  padding: 0.5em 0.5em;
  text-align: center;
}

.name-preview {
  border-top: 1px solid #2c2c2e;
  margin-top: 1.25rem;
  padding-top: 0.9rem;
}

.name-preview-label {
  display: block;
  font-size: 0.8rem;
  color: #8e8e93;
}

.name-preview-name {
  display: block;
  font-size: 1.05rem;
  margin: 0.15rem 0 0.5rem;
  overflow-wrap: break-word;
}

/* --- Panel 3: schedule ---------------------------------------------------- */

.field-group { margin-bottom: 1.1rem; }

/* Two fields per line where there is room, stacked where there is not. The basis
   is what decides that, not a breakpoint: below roughly twice 13rem the second
   field wraps on its own, so a phone gets the stacked layout without a media
   query naming a device width. */
.field-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0 1.25rem;
  min-width: 0;
}

.field-row .field-group {
  flex: 1 1 13rem;
  min-width: 0;
}

/* Hints belong to the row, not to one column, so they cannot make one side taller
   than the other. */
.row-hint { margin: -0.5rem 0 1.1rem; }

.field-group-label {
  display: block;
  font-size: 0.9rem;
  color: #8e8e93;
  margin-bottom: 0.4rem;
}

/* A label and its (i) button on one line. The wrapper carries the bottom margin the
   label had, so the two sit together above the field with the same gap a bare label
   left. */
.label-with-info {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-bottom: 0.4rem;
}
.label-with-info .field-group-label { margin-bottom: 0; }

/* The (i) button that opens a field's explanation in a dialog. A circled lowercase i,
   drawn from a border and a centred glyph rather than an icon font.
   --------------------------------------------------------------------------
   Centred with text-align + line-height, **not flex** -- a <button> is not a reliable
   flex container in WebKit on iOS (see .link-button svg). The glyph is text, so this is
   the text-centring case that note points to, not the SVG one that needs positioning. */
.info-toggle {
  display: inline-block;
  flex: 0 0 auto;
  box-sizing: border-box;
  width: 1.25rem;
  height: 1.25rem;
  padding: 0;
  border: 1px solid currentColor;
  border-radius: 50%;
  background: none;
  color: #8e8e93;
  font: italic 700 0.78rem/1.15rem Georgia, "Times New Roman", serif;
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
}

.info-toggle:hover { color: #f5f5f5; }
.info-toggle:focus-visible { outline: 2px solid #0a84ff; outline-offset: 2px; }

/* An h3 keeps its size; the button rides beside the heading text on the middle line. */
h3 .info-toggle { margin-left: 0.4rem; }

.field-hint {
  font-size: 0.8rem;
  color: #8e8e93;
  margin: 0.4rem 0 0;
}

/* One time input, only as wide as its contents. A time is a few characters;
   stretching it across the modal would be the same mistake as a full-width box for a
   two-digit hour. Was three selects with a colon between them, before the native
   picker replaced them. */
.time-row {
  display: flex;
  align-items: center;
  gap: 0.3rem;
}

/* Safari stretches a time input to a default width and centres nothing; sizing to
   the content keeps it beside its label rather than filling the column. */
.time-row input[type='time'] {
  width: auto;
  min-width: 0;
}

/* Deliberately no width here. Every select in the modal sizes to its own content
   via `select.field { width: auto }` and is capped by `max-width: 100%`. Stretching
   these three to fill their column gave "Next 24 hours" the width of a paragraph. */


/* Three bands: a fixed heading, a scrolling panel, and a fixed button bar.

   The modal itself must NOT scroll. When it did, a tall panel pushed Next and
   Save below the fold on an iPad, and since iOS draws no persistent scrollbar
   there was nothing on screen to suggest they existed. Only .modal-step scrolls
   now, so the buttons are always in view whatever the panel contains. */
.modal {
  background-color: #1c1c1e;
  border: 1px solid #3a3a3c;
  border-radius: 14px;
  padding: 1.25rem;
  width: 100%;
  max-width: 34rem;
  max-height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  text-align: left;
  box-sizing: border-box;
}

/* Compact pill for the button that sits in a .section-head. Shared, because a
   section heading with a full-size .cta-button next to it outweighs the content
   below it -- which is what "Add a passkey" did before it was listed here.
   .add-alert-button has gone: that one is an .icon-button-action now, and the
   heading it sits beside no longer has a pill in it at all. */
.add-passkey-button {
  padding: 0.55em 1.1em;
  font-size: 0.95rem;
  min-height: 44px;
  border-radius: 999px;
  flex-shrink: 0;
}

/* --- Public shared alert page -------------------------------------------- */

/* Surface and border come from the shared glass rule above; only the geometry is
   here. Restating background-color or border in this block would silently win over
   it, since both selectors have the same specificity and this one comes later. */
.public-alert {
  border-radius: 14px;
  padding: 1.3rem;
  text-align: left;
  margin: 1.5rem 0;
}

.public-alert h2 { margin: 0 0 0.9rem; font-size: 1.3rem; overflow-wrap: anywhere; }

/* The windows are why someone follows a shared link, so they sit below the
   conditions with a rule between: the list above is what the alert watches for, the
   list below is when it is next expected, and running them together reads as one
   list of six things. */
.windows {
  border-top: 1px solid #2c2c2e;
  margin-top: 1rem;
  padding-top: 0.9rem;
}

/* **Lighter than the #8e8e93 the rest of the app uses for secondary text, on purpose.**
   ----------------------------------------------------------------------------
   Every grey in this component and in the .when-* grid below is one step up the ramp
   from the site default, and anything that "corrects" them back to match the other
   forty-odd uses of #8e8e93 is reintroducing the bug.

   The reason is the surface. The rest of the app sets secondary text on #111 or
   #1c1c1e, where #8e8e93 is comfortable. This sits on .public-alert -- frosted glass at
   30% over a background photograph -- so the effective backdrop is far lighter than any
   of those, and it is not even a fixed value: it moves with whatever photo is behind
   it. #8e8e93 measured about 3.3:1 there and the axis labels' #6d6d70 about 2.1:1,
   against the 4.5:1 that small text needs.

   Two levels rather than one, because the hierarchy was right and only the range was
   wrong: #c7c7cc for the things that name a row (heading, weekday, count, marker) and
   #aeaeb2 for the scale and rules around them. */
.windows-title {
  margin: 0 0 0.5rem;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #c7c7cc;
}

/* The billing banner, above everything on the dashboard.

   Amber for a warning, red for a stop: an expired trial means alerts are not running,
   which is a different statement from a trial that ends next week. Colour is not the
   only signal -- the text says which it is -- but the two states should not look
   identical at a glance.

   Flex on a div, which is fine; the rule about collapsing is specifically <button>. */
.billing-banner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
  margin-bottom: 1rem;
  padding: 0.85rem 1rem;
  border-radius: 10px;
  border: 1px solid hsl(38 90% 60% / 0.4);
  background-color: hsl(38 90% 50% / 0.12);
  text-align: left;
}

.billing-banner-urgent {
  border-color: hsl(2 70% 60% / 0.45);
  background-color: hsl(2 70% 50% / 0.14);
}

.billing-banner-text {
  margin: 0;
  flex: 1 1 16rem;
  font-size: 0.98rem;
  line-height: 1.45;
  color: #f2f2f7;
}

/* An <a> styled as the call to action. margin-left keeps it at the right on a wide
   row and lets it drop below the text on a narrow one. */
.billing-banner-action {
  margin-left: auto;
  flex: 0 0 auto;
  font-weight: 600;
  white-space: nowrap;
}

/* The operator index. One entry per tool, each saying what it is for -- these pages
   are read rarely and from a standing start, so a bare list of nouns would need
   opening one by one to remember which is which. */
.admin-index {
  list-style: none;
  padding: 0;
  margin: 0;
  text-align: left;
}

.admin-index-item + .admin-index-item { margin-top: 0.8rem; }

.admin-index-link {
  font-size: 1.15rem;
  font-weight: 600;
}

/* The count is the reason to open the page, so it sits directly under the link. */
.admin-index-count {
  margin: 0.3rem 0 0;
  font-size: 0.95rem;
  color: #8e8e93;
  font-variant-numeric: tabular-nums;
}

/* Amber rather than red: an undelivered alert wants looking at, but the page is not
   an incident and colouring it like one makes every visit feel like an emergency. */
.admin-index-count-bad { color: #ffb340; }

.admin-index-note {
  margin: 0.45rem 0 0;
  font-size: 0.85rem;
  line-height: 1.45;
  color: #8e8e93;
}

/* The operator's failed-job page (#54). Deliberately plain -- it is read when
   something is wrong, so it borrows the panel and match-grid the rest of the app
   already defines rather than inventing a second visual language. */
.admin-notice {
  margin: 0 0 0.9rem;
  font-size: 0.95rem;
}

.admin-jobs {
  list-style: none;
  padding: 0;
  margin: 0;
}

.admin-job + .admin-job { margin-top: 0.9rem; }

/* Flex on a <p>, which is fine. It is specifically <button> that collapses as a
   flex container in WebKit on iOS -- see the note on .link-button svg. */
.admin-job-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem;
  margin: 0 0 0.4rem;
  font-size: 0.85rem;
  color: #8e8e93;
}

.admin-job-id {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: #f2f2f7;
}

.admin-job-type { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }

.admin-job-age { margin-left: auto; }

/* The verdict is the sentence the page exists to produce, so it is the largest thing
   in the entry. */
.admin-job-verdict {
  margin: 0 0 0.6rem;
  font-size: 1.02rem;
  line-height: 1.4;
}

.admin-job-delivered {
  margin: 0.6rem 0 0;
  font-size: 0.88rem;
  color: #8e8e93;
}

/* Wraps rather than scrolls sideways: a stack trace that runs off the right edge of a
   phone is a stack trace nobody reads. */
.admin-job-error {
  margin: 0.7rem 0 0;
  padding: 0.6rem 0.7rem;
  border-radius: 6px;
  background-color: #1c1c1e;
  font-size: 0.78rem;
  line-height: 1.45;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.admin-job-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0.9rem 0 0;
}

/* The row's actions on /admin/users (#188).
   --------------------------------------------------------------------------
   **A div, and that is the whole reason this class exists rather than reusing
   .admin-job-actions.** Each button is its own form, and a <form> start tag closes an
   open <p> in the HTML parser -- so the flex container on a <p> ends before the first
   form arrives and the buttons stack. The markup reads correctly and the container is
   simply gone by the time it would matter.

   Centred, because everything else on the row is: a left-aligned strip of buttons under
   centred text reads as a misalignment rather than as a decision. */
.admin-user-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.6rem;
  margin: 0.9rem 0 0;
}

/* A switched-off address on /admin/users (#188). Dimmed rather than hidden: the list
   is the record of who has ever been let in and when that stopped, which is the whole
   argument for toggling access rather than deleting the row. */
.admin-user-off { opacity: 0.62; }

/* Smaller than a page-level call to action: these sit several to a screen, and a
   full-size button per row reads as the page's purpose rather than as a row action. */
.admin-action {
  padding: 0.55em 1.1em;
  font-size: 0.95rem;
}

/* Off-screen but readable: the grid's blocks are positioned rectangles and say
   nothing to a screen reader, so each row carries its own sentence. clip-path plus a
   1px box rather than display:none, which would remove it from the accessibility tree
   along with the visual layout. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The when-grid: days as rows, time left to right (#96).

   Flex on the li and on the axis, which is fine -- the WebKit-on-iOS collapse is
   specific to <button> as a flex container. Nothing here is a button. */
.when-grid { margin: 0 0 0.4rem; }

.when-rows {
  list-style: none;
  padding: 0;
  margin: 0;
}

/* The row is a disclosure now (#143): expanding it shows that day's windows and their
   readings underneath, so the page has one list of days rather than a drawn one and a
   written one. <details>/<summary>, because nothing on the public page may depend on a
   script arriving -- the peek that reads these rows (#295) is a shortcut over them and
   never the way to them. */
.when-row { padding: 0.09rem 0; }

/* The flex row is this span, and never the <summary> that wraps it. A <summary> given
   a display other than list-item has a long history of misbehaving in WebKit -- the
   same family as the <button>-as-flex-container collapse noted on .link-button svg,
   which cost five deploys and presented as "mobile is broken". An inner span cannot be
   caught by it, and costs one element. */
.when-line {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.when-summary {
  cursor: pointer;
  /* Both halves: list-style for everything current, the -webkit- pseudo-element for
     the Safari versions that still draw their marker through it. Ours is .when-mark,
     in the fixed left column the axis and the 48-hour rule already align to. */
  list-style: none;
}

.when-summary::-webkit-details-marker { display: none; }

.when-summary:focus-visible {
  outline: 2px solid #0a84ff;
  outline-offset: 2px;
  border-radius: 4px;
}

/* The fixed left column the axis and the 48-hour rule already align to. The mark
   itself is drawn by the shared plus/minus rule at the end of this file; this is only
   the slot it sits in, and the colour -- the same grey as the weekday, which is the
   quietest thing on the row that is still plainly legible.

   **Centred in its column, because the column is wider than the mark.** 1.5rem of
   track holding a 0.875rem square leaves the mark against the left edge and the gap to
   the day label twice what it looks like in the markup. An empty day has no mark at
   all, so this centres nothing and still holds the column open. */
.when-mark {
  flex: 0 0 1.5rem;
  display: flex;
  justify-content: center;
  align-items: center;
  color: #c7c7cc;
}

/* An opened day is boxed, so its readings are visibly *its*.
   --------------------------------------------------------------------------
   With two days open at once the readings sat in a flat column between two rows,
   and which heading owned which pair of values had to be worked out from vertical
   distance alone -- on a phone, where the next row is a few pixels away. The border
   is the cheapest thing that answers it, and it appears only while open: drawn on
   every row it would box eight days that have nothing inside them. */
.when-details[open] {
  border: 1px solid #2c2c2e;
  border-radius: 8px;
  padding: 0.35rem 0.4rem 0.5rem;
  margin: 0.35rem 0;
  background-color: hsl(205 20% 90% / 0.03);
}

/* The row inside a boxed day keeps its alignment with the rows outside it. The box
   adds horizontal padding, so the track would otherwise start a few pixels in and
   every block on an opened day would sit slightly right of where it belongs. */
.when-details[open] > .when-summary .when-line { margin: 0 -0.4rem; }
.when-details[open] > .when-summary .when-axis-row { margin-left: -0.4rem; margin-right: -0.4rem; }

/* The count answers "is there anything, and how much" without opening anything, which
   is the entire point of the collapsed row. **Windows, not hours** -- it is the number
   of entries expanding reveals. */
.when-count {
  flex: 0 0 1.6rem;
  font-size: 0.72rem;
  color: #c7c7cc;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Fixed, so every track starts at the same x and the columns line up down the grid.
   Tabular figures are irrelevant here -- these are weekday names -- but the width has
   to hold "Wed" without reflowing the row. */
.when-day {
  flex: 0 0 2.1rem;
  font-size: 0.78rem;
  color: #c7c7cc;
  text-align: right;
}

/* The track is the day. Everything inside is positioned as a percentage of it, so it
   scales to whatever width the phone gives it without a media query. */
.when-track {
  position: relative;
  flex: 1 1 auto;
  height: 1.15rem;
  border-radius: 3px;
  background-color: #1c1c1e;
  overflow: hidden;
  /* **The vertical half of the gesture stays with the browser, the horizontal half is
     ours** -- the same split .alert-swipe makes, for the same reason. Dragging along
     the row is how the peek panel finds a block (see gridpeek.js), and a list of cards
     is scrolled far more often than a row is read this way. */
  touch-action: pan-y;
  /* **A held finger must not raise the selection callout.** The row is inside a
     <summary>, so on iOS a press is both an activation and, held, the start of a text
     selection -- and either one takes the gesture away mid-read. There is no text in
     the track to select and nothing here to copy, so nothing is lost by refusing both. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  /* The grey flash iOS paints over an element it thinks is being tapped. The row is
     being dragged along, not tapped, and the flash reads as the card reacting to
     something the reader did not do. */
  -webkit-tap-highlight-color: transparent;
}

/* Six-hourly, matching the axis labels. Four columns rather than eight: on a phone
   eight are ~40px each, and this page is opened from an email link more often than
   not. */
/* Today, before now. Drawn as a lighter fill rather than left as bare track, because
   an empty stretch before the present and one after it are visually identical and mean
   opposite things -- "this did not happen" against "nothing is expected". By the
   afternoon most of the first row is past, so without this the reader is weighing
   eighteen hours of blank that was never a claim.

   Lighter, not darker: the track is already near-black, and the past should read as
   *inert* rather than as a second kind of content. */
.when-elapsed {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  background-color: #2a2a2c;
}

.when-tick {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background-color: #2c2c2e;
}

/* The present. Brighter than a tick and thicker, because it is the one vertical on the
   row that is not decoration -- everything left of it has happened. */
.when-now {
  position: absolute;
  top: -2px;
  bottom: -2px;
  width: 2px;
  margin-left: -1px;
  border-radius: 1px;
  background-color: #f5f5f5;
}

/* The label, above the first row and aligned to the same track. The row of padding
   matches .when-axis so "now" sits over the position it names. */
.when-nowline {
  position: relative;
  height: 0.85rem;
  margin: 0 0 0.1rem;
  padding-left: 3.85rem;
  padding-right: 2.1rem;
}

/* translate rather than a negative margin: the label is centred on its position
   whatever the text width, and "now" is short enough that at the right-hand edge of
   the row it would otherwise overhang the count column. */
.when-nowlabel {
  position: absolute;
  transform: translateX(-50%);
  font-size: 0.62rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #f5f5f5;
  white-space: nowrap;
}

.when-block {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: 2px;
  background-color: #0a84ff;
}

/* What the peek panel is currently describing, marked on the picture itself.
   --------------------------------------------------------------------------
   The panel sits above the row and can be several rows from the finger that opened it,
   so without this a reader on a grid of eight days has to work out which one they are
   being told about.

   **Subtle on purpose.** It follows a hand that already knows where it is; it is a
   pointer, not a selection. The row lifts by one step of the same grey the track is
   drawn in, and the block brightens rather than changing colour -- a block that turned
   a different blue would read as a different kind of match.

   No transition on either: they track a finger, and easing something that follows a
   drag is the same mistake as easing a card behind the thumb pushing it. */
.when-track.is-peeked { background-color: #2a2a2c; }

.when-block.is-peeked {
  background-color: #4da3ff;
  /* A hairline ring, so a hatched block -- which has no solid fill to brighten -- is
     marked as plainly as a solid one. */
  box-shadow: 0 0 0 1px hsl(205 20% 90% / 0.55);
}

/* **The hour the panel is actually describing.**
   --------------------------------------------------------------------------
   The row says which day and a lit block says which window, but neither says which
   *hour* -- and between two blocks there is no element to light at all, so the dark
   stretch had nothing to show for being pointed at. This is drawn instead of found:
   gridpeek.js moves one of these from row to row and sets its left and width from the
   hour under the pointer.

   **A wash, painted over whatever is beneath it.** It is appended last, so it lifts
   dark track and blue block alike; underneath, it would disappear exactly where a
   block is, which is half of a matching row.

   `pointer-events: none` for the same reason the panel has it: this sits directly
   under the pointer that summoned it, and an element that took the hover would end
   the hover. */
.when-hour-lit {
  position: absolute;
  top: 0;
  bottom: 0;
  background-color: hsl(205 20% 90% / 0.22);
  pointer-events: none;
}

/* Past 48 hours the reading is a bucket average, not an hour. The hatching says so
   before the legend does -- and it is a different *texture*, not merely a paler blue,
   so the distinction survives a greyscale print and a colour-blind reader. */
.when-block-approx {
  background-color: transparent;
  background-image: repeating-linear-gradient(
    135deg,
    rgba(10, 132, 255, 0.85) 0 3px,
    rgba(10, 132, 255, 0.25) 3px 6px
  );
}

/* The panel that names a block's window and readings, on hover or under a finger.
   --------------------------------------------------------------------------
   **Fixed, and a child of <body>**, so no ancestor can clip it: .when-track is
   `overflow: hidden` -- that is what keeps a block inside the track's rounded corners
   -- and the card, the alert modal and the enlarged view all have bounds of their own.
   gridpeek.js measures against the viewport and clamps it inside the screen.

   **`pointer-events: none` is what stops it fighting the thing it describes.** It is
   positioned over the row, and a panel that took the pointer would end the hover that
   opened it -- flickering on a desktop -- and would swallow the drag on a phone.

   Opaque rather than the card's frosted glass. It sits *over* a chart of small
   coloured rectangles, and a translucent panel with a blurred grid behind it is the
   one place on the dashboard where the glass would cost legibility rather than buy
   depth. Same fill as .icon-button and .panel, so it is not a new surface either.

   No transition. It follows a finger, and easing an element that is tracking a drag
   is the same mistake as easing a card behind the thumb dragging it. */
.grid-peek {
  position: fixed;
  /* **Above .modal-overlay's 100, not below it.** The enlarged view is a modal, and it
     is the one place the grid is wide enough to read a two-hour window off -- so it is
     where a pointer is most likely to be resting on a block. At any lower z-index the
     panel opens behind the backdrop and nothing appears at all, which reads as the
     feature being broken in the view it matters most in.

     Safe to sit on top, because it takes no pointer events and is only ever visible
     while a pointer is on a block or a finger is down. */
  z-index: 110;
  display: none;
  max-width: min(20rem, calc(100vw - 1rem));
  padding: 0.5rem 0.7rem;
  border: 1px solid #3a3a3c;
  border-radius: 8px;
  background-color: #1c1c1e;
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.45);
  pointer-events: none;
  text-align: left;
  font-size: 0.85rem;
  line-height: 1.45;
}

.grid-peek.is-open { display: block; }

/* The window first and in white, because it is what the block *is*; the readings
   under it are the evidence, in the quieter grey the rest of the card uses for a
   value's supporting text. */
.grid-peek-when {
  display: block;
  color: #f5f5f5;
  font-weight: 600;
}

.grid-peek-values {
  display: block;
  color: #aeaeb2;
  font-variant-numeric: tabular-nums;
}

/* **Green is "this reading clears its own condition".** On an hour that missed, three
   readings with one of them not green says *which* one kept the hour off the grid --
   which is the question the dark track is dragged along to answer. Inside a blue block
   every metric is met and the whole line is green, which is what a block means.

   The same #30d158 the Current Forecast dot uses, so "this counts" is one colour
   wherever it appears. Nothing else in the panel is green. */
.grid-peek-metric.is-met { color: #30d158; }

/* The separator, drawn rather than written into the string: the readings are packed
   in Go one per hour and taken apart in the browser, and a dot carried in the data
   would have to be stripped before it could be styled differently from the text
   either side of it. */
.grid-peek-metric:not(:last-child)::after {
  content: " · ";
  color: #636366;
}

/* Why an hour that reads well is not blue -- "Outside the hours this alert checks".
   --------------------------------------------------------------------------
   Quieter than the readings and set apart from them, because it is a statement about
   the *alert* while everything above it is a statement about the weather. Present only
   when there is something to explain, so the panel stays two lines on the ordinary
   hour and grows a third exactly where the picture would otherwise look wrong. */
.grid-peek-note {
  display: block;
  margin-top: 0.25rem;
  color: #8e8e93;
  font-size: 0.8rem;
}

/* The 48-hour rule, between the two resolutions rather than at a fixed row: it moves
   through the days as "48 hours from now" does.

   Inset to the track on both sides -- the marker column and the day label on the left,
   the count column on the right. The rule spans the day, not the row's furniture. */
.when-boundary {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.3rem 0;
  padding-left: 3.85rem;
  padding-right: 2.1rem;
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #aeaeb2;
}

.when-boundary::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background-color: #2c2c2e;
}

/* Each label sits at the left edge of its quarter, so they line up with the ticks
   rather than being centred between them. */
.when-axis {
  display: flex;
  margin: 0.25rem 0 0;
  padding-left: 3.85rem;
  padding-right: 2.1rem;
  font-size: 0.68rem;
  color: #aeaeb2;
}

.when-axis span { flex: 1 1 0; }

/* The same clock again, under one row's own blocks -- **only while that row is open**.
   --------------------------------------------------------------------------
   The grid is bracketed by a scale top and bottom, which is enough to read any
   collapsed row against. An opened day is the case they cannot serve: its own
   readings push its blocks away from both, and it is the row someone is looking at
   closely enough to want an hour off.

   Present in the markup on every row and revealed by CSS rather than added on open.
   The disclosure is a <details> with no JavaScript behind it, so anything that had to
   *insert* this would need a script the public page cannot depend on -- and two
   mechanisms for one state is how they end up disagreeing.

   The padding matches .when-track's position exactly -- day label column left, count
   column right -- so a label sits under the quarter of the track it names. If either
   column is resized, this, .when-axis and .when-nowline move together or the scale
   silently stops lining up with the picture. */
/* The same grey as .when-axis rather than a step under it, which is what it used to be.
   #5a5a5f was the darkest thing on the page and the smallest, and at 0.62rem the size
   difference already says this scale belongs to one row rather than to the grid. */
.when-axis-row {
  display: none;
  padding-left: 3.85rem;
  padding-right: 2.1rem;
  margin-top: 0.1rem;
  font-size: 0.62rem;
  line-height: 1;
  color: #aeaeb2;
}

.when-details[open] > .when-summary .when-axis-row { display: flex; }

.when-axis-row span { flex: 1 1 0; }

.when-legend {
  margin: 0.55rem 0 0;
  font-size: 0.72rem;
  color: #aeaeb2;
}

/* vertical-align rather than flex, so the swatch sits on the text baseline without
   the label and the key becoming separate flex items that wrap apart on a narrow
   phone. */
.when-key {
  display: inline-block;
  width: 0.75rem;
  height: 0.75rem;
  margin-right: 0.2rem;
  vertical-align: -0.1rem;
  border-radius: 2px;
  background-color: #0a84ff;
}

.when-key + .when-key { margin-left: 0.7rem; }

.when-key-approx {
  background-color: transparent;
  background-image: repeating-linear-gradient(
    135deg,
    rgba(10, 132, 255, 0.85) 0 3px,
    rgba(10, 132, 255, 0.25) 3px 6px
  );
}

.window-list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 1.05rem;
  line-height: 1.5;
}

/* A window opening later than the first is still real, just less urgent. */
.window-list li + li { color: #b8b8bd; font-size: 0.95rem; }

.match-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

/* The same list, inside a grid row it was expanded out of (#143). Indented to where
   the summary's own text begins, so the readings read as belonging to that row -- not
   to the track, because 3.85rem of indent on a phone costs the numbers more width than
   the alignment is worth.

   **Must come after .match-list**, whose `padding: 0` and `margin: 0` are the same
   specificity and would otherwise win on source order. */
.when-matches {
  margin: 0.4rem 0 0.6rem;
  padding-left: 1.05rem;
}

/* A rule between matches rather than around each one: they are entries in one list,
   and boxing every entry inside the panel gives the page a second frame it does not
   need. */
.match + .match {
  border-top: 1px solid #2c2c2e;
  margin-top: 0.8rem;
  padding-top: 0.8rem;
}

.match-when {
  margin: 0 0 0.4rem;
  font-size: 1.05rem;
  font-weight: 600;
  line-height: 1.4;
}

/* Label left, value right, on a grid so every value in the block lines up whatever
   the label lengths are. A <dl> is not a button, so flex or grid here is safe -- see
   the note on .link-button svg for the element where it is not. */
.match-grid {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.15rem 0.9rem;
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.5;
}

.match-grid dt { color: #8e8e93; }

/* Tabular figures so a column of readings stays a column. */
.match-grid dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* The Current Forecast block: a dot marks the readings that clear the alert's bar.
   --------------------------------------------------------------------------
   **The value used to be the signal, and it was carrying two jobs.** Green meant "this
   clears your bar" and grey meant it did not -- so the number's colour answered a
   question about the alert while the number itself answered one about the weather, and
   a grey reading read as a dim or stale one rather than as a miss. Moving the answer to
   a mark beside the value lets every reading be the same legible colour.

   It also frees green to mean one thing. Air quality and UV now print the band they
   fall in ("Moderate", "Very high"), and a scale whose own colours run green to maroon
   sitting next to a value coloured green for an unrelated reason is two vocabularies
   in one row.

   **The space is reserved whether or not the dot is drawn**, which is the whole point:
   the marks sit in a column, the numbers sit in a column, and a reading that misses is
   not indented differently from one that does not.

   Only the block with `now-grid` takes this. The identical <dl> inside an expanded day
   keeps the plainer .match-grid styling, because there every value matched and marking
   all of them says nothing.

   These rules follow .match-grid dt/dd deliberately: same specificity, so source order
   is what makes them win. */
.now-grid dt { color: #f5f5f5; }
.now-grid dd { color: #f5f5f5; }

/* An empty inline-block, so it holds its width with nothing in it. Sized in rem rather
   than em: it tracks the reader's own text size and ignores whatever the row happens to
   be set in, the same reasoning the disclosure marks are sized with. */
.now-flag {
  display: inline-block;
  width: 0.45rem;
  height: 0.45rem;
  border-radius: 50%;
  margin-right: 0.45rem;
  /* An icon beside text, which is what vertical-align is for. The rule against it is
     about centring a glyph inside a fixed box, where font metrics decide the answer
     and get it wrong by a different amount per platform. There is no glyph here. */
  vertical-align: 0.08em;
  background-color: transparent;
}

/* The same green the swipe panel's Resume tile and the criteria list use, so "this
   counts" is one colour across the card.

   **The dot is never the only thing saying so.** Colour alone fails a colour-blind
   reader -- the same rule that keeps the two-hour strip naming its precipitation types
   in words above the bars -- so the template puts a visually-hidden "meets this alert's
   condition" beside it for anything not reading the page by eye. */
.now-flag.is-met { background-color: #30d158; }

/* "Current Forecast" on the public alert page.
   --------------------------------------------------------------------------
   Left-aligned, because it is a label/value grid and the readings have to line up in
   a column -- the page around it is centred.

   **A border and nothing else.** It carried its own near-black fill, which on the
   card's blue panel read as a hole cut in the page rather than as a section of it --
   two surfaces where the reader sees one thing. The border alone is enough to say
   where the block starts and ends, and the card's own background carries through.

   Deliberately quiet. The grid above answers "when will these conditions happen",
   which is what the alert is for; this is the footnote to it, and styled as loudly it
   would read as the page's headline -- a report on the present dressed up as the
   forecast someone came to read. */
.now-readings {
  text-align: left;
  margin: 1.25rem 0 0;
  padding: 0.85rem 1rem;
  border: 1px solid #2c2c2e;
  border-radius: 10px;
}

/* No heavier than the labels beneath it: the heading is a caveat as much as a title,
   and set large it becomes the assertion the wording exists to avoid making. */
.now-heading {
  margin: 0 0 0.6rem;
  font-size: 1rem;
  font-weight: 600;
  color: #e0e0e0;
}

/* The age of the answer, and the only thing that makes a wrong-looking page
   diagnosable afterwards. Quiet, but never absent when it is known. */
.now-fetched {
  margin: 0.6rem 0 0;
  font-size: 0.82rem;
  color: #8e8e93;
}

/* The same readings on the dashboard card, behind a control.
   --------------------------------------------------------------------------
   Boxed like an opened day in the grid above, and for the same reason: this sits
   directly under that grid, and a block of numbers with no edge to it would read as
   belonging to the last row rather than to the card. The border is drawn whether or
   not it is open, unlike .when-details -- there is one of these, not eight, so it
   never boxes a column of empty rows, and an unboxed summary that grows a box on
   opening moves the two rows below it every time it is tapped. */
/* The border is the lighter, translucent one the card's other controls use, not the
   near-black #2c2c2e that reads as a section edge on a flat panel: this sits on the
   card's frosted glass over a photo, where #2c2c2e is close to invisible -- the same
   reason .link-button carries a fill and this border rather than a hairline. */
.now-details {
  margin: 0.5rem 0 0;
  border: 1px solid hsl(205 20% 90% / 0.45);
  border-radius: 8px;
  padding: 0.35rem 0.6rem;
}

/* Padding under the readings only while they are showing. Closed, that space is a
   band of nothing beneath a single line of text. */
.now-details[open] { padding-bottom: 0.6rem; }

/* The control. **A <summary>, and the flex row is the span inside it** -- a <summary>
   given a display other than list-item has a long history of misbehaving in WebKit,
   the same family as the <button>-as-flex-container collapse on .link-button svg. Here
   the two children are laid out with a gap on the marker instead, which needs no flex
   container at all. */
/* The padding is the tap target, not decoration: this is a one-line control on a card
   most people meet on a phone, and the line itself is about twenty pixels tall. */
.now-summary {
  cursor: pointer;
  list-style: none;
  padding: 0.55rem 0;
  font-size: 0.95rem;
  color: #e0e0e0;
}

.now-summary::-webkit-details-marker { display: none; }

.now-summary:focus-visible {
  outline: 2px solid #0a84ff;
  outline-offset: 2px;
  border-radius: 4px;
}

/* The same mark the grid rows above it use, at the same size, so the two disclosures
   on one card open with one gesture and one signal. Inline-block with a gap rather
   than a flex item, per the note above. */
.now-mark {
  display: inline-block;
  margin-right: 0.55rem;
  /* An icon beside a label, which is what vertical-align is for. The rule against it
     elsewhere is about centring a glyph *inside* a fixed box -- see .icon-button svg,
     where the font metrics decide the answer and get it wrong by a different amount on
     each platform. A mark drawn from background layers has no font in it. */
  vertical-align: middle;
  color: #8e8e93;
}

/* Spaced off the control above them. Alignment needs nothing: .alert-card-conditions
   is already left-aligned, and .match-grid is a grid. */
.now-details > .match-grid { margin-top: 0.5rem; }

/* Dimmer than an active heading, but still on the legible side of the ramp -- "nothing
   expected" is a real answer and has to be readable to deliver it. */
.windows-quiet .windows-title { color: #aeaeb2; }

.windows-note {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.5;
  color: #8e8e93;
}

/* The dry two-hour sentence on the public page, which sits *above* the panel rather
   than inside it -- the shared template renders one thing and the page adds its own
   chrome, instead of the template growing a mode flag.
   --------------------------------------------------------------------------------
   .windows-note is margin: 0 because every other use of it is inside a panel that
   spaces its own children. Out here that leaves the sentence touching the panel
   below, so it needs the gap the panel would have given it. */
.windows-dry { margin-bottom: 0.6rem; }

.spinner { color: #8e8e93; }

/* Icon and label on one baseline. The label is a span so the "Copied" fallback can
   swap the text without discarding the glyph. */
/* Quiet enough to ignore, legible enough to read off a phone. */
.build-stamp {
  display: block;
  margin-top: 0.4rem;
  color: #8e8e93;
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
}

/* Card button icons. The buttons are icon-only, so aria-label and title in
   button() are the only thing naming them -- see the note there.

   **Aligned with vertical-align, never with flex.** These buttons previously used
   `display: inline-flex` to line an icon up with its label, and on iPhone and iPad
   they did not render at all -- no box, no space, nothing -- while being correct in
   desktop Safari and Chrome. A <button> is not a reliable flex container in WebKit
   on iOS: it collapses and takes its contents with it. The buttons either side of
   them were plain text and were always fine, which is what finally identified it.

   So the icon is an inline element nudged onto the text baseline, and the button
   stays a plain inline-block. Sized here rather than by width/height attributes,
   because an SVG's intrinsic sizing is the other thing engines disagree about. */
.link-button svg {
  width: 19px;
  height: 19px;
  vertical-align: -4px;
}

/* **19px is the size it wants, not a size it insists on.** In the card's action row the
   buttons shrink rather than wrap (see .alert-card-actions), and a fixed glyph inside a
   shrinking button just overflows it. `max-width: 100%` caps the icon at whatever the
   button has left and `height: auto` lets the viewBox keep it square, so the row scales
   down as one thing instead of coming apart.

   It only ever engages when there is not enough room: with room to spare `width: 19px`
   is under the cap and nothing changes. */
.alert-card-actions .link-button svg {
  max-width: 100%;
  height: auto;
}

/* Legal pages. Long-form prose rather than the app's card layout, so it gets a
   readable measure and enough vertical rhythm to be skimmed for a heading. */
.legal {
  text-align: left;
  max-width: 42rem;
  margin: 0 auto 2rem;
  line-height: 1.6;
}
.legal h2 { margin-bottom: 0.25rem; }
.legal h3 { margin-top: 2rem; margin-bottom: 0.5rem; font-size: 1.05rem; }
.legal p, .legal li { margin-bottom: 0.75rem; }
.legal ul { padding-left: 1.25rem; margin-bottom: 1rem; }
.legal-updated { opacity: 0.7; font-size: 0.9rem; margin-bottom: 1.5rem; }

/* The sign-in notice.
 *
 * **No opacity.** It carried 0.75, which I added for the look of it and which was
 * exactly backwards: this is the notice whose whole job is being conspicuous, and
 * dimming it undercuts the reason it sits beside the button instead of in the
 * footer. Smaller than the status line above is fine; fainter is not. */
.legal-notice { font-size: 0.85rem; margin-top: 0.5rem; }

/* The acceptance gate (#125).
 *
 * **The documents scroll and the button does not.** Several thousand words with the
 * control underneath means the only way to reach it is to scroll past all of them, and
 * on a phone that reads as a page with no way forward. The prose moves behind a control
 * that stays where it is.
 *
 * A fixed viewport fraction rather than a pixel height, so a laptop shows most of a
 * document and a phone still leaves room for the button below it. */
.legal-scroll {
  max-width: 42rem;
  margin: 0 auto 1.25rem;
  max-height: 55vh;
  overflow-y: auto;
  /* A border and a little inset, so the box reads as a window onto a longer document
   * rather than as the end of the page. Without it the text simply stops mid-sentence
   * and nothing says it continues. */
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 6px;
  padding: 1rem 1.25rem;
  text-align: left;
  /* Keeps a flick-scroll from chaining into the page behind it once the end is
   * reached, which on iOS otherwise scrolls the gate itself away. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

/* The documents inside carry their own margins for a full page; in here the box
 * provides them, and the last one must not push a gap under the scroll. */
.legal-scroll .legal { margin-bottom: 0; max-width: none; }
.legal-scroll .legal + .legal { margin-top: 2rem; }

/* Visible focus, because the box is keyboard-scrollable and therefore focusable. A
 * tabindex with no focus ring is a control that cannot be found without a mouse. */
.legal-scroll:focus-visible { outline: 2px solid #6aa9ff; outline-offset: 2px; }

/* The button, always on screen. Centred with the prose it belongs to. */
.legal-accept { max-width: 42rem; margin: 0 auto 0.75rem; }
.legal-accept .cta-button { width: 100%; }

.legal-decline { max-width: 42rem; margin: 0 auto 2rem; }

/* The plain-language summary on the acceptance gate.
 *
 * **Above the scroll box and outside it**, so it is read first and does not move. It is
 * the part most people will actually read, which is the argument for it and also the
 * reason the governing sentence sits at the top of it rather than below the bullets. */
.legal-summary {
  max-width: 42rem;
  margin: 0 auto 1.25rem;
  text-align: left;
}
.legal-summary h3 { margin-bottom: 0.5rem; font-size: 1.05rem; }
.legal-summary ul { padding-left: 1.25rem; margin: 0; }
.legal-summary li { margin-bottom: 0.5rem; line-height: 1.5; }

/* **No opacity, and a rule above it.** The same correction .legal-notice needed: this is
 * the sentence keeping the summary a summary, so dimming it defeats its only job. */
.legal-summary-governs {
  margin-bottom: 0.75rem;
  padding-bottom: 0.75rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.14);
  line-height: 1.5;
}

/* A button that reads as a link, for a state-changing action inside a sentence.
 *
 * **Not .link-button**, which in this codebase is a real bordered control with a 44px
 * minimum height for a touch target -- correct where it sits on its own, and a black
 * slab wedged mid-paragraph when it does not. That is what "Sign out" became on the
 * acceptance gate.
 *
 * It stays a <button> in a POST form because signing out changes state and a link
 * prefetcher must not be able to do it. Only the appearance is a link.
 *
 * `font: inherit` is the part that matters: a button does not inherit the surrounding
 * type, so without it the word sits at the browser's default size and face in the middle
 * of a sentence. Safe to make inline -- the WebKit flex-container failure is specifically
 * about laying a button out *with* flex, which this does not. */
.text-button {
  display: inline;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  min-height: 0;
  font: inherit;
  color: #8ab4f8;
  text-decoration: underline;
  cursor: pointer;
}
.text-button:hover { text-decoration: none; }
.text-button:focus-visible { outline: 2px solid #6aa9ff; outline-offset: 2px; }

/* The code you hold up (the public page).
   ==========================================================================
   Outside the alert's panel, because it is not a fact about the weather -- the panel
   is the report and this is a way to hand the report to somebody. It sits between
   that and the footer's call to action for the same reason.

   Centred on a white card, and the white is not decoration: a QR code needs its quiet
   zone to be lighter than the code, and this page's background is near-black. The
   PNG carries its own white border, but a dark page pressed right up against it
   leaves nothing for a camera to find the edge against.
*/
.qr-block {
  margin: 1rem auto 0;
  max-width: 26rem;
}

/* The padding is the tap target. This is a one-line control on a page most people
   meet on a phone. */
.qr-summary {
  cursor: pointer;
  list-style: none;
  padding: 0.55rem 0;
  font-size: 0.95rem;
  color: #e0e0e0;
  text-align: center;
}

.qr-summary::-webkit-details-marker { display: none; }

.qr-summary:focus-visible {
  outline: 2px solid #0a84ff;
  outline-offset: 2px;
  border-radius: 4px;
}

/* Inline-block with a gap rather than a flex item, per the note on .now-mark: the
   flex container would have to be the <summary>, and that is the construction WebKit
   mishandles. */
.qr-mark {
  display: inline-block;
  margin-right: 0.55rem;
  vertical-align: middle;
  color: #8e8e93;
}

.qr-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding-bottom: 0.6rem;
}

/* The plate the code sits on, sized by the image inside it. The radius matches the
   panels above; the padding is the quiet zone the page's own background would
   otherwise swallow.

   **The same light slate the PNG is drawn in, and it has to be.** The image carries its
   own quiet zone in that colour, so a white plate under it would put a bright frame
   around a dimmer code -- two surfaces where the reader sees one object, and the glare
   this colour exists to remove, reintroduced by the element behind it. If the constant
   in internal/qr moves, this moves with it. */
.qr-image {
  width: 100%;
  max-width: 340px;
  height: auto;
  background: #aab4c2;
  border-radius: 12px;
  padding: 0.5rem;
}

.qr-note {
  margin: 0;
  font-size: 0.85rem;
  color: #8e8e93;
  text-align: center;
}

/* The disclosure marks: + to open, − to close.
 * ---------------------------------------------------------------------------
 * **One rule for all three, which is the point of it.** There are three disclosures
 * in the product -- the alert card on the dashboard, each day row in the conditions
 * grid, and the Current Forecast block under it -- and on the dashboard all three
 * nest inside one another. They were a text glyph at one size and a pair of border
 * triangles at another, so a card opened to reveal a mark half again as large as its
 * own, and the public page and the dashboard did not match. Sizing them here means
 * they cannot drift apart again by editing one of three places.
 *
 * **Drawn from background layers, not from a font.** Same reasoning the border
 * triangles were written down with: no platform can fail to have one, it takes
 * currentColor, and it needs no glyph metrics -- which is what makes "the same size"
 * a fact rather than an aspiration. A "+" set in type is a different size in every
 * font, and these three sit in three different type contexts (1.05rem, 0.72rem and
 * 0.95rem), so an em-sized mark would be three sizes on one screen.
 *
 * Absolute units for the same reason. rem, not em: the mark tracks the reader's own
 * text size and ignores whatever the row around it happens to be set in.
 */
.alert-summary-head::before,
.when-details > .when-summary .when-mark::before,
.now-mark::before,
.qr-mark::before {
  content: "";
  display: block;
  width: 0.875rem;
  height: 0.875rem;
  /* Two bars crossing at the centre. The first layer is the horizontal one and stays
     in both states; the second is the vertical one, and dropping it is what turns the
     plus into a minus. */
  background-image:
    linear-gradient(currentColor, currentColor),
    linear-gradient(currentColor, currentColor);
  background-size: 100% 0.125rem, 0.125rem 100%;
  background-position: center, center;
  background-repeat: no-repeat;
  transition: background-size 0.12s ease-out;
}

/* Open, so the mark says what tapping it does now: collapse. A zero-sized layer is
   not painted, so the vertical bar shrinks to nothing and the horizontal one is left
   -- and because it is a size rather than a swapped image, it animates. */
.alert-card[open] .alert-summary-head::before,
.when-details[open] > .when-summary .when-mark::before,
.now-details[open] > .now-summary .now-mark::before,
.qr-block[open] > .qr-summary .qr-mark::before {
  background-size: 100% 0.125rem, 0 0;
}

/* Nothing moves for someone who asked for that. The transition above is the only
   motion on these, and a mark that changes shape needs no easing to be understood. */
@media (prefers-reduced-motion: reduce) {
  .alert-summary-head::before,
  .when-details > .when-summary .when-mark::before,
  .now-mark::before,
  .qr-mark::before {
    transition: none;
  }
}

/* The record screens (#270): users and alerts, and every table after them.
   ==========================================================================
   A table rather than the panel-per-row list /admin/jobs uses. A dead job is a
   paragraph with a verdict and two buttons; a record is five short values, and five
   values across is how you compare forty of them.

   Nothing here lays out a <button> with flex. See the note on .link-button svg: a
   <button> is not a reliable flex container in WebKit on iOS, and the failure has no
   symptom -- the control renders as nothing at all while being perfect on desktop. */

/* Wider than the 700px the rest of the product uses. Five columns of a table is not a
   column of prose, and the reading-width limit that makes /profile comfortable makes a
   list of accounts wrap every cell. */
.container-wide { max-width: 1000px; }

/* The bar linking the operator screens to each other. One place, so a screen added
   later cannot be the one that is unreachable from the rest of them. */
.admin-nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.9rem;
  margin-top: 0.8rem;
}

.admin-nav a {
  color: #7fb3ff;
  text-decoration: none;
  font-size: 0.95rem;
}

.admin-nav a:hover { text-decoration: underline; }

/* Search and filters, above the list. */
.admin-tools { text-align: left; margin-bottom: 1rem; }

.admin-search {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
}

/* The box takes the row and the button sits beside it, so a long address is readable
   while typing. It collapses under the button on a narrow screen rather than shrinking
   to nothing, which is what flex-wrap plus a minimum does. */
.admin-search .field {
  flex: 1 1 14rem;
  min-width: 0;
  margin: 0;
}

.admin-clear {
  color: #9a9a9a;
  font-size: 0.9rem;
  text-decoration: none;
}

.admin-clear:hover { text-decoration: underline; }

/* Filter chips. Links rather than a <select>, because these pages ship no JavaScript
   and a select would need a submit button and a hidden input per other filter. */
.admin-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  margin-top: 0.7rem;
}

.admin-chip {
  padding: 0.3em 0.75em;
  border: 1px solid #3a3a3a;
  border-radius: 999px;
  color: #b8b8b8;
  font-size: 0.85rem;
  text-decoration: none;
  white-space: nowrap;
}

.admin-chip:hover { border-color: #5a5a5a; color: #e0e0e0; }

/* The current filter, marked by more than colour: aria-current carries it to a screen
   reader and the border weight carries it to anyone who cannot separate these two
   greys. */
.admin-chip-on {
  border-color: #7fb3ff;
  color: #cfe3ff;
  background: rgba(127, 179, 255, 0.12);
}

/* **The table scrolls inside its own box, never the page.** A five-column table on a
   phone is wider than the viewport, and a body that scrolls sideways makes every other
   page element drift with it. */
.admin-table-wrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  text-align: left;
  font-size: 0.95rem;
}

.admin-table th,
.admin-table td {
  padding: 0.6rem 0.7rem;
  border-bottom: 1px solid #2a2a2a;
  vertical-align: top;
}

.admin-table th {
  color: #9a9a9a;
  font-weight: 600;
  font-size: 0.82rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

.admin-table tbody tr:hover { background: rgba(255, 255, 255, 0.03); }

.admin-num { text-align: right; }
.admin-dim { color: #9a9a9a; }
.admin-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }

.admin-row-link {
  color: #cfe3ff;
  text-decoration: none;
  font-weight: 600;
}

.admin-row-link:hover { text-decoration: underline; }

/* A row whose record is not doing anything -- an unentitled account, a paused alert.
   Dimmed rather than dropped: it is still a record, and a list that hides them is a
   list that cannot answer "where did they go". */
.admin-row-off { opacity: 0.6; }

/* The private name under the generated one, never in place of it. */
.admin-sub {
  display: block;
  color: #9a9a9a;
  font-size: 0.85rem;
}

.admin-tag {
  display: inline-block;
  margin-left: 0.4rem;
  padding: 0.1em 0.5em;
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  color: #b8b8b8;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* The account state, coloured by what it means rather than by which state it is: the
   two that are being served read as calm, the three that are not read as attention, and
   closed reads as neither. Never colour alone -- the word is the label. */
.admin-state {
  display: inline-block;
  padding: 0.12em 0.55em;
  border-radius: 4px;
  font-size: 0.82rem;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.06);
  color: #b8b8b8;
}

.admin-state-paying,
.admin-state-granted { color: #7fd1a0; }
.admin-state-trialing { color: #cfe3ff; }
.admin-state-trial_expired,
.admin-state-lapsed { color: #ffb340; }

/* The paging footer. Absent entirely on a single-page list -- these two states are for
   the ends of a list that does page. */
.admin-paging {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 0.9rem;
}

.admin-page-link {
  color: #7fb3ff;
  text-decoration: none;
  white-space: nowrap;
}

.admin-page-link:hover { text-decoration: underline; }

.admin-page-off { color: #5a5a5a; }

.admin-page-range { color: #9a9a9a; font-size: 0.9rem; }

/* The record pages. */
.admin-record-title {
  font-size: 1.7rem;
  word-break: break-word;
}

.admin-related {
  list-style: none;
  padding: 0;
  margin: 0.6rem 0 0;
  text-align: left;
}

.admin-related li {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.45rem 0;
  border-bottom: 1px solid #2a2a2a;
}

.admin-related li:last-child { border-bottom: 0; }

.admin-related a { color: #7fb3ff; text-decoration: none; }
.admin-related a:hover { text-decoration: underline; }

.admin-related-count {
  font-variant-numeric: tabular-nums;
  color: #e0e0e0;
}

/* A label wrapping its checkbox, so the word is part of the hit target. A <label> is a
   safe flex container -- it is specifically <button> that collapses in WebKit. */
.admin-check {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: #e0e0e0;
}

.admin-criteria {
  list-style: none;
  padding: 0;
  margin: 0.6rem 0 0;
  text-align: left;
  color: #e0e0e0;
}

.admin-criteria li {
  padding: 0.35rem 0;
  border-bottom: 1px solid #2a2a2a;
}

.admin-criteria li:last-child { border-bottom: 0; }

/* A statement about the record that needs acting on -- a suppressed address, an alert
   with no criteria. Amber rather than red: none of these is an error the page hit, they
   are facts about the data that an operator should not have to notice for themselves. */
.admin-job-error-note {
  margin: 0.7rem 0 0;
  padding: 0.6rem 0.75rem;
  border-left: 3px solid #ffb340;
  color: #d8d8d8;
  font-size: 0.92rem;
  text-align: left;
}

/* The rest of the tables (#270).
   ========================================================================== */

/* The index's group headings. The page grew from three entries to thirteen, and
   thirteen panels in one column is a list nobody scans -- grouped by the question
   somebody arrives with rather than by which tables share a foreign key. */
.admin-group-heading {
  margin: 1.6rem 0 0.6rem;
  color: #9a9a9a;
  font-size: 0.85rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-align: left;
}

.admin-group-heading:first-of-type { margin-top: 0.6rem; }

/* Row actions inside a table cell. A div rather than a p, because each button is its
   own form and a <form> start tag closes an open <p> in the HTML parser -- the
   container would be gone by the time the forms arrive and the buttons would stack.
   Same trap as .admin-user-actions, from the other direction. */
.admin-row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  justify-content: flex-end;
}

/* An action that destroys something. Amber rather than red: none of these is an error,
   and a table with four red buttons per screen stops meaning anything. The confirm is
   what actually slows the click down. */
.admin-danger { color: #ffb340; }
.admin-danger:hover { color: #ffc773; }

/* A tag marking something to attend to, as against one that is merely a label. */
.admin-tag-bad {
  border-color: #ffb340;
  color: #ffb340;
}
