/* Consumes the design tokens in tokens.css. Nothing here introduces a colour,
   a radius, a duration or a z-index -- if a value is missing, it belongs in
   tokens.css, not in a rule below.

   Two pages share this file. `/status` is a reporting page; `/` is the deck's
   slide-8 wallboard (business-service tiles, dependency preview, site
   reachability, active alarms, expiring certificates). Both draw every status
   from the same `status-*` vocabulary, so a status can only ever have one
   colour across the product.

   Every status is stated on three independent channels, because any one of
   them can fail for some reader: the WORD (always present), the COLOUR (from
   the vocabulary below), and the SHAPE of the dot -- filled for UP, half
   filled for DEGRADED, hollow for UNKNOWN. The shape exists mainly to
   separate UP from DEGRADED, which measurement showed to be the weakest pair
   under protanopia; DOWN needs no help, sitting far from both in lightness. */

* {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

/* A column, so the footer's build stamp can be pushed to the bottom of the
   viewport on a page too short to fill it (see .appmon-main's `flex`) instead
   of floating halfway up under the last panel.

   The footer is now also pinned there while a longer page scrolls, as the top
   bar is -- see .appmon-topbar. An earlier revision of this comment argued
   against that on the grounds that a permanent band costs a wallboard a row of
   tiles; that cost is real and was accepted deliberately, so that the estate's
   nav and the build under discussion are never a scroll away. */
body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  background: var(--color-page);
  color: var(--color-text);
  -webkit-font-smoothing: antialiased;
}

a,
button {
  transition:
    color var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-fast) var(--ease-out);
}

/* Keyboard focus must be visible on both surfaces: the cobalt header eats a
   cobalt ring. --color-focus measures 5.91:1 on the white page, comfortably
   past the 3:1 WCAG asks of a focus indicator. */
:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
  border-radius: 2px;
}

.appmon-header a:focus-visible {
  outline-color: #fff;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

/* The demo bar and the header, frozen at the top of the viewport while the
   page scrolls under them. The nav an operator is reaching for, and the
   warning that this instance has no access control, should never be a scroll
   away on a dashboard that is taller than a screen.

   Sticky, not fixed. A fixed bar leaves the page below it to reserve an equal
   height by hand, and the height here is not knowable: the demo bar is
   conditional, and both bands wrap to a second line on a narrow viewport.
   Sticky keeps them in flow, so the space they occupy is theirs by
   construction and correct at every width.

   One wrapper around both, rather than sticking each -- see base.html for why
   two `top: 0` elements cannot stack. z-index above --z-sticky, not equal to
   it: the matrix's frozen row headers are sticky too and come later in the
   document, so equal values would scroll a site name over the nav. */
.appmon-topbar {
  position: sticky;
  top: 0;
  z-index: var(--z-chrome);
}

/* Demo-mode banner (base.html), above the header on every page. Amber rather
   than the red --color-down: this is a standing condition of the deployment,
   not an incident, and reusing the estate's "something is broken" colour for
   it would make every screenshot of a demo look like an outage. In normal
   flow within .appmon-topbar rather than positioned itself, so it pushes the
   header down instead of covering it, and the pair travel together. */
.demo-bar {
  padding: 0.5rem 1.5rem;
  background: color-mix(in srgb, var(--color-degraded) 20%, var(--color-page));
  color: var(--color-degraded-ink);
  border-bottom: 1px solid
    color-mix(in srgb, var(--color-degraded) 55%, var(--color-page));
  font-size: 0.875rem;
  text-align: center;
}

.appmon-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.35rem 1.25rem;
  padding: 0.75rem 1.5rem;
  background: var(--color-navy);
  color: #fff;
}

.appmon-header__brand {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: #fff;
  text-decoration: none;
}

/* The mark's major arc is stroked in currentColor, so on this band it comes
   out white and only the short DEGRADED arc holds its amber -- the same
   inversion the mark performs on a white panel, from one file. */
.appmon-mark {
  flex: none;
  width: 1.3rem;
  height: 1.3rem;
}

.appmon-header__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1.25rem;
}

.appmon-header__nav a {
  color: #fff;
  text-decoration: none;
  font-size: 0.875rem;
}

.appmon-header__nav a:hover {
  text-decoration: underline;
}

/* Nav group heading. The admin items are a set, not four more peers of
   Dashboard -- without the label an operator and an admin see headers that
   differ only in length. */
