/*
 * errd web interface.
 *
 * Every colour in here comes from the palette declared below and nothing else, so a
 * change to the palette changes the whole interface. The severity ramp deliberately
 * leaves info and debug uncoloured: in a triage tool colour is scarce, and if
 * everything is coloured nothing reads as urgent. Every severity is also paired with
 * a glyph and a word, because a hue on its own is not a distinction everybody can see.
 */

/*
 * Terminess Nerd Font, the patched Terminus, from jsDelivr. OFL 1.1.
 *
 * local() comes first in each src, so a machine that already has the font installed —
 * which, for the people who run a terminal-first error tracker, is a good few of them —
 * uses it and downloads nothing.
 *
 * The URL is pinned to a commit rather than a branch. It is a third party's repository of
 * webfont builds, and a moving reference to somebody else's default branch is a thing that
 * can change under a running install.
 *
 * Regular and bold only. The italic faces are another 1.8MB for source-context lines that
 * currently render upright anyway; a browser will synthesise a slant if something ever
 * asks for one.
 *
 * font-display: swap, so text is readable in the fallback while ~930KB a face arrives
 * rather than invisible until it does.
 */
@font-face {
  font-family: "Terminess Nerd Font";
  src:
    local("TerminessNerdFont-Regular"),
    url("https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@c009b09b14e1de914a552a488b9d9306e0661c1a/build/fonts/TerminessNerdFont-Regular.woff2")
      format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Terminess Nerd Font";
  src:
    local("TerminessNerdFont-Bold"),
    url("https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@c009b09b14e1de914a552a488b9d9306e0661c1a/build/fonts/TerminessNerdFont-Bold.woff2")
      format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

:root {
  --base-950: #100e0d;
  --base-900: #171513;
  --base-850: #1e1b19;
  --base-800: #262220;
  --base-700: #332e2b;
  /* On --base-950, base-600 is 2:1 and base-500 is 3.2:1, so neither passes AA for text.
     They are for borders, dividers and disabled states; base-400 at 5.3:1 is the quietest
     grey that words may be set in. */
  --base-600: #4a433f;
  --base-500: #6b625d;
  --base-400: #8f857f;
  --base-300: #b5aba4;
  --base-200: #d6cec8;
  --base-100: #efeae6;

  --accent-300: #7ceedd;
  --accent-400: #35d9c4;
  --accent-500: #1fb8a6;
  --accent-600: #158c7f;
  --accent-950: #0c2f2b;

  --fatal: #d6335b;
  --error: #f4634e;
  --warning: #e8a33d;
  --info: #8f857f;
  --debug: #6b625d;
  --resolved: #46c97f;

  --trace-400: #a78bf5;
  --trace-950: #241c3d;

  --diff-add: #46c97f;
  --diff-add-bg: #12291e;
  --diff-remove: #e05263;
  --diff-remove-bg: #2e1519;
  --diff-change: #e8a33d;
  --diff-change-bg: #2b2113;

  /* The type scale.
   *
   * Terminess is a bitmap font grown outlines: it is sharp at the sizes Terminus was
   * drawn for — 12, 14, 16, 18, 20, 22, 24 — and mushy between them. Every size here
   * lands on one of those at the default 16px root, which is why there is no 13px or
   * 17px in this file any more; those were the ones that looked soft.
   *
   * In rem rather than px so somebody who has turned their browser's font up gets a
   * bigger interface. That can land between Terminus's sizes, which is a fair trade:
   * slightly soft and readable beats sharp and too small.
   *
   * Nothing is smaller than 12px, including the uppercase micro-labels that used to be
   * 11.
   */
  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: 1.125rem;
  --text-xl: 1.25rem;
  --text-2xl: 1.5rem;

  --sidebar-width: 15rem;
  --radius: 4px;
  /* Terminess for everything, monospaced and not. The two variables stay separate
     because what they mean is different — one is "this is data", the other "this is
     prose" — and their fallbacks differ accordingly. */
  --mono: "Terminess Nerd Font", ui-monospace, "SFMono-Regular", "JetBrains Mono", "Menlo",
    "Consolas", monospace;
  --sans: "Terminess Nerd Font", ui-monospace, ui-sans-serif, system-ui, -apple-system,
    "Segoe UI", sans-serif;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
}

body {
  background: var(--base-950);
  color: var(--base-300);
  /* 600 rather than 400: only the regular and bold faces are loaded, so this resolves to
     bold, and bold Terminus on a dark background is the more readable of the two. The
     cost is that <strong> no longer stands out on weight alone. */
  font: 600 var(--text-base)/1.5 var(--sans);
}

h1,
h2,
h3,
h4 {
  color: var(--base-100);
  font-weight: 600;
  line-height: 1.25;
  margin: 0;
}

h1 {
  font-size: var(--text-xl);
}

h2 {
  font-size: var(--text-lg);
}

h3 {
  /* The same size as body text, distinguished by colour and by the rule under it. A
     heading a shade smaller than the paragraph it introduces reads as a caption. */
  font-size: var(--text-base);
}

a {
  color: var(--accent-400);
  text-decoration: none;
}

a:hover {
  color: var(--accent-300);
  text-decoration: underline;
}

code,
pre,
.mono {
  font-family: var(--mono);
  font-size: var(--text-sm);
}

/* ---------- icons ---------- */

/* Tabler Icons, inlined by internal/web/icons.go. Sized in em so an icon follows the
   text it sits beside, and stroked with currentColor so it takes the colour of whatever
   it is inside with nothing to keep in step. Every one is aria-hidden: an icon here is
   always beside its own label, never instead of it. */
.icon {
  width: 1.125em;
  height: 1.125em;
  flex: 0 0 auto;
  vertical-align: -0.1875em;
  /* Stated here rather than on each element: a table of fifty errors is a hundred icons,
     and these are the same values every time. */
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Anything that puts an icon beside a word gets the gap from its own layout, so a bare
   inline icon needs none of its own. */
.btn .icon,
.notice .icon {
  width: 1.0625em;
  height: 1.0625em;
}

/* The brand mark, from static/icon.svg. Sized in rem rather than em: it is the product's
   mark at a fixed size, not something that should track the text beside it. */
.brand {
  display: block;
  flex: 0 0 auto;
  height: auto;
}

.brand--sidebar {
  /* Four bars, one of them at 70% opacity, so it needs a little more room than a glyph
     would to stay legible. */
  width: 1.25rem;
}

.brand--login {
  width: 1.75rem;
}

/* The pager's chevrons are the only icons that carry direction rather than meaning, so
   they are quieter than the word beside them. */
.pager .icon {
  color: var(--base-600);
}

/* On a link out of the product, half a size down and dimmed: it marks the link rather
   than labelling it. */
a .icon--external-link {
  width: 0.875em;
  height: 0.875em;
  margin-left: 0.25rem;
  color: var(--base-400);
}

::selection {
  background: var(--accent-950);
  color: var(--base-100);
}

:focus-visible {
  outline: 2px solid var(--accent-400);
  outline-offset: 2px;
}

/* Reachable by keyboard only, so a screen reader user is not made to walk the
   whole sidebar before the table on every page. */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--accent-500);
  color: var(--base-950);
  padding: 0.5rem 0.75rem;
  z-index: 10;
}

