/* Department alert app. Plain CSS, no framework, no CDN. */

/* Dark, always -- not "dark if the phone is".
 *
 * The pager is a deliberately black alarm screen and always was. Leaving the
 * rest of the app on `prefers-color-scheme` meant a phone set to light mode got
 * a white Settings page and a black Alerts page: two screens that plainly
 * belong to different apps, reached by one tap of the nav. Picking the OS over
 * the pager would mean a white alarm screen at 3am, so the pager wins and
 * everything else follows it.
 *
 * `color-scheme` is what makes checkboxes, inputs and scrollbars render dark
 * too; without it the browser keeps drawing light form controls on a dark page.
 */
:root {
  color-scheme: dark;
  --bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
  --line: #2a313a; --accent: #f0564a; --ok: #3fb950; --warn: #d29922;
  --radius: 10px;
}
* { box-sizing: border-box; }

/* Vertical scrolling and nothing else.
 *
 * This is a pager. It gets opened one-handed, in a hurry, often while moving,
 * and an accidental pinch or double-tap that leaves the screen zoomed into a
 * corner costs seconds at exactly the wrong moment -- and the way out of it is
 * another deliberate gesture. `pan-y` allows the one interaction the app needs
 * and refuses pinch-zoom, double-tap zoom and sideways panning outright.
 *
 * It applies to html as well as body because the scrolling element differs
 * between engines, and a rule on only one of them leaves the gesture live on
 * the other.
 */
html, body {
  touch-action: pan-y;
  -ms-touch-action: pan-y;
  /* Only the horizontal axis is pinned. Vertical overscroll is left alone
     deliberately: the bounce at the top and bottom of a page is how every
     other app on the phone behaves, and suppressing it makes scrolling feel
     broken rather than solid. The header does not move with it because it is
     fixed, which was the actual problem. */
  overscroll-behavior-x: none;
  /* Nothing is ever wider than the screen, so there is no sideways travel to
     offer in the first place. A stray wide element would otherwise reintroduce
     a horizontal scroll that `pan-y` does not cover on desktop. */
  max-width: 100%;
}
/* `overflow-x` on <html> is deliberately absent.
 *
 * It was there, and on iOS Safari it is one of the documented ways to break
 * `position: fixed`: setting overflow on the root makes it a scroll container,
 * and a fixed child starts scrolling with the page. Chrome does not do this,
 * which is why the header measured as properly fixed there while being visibly
 * unstuck on a phone. `touch-action: pan-y` and `max-width: 100%` above already
 * prevent sideways travel, so the root does not need it and <body> is enough
 * for the desktop case. */
body { overflow-x: hidden; }
body {
  margin: 0; background: var(--bg); color: var(--ink);
  font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  padding-bottom: env(safe-area-inset-bottom);
  /* Room for the fixed header.
   *
   * The fallback is a real calculation, not a guess, and it matters: the
   * runtime measurement lands a moment after first paint, and anything that
   * renders before it was hiding behind the header until the next reflow --
   * which is exactly why the banner looked cut off until you pressed reload.
   * 10px top padding + a 58px brand + 12px bottom padding + a 1px border, plus
   * whatever the notch takes. --header-h then refines it. */
  padding-top: var(--header-h, calc(81px + env(safe-area-inset-top)));
  /* Safari zooms the whole page when a focused input is smaller than 16px.
     Everything here is at least that, and this keeps it so if a rule changes. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}
a { color: var(--accent); }

.top {
  /* Not space-between. With four children the slack is shared between every
     gap, so the reload button moved whenever the nav changed width -- which it
     does between pages, because the active link is bolder than the inactive
     one. The right-hand controls are pinned instead (see #reload), and the
     slack all lands in one place. */
  display: flex; align-items: center; gap: 14px;
  /* Tight at the top and left so the patch sits close to the corner; the
     right keeps its gutter so the status light is not against the edge.
     The safe-area inset stays: without it the mark slides under the notch
     when the app is opened from the Home Screen. */
  padding: 10px 16px 12px 10px;
  padding-top: calc(10px + env(safe-area-inset-top));
  background: var(--panel); border-bottom: 1px solid var(--line);
  /* Fixed, not sticky. A sticky header is still part of the scrolling page, so
     on iOS it rides the rubber-band at the top and visibly snaps back -- which
     is exactly the wobble the header should never have. Fixed takes it out of
     the scroll entirely; `body` reserves the space it no longer occupies. */
  position: fixed; top: 0; left: 0; right: 0; z-index: 5;
}
/* Header icon button. 44px hit area around a 24px glyph -- tapped with a glove
   on, like everything else here -- with no visible chrome so it does not
   compete with the status light beside it. */