.appmon-header__group {
  margin-left: 1.25rem;
  padding-right: 0.25rem;
  color: var(--cobalt-300);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.appmon-main {
  /* Takes the slack in body's column, which is what holds the footer to the
     bottom of a short page. `0 auto` still centres it: the flex basis governs
     the block axis here, the max-width and margins the inline one. */
  flex: 1;
  max-width: 960px;
  margin: 0 auto;
  padding: 1.5rem;
}

/* The build stamp, on every page (base.html). Quiet by construction --
   muted, small, last in the reading order -- because it is reference
   material, and a version string that competes with a status tile for
   attention has misread which of the two an operator came here for. */
.appmon-footer {
  /* Pinned to the bottom of the viewport while the page scrolls under it, the
     mirror of .appmon-topbar. Sticky, not fixed, so it keeps its place in the
     flex column: on a page too short to scroll it simply sits at the end, with
     no duplicate height to reserve and nothing to un-reserve.

     Opaque background and a border are load-bearing here rather than
     decorative -- content passes underneath this band, and a translucent one
     would render the build stamp over a moving panel. */
  position: sticky;
  bottom: 0;
  z-index: var(--z-chrome);
  border-top: 1px solid var(--color-border);
  background: var(--color-surface-sunken);
  color: var(--color-text-muted);
  font-size: 0.8125rem;
}

.appmon-footer__build {
  display: flex;
  flex-wrap: wrap;
  /* Row gap first: wrapped onto two lines on a narrow screen, the three facts
     need to stay legibly separated without the separators drifting. */
  gap: 0.15rem 0.5rem;
  max-width: 960px;
  margin: 0 auto;
  padding: 0.85rem 1.5rem;
}

/* Hidden from assistive technology in the markup -- read aloud, "middle dot"
   between every field is noise, and the fields are already separate elements
   with their own labels. */
.appmon-footer__sep {
  color: var(--color-text-subtle);
}

/* The values carry full-strength ink against the muted labels: "Version" is
   the same on every deployment, "0.9" is the part being looked up. */
.appmon-footer__value {
  color: var(--color-text);
  font-variant-numeric: tabular-nums;
}

.panel {
  background: var(--color-panel);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 1.25rem 1.5rem;
  margin-bottom: 1.25rem;
}

.panel h1,
.panel h2 {
  margin-top: 0;
}

.panel h2 {
  margin-bottom: 0.85rem;
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--color-navy);
}

.panel h3 {
  margin: 1rem 0 0.4rem;
  font-size: 0.875rem;
  font-weight: 600;
}

/* Links inside prose -- the "status page" pointer an empty state offers.
   Without this they render in the browser's default blue, which is the one
   colour on the page that is nobody's. */
.panel a {
  color: var(--color-link);
  text-underline-offset: 0.15em;
}

.muted {
  color: var(--color-muted);
}

.version {
  color: var(--color-muted);
  font-weight: 400;
  font-size: 0.6em;
}

.stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 1rem;
  margin: 0;
}

.stat-grid dt {
  color: var(--color-muted);
  font-size: 0.85em;
}

/* The provenance line is a `dd` too, and `.stat-grid dd` outranks
   `.stat-provenance` on specificity -- so without the :not() it renders "0
   from PRTG, 22 configured locally" at headline size next to the count it is
   meant to footnote. Pre-existing; fixed here because this file now owns two
   pages' worth of type. */
.stat-grid dd:not(.stat-provenance) {
  margin: 0.15rem 0 0;
  font-size: 1.6em;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* --- The status vocabulary -------------------------------------------------

   One place, and only one place, binds a status to a colour. Every surface
   that shows status -- the dot on `/status`, a service tile, a matrix cell, a
   dependency node -- draws from the pair a `status-*` class sets here, so a
   status cannot be amber in one panel and red in another.

   `--status-hue` is the block colour (dots, accents, washes). `--status-ink`
   is the same status set as words, which is not always the same value; see
   the contrast note on --color-degraded-ink at the top of this file. */

.status-up {
  --status-hue: var(--color-up);
  --status-ink: var(--color-up-ink);
}

.status-degraded {
  /* Amber. Never the red. A service that has lost one country is still
     serving every other one (deck slide 10), and painting that the colour of
     a total outage would erase the single distinction this product exists to
     draw. It is also the brightest of the three on purpose: a total outage is
     already known to everyone, whereas "degraded, Zambia only" is the finding
     nobody else in the estate can produce. */
  --status-hue: var(--color-degraded);
  --status-ink: var(--color-degraded-ink);
}

.status-down {
  --status-hue: var(--color-down);
  --status-ink: var(--color-down-ink);
}

.status-unknown {
  /* Unmeasured, not failing: a hollow dot and quiet grey words, so a page of
     it reads as "nothing has been probed" rather than as an estate-wide
     outage. Hollow rather than filled is the point -- an empty ring is
     visibly an absence, which no shade of grey can say on its own. */
  --status-hue: var(--color-unknown);
  --status-ink: var(--color-unknown-ink);
}

.status-suppressed,
.status-paused {
  --status-hue: var(--color-muted);
  --status-ink: var(--color-muted);
}

.status-breakdown {
  list-style: none;
  margin: 0.9rem 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
}

.status-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.dot {
  display: inline-block;
  flex: none;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  /* The fallback is the honest one: a dot with no status class has not been
     told anything, which is UNKNOWN, not UP and not DOWN. */
  background: var(--status-hue, var(--color-unknown));
  /* The dot's own edge, in the status's ink. Two jobs: it gives the shape a
     boundary that clears WCAG's 3:1 for non-text content even where the fill
     alone does not -- DEGRADED's amber measures 2.23:1 on white, its ink
     5.53:1 -- and it is what makes the half-filled and hollow variants below
     read as a circle at all rather than as a stray wedge. */
  box-shadow: inset 0 0 0 1px var(--status-ink, var(--color-unknown-ink));
}

/* DEGRADED is drawn half filled -- the deck's own "(-) DEGRADED - Zambia"
   glyph, and the most literal statement the product makes: some of it is
   working. This is the pair that measurement showed to be weakest under
   protanopia (dE 0.135, the tightest in the set), so it is the pair that gets
   a second channel. The unfilled half is the page, not a lighter amber, so
   the split reads as fill level rather than as two similar colours. */
.status-degraded .dot,
.dot.status-degraded {
  background: linear-gradient(
    90deg,
    var(--status-hue) 0 50%,
    var(--color-page) 50% 100%
  );
}

/* UNKNOWN is hollow: an absence, drawn as one. */
.status-unknown .dot,
.dot.status-unknown {
  background: var(--color-page);
  box-shadow: inset 0 0 0 2px var(--status-ink);
}

.status-count {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.empty-state {
  color: var(--color-muted);
  font-style: italic;
  max-width: 64ch;
  text-wrap: pretty;
}

.stat-provenance {
  font-size: 0.7em;
  font-weight: 400;
  color: var(--color-muted);
  margin: 0.2rem 0 0;
}

.link-list {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
}

.link-list a {
  color: var(--color-link);
  font-weight: 600;
  text-decoration: none;
}

.link-list a:hover {
  text-decoration: underline;
}

/* --- Dashboard: business-service tiles (slide 8, top row) ------------------ */

.service-tiles {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* Reflows down to one column on a phone without a breakpoint, so the row
     wraps rather than pushing the page sideways. */
  grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
  gap: 0.75rem;
}

.service-tile {
  display: grid;
  /* Three columns now: the mark spans the first two rows so it centres
     against name and caption together, while the status dot keeps its own
     position -- it is what the 30-second settle animates and what
     static/js/appmon.js diffs. */
  grid-template-columns: auto auto minmax(0, 1fr);
  /* The `changed` row is declared here but usually empty -- only a tile that
     has actually changed this session gets the element that fills it
     (static/js/appmon.js). An empty auto-sized row collapses to nothing, so
     declaring it costs the other tiles no height. */
  grid-template-areas:
    "mark    dot     name"
    "mark    .       caption"
    "reason  reason  reason"
    "changed changed changed";
  align-items: center;
  column-gap: 0.5rem;
  row-gap: 0.1rem;
  padding: 0.85rem 0.95rem 0.8rem;
  border: 1px solid var(--color-border);
  border-top: 3px solid var(--status-hue);
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--status-hue) 5%, var(--color-page));
  /* Containing block for the settle overlays (see "Live change" below), which
     are absolutely positioned and therefore never become grid items. */
  position: relative;
}