.skip-link:focus {
  left: 0.5rem;
  top: 0.5rem;
}

/* ---------- disclosures ---------- */

/*
 * Every <summary> in the interface carries .disclosure. It exists because there were
 * three of them styled three ways: one as a section label, one as a label with an inline
 * cursor, one as a button — and the first was indistinguishable from the labels around it
 * that do nothing. A thing that opens when clicked has to look like it will.
 *
 * The mechanics live here; what it looks like comes from a modifier, so a disclosure in
 * the sidebar reads as a sidebar row and one above a form reads as a form label. Both are
 * unmistakably clickable, which is the part that has to be the same everywhere.
 *
 * The chevron is in the markup rather than generated, so a disclosure that already has an
 * icon of its own (revealing a key) is not given a second one. Wherever there is one, it
 * turns to point down when the section is open.
 */
.disclosure {
  display: flex;
  align-items: center;
  gap: 0.375rem;
  cursor: pointer;
  /* The browser's own marker is dropped in favour of the chevron: it does not inherit
     colour, it sits outside the flex row, and on a summary with display other than
     list-item most browsers hide it anyway — which is how the sidebar ended up with no
     affordance at all. */
  list-style: none;
}

.disclosure::-webkit-details-marker,
.disclosure::marker {
  display: none;
  content: "";
}

.disclosure .icon--chevron-right {
  color: var(--base-400);
  transition: transform 120ms ease-out;
}

details[open] > .disclosure .icon--chevron-right {
  transform: rotate(90deg);
}

.disclosure:hover .icon--chevron-right,
.disclosure:focus-visible .icon--chevron-right {
  color: var(--accent-400);
}

/* In the sidebar, an interactive row looks like the nav rows around it: same padding,
   same radius, same hover. That is the whole trick — what you can click looks like the
   other things you can click, and the uppercase labels stay for the things you cannot. */
.disclosure--nav {
  padding: 0.375rem 0.5rem;
  border-radius: var(--radius);
  color: var(--base-300);
}

.disclosure--nav:hover {
  background: var(--base-850);
  color: var(--base-100);
}

/* Above a form section, it keeps the micro-label look and gains the chevron. */
.disclosure--label {
  color: var(--base-400);
  font-size: var(--text-sm);
  font-weight: 600;
}

.disclosure--label:hover {
  color: var(--base-200);
}

/* ---------- shell ---------- */

.app {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: var(--sidebar-width);
  flex: 0 0 var(--sidebar-width);
  background: var(--base-900);
  border-right: 1px solid var(--base-800);
  display: flex;
  flex-direction: column;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow-y: auto;
}

.sidebar__brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 1rem 1rem 0.75rem;
}

.sidebar__brand a {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--base-100);
  font: 600 var(--text-2xl) var(--mono);
  letter-spacing: 0.05em;
  text-decoration: none;
}

.sidebar__brand a:hover {
  color: var(--accent-300);
  text-decoration: none;
}

.sidebar__section {
  padding: 0.5rem 0.5rem;
  border-top: 1px solid var(--base-800);
}

.sidebar__label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.25rem 0.5rem;
  color: var(--base-400);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.sidebar__label a {
  font-size: var(--text-xs);
  letter-spacing: 0.02em;
  text-transform: none;
}

.sidebar__current {
  padding: 0.125rem 0.5rem 0.5rem;
  color: var(--base-200);
  font-weight: 600;
}

.sidebar__current small {
  display: block;
  color: var(--base-400);
  font-weight: 400;
  font-family: var(--mono);
  font-size: var(--text-sm);
}