.iconbtn {
  flex: none; width: 44px; height: 44px; padding: 0; margin: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: none; color: var(--muted);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.iconbtn:hover { color: var(--ink); }
/* No plate, no ring, no pressed fill -- the glyph alone. The 44px hit area is
   still there, it just does not draw itself. */
.iconbtn:active { color: var(--accent); }
.iconbtn:focus { outline: none; }
.iconbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.iconbtn svg { display: block; }
/* Spins while the reload is in flight. On a fast connection the page is gone
   before a full turn completes, which is the point: the feedback is that
   something happened, not a progress estimate. */
.iconbtn.busy svg { animation: iconspin .7s linear infinite; }
@keyframes iconspin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .iconbtn.busy svg { animation: none; opacity: .5; }
}

.brand {
  display: flex; align-items: center; gap: 9px;
  text-decoration: none; color: var(--ink);
}
.brand-mark { display: block; width: 58px; height: 58px; flex: none; }
/* Two lines: the department, then what the app is. Stacked rather than run
   together so the short name carries the weight and "Alerts" reads as its
   subtitle instead of competing with it. */
.brand-name { display: flex; flex-direction: column; line-height: 1.18; }
.brand-name strong {
  font-weight: 800; font-size: 20px; letter-spacing: -.01em;
}
.brand-name > span {
  font-weight: 600; font-size: 15px; color: var(--muted);
  letter-spacing: -.01em;
}

/* The nav takes the slack, so it sits centred between the brand and the
   reload icon rather than tight against one of them. */
.top nav { display: flex; gap: 14px; flex: 1; justify-content: center; }
.top nav a {
  text-decoration: none; color: var(--muted); font-size: 14px; font-weight: 600;
}
.top nav a.active { color: var(--accent); font-weight: 700; }

/* The nav absorbs the slack (flex: 1 above), which both centres it and keeps
   everything to its right from moving when it changes width. */

main { max-width: 680px; margin: 0 auto; padding: 16px; }
.hero h1 { font-size: 22px; margin: 8px 0 4px; }
.lead { color: var(--muted); margin-top: 0; }
h2 { font-size: 16px; margin: 0 0 10px; }
.hint { color: var(--muted); font-size: 14px; }

.panel {
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 16px; margin-bottom: 14px;
}
.panel.warn { border-left: 4px solid var(--warn); }
.panel ol { padding-left: 20px; }
.panel ol li { margin-bottom: 6px; }

label { display: block; margin: 12px 0 4px; font-size: 14px; color: var(--muted); }
input, textarea, select {
  font: inherit; width: 100%; padding: 11px 12px; border-radius: var(--radius);
  border: 1px solid var(--line); background: var(--bg); color: var(--ink);
}
input.inline { width: auto; }
textarea { min-height: 90px; font-family: ui-monospace, monospace; font-size: 13px; }
#code { letter-spacing: .4em; font-size: 22px; text-align: center; }

button {
  font: inherit; padding: 11px 16px; border-radius: var(--radius);
  border: 1px solid var(--line); background: var(--panel); color: var(--ink);
  cursor: pointer; margin-top: 12px;
}
button.primary { background: var(--accent); border-color: var(--accent); color: #fff;
                 font-weight: 600; width: 100%; }
button.secondary { width: 100%; }
button.link { background: none; border: none; color: var(--accent); padding: 6px 0;
              width: auto; }
button.small { padding: 5px 10px; font-size: 13px; margin: 0; width: auto; }
button:disabled { opacity: .5; cursor: default; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }

.row {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 10px 0; border-bottom: 1px solid var(--line);
}
.row:last-child { border-bottom: none; }
.row .name { font-weight: 600; }
.row .meta { color: var(--muted); font-size: 13px; }

.toggles { display: flex; gap: 14px; align-items: center; }
.toggle { display: flex; align-items: center; gap: 5px; font-size: 14px; }
.toggle input { width: auto; }
/* Text messages need a configured SMS service. Without one the box is disabled,
   and a disabled control that looks identical to a live one just reads as a
   checkbox that ignores you. :has() lets the label dim with its own input, so
   nothing has to remember to set a class. */