.service-tile .dot {
  grid-area: dot;
  width: 12px;
  height: 12px;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-hue) 22%, transparent);
}

.service-tile .app-mark {
  grid-area: mark;
  margin-right: 0.15rem;
}

.service-tile__name {
  grid-area: name;
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.3;
  overflow-wrap: anywhere;
}

/* The tile's name is also its link to /apps/{key} (M3.2 task 4). Specificity
   over `.panel a` above: the tile heading keeps its own ink and only takes
   the underline on interaction, the same restraint `.matrix tbody th a`
   below applies to the matrix's row-header links. */
.service-tile .service-tile__name {
  color: var(--color-text);
  text-decoration: none;
}

.service-tile .service-tile__name:hover,
.service-tile .service-tile__name:focus-visible {
  text-decoration: underline;
}

.service-tile__caption {
  grid-area: caption;
  color: var(--color-muted);
  font-size: 0.75rem;
  line-height: 1.35;
}

/* The line the whole design turns on: "DEGRADED — Zambia" in amber says the
   service is still up everywhere else, which "DOWN" in red would not. */
.service-tile__reason {
  grid-area: reason;
  margin-top: 0.55rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--color-border);
  color: var(--status-ink);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  line-height: 1.35;
}

/* Nothing has been measured -- so it is stated, not emphasised. */
.service-tile.status-unknown .service-tile__reason {
  font-weight: 500;
}

/* The all-UNKNOWN banner. A plain note, not an alert: a fresh install has not
   failed at anything, and this is the first thing an operator sees on one. */
#dash-no-data {
  margin: 0 0 0.9rem;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: color-mix(in srgb, var(--color-navy) 3%, #fff);
  color: var(--color-text);
  font-style: normal;
  font-size: 0.875rem;
  max-width: none;
}

#dash-no-data strong {
  color: var(--color-navy);
  font-weight: 600;
}

/* --- Dashboard: dependency / fault-tree preview ---------------------------- */

.dep-chain + .dep-chain {
  margin-top: 0.9rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--color-border);
}

.dep-chain h3 {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0 0 0.35rem;
}

/* Same "only trouble takes colour" rule as .dep-node below, applied to a
   fault tree's own root heading (appmon/templates/apps_detail.html). Plain
   dashboard.html headings carry no status class and are unaffected. */
.dep-chain h3:not(.status-up):not(.status-unknown) {
  color: var(--status-ink);
}

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

/* The guide line is the chain: vendor -> host -> gateway -> SQL -> site reads
   down the indent. */
.dep-tree .dep-tree {
  margin-left: 0.3rem;
  padding-left: 0.85rem;
  border-left: 1px solid var(--color-border);
}

.dep-node {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  align-items: center;
  column-gap: 0.5rem;
  padding: 0.22rem 0;
  font-size: 0.875rem;
}

.dep-node > .dep-tree {
  grid-column: 1 / -1;
}