.nav {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.nav a {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.375rem 0.5rem;
  border-radius: var(--radius);
  color: var(--base-300);
  text-decoration: none;
}

.nav a:hover {
  background: var(--base-850);
  color: var(--base-100);
  text-decoration: none;
}

.nav a[aria-current="page"] {
  background: var(--accent-950);
  color: var(--base-100);
  box-shadow: inset 2px 0 0 var(--accent-400);
}

.nav .icon {
  flex: 0 0 auto;
  color: var(--base-400);
}

.nav a[aria-current="page"] .icon,
.nav a:hover .icon {
  color: var(--accent-400);
}

.nav__count {
  margin-left: auto;
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

.sidebar__spacer {
  flex: 1;
}

/* A section pinned below the spacer. It keeps its own top border and, unlike the sections
   above, its distance from the bottom of the sidebar does not depend on what is above it —
   which is the point: these are the entries somebody hunts for occasionally, and they
   should be in the same place every time. */
.sidebar__section--pinned {
  flex: 0 0 auto;
}

/* The whole nav behind one disclosure below the sidebar's breakpoint, closed by default so a
   narrow screen is not the full organisation/project/settings tree on every page before the
   content. .sidebar__nav-toggle holds only the summary, never the nav itself: a <details>'s
   own rule for hiding a closed section's children turns out not to be overridable by an
   author style asking for `display: contents` on the <details> or the <summary>, confirmed
   in Chromium and Firefox both, so .sidebar__nav-body is an ordinary sibling <div> instead,
   whose display this stylesheet controls the same way it controls any other element's.
   Above the breakpoint it is forced to `display: contents` unconditionally, which erases its
   own box and promotes .sidebar__section, .sidebar__spacer and the pinned settings group
   back to being direct flex children of .sidebar, exactly as if this disclosure did not
   exist. A wide screen never sees the summary either. See the media query below for the
   narrow-screen behaviour, keyed off .sidebar__nav-toggle[open] rather than anything native
   to what a <details> normally does with its own children. */
.sidebar__nav-summary {
  display: none;
}

.sidebar__nav-summary-context {
  margin-left: auto;
  color: var(--base-400);
  font-weight: 400;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sidebar__nav-body {
  display: contents;
}

.sidebar__user {
  padding: 0.75rem 1rem;
  border-top: 1px solid var(--base-800);
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex: 0 0 auto;
}

.sidebar__user .who {
  min-width: 0;
  flex: 1;
}

.sidebar__user .who:hover strong {
  color: var(--accent-300);
}

.sidebar__user .who {
  text-decoration: none;
}

.sidebar__user .who strong {
  display: block;
  color: var(--base-200);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sidebar__user .who small {
  color: var(--base-400);
}

.main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.topbar {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.875rem 1.5rem;
  border-bottom: 1px solid var(--base-800);
  background: var(--base-950);
  position: sticky;
  top: 0;
  z-index: 5;
}

.topbar__title {
  min-width: 0;
}

.topbar__actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.crumbs {
  color: var(--base-400);
  font-size: var(--text-sm);
  display: flex;
  gap: 0.375rem;
  flex-wrap: wrap;
}

.crumbs a {
  color: var(--base-400);
}

.crumbs .sep {
  color: var(--base-700);
}

.content {
  padding: 1.5rem;
  max-width: 90rem;
  width: 100%;
}

/* ---------- cards ---------- */

.card {
  background: var(--base-850);
  border: 1px solid var(--base-800);
  border-radius: var(--radius);
  margin-bottom: 1rem;
}

.card__head {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.625rem 0.875rem;
  border-bottom: 1px solid var(--base-800);
}

.card__head h2,
.card__head h3 {
  margin-right: auto;
}

.card__body {
  padding: 0.875rem;
}

.card__body--flush {
  padding: 0;
}

.grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}

/* Six cards that break in even rows: six across, then two rows of three, then three rows of two.
   Never one card alone on a line of its own.

   `.grid` on its own cannot do this, and the arithmetic is why rather than a matter of taste. The
   content column is capped at 90rem with 1.5rem of padding, so the grid gets at most 87rem, while
   `auto-fit` with a 15rem minimum needs 95rem to fit six. Six was therefore never reachable: the
   widest it ever managed was five, leaving the sixth card — Owner — stranded on its own row at every
   window size. An explicit column count is the only way to say "five is not an option".

   The count is in the class name on purpose. If a seventh card is added to the error detail page,
   this needs changing with it, and a name that says six is a name somebody notices. */
.grid--six {
  grid-template-columns: repeat(6, minmax(0, 1fr));
}

/* minmax(0, 1fr) and not 1fr, throughout: a bare 1fr means minmax(auto, 1fr), and auto floors at
   min-content, so the longest label ("People affected combined") would widen its column past its
   share and push the row out. Zero as the floor lets a label wrap instead. */
@media (max-width: 84rem) {
  .grid--six {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (max-width: 34rem) {
  .grid--six {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.stat {
  background: var(--base-850);
  border: 1px solid var(--base-800);
  border-radius: var(--radius);
  padding: 0.75rem 0.875rem;
}

.stat dt {
  color: var(--base-400);
  font-size: var(--text-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.stat dd {
  margin: 0.25rem 0 0;
  color: var(--base-100);
  font: 600 var(--text-xl)/1.2 var(--mono);
}

.meta {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.375rem 1rem;
  margin: 0;
}

.meta dt {
  color: var(--base-400);
}

.meta dd {
  margin: 0;
  color: var(--base-200);
  font-family: var(--mono);
  font-size: var(--text-sm);
  overflow-wrap: anywhere;
}

/* ---------- tables ---------- */

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-base);
}

.table th {
  text-align: left;
  padding: 0.5rem 0.875rem;
  color: var(--base-400);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border-bottom: 1px solid var(--base-800);
  white-space: nowrap;
}

.table td {
  padding: 0.5rem 0.875rem;
  border-bottom: 1px solid var(--base-800);
  vertical-align: top;
}

.table tbody tr:hover {
  background: var(--base-850);
}

.table tbody tr.is-selected {
  background: var(--accent-950);
}

.table .num {
  font-family: var(--mono);
  text-align: right;
  white-space: nowrap;
}

/* People affected shares the occurrence count's cell rather than taking an eighth column.
   Dimmer than the count so the eye reads one number first, and it is the second half of one
   fact rather than a competing one. */
.num__people {
  color: var(--base-400);
}

/* A column that takes whatever width the others do not need, rather than sizing to its own
   longest value: the name column in a member list, where the alternative is a name column
   exactly as wide as the longest name and an arbitrary stretch of empty cell before the next
   one starts. Set on the header cell, which is enough for table-layout: auto to size the
   whole column by it. */
.table .grow {
  width: 100%;
}

.table .when {
  color: var(--base-400);
  font-family: var(--mono);
  white-space: nowrap;
}

.table__scroll {
  overflow-x: auto;
}

.row-title {
  display: block;
  color: var(--base-100);
  font-weight: 600;
}

.row-title:hover {
  color: var(--accent-300);
  text-decoration: none;
}

.row-sub {
  display: block;
  margin-top: 0.125rem;
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-sm);
  overflow-wrap: anywhere;
}

.row-type {
  color: var(--base-200);
  font-family: var(--mono);
}

.empty {
  padding: 2.5rem 1rem;
  text-align: center;
  color: var(--base-400);
}

.empty strong {
  display: block;
  color: var(--base-200);
  margin-bottom: 0.25rem;
}

/* ---------- badges ---------- */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.3125rem;
  padding: 0.0625rem 0.375rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-900);
  color: var(--base-300);
  font-family: var(--mono);
  font-size: var(--text-sm);
  white-space: nowrap;
}

.badge .icon {
  /* A shade larger than the label: at 12px the stroke needs the room, and the badge's
     own line height stops it changing the row's height. */
  width: 0.9375rem;
  height: 0.9375rem;
  stroke-width: 2.25;
}

.badge--fatal {
  color: var(--fatal);
  border-color: color-mix(in srgb, var(--fatal) 40%, var(--base-700));
}

.badge--error {
  color: var(--error);
  border-color: color-mix(in srgb, var(--error) 40%, var(--base-700));
}

.badge--warning {
  color: var(--warning);
  border-color: color-mix(in srgb, var(--warning) 40%, var(--base-700));
}

.badge--info {
  color: var(--info);
}

.badge--debug {
  color: var(--debug);
}

.badge--resolved {
  color: var(--resolved);
  border-color: color-mix(in srgb, var(--resolved) 40%, var(--base-700));
}

.badge--regressed {
  color: var(--warning);
  border-color: color-mix(in srgb, var(--warning) 40%, var(--base-700));
}

.badge--ignored {
  color: var(--base-400);
}

.badge--unresolved {
  color: var(--base-300);
}

.badge--trace {
  color: var(--trace-400);
  background: var(--trace-950);
  border-color: color-mix(in srgb, var(--trace-400) 30%, var(--base-700));
}

.badge--plain {
  border-color: transparent;
  background: transparent;
  color: var(--base-400);
}

/* A muted error is not a severity, so it gets no severity colour: it is dimmed, and the
   bell-off glyph beside it is what actually says "muted". Colour is never the only signal
   here, and this badge is the case where colour says nothing at all. */
.badge--muted {
  color: var(--base-400);
  border-color: var(--base-700);
  border-style: dashed;
}

/* A saved filter's own delete control, inside its badge. Bare-reset rather than .btn: a
   button the size of the label beside it would make the badge taller than the row it sits
   in, which is what happened the first time this used .btn--small. Hover reads as
   destructive, the same rule .field__clear follows. */
.badge__remove {
  display: inline-flex;
  border: none;
  background: none;
  padding: 0;
  margin: 0;
  color: inherit;
  cursor: pointer;
}

.badge__remove:hover {
  color: var(--error);
}

.badge__remove .icon {
  width: 0.8125em;
  height: 0.8125em;
}

/* One row of saved views, wrapping rather than scrolling: a project with a dozen of them is
   rare, and a dozen short badges wrap onto a second line more readably than a scrollbar
   would show them. */
.saved-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}

.tag {
  display: inline-block;
  padding: 0 0.3125rem;
  border-radius: var(--radius);
  background: var(--base-800);
  color: var(--base-300);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

/* A card holding something irreversible. The border is the error red rather than the
   neutral one: this is the one place in the interface where the frame itself is a warning. */
.card--danger {
  border-color: color-mix(in srgb, var(--error) 45%, var(--base-800));
}

.card--danger .card__head {
  border-bottom-color: color-mix(in srgb, var(--error) 30%, var(--base-800));
}

.card--danger .card__head .icon {
  color: var(--error);
}

/* What a delete would take with it. A list, because a sentence with five clauses in it is
   not something anybody reads before clicking. */
.loss {
  margin: 0.5rem 0 1rem;
  padding-left: 1.125rem;
  color: var(--base-300);
}

.loss li {
  margin-bottom: 0.25rem;
}

/* ---------- buttons and forms ---------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.3125rem 0.6875rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-850);
  color: var(--base-200);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}

.btn:hover {
  background: var(--base-800);
  color: var(--base-100);
  text-decoration: none;
}

.btn:active {
  background: var(--base-700);
}

.btn[disabled],
.btn[aria-disabled="true"] {
  color: var(--base-500);
  cursor: not-allowed;
}

.btn--primary {
  background: var(--accent-500);
  border-color: var(--accent-500);
  color: var(--base-950);
  font-weight: 600;
}

.btn--primary:hover {
  background: var(--accent-400);
  border-color: var(--accent-400);
  color: var(--base-950);
}

.btn--primary:active {
  background: var(--accent-600);
  border-color: var(--accent-600);
}

.btn--danger:hover {
  color: var(--fatal);
  border-color: color-mix(in srgb, var(--fatal) 50%, var(--base-700));
}

/* Filled, for the button that actually performs the destruction. The quiet .btn--danger
   above is for the one that only leads to the page asking about it. */
.btn--destructive {
  background: var(--fatal);
  border-color: var(--fatal);
  color: var(--base-100);
  font-weight: 600;
}

.btn--destructive:hover {
  background: color-mix(in srgb, var(--fatal) 85%, black);
  border-color: color-mix(in srgb, var(--fatal) 85%, black);
  color: var(--base-100);
}

.btn--small {
  padding: 0.125rem 0.4375rem;
  font-size: var(--text-sm);
}

.btn--link {
  border-color: transparent;
  background: transparent;
  color: var(--accent-400);
  padding: 0.125rem 0.25rem;
}

.btn--link:hover {
  background: transparent;
  color: var(--accent-300);
  text-decoration: underline;
}

.field {
  display: block;
  margin-bottom: 0.75rem;
}

.field > label,
.label {
  display: block;
  margin-bottom: 0.25rem;
  color: var(--base-400);
  font-size: var(--text-sm);
  font-weight: 600;
  letter-spacing: 0.02em;
}

.field small {
  display: block;
  margin-top: 0.25rem;
  color: var(--base-400);
}

/* Every text-like input, by exclusion rather than by listing the types. The list it
   replaces had no "url" in it, so the Slack and webhook fields on both settings pages were
   rendering as unstyled browser defaults; a checkbox and a radio are what actually need
   keeping out, and there are only a handful of those. */
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="file"]):not([type="range"]):not([type="color"]):not([type="image"]),
select,
textarea {
  width: 100%;
  padding: 0.375rem 0.5rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-950);
  color: var(--base-100);
  font: inherit;
  /* 16px, not a step down: a form control under 16px makes iOS Safari zoom the page when
     it is focused, and a field is the last place to save two pixels. */
  font-size: var(--text-base);
}

textarea {
  min-height: 4.5rem;
  resize: vertical;
}

input::placeholder,
textarea::placeholder {
  color: var(--base-500);
}

input:focus,
select:focus,
textarea:focus {
  border-color: var(--accent-400);
  outline: none;
}

select {
  appearance: none;
  padding-right: 1.5rem;
  background-image: linear-gradient(45deg, transparent 50%, var(--base-400) 50%),
    linear-gradient(135deg, var(--base-400) 50%, transparent 50%);
  background-position: calc(100% - 0.75rem) 55%, calc(100% - 0.5rem) 55%;
  background-size: 0.25rem 0.25rem;
  background-repeat: no-repeat;
}

.filters {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.625rem;
  padding: 0.75rem 0.875rem;
  border-bottom: 1px solid var(--base-800);
}

.filters .field {
  margin: 0;
}

/* .filters is the same arrangement as .inline-form and predates it. Named here rather than
   stacking the two classes on the form: they both set gap and align-items, so which one won
   would depend on their order in this file. */

.filters .field--grow {
  flex: 1 1 14rem;
  min-width: 10rem;
}

/* Clearing one control rather than the whole bar. It sits in the field's own label, which
   is where somebody looks when deciding what to change, and only appears when that filter is
   actually set. */
.field__clear {
  margin-left: 0.375rem;
  color: var(--base-400);
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  white-space: nowrap;
}

.field__clear:hover {
  color: var(--error);
  text-decoration: none;
}

.field__clear .icon {
  width: 0.9375em;
  height: 0.9375em;
}

/* The two ends of a date range, with an arrow between them. They stay side by side: a range
   split across two lines stops reading as one thing. */
.range {
  display: flex;
  align-items: center;
  gap: 0.375rem;
}

.range input {
  /* Wide enough for a date and a time without the native picker's icon crowding them. */
  min-width: 11.5rem;
}

.range__to {
  flex: 0 0 auto;
  /* base-400 rather than the fainter grey. It is decoration, and it could have joined the
     exceptions the contrast test allows, but a shorter exception list is worth more than two
     shades on an arrow. */
  color: var(--base-400);
}

.filters .field--pick {
  flex: 0 0 auto;
  width: 9.5rem;
}

/* The filter bar's submit, on its own line at the far end.
 *
 * Its own line because it cannot share one: eight fixed-width controls and a growing search box
 * leave no room, so in the flow it wrapped anyway — the difference is that it now wraps on purpose
 * and lands where a submit belongs rather than on the left under whichever field happened to end
 * the row above it.
 *
 * flex-end rather than space-between, for the reason .form-footer records: space-between only
 * right-aligns with two children, and "Clear filters" appears only when a filter is set, so the
 * common case is one child and would sit left. */
.filters__actions {
  flex: 1 1 100%;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 0.5rem;
}

/* A form of a few fields with their own explanations under them. Two columns where there
   is room, one where there is not; the actions row spans both.

   The alternative it replaces was a single flex row per form, which works for two short
   fields and squeezes badly at four. */
.form-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: 0.25rem 1.25rem;
  align-items: start;
}