.toggle:has(input:disabled) { opacity: .4; cursor: not-allowed; }
.toggle input:disabled { cursor: not-allowed; }

.tag {
  display: inline-block; font-size: 12px; padding: 2px 8px; border-radius: 12px;
  border: 1px solid var(--line); color: var(--muted);
}
.tag.ok { color: var(--ok); border-color: var(--ok); }
.tag.bad { color: var(--accent); border-color: var(--accent); }
.tag.warn { color: var(--warn); border-color: var(--warn); }

.call { border-bottom: 1px solid var(--line); padding: 14px 0; }
.call:last-child { border-bottom: none; }
.call h3 { margin: 0 0 2px; font-size: 16px; }
.call .when { color: var(--muted); font-size: 13px; }
.call audio { width: 100%; margin-top: 8px; }
.call.focus { background: rgba(179,38,30,.06); border-radius: var(--radius);
              padding: 14px 12px; }

.mono { font-family: ui-monospace, monospace; font-size: 13px; word-break: break-all; }
.secret {
  background: var(--bg); border: 1px dashed var(--accent); border-radius: var(--radius);
  padding: 10px; margin-top: 8px;
}
.toasts { position: fixed; left: 16px; right: 16px; bottom: 16px; display: grid;
          gap: 8px; z-index: 50; max-width: 680px; margin: 0 auto; }
.toast {
  background: var(--panel); border: 1px solid var(--line); border-left: 4px solid var(--accent);
  border-radius: var(--radius); padding: 12px 14px; box-shadow: 0 6px 20px rgba(0,0,0,.2);
}
.toast.ok { border-left-color: var(--ok); }
.empty { color: var(--muted); font-style: italic; }


/* Staging banner. Deliberately loud: somebody who enrols on a test paging
   system and expects to be woken up has been failed by a subtle notice. */
.staging {
  /* Sits between the header and the content, matching main's own gutter, so
     it cannot squeeze the header or push the page wider than the viewport. */
  margin: 6px auto 0;
  max-width: 680px;
  width: calc(100% - 32px);
  box-sizing: border-box;
  padding: 10px 14px;
  border: 1px solid #b3261e;
  border-left-width: 5px;
  border-radius: 8px;
  background: rgba(179, 38, 30, .10);
  font-size: 14px;
  line-height: 1.45;
}

/* Independent of the staging banner: open enrolment is worth announcing even
   on a deployment that has otherwise been declared live, because it is the one
   setting whose whole effect is "the front door is unlocked". */
.staging.open { border-color: var(--warn); background: rgba(154, 103, 0, .12); }

/* ------------------------------------------------------------------ pager
 *
 * The screen behind the Home Screen icon. It is read one-handed, in the dark,
 * by somebody who has just been woken up, so: true black rather than the
 * app's usual charcoal (an OLED panel lights no pixels for #000, which is
 * both easier on night vision and cheaper on battery for a screen that may
 * sit lit on a nightstand), large type, and nothing competing with the two
 * facts that matter -- which department, and how long ago.
 */
/* The pager used to redefine the whole palette -- pure black, its own panel,
   line, ink and muted values -- which left Alerts a visibly different shade
   from Settings one nav tap away. Being darker than the rest of the app is not
   worth looking like a different app. It keeps only its layout difference: the
   call is the page, so it gets the width. */
html[data-page="alerts"] main { padding: 0 16px 32px; }

.quiet { padding: 64px 4px; text-align: center; }
.quiet-title { font-size: 20px; font-weight: 600; margin: 0 0 6px; }
.quiet-sub { color: var(--muted); margin: 0; }

/* -- the call in hand --------------------------------------------------- */
.call-lead { padding: 22px 0 8px; }
.call-lead .flag {
  display: inline-block; margin: 0 0 12px;
  padding: 3px 10px; border-radius: 999px;
  font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
  color: #ffd6a0; background: rgba(210, 153, 34, .16);
  border: 1px solid rgba(210, 153, 34, .5);
}
.call-lead .flag.manual { color: #cfe3ff; background: rgba(80, 140, 220, .16);
                     border-color: rgba(80, 140, 220, .5); }