.dep-kind {
  min-width: 4.25rem;
  color: var(--color-muted);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.dep-label {
  overflow-wrap: anywhere;
}

/* Only the layers that are actually in trouble take colour; a tree where
   every line is coloured tells an operator nothing about where to look. */
.dep-node:not(.status-up):not(.status-unknown) > .dep-label {
  color: var(--status-ink);
  font-weight: 600;
}

/* --- Dashboard: site x application reachability ---------------------------- */

/* A wide estate scrolls inside this box. The page itself must never scroll
   sideways, which is the whole reason the table is wrapped. */
.matrix-scroll {
  max-width: 100%;
  overflow-x: auto;
}

.matrix {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.8125rem;
}

.matrix th,
.matrix td {
  padding: 0.4rem 0.65rem;
  border: 1px solid var(--color-border);
  text-align: left;
  white-space: nowrap;
}

.matrix thead th {
  background: var(--color-surface);
  color: var(--color-muted);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* The site names stay put while a wide grid scrolls under them. Collapsed
   borders do not travel with a sticky cell, so they are redrawn as a shadow. */
.matrix tbody th {
  position: sticky;
  left: 0;
  z-index: var(--z-sticky);
  background: #fff;
  font-weight: 600;
  box-shadow:
    inset -1px 0 0 var(--color-border),
    inset 0 -1px 0 var(--color-border);
}

/* The row header is also the site's link to /sites/{code} (M3.2 task 4) --
   same restraint as .service-tile__name above: it reads as a data label
   first, and takes the underline only on interaction. */
.matrix tbody th a {
  color: inherit;
  text-decoration: none;
}

.matrix tbody th a:hover,
.matrix tbody th a:focus-visible {
  text-decoration: underline;
}

.matrix-cell {
  background: color-mix(in srgb, var(--status-hue) 8%, #fff);
}

.matrix-cell .dot {
  margin-right: 0.4rem;
  vertical-align: middle;
}

/* The word as well as the colour: a grid read on colour alone is unreadable
   to a colour-blind operator and unquotable over the phone. */
.cell-status {
  color: var(--status-ink);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  vertical-align: middle;
}

/* Symbol above the key, both centred. Inline would widen every column. */
.matrix-head {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
}

/* --- Dashboard: active alarms and expiring certificates -------------------- */

/* `.status-breakdown` is a wrapping row on /status; here the same list is a
   stack of records, one per line. */
.alarm-list,
.cert-list {
  margin: 0;
  flex-direction: column;
  gap: 0;
}

.alarm-list .status-row,
.cert-list .status-row {
  width: 100%;
  flex-wrap: wrap;
  gap: 0.25rem 0.5rem;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--color-border);
  font-size: 0.875rem;
}

.alarm-list .status-row:first-child,
.cert-list .status-row:first-child {
  padding-top: 0;
}

.alarm-list .status-row:last-child,
.cert-list .status-row:last-child {
  padding-bottom: 0;
  border-bottom: none;
}

.alarm-list .status-label,
.cert-list .status-label {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.alarm-list .status-label {
  color: var(--status-ink);
}

/* Priority is a business classification, not a status. It stays neutral so
   the only colour on this list is the alarm's own. */
.alarm-list .badge {
  padding: 0.1rem 0.45rem;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  color: var(--color-muted);
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
}

.alarm-since {
  margin-left: auto;
  color: var(--color-muted);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.cert-days {
  margin-left: auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.cert-date {
  color: var(--color-muted);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* --- Live change: what the 30-second poll actually changed -----------------

   The dashboard's three panels replace themselves wholesale every 30 seconds.
   Almost every swap replaces the markup with identical markup, so the motion
   below must NOT be attached to the swap itself -- a panel that flashes twice
   a minute forever is the "software performing urgency" PRODUCT.md rules out,
   and after an hour nobody sees it at all. `is-changed` and `is-new` are set
   by static/js/appmon.js only on the elements whose content genuinely moved.

   One-shot by construction: the class only ever lands on the element the swap
   just built, and the next swap builds a fresh one without it. There is no
   iteration count to get wrong and nothing to clean up. */

/* A wash of the status's own colour, already at full strength when the frame
   arrives and decaying to nothing. It fades OUT rather than in: an element
   that brightens then dims reads as a blink, while one that is simply caught
   settling reads as something that just happened. */
@keyframes appmon-settle {
  from {
    opacity: 0.16;
  }
  to {
    opacity: 0;
  }
}

/* The top rule redrawing itself left to right -- the tile restating its own
   status border. Drawn in the status INK over the lighter status hue the
   border already carries, so the wipe is visible against it. */
@keyframes appmon-wipe {
  from {
    transform: scaleX(0);
    opacity: 1;
  }
  55% {
    transform: scaleX(1);
    opacity: 1;
  }
  to {
    transform: scaleX(1);
    opacity: 0;
  }
}

.service-tile.is-changed::before,
.alarm-list .status-row.is-new::before,
.cert-list .status-row.is-new::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--status-hue);
  opacity: 0;
  /* The tile's name is a link and its reason is selectable text; an overlay
     that swallowed either would trade a 450ms flourish for a broken tile. */
  pointer-events: none;
  animation: appmon-settle var(--dur-settle) var(--ease-out-expo);
}

/* Absolutely positioned children resolve against the padding box, so the
   negative offsets are what put this back over the border it is redrawing. */
.service-tile.is-changed::after {
  content: "";
  position: absolute;
  top: -3px;
  left: -1px;
  right: -1px;
  height: 3px;
  background: var(--status-ink);
  transform-origin: left center;
  pointer-events: none;
  /* The resting state is invisible, and it has to be declared rather than
     left to the keyframes. These animations carry no fill-mode, so when one
     ends the element snaps back to its base style -- and an undeclared
     opacity bases at 1, which would leave this dark rule painted across the
     top of the tile until the next swap replaced the element, up to thirty
     seconds later. Same reason ::before above declares opacity: 0. */
  opacity: 0;
  animation: appmon-wipe var(--dur-settle) var(--ease-out-expo);
}

.alarm-list .status-row.is-new,
.cert-list .status-row.is-new {
  position: relative;
}

/* The persistent half of the answer, and the reason this still works with
   motion turned off. The settle is over in 450ms; someone who was in a
   meeting when Sage X3 went amber comes back to a screen where the only
   remaining evidence is this line. Cleared by a real page load, so it answers
   "what changed while I was away" rather than "what changed today". */
.service-tile__changed {
  grid-area: changed;
  margin-top: 0.35rem;
  color: var(--color-text-muted);
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.service-tile__changed::before {
  content: "↳ ";
}

/* --- Breadcrumbs (M3.2 task 4, the three drill-downs' own way back) -------- */

.breadcrumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin: 0 0 0.85rem;
  font-size: 0.8125rem;
  color: var(--color-muted);
}

.breadcrumbs a {
  color: var(--color-link);
  font-weight: 600;
  text-decoration: none;
}

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

.breadcrumbs__sep {
  color: var(--color-border);
}

.breadcrumbs [aria-current="page"] {
  color: var(--color-text);
  font-weight: 600;
}

/* --- Application drill-down (appmon/templates/apps_detail.html, GET /apps/{key}) --- */

/* The header band the three drill-downs open with (also .country-detail's
   and .site-detail's own first panel, which share this markup verbatim --
   see those templates). Same device the service tiles use
   (.service-tile's own border-top), so a page's overall status is legible
   before a word of it is read. */
.detail-header {
  border-top: 4px solid var(--status-hue);
}

.app-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.75rem;
}

.app-title .dot {
  width: 12px;
  height: 12px;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-hue) 22%, transparent);
}

.app-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem 2rem;
  margin: 0 0 0.75rem;
}