.form-grid .field {
  margin-bottom: 0.75rem;
}

/* flex-end, the same reason .form-footer is: the primary action for a form belongs at the far
   right, whether it sits below the card (.form-footer) or, as here, on the action row inside
   it. A lone button in a flex-start row sits on the left, which is not where a submit belongs. */
.form-grid__actions {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 0.5rem;
}

/* The bar holding the button that commits a form, below the card the fields are in.
 *
 * Outside the card on purpose. A card is a group of related fields; the action that commits them
 * is not one of the fields, and putting it inside made it read as the last row of the form.
 *
 * flex-end, not space-between. space-between right-aligns only when there are two children, and
 * every one of these footers has one: a single button in a space-between row sits on the left,
 * which is exactly where these all were. Anything that belongs at the other end asks for it with
 * .form-footer__aside rather than relying on being counted.
 *
 * Wraps, so a note and a button on a narrow window stack instead of squeezing the note to nothing.
 */
.form-footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  gap: 0.5rem;
  /* Pulled up against the card above and given the room a card would have had below it, so the
     button reads as belonging to that card rather than floating between two. */
  margin: -0.5rem 0 1.5rem;
}

/* The far end of the footer: a destructive alternative to saving, or a note about what saving
   does. Pushed away from the button rather than sitting beside it, so the two are not mistaken
   for a pair of equal choices. */