.call-lead .dept {
  font-size: 30px; line-height: 1.15; font-weight: 700;
  margin: 0 0 10px; letter-spacing: -.02em;
}
/* The headline number on this screen: how long ago. */
.call-lead .ago {
  margin: 0; font-size: 19px; font-weight: 600; color: var(--accent);
}
.call-lead .at { margin: 2px 0 18px; color: var(--muted); font-size: 15px; }
.call-lead .transcript {
  margin: 0 0 20px; font-size: 18px; line-height: 1.5;
  /* Dispatch text is the payload. Give it room and never truncate it. */
  overflow-wrap: break-word;
}
.call-lead .transcript.pending { color: var(--muted); font-style: italic; font-size: 16px; }
.call-lead .no-audio { color: var(--muted); font-size: 14px; margin: 0; }

/* -- audio -------------------------------------------------------------- */
.player { display: flex; align-items: center; gap: 14px; margin: 0 0 4px; }
.player audio { display: none; }
.play {
  flex: none; width: 56px; height: 56px; border-radius: 50%;
  border: none; background: var(--accent); color: #fff;
  font-size: 19px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  /* 56px because this gets tapped with a glove on. */
}
.play:active { transform: scale(.96); }
.play-icon { pointer-events: none; }
/* The hit area is 36px tall so it can be scrubbed one-handed; the visible
   track inside it is 8px. Padding cannot do this job -- with border-box the
   vertical padding eats the height and the track disappears entirely. */
.player .bar {
  flex: 1; height: 36px; cursor: pointer;
  display: flex; align-items: center;
}
.player .track {
  width: 100%; height: 8px; border-radius: 4px;
  background: #23262b; overflow: hidden;
}
.player .fill { display: block; height: 100%; background: var(--accent); width: 0; }
/* Same 44px minimum as everything else here: tapped one-handed, outdoors,
   often in a hurry. The label says the factor rather than showing a speaker
   icon, because "is it boosted right now" is the only question it answers. */