.app-meta dt {
  color: var(--color-muted);
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.app-meta dd {
  margin: 0.1rem 0 0;
}

.app-impact {
  max-width: 64ch;
  margin: 0 0 0.5rem;
}

/* Same "DEGRADED reads amber, not red" rule the service tiles use
   (.service-tile__reason) -- the colour comes from the status class on this
   same element, never a token named directly here. */
.app-reason {
  margin: 0;
  padding-top: 0.5rem;
  border-top: 1px solid var(--color-border);
  color: var(--status-ink);
  font-size: 0.9375rem;
  font-weight: 600;
}

/* A wide checks table scrolls inside its own box, same as .matrix-scroll --
   the page itself must never scroll sideways.
   NOTE: `max-width: 100%` does not actually deliver that promise once a
   table's min-content exceeds the viewport -- see `.prtg-scroll` at the
   bottom of this file, which documents the mechanism and the fix. This rule
   is left as it is deliberately: changing it moves the checks tables on
   /sites/{code} and /apps/{key}, which is not a change to make without a
   case to verify each of those pages against. */
.checks-scroll {
  max-width: 100%;
  overflow-x: auto;
}

.checks-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.875rem;
}

.checks-table th,
.checks-table td {
  padding: 0.5rem 0.65rem;
  border-bottom: 1px solid var(--color-border);
  text-align: left;
  vertical-align: middle;
}