.form-footer__aside {
  margin-right: auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* A select and its submit, kept together and given the row's spare width. */
.role-form {
  display: flex;
  flex: 1 0;
  gap: 0.5rem;
}

/* A form on one line: a field or two and its submit.
 *
 * flex-end, not center. A field is a label stacked on an input, so it is taller than the
 * button beside it; centring the row leaves the button floating in the middle of that stack,
 * and the nudge that used to correct it (.btn--aligned, margin-top: 1.375rem) was a guess at
 * a label's height that landed too low. Aligning the bottoms puts the button's lower edge on
 * the input's, which is what "next to the field" means, and needs no number at all.
 *
 * The same rule the error list's filter bar has always used.
 *
 * The one thing to know: a field with a <small> under its input is taller again, so its
 * neighbours would align to the bottom of the help text. Those rows want .field-row--top. */
.inline-form {
  display: flex;
  align-items: flex-end;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* The field that should take the row's spare width — a password beside a short fixed-width
   code, say. Scoped to .inline-form for the same reason .filters and .field-row scope their
   own copies: as a bare class it would set flex on fields in a stacked form, where there is
   no row to grow inside. */
.inline-form .field--grow {
  flex: 1 1 14rem;
  min-width: 10rem;
}

/* And the field's own bottom margin goes, because a margin is part of the box flex-end
   aligns. Left on, the button lines up with the bottom of 0.75rem of empty space rather than
   with the input above it — which is the whole reason a button here ever looked too low,
   first slightly and then, once the row was aligned, by the full margin.

   The spacing that margin was providing comes from the row's own gap. .filters has done both
   halves of this since it was written; copying only the alignment is what went wrong. */
.inline-form > .field,
.field-row > .field {
  margin-bottom: 0;
}

/* Then two pixels back up, because flush is not level to the eye.
 *
 * A button is 33px tall and an input 38px, so bottoms flush leaves the button's text sitting
 * below the input's. Half the difference is 2.5px; 2px, because a half-pixel margin rounds
 * differently at different zoom levels and this is a correction, not a measurement.
 *
 * The one number in this arrangement, and it is an optical one rather than a stand-in for a
 * height nobody wrote down. */
.inline-form > .btn,
.field-row > .btn,
.filters > .btn {
  margin-bottom: 0.125rem;
}

/* A row of actions at the foot of a card: one line, wrapping when it has to, with whatever
   should sit at the far end after a spacer. It is not a .card__head, which is for a heading
   and was being used as a toolbar because it happened to be a flex row with the right
   padding. */
.toolbar {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.75rem;
  padding: 0.625rem 0.875rem;
  border-top: 1px solid var(--base-800);
}

.toolbar__spacer {
  flex: 1 1 auto;
}

/* A form that takes the whole width of the toolbar rather than sharing a line.
 *
 * .toolbar is a wrapping flex row, so a form inside it is a flex item and sizes to its content. That
 * is right for the short ones — resolve, assign, mute — and wrong for the merge picker, whose options
 * are an error type and most of a message: left to size itself it is a narrow select truncating the
 * one thing somebody needs to read to choose correctly.
 *
 * flex-basis 100% rather than width, because a flex item's width is a suggestion its basis overrides.
 * It takes its own line and its field grows into all of it, leaving the button at the end. */
.toolbar__row {
  flex: 1 1 100%;
}

/* A row of fields, with or without a submit of its own, inside a larger form. Same alignment,
   same reasoning; separate from .row because .row centres and most of its uses want that.

   Centring is what goes wrong when a row's fields are unequally tall — one carrying help text
   and its neighbour not — because it lines up the middles of the boxes rather than the controls
   inside them, and the controls then sit at visibly different heights. Bottoms flush is right
   whenever every field is a label above a control. */
.field-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.5rem 0.875rem;
}

/* For a row whose fields carry help text under them: aligning the bottoms there would line
   everything up with the last line of the longest explanation. */
.field-row--top {
  align-items: flex-start;
}

/* The field that should take the row's spare width, usually the one holding a path or a URL.
   Scoped to .field-row for the same reason .filters scopes its own copy: as a bare class it
   would set flex on fields in stacked forms, where there is no row to grow inside. */
.field-row .field--grow {
  flex: 1 1 14rem;
  min-width: 10rem;
}

/* And the one that holds three digits. A number input's intrinsic width is a browser's guess at
   a general number, which is most of a row for something that never exceeds "100". */
.field-row .field--tight {
  flex: 0 0 5.5rem;
}

/* A checkbox standing where a control would in a row of fields.
 *
 * It takes an input's height so that its own bottom, and the label beside it, line up with the
 * inputs either side instead of floating a few pixels above them. 2.375rem is the 38px an input
 * measures, the same number the .btn correction above is derived from; .check already centres its
 * contents, so the text lands on the inputs' baseline rather than being nudged there. */
.field-row .check {
  min-height: 2.375rem;
  margin: 0;
}

/* ---------- notices ---------- */

.notice {
  display: flex;
  align-items: baseline;
  gap: 0.4375rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--base-700);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--base-850);
  color: var(--base-200);
  margin-bottom: 1rem;
}