.boost {
  flex: none; min-width: 44px; height: 44px; border-radius: 10px;
  border: 1px solid var(--line); background: transparent; color: var(--muted);
  font-size: 14px; font-weight: 700; cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.boost.on { color: var(--accent); border-color: var(--accent); }
.boost:active { transform: scale(.96); }

.player .elapsed {
  flex: none; min-width: 44px; text-align: right;
  font-variant-numeric: tabular-nums; color: var(--muted); font-size: 14px;
}

/* -- everything before it ----------------------------------------------- */
.earlier-title {
  font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--muted); margin: 26px 0 10px; font-weight: 600;
}
.call-row {
  border-top: 1px solid var(--line); padding: 14px 16px 14px 18px;
}
.call-row-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.call-row-dept { font-size: 17px; font-weight: 600; }
.call-row-ago { color: var(--muted); font-size: 14px; flex: none; }
.call-row-at { margin: 2px 0 8px; color: var(--muted); font-size: 13px; }
.call-row-text { margin: 0 0 10px; font-size: 15px; line-height: 1.45; color: #d7dbe0; }
.call-row .player { margin-bottom: 0; }
.call-row .play { width: 40px; height: 40px; font-size: 14px; }
.call-row .bar { height: 30px; }


/* -- am I actually going to be woken? ----------------------------------
 *
 * The first thing on the pager screen, and loud when the answer is no. A
 * pager app showing calls to a phone with no push subscription looks like it
 * is working, which is the worst way for this to fail.
 */
.blocker {
  margin: 18px 0 4px; padding: 16px;
  border: 1px solid var(--accent); border-radius: 12px;
  background: rgba(240, 86, 74, .10);
}
.blocker-why { margin: 0 0 6px; font-size: 17px; font-weight: 700; }
.blocker-detail { margin: 0 0 14px; color: var(--muted); font-size: 15px; line-height: 1.45; }
.blocker .block {
  display: block; width: 100%; padding: 15px; font-size: 17px; font-weight: 600;
  border-radius: 10px; border: none; background: var(--accent); color: #fff;
}
.blocker .block[disabled] { opacity: .6; }
/* The nav is two words on a phone; let it shrink rather than push the brand
   off the screen. */
html[data-page="alerts"] .top nav a,
html[data-page="settings"] .top nav a { font-size: 13px; }
@media (max-width: 380px) { .top { gap: 8px; } }
.admin-link { margin-top: 24px; text-align: center; }

/* -- system status ------------------------------------------------------
 *
 * Green only when a station is actually checking in AND this device is set up
 * to receive. The amber and red states are the useful ones: a pager app that
 * cannot say "you are not going to be woken" is worse than one that says
 * nothing, because it looks like it is working.
 */
.sysstat {
  margin: 0; display: flex; flex-direction: column; align-items: center;
  gap: 1px; font-size: 12px; color: var(--muted); white-space: nowrap;
  /* Fixed width so "all good" and "no station" occupy the same space. Without
     it the status text drags the reload button left and right as the state
     changes, which is exactly the sort of movement a header should never do. */
  /* Wide enough for the longest state ("NO STATION") plus the light, so the
     label can never be what changes the width. 84px fitted the lower-case
     labels this used to have and not the ones it has now. */
  min-width: 112px;
}
.sysstat-line {
  display: flex; align-items: center; gap: 8px;
  /* Room around the light so it reads as an indicator rather than a full
     stop jammed against the word beside it. */
  padding: 2px 6px;
}
/* The "why", small and quiet under the light. Clipped rather than wrapped: the
   header has one height and keeps it. */
/* Up to two lines. "add to Home Screen" is the longest and the most important
   -- it is the one blocking somebody from being paged at all -- and an
   ellipsis in the middle of an instruction is worse than a taller header. The
   header measures itself, so the page below adjusts. */
.sysstat .note {
  font-size: 10px; line-height: 1.2; color: var(--muted); opacity: .85;
  text-align: center; max-width: 96px;
  white-space: normal; overflow-wrap: break-word;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
  /* Two lines' worth, always, whether there is anything to say or not.
     This is the last thing in the header that still moved: the note starts
     empty, resolves to one or two lines a moment later, and the header grows
     under the reader's eyes -- which then re-measures --header-h and shifts
     the whole page down. Reserving the space costs a few millimetres of
     header and buys a page that never jumps. */
  min-height: 24px;
}
.sysstat .dot {
  width: 10px; height: 10px; border-radius: 50%; flex: none;
  background: var(--muted);
}
.sysstat.good .dot { background: var(--ok); }
.sysstat.warn .dot { background: var(--warn); }
.sysstat.bad  .dot { background: var(--accent); }
.sysstat.bad .label, .sysstat.warn .label { color: var(--ink); }
/* Set as a label rather than a sentence: it is a state, and small caps read
   as one at this size. */
.sysstat .label {
  text-transform: uppercase; letter-spacing: .06em; font-weight: 600;
  font-size: 11px;
}
/* Narrow screens keep the label and give up the reserved width instead.
   Hiding it left a bare coloured dot, which is a state only somebody who
   already knows the colours can read -- and on a phone, which is the only
   device that matters here, it meant the status had no words at all. Small
   caps at 10px is about fourteen pixels of text; the reserved 112px was the
   expensive part and that is what goes. */
@media (max-width: 430px) {
  .sysstat { min-width: 0; }
  /* On a phone the state and the advice say nearly the same thing in the
     space of one -- "ALL GOOD" over "alerts on". The advice is the half worth
     keeping: it is the half that differs when something is wrong. So the
     light stands alone on top and the note carries the words. */
  .sysstat .label { display: none; }
  /* The light needs air under it: with the label gone the dot sits directly
     on top of the words, and the two read as one smudge at arm's length. */
  .sysstat { gap: 5px; }
  .sysstat-line { padding: 1px 0 0; }
  .sysstat .note {
    text-transform: uppercase; letter-spacing: .04em;
    font-size: 10px; font-weight: 600; max-width: 88px;
  }
}

/* -- the one tap that cannot be avoided --------------------------------
 *
 * Notification permission needs a user gesture, and on iOS the Push API does
 * not exist until the app has been installed -- so it cannot be folded into
 * "Add to Home Screen" and one deliberate tap is the floor. What can be fixed
 * is whether that tap is the obvious next thing or a button to scroll past.
 * A member who has never enrolled gets this instead of the call list.
 */
.takeover {
  min-height: 58vh;
  display: flex; flex-direction: column; justify-content: center;
  padding: 8px 2px 24px; text-align: left;
}
.takeover-eyebrow {
  margin: 0 0 8px; font-size: 12px; font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase; color: var(--accent);
}
.takeover-why {
  margin: 0 0 10px; font-size: 26px; line-height: 1.2; font-weight: 700;
  letter-spacing: -.02em;
}
.takeover-detail {
  margin: 0 0 26px; font-size: 17px; line-height: 1.5; color: var(--muted);
}
.takeover .block {
  display: block; width: 100%; padding: 17px; font-size: 18px; font-weight: 600;
  border-radius: 12px; border: none; background: var(--accent); color: #fff;
}
.takeover .block[disabled] { opacity: .6; }
.takeover-skip {
  margin: 18px auto 0; display: block; background: none; border: none;
  color: var(--muted); font-size: 15px; text-decoration: underline; cursor: pointer;
}
.row-actions { display: flex; align-items: center; gap: 12px; flex: none; }
.link.danger { color: var(--accent); }

/* -- department groups --------------------------------------------------- */
/* Your own department and the ones you opted into are different decisions,
   so they get different headings rather than one flat list of checkboxes. */
.dept-group {
  margin: 18px 0 6px; font-size: 13px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
}
.dept-group:first-child { margin-top: 0; }
.dept-note { margin: 0 0 10px; }
.row.dept-optional .name { font-weight: 600; }

/* -- swipe a call away ---------------------------------------------------- */
/* The iOS table-row pattern: the row slides left to uncover a Delete button
   that has to be tapped. Two steps on purpose -- a single gesture that removes
   something the moment you let go is very easy to do by accident on a list you
   are scrolling with your thumb.
   Clipped to the row's own bounds; a card sliding across its neighbours reads
   as a broken layout. */
.swipe { position: relative; overflow: hidden; }
.swipe > .call-row {
  position: relative; background: var(--bg);
  will-change: transform; touch-action: pan-y;
}
/* Red, because this is the destructive slot in an iOS row and that is the
   colour people read there. The label says Delete for the same reason, even
   though nothing is really deleted -- the call stays in the department's
   record and only this member's view of it changes. */
.swipe-action {
  position: absolute; top: 0; right: 0; bottom: 0; width: 96px;
  display: flex; align-items: center; justify-content: center;
  border: 0; border-radius: 0; margin: 0; padding: 0;
  background: #d5342a; color: #fff;
  font-size: 15px; font-weight: 600; font-family: inherit;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.swipe-action:active { background: #b3261e; }
/* Unreachable until the row is out of the way, so a stray tap on a closed row
   cannot hit it. */
.swipe:not(.open) .swipe-action { pointer-events: none; }



/* -- pull down to reload -------------------------------------------------- */
/* The icon turns with the finger during a pull and keeps turning once the
   reload is in flight, so the gesture and the button visibly do one job. */
.iconbtn { transition: transform .12s linear, color .12s linear; }
/* Namespaced, not a bare `armed`. It used to be, and it collided with a
   generic `.armed` utility elsewhere in this file whose ::before drew a green
   dot -- so pulling the page down grew a second status light on the reload
   button and spun it along with the icon. A class on a shared element needs a
   name nothing else would reach for. */
.iconbtn.pull-armed { color: var(--accent); }
.iconbtn.busy { transition: none; }

/* The pull moves this, not <body>. `will-change` lives here for the same
   reason the transform does: on <body> it would make body the containing block
   for the fixed header even when no transform is applied, which quietly turns
   the header back into a scrolling one. */
.page { will-change: transform; }

/* -- Save, and what it is currently telling you --------------------------- */
/* Three states, because the button answers three different questions:
     grey   nothing to save -- and disabled, so it cannot be pressed for nothing
     red    you have unsaved changes
     green  that worked
   The green is why there is no "Saved." banner. A toast puts the confirmation
   somewhere other than the control you just pressed, and covers the bottom of
   the page while it does it. */
#save-prefs {
  transition: background-color .45s ease, border-color .45s ease, color .45s ease;
}
/* Beats the global button:disabled fade: a washed-out red still reads as red,
   and the point of the resting state is that it does not. */
#save-prefs:disabled {
  opacity: 1;
  background: var(--line); border-color: var(--line); color: var(--muted);
  cursor: default;
}
#save-prefs.saved:disabled {
  background: var(--ok); border-color: var(--ok); color: #fff;
}
@media (prefers-reduced-motion: reduce) { #save-prefs { transition: none; } }

/* -- editing a member ----------------------------------------------------- */
.row.inactive .name, .row.inactive .meta { opacity: .55; }

/* Member actions line up in columns down the list.
 *
 * Four buttons whose widths depend on their own labels -- and one of which
 * changes between "Suspend" and "Restore" -- meant no two rows agreed on where
 * anything was, so Remove on one row sat under Suspend on the next. A fixed
 * track per action means the column you aim at is the action you get, which
 * matters most for the one that is irreversible. */
#members .actions {
  display: grid; grid-template-columns: repeat(4, minmax(72px, 1fr));
  gap: 6px; flex: none; width: min(340px, 100%);
}
#members .actions button { width: 100%; margin: 0; }
/* Stacked on a narrow screen rather than four unreadable slivers. */
@media (max-width: 560px) {
  #members .row { flex-direction: column; align-items: stretch; gap: 10px; }
  #members .actions { width: 100%; grid-template-columns: repeat(2, 1fr); }
}
.member-edit {
  padding: 12px 0 16px; border-bottom: 1px solid var(--line);
}
.member-edit input[type="text"], .member-edit input[type="tel"] { width: 100%; }
/* Two columns where there is room: a county's worth of departments in one
   column turns a short form into a scroll. */