.checks-table thead th {
  color: var(--color-muted);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* One inline <svg> per check row (appmon/templates/_sparkline.html). No
   charting library and no new colour: the polyline is stroked from the same
   --status-hue the dots and tiles already draw from, set by the status-*
   class on the <svg> itself. */
.sparkline {
  display: block;
  width: 110px;
  height: 26px;
}

.sparkline polyline {
  fill: none;
  stroke: var(--status-hue);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

.sparkline-empty {
  font-size: 0.8125rem;
}


/* --- Buttons ---------------------------------------------------------------

   Every form in the app renders its primary action here, so the "save" of one
   screen cannot drift from the "save" of another, and so every one of them
   gets the full set of states rather than a hover alone.

   `.account-form` was missing from this list, which is why "Create" on
   /admin/users and "Save" on the user editor were rendering as unstyled
   browser buttons next to a fully styled "Sign in" -- the same action, two
   appearances, which is exactly the inconsistency one shared rule exists to
   prevent. */

.vault-form button,
.auth-form button,
.users-invite-form button,
.account-form button,
.prtg-import-form button {
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--color-brand);
  color: var(--color-text-on-brand);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  /* Transform is listed so the press below is eased rather than snapped. The
     shared `a, button` rule at the top of this file covers colour. */
  transition: transform var(--dur-instant) var(--ease-out);
}

.vault-form button:hover,
.auth-form button:hover,
.users-invite-form button:hover,
.account-form button:hover,
.prtg-import-form button:hover {
  background: var(--color-brand-hover);
}

/* A press, not a bounce. One pixel down for 90ms is under the ~80-100ms
   threshold where feedback still reads as instant, and it says "received"
   without the control drawing attention to itself -- a scale-and-return on a
   full-width Sign in button reads as the page wobbling. Guarded on :disabled
   so a button already submitting does not respond to being clicked again. */
.vault-form button:active:not(:disabled),
.auth-form button:active:not(:disabled),
.users-invite-form button:active:not(:disabled),
.account-form button:active:not(:disabled),
.prtg-import-form button:active:not(:disabled) {
  background: var(--color-brand-active);
  transform: translateY(1px);
}

/* Disabled has to be legible: it still says what the button would do, and a
   user who cannot read it cannot work out what to fix. Grey-on-grey at 20%
   opacity is the usual failure. */
.vault-form button:disabled,
.auth-form button:disabled,
.users-invite-form button:disabled,
.account-form button:disabled,
.prtg-import-form button:disabled {
  background: var(--color-surface);
  border-color: var(--color-border-strong);
  color: var(--color-text-subtle);
  cursor: not-allowed;
}

/* --- In flight --------------------------------------------------------------

   Every form here is a plain POST that navigates. Between the click and the
   new page there was previously no acknowledgement at all, which on a slow
   PRTG import invites a second click on the same button -- and the second
   click is a second import.

   static/js/appmon.js sets aria-busy and disables the control once the
   browser has serialised the form. The disabled styling alone would be a
   misread: a greyed-out button says "you may not do this", not "this is
   happening". The ring is what distinguishes the two. */

@keyframes appmon-spin {
  to {
    transform: rotate(1turn);
  }
}

.vault-form button[aria-busy="true"]::after,
.auth-form button[aria-busy="true"]::after,
.users-invite-form button[aria-busy="true"]::after,
.account-form button[aria-busy="true"]::after,
.prtg-import-form button[aria-busy="true"]::after {
  content: "";
  display: inline-block;
  width: 0.75em;
  height: 0.75em;
  margin-left: 0.5em;
  vertical-align: -0.08em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: var(--radius-pill);
  /* Linear on purpose. This is the one place an eased curve would be wrong:
     a spinner that accelerates and decelerates reads as struggling. */
  animation: appmon-spin 620ms linear infinite;
}

/* --- Form controls ---------------------------------------------------------

   The focus ring itself is deliberately NOT transitioned. Focus has to be
   instant -- someone tabbing quickly through a form outruns any easing, and a
   ring that fades in reads as lag. What eases is the field's own border,
   which is a state change rather than a position indicator. */

input[type="text"],
input[type="password"],
input[type="email"],
select,
textarea {
  transition: border-color var(--dur-fast) var(--ease-out);
}

.auth-form input:focus,
.vault-form input:focus,
.account-form input:focus,
.account-form select:focus {
  border-color: var(--color-brand);
}

/* --- Disclosure ------------------------------------------------------------

   The panel's height still snaps open; only its contents ease in. That is the
   deliberate half. Animating the container's height would push every row of
   the users table down over 260ms, and a table that slides while you are
   reading it costs more than the transition buys. The native marker rotation
   browsers already give <details> is left alone. */

@keyframes appmon-disclose {
  from {
    opacity: 0;
    transform: translateY(-0.35rem);
  }
}

.new-account[open] > *:not(summary) {
  animation: appmon-disclose var(--dur-slow) var(--ease-out);
}

.new-account summary:hover {
  color: var(--color-link);
}

/* --- Reduced motion --------------------------------------------------------

   The blanket rule near the top of this file collapses durations, which is the
   right default but leaves two things wrong, so both are stated explicitly.

   The status settle needs no replacement: its job is already done by
   .service-tile__changed, which is text and was never motion. That is why the
   recency mark exists rather than being a nicety on top of the animation.

   The spinner does need one. Frozen mid-rotation it is a ring with a bite out
   of it, which reads as broken rather than busy -- so it is completed into a
   plain ring and simply sits there. */

@media (prefers-reduced-motion: reduce) {
  .vault-form button[aria-busy="true"]::after,
  .auth-form button[aria-busy="true"]::after,
  .users-invite-form button[aria-busy="true"]::after,
  .account-form button[aria-busy="true"]::after,
  .prtg-import-form button[aria-busy="true"]::after {
    animation: none;
    border-right-color: currentColor;
    opacity: 0.55;
  }

  .service-tile.is-changed::before,
  .service-tile.is-changed::after,
  .alarm-list .status-row.is-new::before,
  .cert-list .status-row.is-new::before {
    display: none;
  }
}

/* Admin vault console (appmon/templates/admin_vault.html). */

.badge {
  display: inline-block;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: 0.85em;
  font-weight: 600;
  color: #fff;
}

.badge-locked {
  background: var(--color-down);
}

/* The block colour is tuned to be seen as an object on white, not to carry
   white text; filled badges use the ink instead (6.52:1 rather than 3.93:1). */
.badge-unlocked {
  background: var(--color-up-ink);
}

.vault-form {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.75rem;
}

.vault-form label {
  font-size: 0.9em;
  color: var(--color-muted);
}

.vault-form input[type="password"] {
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--color-field-border);
  border-radius: var(--radius-sm);
}

.vault-form button {
  padding: 0.4rem 0.9rem;
}

.vault-key-list {
  list-style: none;
  margin: 0.75rem 0 0;
  padding: 0;
}

.vault-key-list li {
  padding: 0.3rem 0;
  border-bottom: 1px solid var(--color-border);
}

.vault-key-list li:last-child {
  border-bottom: none;
}

/* Login and set-password (appmon/templates/{login,set_password}.html). */