.notice .icon {
  /* Baseline alignment keeps the icon on the first line of a message that wraps, which
     is where it belongs. The nudge is because an icon has no baseline of its own. */
  position: relative;
  top: 0.1875em;
}

.notice--error {
  border-left-color: var(--error);
}

.notice--ok {
  border-left-color: var(--resolved);
}

.notice--warn {
  border-left-color: var(--warning);
}

/* ---------- stack trace ---------- */

.frame {
  border-bottom: 1px solid var(--base-800);
}

.frame:last-child {
  border-bottom: 0;
}

.frame__head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 0.4375rem 0.875rem;
  font-family: var(--mono);
  font-size: var(--text-base);
  flex-wrap: wrap;
}

.frame__n {
  color: var(--base-500);
}

.frame__file {
  color: var(--accent-400);
  overflow-wrap: anywhere;
}

.frame__fn {
  color: var(--base-400);
}

.frame__code {
  margin: 0;
  padding: 0.375rem 0;
  background: var(--base-950);
  border-top: 1px solid var(--base-800);
  overflow-x: auto;
}

.frame__code .line {
  display: flex;
  gap: 0.875rem;
  padding: 0 0.875rem;
  white-space: pre;
  font-family: var(--mono);
  font-size: var(--text-base);
  color: var(--base-400);
}

.frame__code .line .n {
  color: var(--base-500);
  text-align: right;
  min-width: 3.5rem;
  user-select: none;
}

.frame__code .line--current {
  background: var(--base-800);
  color: var(--base-100);
}

.frame__code .line--current .n {
  color: var(--base-300);
}

pre.raw {
  font-size: var(--text-base);
  margin: 0;
  padding: 0.875rem;
  background: var(--base-950);
  border: 1px solid var(--base-800);
  border-radius: var(--radius);
  color: var(--base-300);
  overflow-x: auto;
}

/* ---------- context json tree ---------- */

/*
 * The context card's collapsible view: an object or array is a <details>, indented one step
 * further than its parent, and a scalar is one line. Only the tree's immediate children open by
 * default — everything nested inside one of those starts collapsed, which is the difference
 * between a payload that reads at a glance and one that is a wall of JSON.
 */
.json-tree {
  font-size: var(--text-base);
}

.json-tree__node {
  margin: 0.125rem 0;
}

.json-tree__children {
  padding-left: 1.25rem;
  border-left: 1px solid var(--base-800);
  margin-left: 0.3125rem;
}

.json-tree__summary {
  gap: 0.5rem;
  padding: 0.125rem 0;
}

.json-tree__row {
  display: flex;
  gap: 0.5rem;
  padding: 0.125rem 0;
  overflow-wrap: anywhere;
}

.json-tree__key {
  color: var(--accent-400);
  font-family: var(--mono);
  flex-shrink: 0;
}

.json-tree__colon {
  color: var(--base-400);
}

.json-tree__value {
  color: var(--base-300);
  overflow-wrap: anywhere;
}

.json-tree__count {
  font-size: var(--text-sm);
}

/* ---------- breadcrumb trail (violet: forensics, not chrome) ---------- */

.trail {
  background: var(--trace-950);
  border-radius: var(--radius);
  padding: 0.25rem 0;
}

.trail__item {
  display: grid;
  grid-template-columns: 6.5rem 5.5rem 1fr;
  gap: 0.75rem;
  padding: 0.3125rem 0.875rem;
  font-family: var(--mono);
  font-size: var(--text-base);
  border-bottom: 1px solid color-mix(in srgb, var(--trace-400) 12%, transparent);
}

.trail__item:last-child {
  border-bottom: 0;
}

.trail__when {
  color: var(--base-400);
}

.trail__kind {
  color: var(--trace-400);
}

.trail__what {
  color: var(--base-300);
  overflow-wrap: anywhere;
}

.trail__what .data {
  display: block;
  color: var(--base-400);
}

/* ---------- trend ---------- */

/* The occurrence chart: a fixed thirty-day window, a value axis down the left, and a
   tooltip that appears immediately.

   It used to be a bare row of bars, one per day that had data, with no numbers anywhere and
   a title attribute for the counts. That could only be compared with itself: you could see
   that one day was taller than another and not what either of them was. */
.chart {
  display: flex;
  gap: 0.5rem;
  height: 6.5rem;
}

.chart__ticks {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
  width: 2.5rem;
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-xs);
  /* The labels mark the top, middle and bottom of the plot, so the first and last sit
     against its edges rather than centred on their own rows. */
  line-height: 1;
  padding: 0 0 0 0;
}

.chart__plot {
  position: relative;
  flex: 1;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  /* Lines at the same heights as the tick labels, so a bar can be read against them
     instead of guessed at. */
  background-image: linear-gradient(to top, var(--base-800) 1px, transparent 1px),
    linear-gradient(to top, var(--base-800) 1px, transparent 1px),
    linear-gradient(to top, var(--base-800) 1px, transparent 1px);
  background-position: 0 0, 0 50%, 0 100%;
  background-size: 100% 100%, 100% 50%, 100% 1px;
  background-repeat: no-repeat;
}

.chart__bar {
  position: relative;
  flex: 1;
  min-width: 2px;
  background: var(--accent-600);
  border-radius: 1px 1px 0 0;
}

.chart__bar:hover {
  background: var(--accent-300);
}

/* A day with nothing in it still gets a mark on the baseline, so thirty days always read as
   thirty and a gap is visibly a gap. */