.member-depts {
  display: grid; gap: 4px 16px; margin: 4px 0 10px;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
}
.member-depts .toggle { margin: 0; }
button.small.danger { color: var(--accent); border-color: var(--accent); }

/* A roster entry set to "everything" keeps covering departments that do not
   exist yet, so the specific list is greyed rather than shown as a subset it
   is not. */
.member-depts.disabled { opacity: .4; pointer-events: none; }
#roster .actions { display: flex; gap: 8px; align-items: center; }

/* Other districts, grouped by the channel they are dispatched on. Thirty-two
   agencies in one flat column is a scroll rather than a choice. */
.dept-channel {
  border-top: 1px solid var(--line);
  --dept-arrow: 5px;   /* the triangle drawn in ::before */
  --dept-gap: 10px;    /* the flex gap after it */
}
.dept-channel > summary {
  display: flex; align-items: center; justify-content: space-between;
  /* The disclosure triangle sits in front of the channel name, and the rows
     underneath are indented by exactly its width plus the gap after it -- so
     an agency name lines up with the channel name above it rather than with
     the arrow. Derived from the two measurements rather than typed as a
     number, so they cannot drift apart. */
  gap: var(--dept-gap); padding: 12px 4px; cursor: pointer; list-style: none;
  font-weight: 600;
}
.dept-channel > summary::-webkit-details-marker { display: none; }
.dept-channel > summary::before {
  content: ""; width: 0; height: 0; flex: none;
  border-left: 5px solid var(--muted);
  border-top: 4px solid transparent; border-bottom: 4px solid transparent;
  transition: transform .15s;
}
.dept-channel[open] > summary::before { transform: rotate(90deg); }
/* The channel is the heading of everything under it, and there can be nine
   of them -- so it has to be findable by size when the list is scrolled past
   at speed, not only by its position. */