/* The sign-in screen only (appmon/templates/login.html): a centred card on
   the surface tone, with the app's own name set as a small branded header
   above the "Sign in" heading -- the same navy the main header's brand link
   uses, so the two read as one identity even though this page never renders
   that header's markup. set_password.html keeps rendering its panel full
   width; it never opts into `.auth-card`. */
.auth-page {
  display: flex;
  justify-content: center;
  padding-top: 2.5rem;
}

.auth-card {
  width: 100%;
  max-width: 22rem;
}

.auth-card__brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.9rem;
  color: var(--color-brand);
  font-weight: 700;
  font-size: 0.8125rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
}

.auth-card__brand .appmon-mark {
  width: 1.75rem;
  height: 1.75rem;
}

.auth-card h1 {
  margin: 0 0 1rem;
  font-size: 1.3rem;
}

/* Present to assistive technology, absent from the page. login.html's only
   use: the tab strip is the visible heading, so its <h1> would otherwise
   either duplicate the active tab's label or not exist at all. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The sign-in / set-up-password tabs on login.html. Two links, not scripted
   panels -- the active one is chosen server-side, so the only state here is
   which of them carries --active. Bottom borders do the work: the active tab
   drops its own so it reads as continuous with the card body below. */
.auth-tabs {
  display: flex;
  gap: 0.25rem;
  margin: 0 0 1rem;
  border-bottom: 1px solid var(--color-border);
}

/* Scoped under .auth-tabs so it outranks `.panel a` above (0,2,0 against
   0,1,1). Without that, the inactive tab inherits the link colour and reads
   as the more prominent of the two -- exactly backwards. */
.auth-tabs .auth-tabs__tab {
  padding: 0.45rem 0.8rem;
  border: 1px solid var(--color-border);
  border-bottom: none;
  border-radius: 4px 4px 0 0;
  margin-bottom: -1px;
  background: var(--color-surface);
  color: var(--color-muted);
  font-size: 0.9rem;
  text-decoration: none;
}

.auth-tabs .auth-tabs__tab:hover {
  color: var(--color-navy);
}

.auth-tabs .auth-tabs__tab--active {
  background: #fff;
  border-bottom: 1px solid #fff;
  color: var(--color-navy);
  font-weight: 600;
}

.auth-form {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  max-width: 320px;
  margin-top: 0.75rem;
}

.auth-form label {
  font-size: 0.9em;
  color: var(--color-muted);
}

.auth-form input[type="text"],
.auth-form input[type="password"] {
  padding: 0.45rem 0.6rem;
  border: 1px solid var(--color-field-border);
  border-radius: var(--radius-sm);
  margin-bottom: 0.5rem;
}

.auth-form button {
  padding: 0.5rem 0.9rem;
}

/* Admin user console (appmon/templates/admin_users.html). */

.users-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 0.75rem;
}

.users-table th,
.users-table td {
  text-align: left;
  padding: 0.5rem 0.6rem;
  border-bottom: 1px solid var(--color-border);
  font-size: 0.9em;
}

.users-invite-form {
  margin: 0;
}

.users-invite-form button {
  padding: 0.3rem 0.7rem;
  font-size: 0.85em;
}

/* Four actions per row stops being a column and starts being a paragraph, so
   they sit on one line with the state-changing one held apart from the two
   that only send mail. */
.row-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.row-actions__last {
  margin-left: auto;
  padding-left: 0.75rem;
  border-left: 1px solid var(--color-border);
}

.account-form {
  display: grid;
  gap: 0.35rem;
  max-width: 28rem;
  margin: 0.75rem 0 0;
}

.account-form label {
  font-weight: 600;
}

.account-form input[type="text"],
.account-form select {
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--color-field-border);
  border-radius: var(--radius-sm);
}

.account-form .checkbox-label {
  font-weight: 400;
}

.field-note {
  margin: 0;
  color: var(--color-muted);
  font-size: 0.8rem;
}

/* A full border and a wash of the status's own hue, not a thick rule down one
   edge -- a stripe on one side reads as decoration, and an error is not
   decoration. The ink is --color-down-ink (8.88:1 on white) rather than a
   mid-red, because this is a sentence someone has to read and act on. */
.form-error {
  padding: 0.6rem 0.8rem;
  border: 1px solid color-mix(in srgb, var(--color-down) 35%, var(--color-page));
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--color-down) 6%, var(--color-page));
  color: var(--color-down-ink);
}

.new-account {
  margin-bottom: 1rem;
}

/* This block was previously left unclosed, which made the CSS parser swallow
   every declaration of .prtg-import-form below as an invalid declaration of
   this rule -- the PRTG import form has been shipping unstyled. */
.new-account summary {
  cursor: pointer;
  font-weight: 600;
}

/* PRTG import (appmon/templates/prtg_import.html, prtg_import_preview.html). */

.prtg-import-form {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.75rem;
}

.prtg-import-form button {
  padding: 0.4rem 0.9rem;
}

/* Cancel, next to Apply on the preview screen -- same size, deliberately
   lower visual weight (an outline, not a fill) so Apply -- the write --
   stays the one button on the page that reads as the primary action. No
   new colour: the border and hover wash are both the neutral tokens
   already used elsewhere in this file. */
.prtg-import-form button.button-secondary {
  background: none;
  color: var(--color-text);
  border-color: var(--color-field-border);
}