.chart__bar--empty {
  height: 1px !important;
  background: var(--base-700);
}

/* The tooltip. No transition and no delay: it is there to be read while the pointer moves
   along the bars. */
.chart__bar:hover::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 0.375rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 6;
  padding: 0.1875rem 0.4375rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-900);
  color: var(--base-100);
  font-family: var(--mono);
  font-size: var(--text-xs);
  white-space: nowrap;
  pointer-events: none;
}

/* Near the right-hand edge a centred tooltip would run off the card. */
.chart__bar:nth-last-child(-n + 4):hover::after {
  left: auto;
  right: 0;
  transform: none;
}

.chart__bar:nth-child(-n + 4):hover::after {
  left: 0;
  transform: none;
}

.chart__dates {
  display: flex;
  justify-content: space-between;
  margin-top: 0.375rem;
  /* Aligned with the plot rather than the card, since the ticks column is not part of it. */
  padding-left: 3rem;
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-xs);
}

/* The same rollup as .chart, shrunk to a row of the error list: no ticks, no dates, just the
   shape, because the count already sitting in the next cell is the number and this is only
   here to say whether it is climbing, quiet, or one bad afternoon three weeks ago. */
.sparkline {
  display: flex;
  align-items: flex-end;
  gap: 1px;
  height: 1.5rem;
  width: 5.5rem;
}

.sparkline__bar {
  position: relative;
  flex: 1;
  min-width: 1px;
  background: var(--accent-600);
  border-radius: 1px 1px 0 0;
}

.sparkline__bar:hover {
  background: var(--accent-300);
}

/* A day with nothing in it still gets a mark on the baseline, the same reason .chart__bar's
   does: thirty days always reads as thirty, and a gap is visibly a gap rather than absent. */
.sparkline__bar--empty {
  height: 1px !important;
  background: var(--base-700);
}

.sparkline__bar:hover::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 0.375rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 6;
  padding: 0.1875rem 0.4375rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-900);
  color: var(--base-100);
  font-family: var(--mono);
  font-size: var(--text-xs);
  white-space: nowrap;
  pointer-events: none;
}

/* Near either edge of the narrow column a centred tooltip would run off it, the same problem
   .chart__bar's nth-child rules solve for the wider chart. */
.sparkline__bar:nth-last-child(-n + 3):hover::after {
  left: auto;
  right: 0;
  transform: none;
}

.sparkline__bar:nth-child(-n + 3):hover::after {
  left: 0;
  transform: none;
}

/* ---------- comments ---------- */

.comment {
  padding: 0.625rem 0.875rem;
  border-bottom: 1px solid var(--base-800);
}

.comment:last-child {
  border-bottom: 0;
}

.comment__who {
  display: flex;
  gap: 0.5rem;
  align-items: baseline;
  margin-bottom: 0.25rem;
}

.comment__who strong {
  color: var(--base-200);
}