.dept-channel-name {
  flex: 1; min-width: 0; font-size: 17px; font-weight: 600; color: var(--ink);
}
/* Every agency under a channel starts where the channel's own name starts. */
.dept-channel .row { padding-left: calc(var(--dept-arrow) + var(--dept-gap)); }
.dept-channel-count { color: var(--muted); font-weight: 400; font-size: 13px; }

/* The one-off demonstration of the delete gesture slides the row a quarter of
   the way, which is enough to say the button is there without looking as
   though it opened on its own. Nothing else is needed here: the action is
   always behind the row, and the `:not(.open)` rule above already keeps it
   untappable throughout -- the demonstration must not be something you can
   accidentally complete. */

/* The box an animated <details> moves. Its height is what is transitioned, so
   it must not be able to spill while it is part-way there. */
.disclosure-body { overflow: hidden; }

/* The department switches read as on/off, so the on state should look like the
   status light and the Alerts-are-working green rather than the browser's own
   accent, which is whatever the phone was set to. */
.toggle input[type="checkbox"] { accent-color: var(--ok); }

/* The address in a transcript. Underlined because that is what says "this does
   something" without a button in the middle of a sentence -- and it has to be
   obvious, because the whole point is that somebody reads it before tapping. */
.addr {
  color: inherit; text-decoration: underline;
  text-decoration-color: var(--ok); text-underline-offset: 3px;
  text-decoration-thickness: 2px; font-weight: 600;
}
.addr:active { color: var(--ok); }