.prtg-import-form button.button-secondary:hover {
  background: var(--color-surface);
}

.prtg-import-form button.button-secondary:active {
  background: var(--color-border);
}

/* Phone. Structural only -- the columns collapse and the gutters tighten; the
   type scale does not move, because a wallboard read at arm's length and a
   phone read at 30cm want the same sizes. */

@media (max-width: 40rem) {
  .appmon-header {
    padding: 0.7rem 1rem;
  }

  .appmon-main {
    padding: 1rem;
  }

  .appmon-footer__build {
    padding: 0.85rem 1rem;
  }

  .panel {
    padding: 1rem;
  }

  .service-tiles {
    grid-template-columns: 1fr;
  }

  .auth-page {
    padding-top: 1rem;
  }
}

/* --- Symbols: application marks and country flags -------------------------- */

/* One tile, two sources. A vendor mark and a derived monogram share the same
   20px footprint and the same frame, so the one application that happens to
   have a freely-licensed, legible-at-13px logo does not read as the finished
   one and the other twenty-one as placeholders -- a distinction the shared
   frame erases at any ratio, and erases hardest at one-in-twenty-two, where it
   would otherwise be the loudest thing on the page. The shared width also
   aligns the leading column down every list and across every page. */
.app-mark {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-surface-sunken);
  color: var(--color-text-muted);
}

/* The mark is masked rather than <img>-ed so its colour comes from a token:
   DESIGN.md reserves colour for status, and a brand's shape is what carries
   recognition anyway. Masking also keeps the asset referenced rather than
   inlined, which is what makes the 30-second tile swap cost zero bytes.

   The transparent default matters: an unset --mark would make the shorthand
   invalid, mask would fall back to `none`, and an unmasked currentColor
   background would paint a solid 13px block. */
.app-mark__glyph {
  --mark: linear-gradient(#0000, #0000);
  width: 13px;
  height: 13px;
  background: currentColor;
  -webkit-mask: var(--mark) center / contain no-repeat;
  mask: var(--mark) center / contain no-repeat;
}

.app-mark__text {
  font-size: 0.625rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  line-height: 1;
}

/* The hairline is load-bearing, not trim: South Africa, Zambia and Mauritius
   all have white or near-white edges that would otherwise dissolve into a
   white panel and leave a flag with no boundary.

   inline-block, not block: eight of the nine surfaces place this inside a
   flex or grid container, where a flex/grid item is blockified regardless and
   the two are indistinguishable -- but site_detail.html's header country line
   sets it in a plain `<p class="muted">`, whose paragraph has no formatting
   context of its own (`.muted` is a colour rule only). A block box there
   splits that paragraph's inline content into three anonymous boxes -- the
   em-dash, the flag and the country name each land on their own line.
   inline-block keeps the paragraph's line intact everywhere; vertical-align
   puts the 15px flag back on the surrounding text's baseline. */
.flag {
  display: inline-block;
  vertical-align: -0.2em;
  flex: none;
  width: 20px;
  height: 15px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
}

/* --- Imported from PRTG (appmon/templates/status.html) -------------------- */

/* The probes and sensor-type tables scroll inside their own box; the page
   itself must never scroll sideways.

   `width: 0; min-width: 100%`, not `max-width: 100%` as `.checks-scroll`
   above uses -- that form silently fails the moment a table's min-content is
   wider than the viewport. `body` is a column flex container, so its
   `main.appmon-main` item takes a stretched *fit-content* cross size floored
   at min-content: a wide table pushes `main` past the viewport, and
   `max-width: 100%` then resolves against that already-widened ancestor
   rather than against the viewport. It is circular, and the table wins.
   Every panel on the page inherits the stretch, so one wide table tips the
   whole page sideways.

   Measured at a 390px viewport before the fix: `main` grew to 563px and the
   wrapper to 497px with no internal scrolling at all. `min-width: 0` on the
   wrapper, on `.panel`, or on `main` changes nothing -- the floor is the flex
   item's fit-content sizing, not a min-width -- and `overflow-x: clip` on
   `main` only hides the symptom. Starting from `width: 0` gives the
   percentage something definite to resolve against: the wrapper lands at the
   panel's content width and the overflow goes where it was always meant to.

   Scoped to this panel rather than fixed in `.checks-scroll` on purpose. The
   same latent bug is in `.checks-scroll` and `.matrix-scroll`, but applying
   this there also narrows the existing tables on /sites/{code} and
   /apps/{key} (measured: one goes from spilling 28px outside its panel to
   scrolling inside it), and that is a visual change to those two pages which
   belongs with a look at each of them, not with this panel. */
.prtg-scroll {
  width: 0;
  min-width: 100%;
  overflow-x: auto;
}

/* A probe PRTG no longer offers, or one whose imported fields have drifted.
   Filled with the degraded *ink* rather than the block colour, so the white
   text `.badge` sets stays readable -- the same choice `.badge-unlocked`
   documents above. The state is spelled out in the badge's text, never
   carried by colour alone: a tint is unquotable over the phone and invisible
   to a colour-blind operator. */
.badge-drift {
  background: var(--color-degraded-ink);
}

/* A sensor type with no registered probe. Deliberately not a badge: nothing
   is wrong -- this system simply cannot probe that type yet, and dressing a
   boundary as a warning would send someone looking for a fault. */
.type-inventory {
  color: var(--color-muted);
  font-size: 0.85em;
}