.comment__who .when {
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

.comment__body {
  color: var(--base-300);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* ---------- pagination ---------- */

.pager {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.625rem 0.875rem;
  color: var(--base-400);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

.pager__spacer {
  flex: 1;
}

/* A model's prose. Pre-wrapped rather than rendered as markdown: what comes back is text, and
   turning it into HTML would mean trusting a third party's output with a template. The whitespace
   is worth keeping because a numbered answer arrives as numbered lines. */
.analysis {
  white-space: pre-wrap;
  color: var(--base-200);
  line-height: 1.55;
  border-left: 3px solid var(--base-600);
  padding-left: 0.75rem;
  margin: 0.5rem 0 0.75rem;
}

/* The redacted payload on its own page. Scrolls in both directions inside its own box, so a long
   line does not make the whole page scroll sideways. */
.payload {
  display: block;
  max-height: 32rem;
  overflow: auto;
  white-space: pre;
  line-height: 1.45;
}

/* A unified diff. One span per line so each carries its own colour: a diff rendered as one block
   is unreadable at any real size, and colour is the only thing that makes a +/- scannable.

   The background carries the meaning as well as the text colour, because +/- at the start of a line
   is a one-character difference and this has to survive being skimmed. */
.diff {
  margin: 0.75rem 0;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  overflow: hidden;
}

.diff__path {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0.6rem;
  background: var(--base-850);
  border-bottom: 1px solid var(--base-700);
  font-size: var(--text-xs);
}

.diff__body {
  margin: 0;
  padding: 0.4rem 0;
  overflow-x: auto;
  background: var(--base-950);
  font-size: var(--text-xs);
  line-height: 1.5;
}

.diff__line {
  display: block;
  padding: 0 0.6rem;
  white-space: pre;
}

/* The palette has carried --diff-add and --diff-remove since before anything used them. This is the
   first use, and reusing them rather than picking a green matters for the same reason they were
   declared: a second green drifts from this one the first time either is adjusted. */
.diff__line--add {
  background: var(--diff-add-bg);
  color: var(--diff-add);
}

.diff__line--remove {
  background: var(--diff-remove-bg);
  color: var(--diff-remove);
}

/* The @@ and +++/--- lines. Dimmer than the code, because they are structure rather than content. */
.diff__line--meta {
  color: var(--base-400);
}

.diff__line--context {
  color: var(--base-300);
}

/* Toasts, for a new error arriving while somebody is looking at the list.
   ------------------------------------------------------------------------ */

/* Bottom right rather than top: the top of this page is the filter bar, and a notice that covers a
   control somebody is reaching for is worse than one they have to glance at. */
.toasts {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-width: min(24rem, calc(100vw - 2rem));
  pointer-events: none;
}

.toast {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--base-700);
  border-left-width: 3px;
  border-radius: var(--radius);
  background: var(--base-850);
  box-shadow: 0 6px 20px rgb(0 0 0 / 45%);
  font-size: var(--text-sm);
  /* The container ignores the pointer so it never blocks the page underneath; each toast takes it
     back, so its link is still clickable. */
  pointer-events: auto;
}

/* Colour on the leading edge, and the words say which it is as well: "A new error" and "An error
   came back" are different sentences, so this is not the only signal. */
.toast--new {
  border-left-color: var(--error);
}

.toast--regression {
  border-left-color: var(--fatal);
}

.toast strong {
  color: var(--base-100);
}

.toast span {
  color: var(--base-300);
  overflow-wrap: anywhere;
}

.toast a {
  align-self: flex-start;
  margin-top: 0.2rem;
  font-size: var(--text-xs);
}

.toast--leaving {
  opacity: 0;
  transition: opacity 0.3s ease-out;
}

/* Somebody who has asked for less motion gets the toast without the fade, not without the toast. */
@media (prefers-reduced-motion: reduce) {
  .toast--leaving {
    transition: none;
  }
}

/* A list of requirements, each with a tick or a cross. Used where several separate things have to
   be true and the only question is which one is missing, so the icon carries the state and the text
   carries what to do about it. */
.checklist {
  margin: 0.75rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.checklist li {
  display: flex;
  align-items: start;
  gap: 0.5rem;
  color: var(--base-300);
  font-size: var(--text-sm);
}

/* The icon must not shrink when the text beside it wraps, which it will: these entries are
   sentences, not labels. */
.checklist li > .icon {
  flex: none;
  margin-top: 0.15rem;
}

.checklist strong {
  color: var(--base-100);
}

/* ---------- secret reveal ---------- */

.secret {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.secret code {
  flex: 1;
  padding: 0.3125rem 0.5rem;
  border: 1px solid var(--base-700);
  border-radius: var(--radius);
  background: var(--base-950);
  color: var(--base-200);
  overflow-x: auto;
  white-space: nowrap;
}

/* ---------- login ---------- */

.login {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

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

.login__brand {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  margin-bottom: 1.25rem;
  color: var(--base-100);
  font: 600 var(--text-2xl)/1 var(--mono);
}

.login__box .card__body {
  padding: 1.125rem;
}

.login__box .btn {
  width: 100%;
  justify-content: center;
  margin-top: 0.25rem;
}

.hint {
  margin-top: 1rem;
  text-align: center;
  color: var(--base-400);
  font-size: var(--text-sm);
}

/* ---------- htmx affordances ---------- */

.htmx-indicator {
  opacity: 0;
  transition: opacity 120ms linear;
  color: var(--accent-400);
  font-family: var(--mono);
  font-size: var(--text-sm);
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  opacity: 1;
}

.htmx-swapping {
  opacity: 0.5;
}

/* ---------- utilities ---------- */

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

.dim {
  color: var(--base-400);
}

.strong {
  color: var(--base-100);
}

/* Alignment. The short names are aliases: `.right` predates the rest and the templates
   still use it. Each pair is one rule rather than two, so they cannot drift. */
.text-left,
.left {
  text-align: left !important;
}

.text-center,
.center {
  text-align: center !important;
}

.text-right,
.right {
  text-align: right!important;
}

.nowrap {
  white-space: nowrap;
}

.stack {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

@media (max-width: 60rem) {
  .app {
    flex-direction: column;
  }

  .sidebar {
    width: 100%;
    flex: none;
    height: auto;
    position: static;
    border-right: 0;
    border-bottom: 1px solid var(--base-800);
  }

  /* The disclosure proper, only below this width: the summary becomes visible, and the
     sibling .sidebar__nav-body is collapsed unless the <details> right before it is open.
     `+` rather than nesting, because nesting is exactly the arrangement a <details> will not
     let an author style reopen once closed; see the comment above .sidebar__nav-summary. */
  .sidebar__nav-summary {
    display: flex;
  }

  .sidebar__nav-body {
    display: none;
  }

  .sidebar__nav-toggle[open] + .sidebar__nav-body {
    display: block;
  }

  /* The spacer exists to push the pinned settings group to the bottom of a fixed 100vh
     column. Inside a closed-by-default disclosure with no fixed height, "the bottom" is not
     a place, so the trick has nothing to push against; hidden here rather than left to
     collapse to nothing, which would still be a gap nobody meant to leave. */
  .sidebar__nav-body .sidebar__spacer {
    display: none;
  }

  .content {
    padding: 1rem;
  }
}

/* A notice card states something about the page as a whole: this error is merged into
   another, or its alerts are silenced. Bordered on the leading edge rather than filled,
   because a filled panel at the top of a page reads as an error and these are not errors. */
.card--notice {
  border-left: 3px solid var(--base-600);
}

.card--notice strong {
  display: block;
  margin-bottom: 0.25rem;
}

.card--notice p {
  margin: 0.25rem 0 0.75rem;
}

/* A checkbox with its label on one line, for a single yes-or-no beside a form's fields. */
.check {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.5rem 0 1rem;
  color: var(--base-300);
}

.check input {
  width: auto;
  margin: 0;
}

/* The settings tabs, above whichever settings page is open.
   ------------------------------------------------------------------------ */

/* Scrolls rather than wraps: seven entries wrap on a narrow window, and a nav that grows a
   second row pushes the whole page down and makes it look broken. The same reasoning as the
   error table's own horizontal scroll. */
.subnav {
  display: flex;
  gap: 0.25rem;
  margin-bottom: 1rem;
  padding-bottom: 0.25rem;
  overflow-x: auto;
  border-bottom: 1px solid var(--base-800);
}

.subnav__link {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.375rem 0.625rem;
  border-radius: var(--radius);
  color: var(--base-400);
  text-decoration: none;
  white-space: nowrap;
}

.subnav__link:hover {
  background: var(--base-900);
  color: var(--base-200);
}

/* The current page is marked by weight and a filled background, not by colour alone: the
   accent hue is the fast signal and the fill is the one every reader gets. */
.subnav__link--current {
  background: var(--accent-950);
  color: var(--accent-300);
  font-weight: 600;
}

/* "Delete project" should not sit in a row looking like "Grouping".
   ------------------------------------------------------------------------

   Set apart by POSITION, pushed to the far end of the row, rather than by being a quieter
   grey. base-500 is 3.2:1 against the background and fails AA, and
   TestNoTextIsSetInAGreyThatCannotBeRead is right to refuse it: a label somebody has to read
   before deleting a project is the last place to spend legibility on tone. It keeps the same
   readable colour as its siblings and turns red on hover, where the intent is already clear
   from the pointer. */
.subnav__link--danger {
  margin-left: auto;
}

/* Two danger tabs, not one: with both carrying margin-left: auto, flexbox split the leftover
   space between the two gaps that made, one before "Erase a person" and a second before
   "Delete project", leaving the first stranded in the middle of the row instead of grouped
   with the second at the far end. A danger tab right after another gets none of its own: the
   first already claimed the row's free space, and everything after it should just follow. */
.subnav__link--danger + .subnav__link--danger {
  margin-left: 0;
}

.subnav__link--danger:hover {
  color: var(--danger);
  background: transparent;
}

.nav__link--danger:hover {
  color: var(--danger);
}

/* A disclosure nested inside the project section, for its settings pages. */
.sidebar__section--nested {
  margin-top: 0.5rem;
  border-top: 1px solid var(--base-800);
  padding-top: 0.5rem;
}
