/* ==========================================================================
   SEED Society Analytics - design tokens and components.

   Self-contained: no CSS framework is loaded, so the container has no runtime
   dependency on a CDN. The handful of Bootstrap-style class names used by the
   Django forms (.form-control, .form-select, .btn) are implemented here.

   Colour is declared once, as custom properties, and every component and chart
   reads from these roles. Dark mode is a deliberately chosen set of steps for
   the dark surface, not an automatic inversion.
   ========================================================================== */

:root {
  color-scheme: light;

  /* Surfaces and ink */
  --plane: #f9f9f7;
  --surface: #fcfcfb;
  --surface-sunken: #f3f2ee;
  --text-primary: #0b0b0b;
  --text-secondary: #52514e;
  --text-muted: #898781;
  --grid: #e1e0d9;
  --axis: #c3c2b7;
  --border: rgba(11, 11, 11, 0.1);
  --border-strong: rgba(11, 11, 11, 0.18);
  --shadow: 0 1px 2px rgba(11, 11, 11, 0.04), 0 8px 24px rgba(11, 11, 11, 0.05);

  /* Categorical series - fixed order, never cycled past slot 8. */
  --series-1: #2a78d6;
  --series-2: #eb6834;
  --series-3: #1baf7a;
  --series-4: #eda100;
  --series-5: #e87ba4;
  --series-6: #008300;
  --series-7: #4a3aa7;
  --series-8: #e34948;

  /* Status - reserved meanings, never used for a data series. */
  --status-good: #0ca30c;
  --status-warning: #fab219;
  --status-serious: #ec835a;
  --status-critical: #d03b3b;
  --status-unknown: #898781;
  --success-text: #006300;

  /* Spacing scale. Every gap, pad and margin below picks from this, so
     rhythm stays consistent instead of drifting into arbitrary pixel values. */
  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 22px;
  --s6: 30px;
  --s7: 44px;

  /* How wide the page may get. Everything between a phone and this is fluid;
     past it the page centres, because a table wider than about this is harder
     to read, not easier. */
  --page-max: 2100px;
  --radius: 10px;
  --radius-sm: 6px;
  --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --plane: #0d0d0d;
    --surface: #1a1a19;
    --surface-sunken: #131312;
    --text-primary: #ffffff;
    --text-secondary: #c3c2b7;
    --text-muted: #898781;
    --grid: #2c2c2a;
    --axis: #383835;
    --border: rgba(255, 255, 255, 0.1);
    --border-strong: rgba(255, 255, 255, 0.2);
    --shadow: none;
    --series-1: #3987e5;
    --series-2: #d95926;
    --series-3: #199e70;
    --series-4: #c98500;
    --series-5: #d55181;
    --series-6: #008300;
    --series-7: #9085e9;
    --series-8: #e66767;
    --success-text: #0ca30c;
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --plane: #0d0d0d;
  --surface: #1a1a19;
  --surface-sunken: #131312;
  --text-primary: #ffffff;
  --text-secondary: #c3c2b7;
  --text-muted: #898781;
  --grid: #2c2c2a;
  --axis: #383835;
  --border: rgba(255, 255, 255, 0.1);
  --border-strong: rgba(255, 255, 255, 0.2);
  --shadow: none;
  --series-1: #3987e5;
  --series-2: #d95926;
  --series-3: #199e70;
  --series-4: #c98500;
  --series-5: #d55181;
  --series-6: #008300;
  --series-7: #9085e9;
  --series-8: #e66767;
  --success-text: #0ca30c;
}

/* ---------------------------------------------------------------- base --- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: var(--font);
  font-size: 15px;
  line-height: 1.55;
  color: var(--text-primary);
  background: var(--plane);
  -webkit-font-smoothing: antialiased;
}

h1,
h2,
h3,
h4 {
  margin: 0 0 0.4em;
  line-height: 1.25;
  font-weight: 650;
  letter-spacing: -0.01em;
}
h1 { font-size: 1.75rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.02rem; }
p { margin: 0 0 1em; }
a { color: var(--series-1); }
a:hover { color: var(--text-primary); }

.muted { color: var(--text-muted); }
.secondary { color: var(--text-secondary); }
.small { font-size: 0.84rem; }
.nowrap { white-space: nowrap; }
.tabular { font-variant-numeric: tabular-nums; }
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ---------------------------------------------------------------- shell --- */
/* The page fills the window rather than sitting in a 1200px column with half a
   widescreen monitor left empty either side. It keeps growing to --page-max,
   which exists so a 4K display does not stretch a table to three thousand
   pixels and make a row impossible to follow across.

   Widening this is safe for reading because prose carries its own measure:
   `.secondary` and `.section-sub` are capped in ch above, so paragraphs stay
   the same width no matter how wide the window gets. What gains the space is
   what should - cards, tables, charts, and the auto-fill grids, which simply
   fit another column in. */
.shell {
  width: min(100%, var(--page-max));
  margin-inline: auto;
  /* Gutters grow with the window too: cramped on a phone, generous on a
     desktop, without a breakpoint for each step. */
  padding: 0 clamp(14px, 1.8vw, 36px) 64px;
}

.topbar {
  border-bottom: 1px solid var(--border);
  background: var(--surface);
  position: sticky;
  top: 0;
  z-index: 20;
}
.topbar-inner {
  width: min(100%, var(--page-max));
  margin-inline: auto;
  /* More above than below: the wordmark is cropped tight to its own
     ascenders, so an even gap reads as though it is hanging off the top of
     the bar. */
  padding: 14px clamp(14px, 1.8vw, 36px) 8px;
  display: flex;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
}
.brand {
  display: flex;
  align-items: center;
  text-decoration: none;
  flex: none;
}

/* The wordmark. Two files rather than one: the artwork is white on
   transparent, which is invisible against the light header, so a second copy
   has its white recoloured to ink. The globe keeps its blue and green in both.

   Declared light-first, then swapped for the system preference, then swapped
   again for an explicit data-theme - so the toggle wins over the operating
   system in both directions. */
.brand-logo {
  display: block;
  /* Sized to the artwork's own 3.49:1 ratio, so "Seed The Future" stays
     legible rather than collapsing into a grey smudge. */
  width: 220px;
  height: 63px;
  background: url("../img/logo-light.cf98531ed8bf.png") left center / contain no-repeat;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .brand-logo {
    background-image: url("../img/logo-dark.15e7f851a73d.png");
  }
}
:root[data-theme="dark"] .brand-logo {
  background-image: url("../img/logo-dark.15e7f851a73d.png");
}
:root[data-theme="light"] .brand-logo {
  background-image: url("../img/logo-light.cf98531ed8bf.png");
}
@media (max-width: 900px) {
  .brand-logo { width: 178px; height: 51px; }
}
@media (max-width: 430px) {
  .brand-logo { width: 140px; height: 40px; }
}

.nav {
  display: flex;
  gap: 2px;
  flex-wrap: wrap;
  align-items: center;
  /* No margin-left:auto here: .nav sits inside .nav-disclosure, which is the
     flex item of .topbar-inner and carries the push-right. */
}
.nav a {
  padding: 6px 11px;
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  text-decoration: none;
  font-size: 0.9rem;
}
.nav a:hover { background: var(--surface-sunken); color: var(--text-primary); }
.nav a[aria-current="page"] {
  color: var(--text-primary);
  background: var(--surface-sunken);
  font-weight: 600;
}
.nav .nav-sep {
  width: 1px;
  height: 20px;
  background: var(--border);
  margin: 0 6px;
}

.theme-toggle {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  border-radius: var(--radius-sm);
  padding: 5px 9px;
  cursor: pointer;
  font-size: 0.86rem;
  font-family: inherit;
}
.theme-toggle:hover { color: var(--text-primary); border-color: var(--border-strong); }

/* ---- Search ------------------------------------------------------------ */
.nav-search { display: flex; margin: 0; }
.nav-search-input {
  width: 8.5rem;
  padding: 5px 10px;
  font: inherit;
  font-size: 0.85rem;
  color: var(--text-primary);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  /* Grows when it is being used, so it stays out of the way of the nav the
     rest of the time. */
  transition: width 0.15s ease;
}
.nav-search-input:focus {
  width: 13rem;
  outline: 2px solid var(--series-1);
  outline-offset: 1px;
  border-color: var(--series-1);
}
.nav-search-input::placeholder { color: var(--text-muted); }

@media (max-width: 900px) {
  /* In the collapsed phone panel the bar is a column, so a fixed-width box
     looks stranded. */
  .nav-search { width: 100%; }
  .nav-search-input, .nav-search-input:focus { width: 100%; }
}

.search-page-form {
  display: flex;
  gap: var(--s3);
  margin-bottom: var(--s4);
}
.search-page-form .form-control { flex: 1 1 auto; min-width: 0; }

.search-hits { list-style: none; margin: 0; padding: 0; }
.search-hits li {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  padding: 6px 0;
  border-bottom: 1px solid var(--border);
}
.search-hits li:last-child { border-bottom: 0; }

/* ---- Latest announcement, on the overview ------------------------------ */
.news-flash {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
}
.news-flash-body { flex: 1 1 22rem; min-width: 0; }
.news-flash h3 { margin: 2px 0 var(--s2); }
.news-flash h3 a { color: var(--text-primary); text-decoration: none; }
.news-flash h3 a:hover { color: var(--series-1); }

/* The Discord counts, in the footer and on the join button. A dot each
   rather than the words "online"/"offline" twice over in different colours -
   green for present, muted for not. */
.discord-counts {
  display: inline-flex;
  align-items: center;
  gap: var(--s3);
  margin-left: 8px;
  padding-left: 10px;
  border-left: 1px solid var(--border);
  font-size: 0.82rem;
  color: var(--text-muted);
  white-space: nowrap;
}
.discord-count {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.discord-count::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  flex: none;
}
.discord-count.is-online::before { background: var(--status-good); }
.discord-count.is-offline::before { background: var(--text-muted); opacity: 0.55; }

/* On the join button the counts sit inside an anchor, so they must not
   inherit its colour or they stop reading as a quiet annotation. */
.btn .discord-counts { border-left-color: currentColor; opacity: 0.85; }

@media (max-width: 430px) {
  /* Stacked under the link rather than pushing the footer into a third row. */
  .discord-counts {
    margin-left: 0;
    padding-left: 0;
    border-left: 0;
    gap: var(--s2);
  }
}
@media (prefers-reduced-motion: reduce) {
  .nav-search-input { transition: none; }
}

/* --------------------------------------------------------------- cards --- */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--s4) var(--s5);
  box-shadow: var(--shadow);
}
.card + .card { margin-top: var(--s4); }
/* ...but not when the cards are grid cells. The grid's own gap already
   spaces them, and because this is an adjacent-sibling rule it skips the
   first card only - so every grid put its opening card a step higher than
   the rest of its row. Subtle per card, obvious across a page of them. */
.grid > .card + .card,
.tiles > .card + .card,
.status-grid > .card + .card { margin-top: 0; }
.card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2);
  flex-wrap: wrap;
  margin-bottom: var(--s1);
}
.card-head h3 { margin: 0; flex: 1 1 auto; min-width: 0; }
.card-head .avatar { margin: 0; }
.card-head h2,
.card-head h3 { margin-bottom: 2px; }
.card-sub {
  color: var(--text-muted);
  font-size: 0.86rem;
  margin: 0 0 var(--s3);
}
/* A card whose last child is a paragraph should not carry its bottom margin
   as extra padding. */
.card > *:last-child { margin-bottom: 0; }

.grid { display: grid; gap: var(--s4); }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-auto { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
/* Headline tiles: five across on a wide screen, never orphaning one on a row
   of its own the way auto-fill did. */
.tiles {
  display: grid;
  gap: var(--s3);
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
/* For a set of six or so small counters, which at the wider minimum wrapped
   4 + 2 and left a hole across half a row. */
.tiles-small {
  display: grid;
  gap: var(--s3);
  grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
}

/* Tablet bands.
   Auto-fit alone picks column counts that divide badly at these widths: six
   counters wrapped 5 + 1, four tiles 3 + 1, five tiles 4 + 1 - each leaving a
   near-empty row. Pinning an even count through the awkward range keeps the
   rows balanced.

   The five-tile case is scoped by child count, so the four-tile grid on the
   resources page is not dragged to 3 + 1 along with it. Every narrower rule
   below repeats that :has() selector: without it the plain `.tiles` rule
   loses on specificity and five tiles would never collapse on a phone.
   Browsers without :has() fall back to plain auto-fit, which is untidy at
   two widths rather than broken. */
@media (max-width: 1080px) {
  .tiles:has(> :nth-child(5)) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1023px) {
  .tiles-small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 880px) {
  .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
  .tiles-small { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .tiles:has(> :nth-child(5)) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 430px) {
  .tiles,
  .tiles:has(> :nth-child(5)) { grid-template-columns: minmax(0, 1fr); }
}

.section { margin-top: var(--s6); }
.section > h2 { margin-bottom: var(--s1); }
.section-sub {
  color: var(--text-muted);
  font-size: 0.86rem;
  margin: 0 0 var(--s3);
  max-width: 78ch;
}
/* Page head. Every page opens with an h1 and an optional lead paragraph; the
   spacing lives here rather than as an inline style repeated on each one. */
.shell > h1 { margin-top: var(--s5); }
.shell > h1 + p { margin-top: calc(-1 * var(--s2)); }
.shell > h1 + p.secondary { max-width: 80ch; }

/* Utilities, deliberately few. Anything needed more than a handful of times
   becomes a component instead. */
.inline-form { display: inline; }
.flush { margin-bottom: 0; }
.push-right { margin-left: auto; }
.row-actions {
  display: flex;
  gap: var(--s3);
  align-items: center;
}
.card-compact { padding: var(--s3) var(--s4); }
.spaced { margin-top: var(--s3); }
.scroll-260 { max-height: 260px; overflow: auto; }
.narrow-page { max-width: 460px; margin: var(--s7) auto; }
.announcement,
.messages { margin-top: var(--s4); }
.back-link { margin: var(--s5) 0 var(--s1); }
.back-link + h1 { margin-top: 0; }
/* A table row that continues the one above it, with no rule between them. */
.cell-continued { padding-top: 0; }

/* Patch-note list items. */
.post-title { margin: 0 0 2px; }
.post-head { min-width: 0; }
.post-item + .post-item { margin-top: var(--s2); }
.post-item .card-head { margin-bottom: var(--s2); }
summary { cursor: pointer; }
/* Spacing for prose disclosures. Scoped away from the navigation, which is
   also a <details> but sits in a vertically-centred flex row where a top
   margin would knock it out of alignment with the brand. */
details:not(.nav-disclosure) > :not(summary) { margin-top: var(--s3); }
.empty { margin-top: var(--s6); }

@media (max-width: 960px) {
  .grid-3, .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 680px) {
  .grid-2, .grid-3, .grid-4 { grid-template-columns: minmax(0, 1fr); }
  .shell { padding: 0 14px 48px; }
  /* The clamp above already narrows the gutter; this keeps the vertical
     padding tighter on a phone without re-fixing the horizontal one. */
  .topbar-inner { padding-block: 10px 6px; }
}

/* --------------------------------------------------------------- hero ---- */
.hero {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: var(--s6);
  align-items: center;
  margin: var(--s5) 0 var(--s2);
  padding: var(--s5) var(--s6);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

/* The score gauge. A single number is the whole story here, so it gets one
   clear dial rather than a chart. */
.gauge { position: relative; width: 132px; height: 132px; flex: 0 0 auto; }
.gauge-svg { width: 100%; height: 100%; transform: rotate(0deg); }
.gauge-svg circle {
  fill: none;
  stroke-width: 9;
  stroke-linecap: round;
}
.gauge-track { stroke: var(--grid); }
.gauge-fill { transition: stroke-dasharray 600ms ease; }
.status-arc-critical { stroke: var(--status-critical); }
.status-arc-attention { stroke: var(--status-serious); }
.status-arc-watch { stroke: var(--status-warning); }
.status-arc-healthy,
.status-arc-excellent { stroke: var(--status-good); }
.status-arc-unknown,
.status-arc-dormant { stroke: var(--status-unknown); }

.gauge-label {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0;
}
.gauge-score {
  /* Proportional figures: tabular-nums makes a large number look loose. */
  font-size: 2.9rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.03em;
}
.gauge-unit { color: var(--text-muted); font-size: 0.74rem; margin-top: 2px; }

.hero-eyebrow {
  margin: 0 0 var(--s1);
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.hero-verdict h1 {
  margin: 0 0 var(--s3);
  font-size: 1.6rem;
  line-height: 1.3;
  max-width: 34ch;
}
.hero-meta {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  margin: 0;
  color: var(--text-muted);
  font-size: 0.84rem;
}

/* Three counters that double as jump links to the matching section. */
.hero-counts { display: flex; gap: var(--s2); flex: 0 0 auto; }
.count {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 86px;
  padding: var(--s3) var(--s2);
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  text-decoration: none;
  color: var(--text-primary);
}
.count:hover { border-color: var(--border-strong); background: var(--surface-sunken); }
.count-n { font-size: 1.6rem; font-weight: 700; line-height: 1.1; }
.count-l {
  font-size: 0.72rem;
  color: var(--text-muted);
  text-align: center;
  margin-top: 2px;
}
.count-bad .count-n { color: var(--status-critical); }
.count-warn .count-n { color: #a8521f; }
.count-good .count-n { color: var(--success-text); }
/* A zero is good news: stop it shouting in red. */
.count.is-zero .count-n { color: var(--text-muted); }

:root[data-theme="dark"] .count-warn .count-n { color: var(--status-serious); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .count-warn .count-n { color: var(--status-serious); }
}

@media (max-width: 1040px) {
  .hero { grid-template-columns: auto minmax(0, 1fr); gap: var(--s5); }
  .hero-counts { grid-column: 1 / -1; }
}
@media (max-width: 620px) {
  .hero {
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    text-align: center;
    padding: var(--s5) var(--s4);
    gap: var(--s4);
  }
  .hero-verdict h1 { font-size: 1.35rem; }
  .hero-meta { justify-content: center; }
  .hero-counts { width: 100%; }
  .count { flex: 1 1 0; min-width: 0; }
}

/* -------------------------------------------------------------- status --- */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 9px 2px 7px;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 600;
  border: 1px solid transparent;
  white-space: nowrap;
}
/* The glyph is a second channel: status is never colour alone. */
.chip-icon {
  width: 14px;
  height: 14px;
  flex: 0 0 14px;
  display: inline-grid;
  place-items: center;
  border-radius: 50%;
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
  color: var(--surface);
}

.status-critical { color: var(--status-critical); border-color: color-mix(in srgb, var(--status-critical) 35%, transparent); background: color-mix(in srgb, var(--status-critical) 10%, transparent); }
.status-critical .chip-icon { background: var(--status-critical); }
.status-attention { color: #a8521f; border-color: color-mix(in srgb, var(--status-serious) 45%, transparent); background: color-mix(in srgb, var(--status-serious) 14%, transparent); }
.status-attention .chip-icon { background: var(--status-serious); }
.status-watch { color: #7a5600; border-color: color-mix(in srgb, var(--status-warning) 45%, transparent); background: color-mix(in srgb, var(--status-warning) 16%, transparent); }
.status-watch .chip-icon { background: var(--status-warning); }
.status-healthy,
.status-excellent { color: var(--success-text); border-color: color-mix(in srgb, var(--status-good) 35%, transparent); background: color-mix(in srgb, var(--status-good) 10%, transparent); }
.status-healthy .chip-icon,
.status-excellent .chip-icon { background: var(--status-good); }
.status-unknown { color: var(--text-muted); border-color: var(--border); }
.status-unknown .chip-icon { background: var(--status-unknown); }

/* Suggestion standings. Built reuses the healthy green, planned the warning
   amber - the same vocabulary the dashboard already teaches - and declined is
   deliberately quiet rather than red: a reasoned no is not a failure. */
.status-planned { color: #7a5600; border-color: color-mix(in srgb, var(--status-warning) 45%, transparent); background: color-mix(in srgb, var(--status-warning) 16%, transparent); }
.status-built { color: var(--success-text); border-color: color-mix(in srgb, var(--status-good) 35%, transparent); background: color-mix(in srgb, var(--status-good) 10%, transparent); }
.status-declined { color: var(--text-muted); border-color: var(--border); }

:root[data-theme="dark"] .status-attention,
:root[data-theme="dark"] .status-watch { color: var(--text-primary); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .status-attention,
  :root:not([data-theme="light"]) .status-watch { color: var(--text-primary); }
}

/* Left rule echoing the chip colour, for scanning a list of metrics. */
.rule-critical { border-left: 3px solid var(--status-critical); }
.rule-attention { border-left: 3px solid var(--status-serious); }
.rule-watch { border-left: 3px solid var(--status-warning); }
.rule-healthy,
.rule-excellent { border-left: 3px solid var(--status-good); }
.rule-unknown { border-left: 3px solid var(--status-unknown); }

/* ---------------------------------------------------------- stat tiles --- */
.tile {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--s3) var(--s4);
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  min-width: 0;
}
.tile-label {
  font-size: 0.8rem;
  color: var(--text-secondary);
  font-weight: 600;
}
.tile-value {
  font-size: 1.85rem;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.02em;
}
.tile-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  font-size: 0.8rem;
  color: var(--text-muted);
}

.trend { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; }
.trend-good { color: var(--success-text); }
.trend-bad { color: var(--status-critical); }
.trend-neutral { color: var(--text-muted); }

/* Metric rows in the verdict lists.
   The row wraps: at phone width the name takes the first line and the value,
   trend and status chip drop to a second one. Without wrapping, the chip is
   pushed past the right edge and the page scrolls sideways. */
.metric-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px 12px;
  padding: var(--s2) var(--s3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  min-width: 0;
}
.metric-row + .metric-row { margin-top: var(--s2); }
.metric-row .m-name {
  font-weight: 600;
  min-width: 0;
  /* Grows to fill, but may shrink to its content before the row wraps. */
  flex: 1 1 min(100%, 220px);
}
.metric-row .m-desc {
  color: var(--text-muted);
  font-size: 0.82rem;
  font-weight: 400;
  display: block;
  overflow-wrap: anywhere;
}
.metric-row .m-value {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  white-space: nowrap;
  margin-left: auto;
}
.metric-row .trend { flex: 0 1 auto; min-width: 0; }

/* --------------------------------------------------------------- chart --- */
.chart-card { display: flex; flex-direction: column; }
.chart-frame {
  position: relative;
  /* Height includes room for the x-axis band, so the card never scrolls. */
  height: 260px;
}
.chart-frame.tall { height: 330px; }
.chart-frame.short { height: 150px; }
canvas { max-width: 100%; }

.chart-tools {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-top: 10px;
}
.chart-tools .btn-quiet { margin-left: auto; }

.chart-table-wrap {
  margin-top: 12px;
  max-height: 320px;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.chart-table-wrap[hidden] { display: none; }

/* Small multiples: one plot per need, so identity never rides on colour. */
.multiples { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.multiple {
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 10px 12px 6px;
  background: var(--surface);
}
.multiple h4 { font-size: 0.84rem; margin-bottom: 2px; }
.multiple .chart-frame { height: 84px; }
/* A need nobody currently has: shown for completeness, visually recessive. */
.multiple.is-dormant { opacity: 0.6; }

/* Sparkline inside a stat tile. Server-rendered SVG: no library, prints fine. */
.spark {
  width: 100%;
  height: 26px;
  margin: 2px 0;
  overflow: visible;
}
.spark polyline {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.spark.tone-good { color: var(--status-good); }
.spark.tone-bad { color: var(--status-critical); }
/* --axis was too pale to read as a line at 26px tall. */
.spark.tone-neutral { color: var(--text-muted); }

/* Compact status grid: a dozen settled indicators in one card instead of a
   dozen full-width rows the reader has to scroll past. */
.status-grid {
  display: grid;
  gap: var(--s2);
  grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
}
.status-cell {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  background: var(--surface-sunken);
  border-radius: var(--radius-sm);
  min-width: 0;
}
.status-cell-name {
  font-size: 0.86rem;
  min-width: 0;
  /* Wrap rather than ellipsise: "Average need satisfaction" truncated to
     "Average need satisfacti…" tells the reader nothing the full name would
     not have, and the cell has room for a second line. */
  overflow-wrap: anywhere;
}
.status-cell-value {
  font-weight: 700;
  font-size: 0.86rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  flex: 0 0 auto;
}
/* Dormant cells carry a sentence, not a number, so they stack. */
.status-cell.stacked {
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
}
.status-cell.stacked .status-cell-value { font-weight: 400; }
.rule-dormant { border-left: 3px solid var(--status-unknown); }
.status-dormant {
  color: var(--text-muted);
  border-color: var(--border);
  background: var(--surface-sunken);
}
.status-dormant .chip-icon { background: var(--status-unknown); }

/* Positive confirmation, for when there is genuinely nothing wrong. */
.notice {
  display: flex;
  align-items: flex-start;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface);
}
.notice-good { border-color: color-mix(in srgb, var(--status-good) 35%, transparent); }
.notice-icon {
  flex: 0 0 22px;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--status-good);
  color: #fff;
  font-weight: 800;
  font-size: 12px;
  line-height: 1;
}
.notice div { min-width: 0; }
.notice strong { display: block; }

/* -------------------------------------------------------------- tables --- */
table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.88rem;
}
thead th {
  text-align: left;
  font-weight: 600;
  color: var(--text-secondary);
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
  position: sticky;
  top: 0;
}
tbody td { padding: 7px 10px; border-bottom: 1px solid var(--grid); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--surface-sunken); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); }

/* --------------------------------------------------------------- forms --- */
label { font-weight: 600; font-size: 0.88rem; display: inline-block; margin-bottom: 4px; }
.form-control,
.form-select {
  width: 100%;
  padding: 8px 10px;
  font: inherit;
  font-size: 0.92rem;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
}
.form-control:focus,
.form-select:focus {
  outline: 2px solid var(--series-1);
  outline-offset: 1px;
  border-color: var(--series-1);
}
.form-row { margin-bottom: 14px; }
.form-row .helptext { display: block; color: var(--text-muted); font-size: 0.8rem; margin-top: 4px; }
/* Django's own help text is .helptext; hand-written rows use .form-help. Both
   sit under their label rather than continuing the sentence it started - as a
   direct child only, so the flex layouts that place help text themselves
   (.avatar-editor, .add-form) keep their own arrangement. */
.form-row > label { display: block; }
.form-row > .form-help {
  display: block;
  color: var(--text-muted);
  font-size: 0.8rem;
  margin-top: 2px;
  margin-bottom: 6px;
}
.form-check { display: flex; gap: 8px; align-items: flex-start; }
.form-check-input { margin-top: 4px; width: 16px; height: 16px; accent-color: var(--series-1); }
.form-check label { font-weight: 500; }
.errorlist {
  list-style: none;
  margin: 6px 0 0;
  padding: 0;
  color: var(--status-critical);
  font-size: 0.84rem;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 15px;
  font: inherit;
  font-size: 0.9rem;
  font-weight: 600;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  cursor: pointer;
  text-decoration: none;
}
.btn-primary { background: var(--series-1); color: #fff; }
.btn-primary:hover { filter: brightness(0.93); color: #fff; }
.btn-quiet,
.btn-secondary {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text-secondary);
}
.btn-quiet:hover,
.btn-secondary:hover { color: var(--text-primary); background: var(--surface-sunken); }
.btn-sm { padding: 5px 10px; font-size: 0.82rem; }

/* ------------------------------------------------------------- filters --- */
/* One filter row above everything it scopes - never inside a chart card. */
.filterbar {
  display: flex;
  gap: var(--s3);
  align-items: flex-end;
  flex-wrap: wrap;
  padding: var(--s3) var(--s4);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* Matches the rhythm a .section would give it. A filter bar placed
     directly in the page flow was otherwise sitting flush against whatever
     came before it. */
  margin-top: var(--s6);
  margin-bottom: var(--s4);
}
/* ...unless a section is already providing that gap. */
.section > .filterbar:first-child { margin-top: 0; }
.filterbar .field { display: flex; flex-direction: column; gap: 3px; }
.filterbar .field .form-control,
.filterbar .field .form-select { width: auto; min-width: 170px; }
.pill-row { display: flex; gap: 6px; flex-wrap: wrap; }
.pill {
  padding: 5px 11px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  text-decoration: none;
  font-size: 0.84rem;
}
.pill:hover { background: var(--surface-sunken); color: var(--text-primary); }
.pill[aria-current="true"] {
  background: var(--text-primary);
  color: var(--surface);
  border-color: var(--text-primary);
}

/* ------------------------------------------------------------ messages --- */
.banner {
  padding: 11px 15px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  margin-bottom: 14px;
  font-size: 0.9rem;
}
.banner-success { border-color: color-mix(in srgb, var(--status-good) 40%, transparent); background: color-mix(in srgb, var(--status-good) 9%, transparent); }
.banner-error { border-color: color-mix(in srgb, var(--status-critical) 40%, transparent); background: color-mix(in srgb, var(--status-critical) 9%, transparent); }
.banner-warning { border-color: color-mix(in srgb, var(--status-warning) 45%, transparent); background: color-mix(in srgb, var(--status-warning) 12%, transparent); }
.banner-info { background: var(--surface-sunken); }

.freshness {
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
  align-items: center;
  color: var(--text-muted);
  font-size: 0.82rem;
  padding: 10px 0 0;
}

.empty {
  text-align: center;
  padding: 48px 20px;
  color: var(--text-muted);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
}

/* Two rows on one hairline: the facts about the data on the left, the links
   on the right, and the standing disclaimer beneath both. Previously one flat
   run of spans where the build hash, the freshness and the Discord link all
   had the same weight and ran together. */
.footer {
  border-top: 1px solid var(--border);
  margin-top: var(--s7);
  padding-top: var(--s4);
  color: var(--text-muted);
  font-size: 0.82rem;
  display: flex;
  align-items: center;
  gap: var(--s3) var(--s4);
  flex-wrap: wrap;
}
/* A hairline between items, so they read as separate facts without needing a
   middot typed into the markup at every join. */
.footer > span + span::before {
  content: "";
  display: inline-block;
  width: 1px;
  height: 0.8em;
  margin-right: var(--s4);
  vertical-align: -1px;
  background: var(--border);
}
.footer a {
  color: var(--text-secondary);
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.footer a:hover {
  color: var(--text-primary);
  border-bottom-color: var(--border-strong);
}

.footer-legal {
  display: inline-flex;
  gap: var(--s4);
  margin-left: auto;
}
/* The separator belongs between the facts, not before the link group that is
   pushed to the far side. */
.footer > .footer-legal::before { display: none; }

@media (max-width: 680px) {
  .footer { gap: var(--s2) var(--s3); }
  .footer > span + span::before { margin-right: var(--s3); }
  .footer-legal { margin-left: 0; flex-basis: 100%; }
}

/* ---------------------------------------------------------- pagination --- */
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 16px; flex-wrap: wrap; }

/* --------------------------------------------------------------- print --- */
@media print {
  .topbar, .chart-tools, .filterbar, .footer { display: none; }
  .card { break-inside: avoid; box-shadow: none; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

/* Chart range picker. Sits above the charts it scopes, per the rule that a
   filter belongs to everything below it rather than to one card. */
.range-picker {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-bottom: var(--s4);
}

/* Skip link: hidden until focused, first thing a keyboard reaches. */
.skip-link {
  position: fixed;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: var(--s2) var(--s4);
  background: var(--surface);
  color: var(--text-primary);
  border: 1px solid var(--border-strong);
  border-radius: 0 0 var(--radius-sm) 0;
  text-decoration: none;
}
.skip-link:focus { left: 0; }

/* Navigation.
   On a wide screen the <details> is transparent: the summary is hidden and
   the nav shows as a normal row. Below the breakpoint the summary becomes a
   Menu button and the links collapse behind it. Using <details> rather than
   JavaScript keeps it keyboard- and screen-reader-operable by default, and it
   still works if the page's script never loads. */
.nav-disclosure { margin-left: auto; }
.nav-toggle { display: none; }

@media (max-width: 900px) {
  .nav-toggle {
    display: inline-block;
    cursor: pointer;
    list-style: none;
    padding: 5px 11px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-sm);
    color: var(--text-secondary);
    font-size: 0.88rem;
    font-weight: 600;
    user-select: none;
  }
  .nav-toggle::-webkit-details-marker { display: none; }
  .nav-toggle::after { content: " \25be"; }
  .nav-disclosure[open] .nav-toggle::after { content: " \25b4"; }
  .nav-disclosure .nav {
    position: absolute;
    /* Matches .topbar-inner's gutter, which is 20px until the 680px break. */
    right: 20px;
    left: 20px;
    margin-top: var(--s2);
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    padding: var(--s2);
    background: var(--surface);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    z-index: 30;
  }
  .nav-disclosure .nav a { padding: var(--s2) var(--s3); }
  .nav .nav-sep { width: auto; height: 1px; margin: var(--s1) 0; }
}
/* Below 680px the topbar gutter narrows, so the panel follows it. This has to
   come after the block above, or the cascade would keep the wider value. */
@media (max-width: 680px) {
  .nav-disclosure .nav { right: 14px; left: 14px; }
}

/* An overflow disclosure: the rest of a capped list, opened in place. Styled
   as a quiet link so it reads as "more of the same", not a new section. */
.overflow > summary {
  cursor: pointer;
  margin-top: var(--s3);
  color: var(--accent);
  font-size: 0.875rem;
}
.overflow > summary:hover { text-decoration: underline; }
.overflow[open] > summary { margin-bottom: var(--s3); }
/* Only the first child: the generic `details > :not(summary)` rule above
   would otherwise stack its gap on top of `.metric-row + .metric-row`. The
   rows keep their own rhythm. */
.overflow > summary + * { margin-top: 0; }

/* ---- Accounts ---------------------------------------------------------- */
/* The landing page: the only thing a signed-out visitor sees, so it carries
   the whole first impression. */
/* The one page a stranger sees, so it is laid out as a landing page rather
   than as another dashboard screen: one centred column, the sign-in card as
   the thing the eye lands on, and everything explanatory below it.

   `margin-inline: auto` is the fix for the obvious fault - the max-width was
   there but nothing centred it, so on a wide screen the whole page sat in the
   left third with a third of the window empty beside it. */
.welcome {
  max-width: 62rem;
  margin-inline: auto;
  padding-top: var(--s5);
}

.welcome-hero { text-align: center; }
.welcome .eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 0.76rem;
  color: var(--text-muted);
  margin-bottom: var(--s2);
}
.welcome-hero h1 {
  font-size: clamp(1.8rem, 3.4vw, 2.6rem);
  line-height: 1.15;
  margin-bottom: var(--s3);
}
.welcome .lead {
  font-size: 1.05rem;
  line-height: 1.6;
  color: var(--text-secondary);
  max-width: 42rem;
  margin-inline: auto;
}

/* Narrower than the page and centred within it: the call to action should not
   be as wide as the prose explaining it. */
.welcome-card {
  max-width: 40rem;
  margin: var(--s6) auto 0;
  text-align: center;
  padding: var(--s5);
}
.welcome-card-title {
  font-size: 1.1rem;
  margin-bottom: var(--s2);
}
.welcome-card .card-sub { margin-bottom: var(--s4); }
.welcome-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  justify-content: center;
  align-items: center;
}
/* The primary action gets its own line on a phone rather than sharing one
   with the two secondary ones and wrapping mid-row. */
@media (max-width: 560px) {
  .welcome-actions { flex-direction: column; align-items: stretch; }
}

/* Quieter than the body text around it - a standing statement, not a
   paragraph somebody has to read before signing in. */
.welcome-note {
  max-width: 44rem;
  margin: var(--s5) auto 0;
  text-align: center;
  font-size: 0.84rem;
  line-height: 1.6;
  color: var(--text-muted);
}

.welcome-panels { margin-top: var(--s6); }
.plain-list { margin: 0; padding-left: 1.1rem; }
.plain-list li { margin-bottom: var(--s1); color: var(--text-secondary); }

.title-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}

/* Watch toggle. A form so it works without JavaScript, but it should read as
   a single control rather than a form. */
.watch-form { display: inline; margin: 0; }
.watch-btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  padding: 3px 7px;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 0.85rem;
  line-height: 1;
}
.watch-btn:hover {
  color: var(--text-primary);
  border-color: var(--border);
}
.watch-btn.is-watching { color: var(--accent); }

.row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  align-items: center;
}
.row-actions form { display: flex; gap: 4px; align-items: center; margin: 0; }
.form-select-sm { padding: 3px 6px; font-size: 0.85rem; }
.btn-sm { padding: 3px 9px; font-size: 0.85rem; }
.row-actions .btn-sm { white-space: nowrap; }

/* ---- Header menus ------------------------------------------------------ */
/* Pushes everything after it to the right edge of the bar. */
.nav-grow { flex: 1 1 auto; }

.menu { position: relative; }
.menu > summary {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 11px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  color: var(--text-secondary);
  font-size: 0.9rem;
  list-style: none;
  white-space: nowrap;
}
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary::after {
  content: "\25be";
  font-size: 0.7em;
  opacity: 0.7;
}
.menu[open] > summary::after { content: "\25b4"; }
.menu > summary:hover { color: var(--text-primary); background: var(--surface); }
.menu[open] > summary { background: var(--surface); color: var(--text-primary); }

.menu-panel {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 60;
  min-width: 13rem;
  display: flex;
  flex-direction: column;
  padding: var(--s1);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
}
.menu-panel-right { left: auto; right: 0; }
.menu-panel a,
.menu-signout {
  display: block;
  width: 100%;
  text-align: left;
  padding: 7px 10px;
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text-secondary);
  font: inherit;
  font-size: 0.9rem;
  cursor: pointer;
  white-space: nowrap;
}
.menu-panel a:hover,
.menu-signout:hover { background: var(--surface-2, var(--bg)); color: var(--text-primary); }
.menu-panel a[aria-current="page"] { color: var(--text-primary); font-weight: 600; }
.menu-panel form { margin: 0; }
.menu-head {
  padding: 6px 10px 8px;
  font-size: 0.78rem;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.menu-sep {
  display: block;
  height: 1px;
  margin: var(--s1) 6px;
  background: var(--border);
}
.menu-name { font-weight: 600; color: var(--text-primary); }

/* ---- Avatars ----------------------------------------------------------- */
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  object-fit: cover;
  background: var(--border);
  color: var(--text-secondary);
  font-weight: 700;
  letter-spacing: 0.02em;
  flex: none;
  overflow: hidden;
}
.avatar-sm { width: 24px; height: 24px; font-size: 0.65rem; }
.avatar-md { width: 44px; height: 44px; font-size: 1.05rem; }
.avatar-lg { width: 96px; height: 96px; font-size: 2rem; }
.avatar-initials { user-select: none; }

/* A business is a place, not a person. Rounding its sign into a circle reads
   as a profile picture and crops the corners off a logo, so this one keeps
   its edges - softened to match the cards around it, not removed. */
.avatar-square { border-radius: var(--radius-sm); }
.avatar-square.avatar-lg { border-radius: var(--radius); }

.avatar-editor {
  display: flex;
  gap: var(--s4);
  align-items: flex-start;
  flex-wrap: wrap;
}
.avatar-editor .avatar-actions {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  /* Keeps the explanation from stretching the whole card width and pushing
     the picture onto its own line. */
  flex: 1 1 22rem;
  min-width: 0;
  max-width: 34rem;
}
.avatar-editor form { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; }
.avatar-editor .form-help { flex: 1 1 100%; margin: 0; }
.avatar-editor input[type="file"] { font-size: 0.85rem; color: var(--text-secondary); min-width: 0; }

/* ---- Drop zones -------------------------------------------------------- */
/* Built by static/js/uploads.js around whatever file input it finds, so none
   of this exists unless the script ran. The input itself is still the control:
   it lies over the target at zero opacity, which is why a click anywhere here
   opens the picker and why the keyboard reaches it without any help. */
.dropzone { width: 100%; min-width: 0; }
.avatar-editor .dropzone { flex: 1 1 100%; }

.dropzone-target {
  position: relative;
  display: block;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken, var(--surface));
  transition: border-color 0.12s ease, background-color 0.12s ease;
}
.dropzone-target:hover { border-color: var(--series-1); }

.dropzone-target input[type="file"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
}

.dropzone-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: var(--s4) var(--s3);
  text-align: center;
  pointer-events: none; /* the input underneath takes every click */
}
.dropzone-icon { width: 26px; height: 26px; color: var(--text-muted); margin-bottom: var(--s1); }
.dropzone-title { font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); }
.dropzone-hint { font-size: 0.8rem; color: var(--text-muted); }

/* Dragged over, and focused from the keyboard. The focus ring is drawn on the
   wrapper because the input it belongs to is invisible - without this, tabbing
   to an upload would land on nothing a sighted user can see. */
.dropzone.is-over .dropzone-target {
  border-color: var(--series-1);
  border-style: solid;
  background: var(--surface);
}
.dropzone.is-over .dropzone-icon,
.dropzone.is-over .dropzone-title { color: var(--series-1); }
.dropzone.is-focus .dropzone-target {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
  border-color: var(--series-1);
}

.dropzone-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: var(--s2);
}
.dropzone-list:empty { margin-top: 0; }

.dropzone-file {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  padding: 3px 4px 3px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  font-size: 0.8rem;
}
.dropzone-file-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 18rem;
}
.dropzone-file-size { color: var(--text-muted); flex: none; }
.dropzone-file-remove {
  flex: none;
  width: 18px;
  height: 18px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  line-height: 1;
  cursor: pointer;
}
.dropzone-file-remove:hover { background: var(--border); color: var(--text-primary); }
.dropzone-file-remove:focus-visible { outline: 2px solid var(--series-1); outline-offset: 1px; }

.dropzone-message {
  margin: var(--s2) 0 0;
  font-size: 0.8rem;
  color: var(--status-warning, var(--text-secondary));
}

@media (max-width: 900px) {
  /* In the collapsed phone panel the bar is a column, so a floating panel
     would land on top of the links below it. Let them stack instead. */
  .nav-grow { display: none; }
  .menu { width: 100%; }
  .menu > summary { width: 100%; }
  .menu-panel {
    position: static;
    border: 0;
    box-shadow: none;
    padding-left: var(--s3);
    background: none;
    min-width: 0;
  }
}
.who { display: inline-flex; align-items: center; gap: 8px; }

/* ---- Member listings --------------------------------------------------- */
/* A claim measured against the exports. Deliberately not a status colour:
   these are not health verdicts, and reusing the status palette would make a
   member's boast look like a reading from the game. */
.claim-note { font-size: 0.82rem; }
.claim-strong { color: var(--series-1, var(--accent)); font-weight: 600; }
.claim-good { color: var(--text-secondary); }
.claim-muted { color: var(--text-muted); }
/* A level the exports cannot account for. Neutral, not alarming - the export
   may simply be stale. */
.claim-beyond {
  color: var(--text-primary);
  border-bottom: 1px dotted var(--border-strong, var(--border));
}

/* ---- Icons ------------------------------------------------------------- */
/* Inherit the surrounding colour and sit on the text baseline, so one set
   works in light and dark and nothing needs a second variant. */
.icon {
  display: inline-block;
  vertical-align: -0.15em;
  flex: none;
  color: var(--text-muted);
}
.icon-sm { width: 1em; height: 1em; margin-right: 0.4em; }
.icon-md { width: 1.25em; height: 1.25em; margin-right: 0.45em; }
.metric-row:hover .icon,
.tile:hover .icon { color: var(--text-secondary); }

/* ---- Data freshness ---------------------------------------------------- */
.freshness { color: var(--text-secondary); }
.freshness.is-stale { color: var(--status-attention-fg, var(--text-primary)); font-weight: 600; }
.stale-banner { display: flex; gap: 8px; flex-wrap: wrap; align-items: baseline; }
.banner-warning {
  background: var(--surface);
  border: 1px solid var(--border-strong, var(--border));
  border-left: 3px solid #d08c26;
  color: var(--text-primary);
}
/* An in-game office, not a site permission. Deliberately unlike the status
   chips, which mean something measured. */
.chip-game {
  background: none;
  border: 1px solid var(--border-strong, var(--border));
  color: var(--text-secondary);
  font-weight: 600;
}
.check-grid { display: flex; flex-direction: column; gap: var(--s2); }
.check-grid .form-check { display: flex; gap: 8px; align-items: flex-start; }
.check-grid .form-check span { display: flex; flex-direction: column; gap: 2px; }
.member-name { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.member-name .chip-game { font-size: 0.72rem; padding: 1px 6px; }

/* ---- Profile ----------------------------------------------------------- */
/* Identity first: a picture, a name, what they are. The upload form is a
   setting and lives with the other settings, not at the top of the page. */
.profile-header {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
  padding-bottom: var(--s4);
  margin-bottom: var(--s2);
  border-bottom: 1px solid var(--border);
}
.profile-identity { flex: 1 1 14rem; min-width: 0; }
.profile-identity h1 { margin: 0 0 var(--s1); }
.profile-meta {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  margin: 0;
  font-size: 0.88rem;
}

/* Chips in a row need their own gap - relying on whitespace between inline
   elements gives an uneven rag once they wrap. */
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }

.discord-handle { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }

/* Separates two stanzas inside one card - the Discord card holds the verified
   account and the free-text handle, which are different things and should not
   read as one paragraph. */
hr.rule {
  border: 0;
  border-top: 1px solid var(--border);
  margin: var(--s4) 0;
}

/* ---- Legal pages ------------------------------------------------------- */
/* Prose, so it gets a readable measure rather than the full page width - and
   centred within it, or the column hugs the left edge with a third of a wide
   window empty beside it. The same fault the welcome page had: a max-width
   with nothing centring it reads fine at 1200px and badly at 2100. */
.legal { max-width: 46rem; margin-inline: auto; }
.legal h2 { margin-bottom: var(--s3); }
.legal-list {
  margin: 0;
  padding-left: 1.2rem;
  color: var(--text-muted);
  font-size: 0.86rem;
}
.legal-list li { margin-bottom: var(--s2); }
.legal-list li:last-child { margin-bottom: 0; }

/* One question per row, closed. Somebody arrives with a single question and
   should be able to see every question at once to find theirs, rather than
   scrolling a wall of answers they did not ask for. */
.faq {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  margin-bottom: var(--s2);
}
.faq > summary {
  cursor: pointer;
  padding: var(--s3) var(--s4);
  font-weight: 600;
  font-size: 0.92rem;
  list-style: none;
  display: flex;
  align-items: center;
  gap: var(--s3);
  border-radius: var(--radius);
}
.faq > summary::-webkit-details-marker { display: none; }
/* The marker is drawn rather than inherited, so it rotates and so it does not
   differ between browsers. */
.faq > summary::before {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-left: 5px solid currentColor;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  color: var(--text-muted);
  transition: transform 0.15s ease;
}
.faq[open] > summary::before { transform: rotate(90deg); }
.faq > summary:hover { background: var(--surface-sunken); }
.faq > summary:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: -2px;
}
.faq-body {
  padding: 0 var(--s4) var(--s4);
  color: var(--text-secondary);
  font-size: 0.88rem;
  line-height: 1.6;
}
.faq-body p { margin: 0 0 var(--s3); }
.faq-body p:last-child { margin-bottom: 0; }
.faq-body ol,
.faq-body ul { margin: 0 0 var(--s3); padding-left: 1.3rem; }
.faq-body li { margin-bottom: var(--s2); }
.faq-body li:last-child { margin-bottom: 0; }
.faq-body code {
  background: var(--surface-sunken);
  padding: 1px 5px;
  border-radius: 4px;
  font-size: 0.85em;
}
@media (prefers-reduced-motion: reduce) {
  .faq > summary::before { transition: none; }
}

/* Pushed to the end of the footer row, so the build hash and the data
   freshness - the things somebody actually checks - stay leftmost. */

/* Under the footer row rather than in it: it is a standing statement about
   what this site is, not another link somebody scans past. */
.footer-disclaimer {
  width: min(100%, var(--page-max));
  margin-inline: auto;
  padding: var(--s3) 0 var(--s5);
  color: var(--text-muted);
  font-size: 0.76rem;
  line-height: 1.6;
  opacity: 0.85;
}
@media (max-width: 680px) {
  .footer-disclaimer { padding-inline: 14px; }
}

/* "Add a seedling" and "Add a business" used to be cells in the same grid as
   the things they add, which made every card in their row as tall as a form.
   A seedling with one skill came out 240px tall and mostly empty. They now sit
   below their grid as one full-width card, laid out along a row so the card
   stays shallow and still reads as an action rather than another entry. */
.add-form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
  /* Top-aligned, so every label starts on one line and every input starts on
     the next. Bottom-aligning instead lined up the feet of the fields, and
     since only one of them carries help text underneath, that pushed its input
     higher than its neighbour - the same kind of near-miss this page already
     had elsewhere. */
  align-items: flex-start;
}
.add-form .form-row {
  flex: 1 1 13rem;
  min-width: 0;
  margin-bottom: 0;
}
.add-form .form-row label {
  display: block;
  font-size: 0.85rem;
  line-height: 1.3;
  color: var(--text-secondary);
  margin-bottom: 4px;
}
.add-form .form-help {
  display: block;
  color: var(--text-muted);
  font-size: 0.8rem;
  margin-top: 4px;
}
/* Dropped by exactly one label - the height above plus its margin - so the
   button meets the inputs rather than floating level with their labels. */
.add-form .add-submit {
  flex: 0 0 auto;
  margin-top: calc(0.85rem * 1.3 + 4px);
}

/* An irreversible action, set apart from the settings it sits below rather
   than sharing their grid - it should not look like one more preference. */
.danger-zone { border-left: 3px solid #b4483c; }
.btn-danger {
  background: #b4483c;
  border-color: #b4483c;
  color: #fff;
}
.btn-danger:hover { background: #9c3d33; border-color: #9c3d33; }

/* ---- Suggestions ------------------------------------------------------- */
.suggestion-row,
.suggestion-head {
  display: flex;
  align-items: flex-start;
  gap: var(--s4);
}
.suggestion-head { margin-bottom: var(--s4); }
.suggestion-body { flex: 1 1 auto; min-width: 0; }
.suggestion-body h3 { margin: 0 0 4px; }
.suggestion-body h3 a { color: var(--text-primary); text-decoration: none; }
.suggestion-body h3 a:hover { color: var(--series-1); }

.want-form { margin: 0; flex: none; }

/* A button, not a link: it changes something, and a browser may fetch a link
   on its own. Pressed state is carried by aria-pressed as well as colour. */
.want {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  min-width: 3rem;
  padding: 6px 8px;
  font: inherit;
  line-height: 1.1;
  color: var(--text-secondary);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.want:hover { border-color: var(--border-strong); color: var(--text-primary); }
.want:focus-visible { outline: 2px solid var(--series-1); outline-offset: 1px; }
.want-mark { font-size: 0.7rem; }
.want-count { font-weight: 700; font-size: 0.95rem; }
.want.is-mine {
  color: var(--series-1);
  border-color: color-mix(in srgb, var(--series-1) 45%, transparent);
  background: color-mix(in srgb, var(--series-1) 10%, transparent);
}

/* Member-written text. `white-space: pre-wrap` keeps the paragraphs somebody
   typed without letting anything be interpreted as markup. */
.suggestion-text {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  color: var(--text-secondary);
  font-size: 0.9rem;
  line-height: 1.6;
}
/* Author on the left, remove on the right, on one line. Built as its own row
   rather than leaning on .card-sub, which carries a bottom margin and no
   layout of its own and put the button on a line by itself. */
.comment-head {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin-bottom: var(--s2);
  font-size: 0.86rem;
}
.comment-head form { margin: 0 0 0 auto; }

/* A count beside a menu item. Small, and never the only thing saying what it
   is: the label is right beside it. */
.menu-badge {
  display: inline-block;
  min-width: 1.25em;
  padding: 0 5px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--status-warning) 22%, transparent);
  color: var(--text-primary);
  font-size: 0.72rem;
  font-weight: 700;
  text-align: center;
}

/* ---- Bug reports ------------------------------------------------------- */
/* A row is the whole card, and the whole card is the link. On the suggestion
   board the card holds a want button, so only the title could be a link;
   here there is nothing else to press, and half a card that responds to the
   cursor while the other half does not is the sort of thing people notice
   without being able to say why. */
.bug-row {
  display: flex;
  align-items: center;
  gap: var(--s4);
  text-decoration: none;
  color: inherit;
  transition: border-color 120ms ease, transform 120ms ease;
}
.bug-row:hover,
.bug-row:focus-visible { border-color: var(--border-strong); }
.bug-row:hover .bug-row-go { transform: translateX(3px); }
.bug-row-main { flex: 1 1 auto; min-width: 0; }
.bug-row-title {
  display: block;
  font-weight: 650;
  color: var(--text-primary);
  margin-bottom: 4px;
}
.bug-row:hover .bug-row-title { color: var(--series-1); }
.bug-row-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0;
}
.bug-row-go {
  flex: none;
  color: var(--text-muted);
  transition: transform 120ms ease;
}

/* The queue. A row that nobody has answered is marked twice - a coloured
   edge and the words "No reply yet" - because the edge alone says it to some
   readers and not others. */
.is-waiting > td:first-child { box-shadow: inset 3px 0 var(--status-serious); }
.queue-meta {
  display: block;
  margin-top: 2px;
}
.chip-sm { font-size: 0.7rem; padding: 0 6px; }
.chip-sm .chip-icon { width: 10px; height: 10px; flex-basis: 10px; }
/* A second row of pills, narrower than the first: these are a refinement of
   the filter above rather than a choice of the same weight. */
.pill-sm { font-size: 0.76rem; padding: 3px 10px; }

/* The thread. One card holding every reply, separated by a rule rather than
   each in a card of its own: a support conversation reads top to bottom, and
   a stack of separate cards reads as a list of unrelated things. */
.reply { padding: var(--s4) 0; border-top: 1px solid var(--border); }
.reply:first-of-type { border-top: 0; padding-top: 0; }
.reply-head {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  margin: 0 0 var(--s2);
  font-size: 0.86rem;
}
.reply-head form { margin: 0 0 0 auto; }

/* An answer from an administrator is the thing the reporter came back to
   read, so it is marked in two ways rather than one: an edge down the side
   and a label. Colour alone would say it to some people and not others. */
.reply-official {
  border-left: 3px solid var(--series-1);
  padding-left: var(--s4);
  margin-left: -3px;
}
.chip-official {
  font-size: 0.7rem;
  padding: 1px 7px;
  color: var(--series-1);
  border-color: color-mix(in srgb, var(--series-1) 40%, transparent);
  background: color-mix(in srgb, var(--series-1) 12%, transparent);
}

/* Screenshots. Every tile is the same box whatever shape the picture is:
   people paste a wide window and a tall phone capture into the same report,
   and letting each one size itself leaves the row ragged and the captions at
   different heights. The box is fixed, the picture is fitted whole inside it
   rather than cropped - a thumbnail that hides half the screenshot defeats
   the point of attaching one - and clicking opens it at full size. */
.shot-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
  gap: var(--s4);
}
.shot { margin: 0; min-width: 0; }
.shot a {
  /* A flat pixel height, not `aspect-ratio`, and a block rather than a grid.
     Both of those are the fix for the same thing: a percentage height on the
     image needs a parent whose height is already settled. Under
     `aspect-ratio` the parent's height depends on its content, so `height:
     100%` had nothing to resolve against - a tall screenshot kept its own
     414px and was clipped by the overflow rule into looking full-bleed. */
  display: block;
  height: 170px;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--surface-sunken);
}
.shot a:hover { border-color: var(--border-strong); }
.shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.shot figcaption {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin-top: 6px;
}
.shot figcaption form { margin: 0 0 0 auto; }

/* ---- Blog -------------------------------------------------------------- */
.blog-row-head {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  margin-bottom: var(--s2);
}
.blog-row-head h3 { margin: 0; }
.blog-row-head h3 a { color: var(--text-primary); text-decoration: none; }
.blog-row-head h3 a:hover { color: var(--series-1); }

/* Pinned is a state an editor chose, not a status the site worked out, so it
   gets its own colour rather than borrowing one of the health ones. */
.chip-pinned {
  color: var(--series-1);
  border-color: color-mix(in srgb, var(--series-1) 40%, transparent);
  background: color-mix(in srgb, var(--series-1) 10%, transparent);
}

/* The post itself. `pre-wrap` keeps the paragraphs somebody typed without
   letting anything in them be read as markup. */
.blog-body {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  color: var(--text-secondary);
  font-size: 0.95rem;
  line-height: 1.7;
  max-width: 44rem;
}

/* The editor's controls, set apart from the post so they do not read as part
   of it. Remove is pushed away from Edit and Pin, which are the safe two. */
.editor-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}
.editor-bar .inline-form { margin: 0; }
.editor-bar .push-right { margin-left: auto; }

/* A pinned post on the overview borrows the announcement card but leads with
   the society's own accent rather than the muted one Klang's note uses. */
.blog-pinned { border-left: 3px solid var(--series-1); }
.blog-pinned + .blog-pinned { margin-top: var(--s3); }

/* ---- Game status ------------------------------------------------------- */
/* Two rows of information, so two rows of height. This sits above everything
   a reader came for and was taking five: an eyebrow, a heading, a rule, the
   groups, and a sentence about the refresh interval. The interval is a title
   attribute now and the rule is gone. */
.status-panel {
  border-left-width: 3px;
  border-left-style: solid;
  padding: var(--s3) var(--s4);
}
.status-line {
  display: flex;
  align-items: center;
  gap: var(--s3) var(--s5);
  flex-wrap: wrap;
}
.status-verdict {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s2);
  flex: none;
}
.status-verdict .hero-eyebrow { margin: 0; }
.status-verdict strong { font-size: 0.95rem; }

/* The groups take the middle and push the link to the far edge. */
.status-groups {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2) var(--s4);
  min-width: 0;
}
.status-group {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.status-group-name {
  font-size: 0.82rem;
  color: var(--text-muted);
  white-space: nowrap;
}
/* Only drawn when the last check failed, and drawn as a chip in the watch
   tone the rest of the panel already speaks - four green lights nobody can
   tell are stale is the one way this panel could mislead. It says nothing
   about the game, only about our own request, which is why it sits apart
   from the four group chips. */
.status-stale { flex: none; white-space: normal; }
.status-link { margin-left: auto; flex: none; }
.status-headline { margin-top: var(--s2); }

@media (max-width: 680px) {
  .status-link { margin-left: 0; }
}

/* ---- Item icons -------------------------------------------------------- */
/* The game's own artwork beside a resource name. Small on purpose: the source
   is a ~24px sprite lifted from a screenshot, so it is sharp at this size and
   soft at any other. */
.item-name {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
}
/* The slot is there whether or not the item has art. Without it the handful
   of named resources that do have an icon sit indented from all the ones that
   do not, and a column of names reads with a ragged left edge. */
.item-icon-slot {
  width: 20px;
  height: 20px;
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.item-icon {
  width: 20px;
  height: 20px;
  /* Rendered as-is rather than smoothed. These are pixel sprites lifted from a
     screenshot and bilinear scaling turns them to mush. */
  image-rendering: -webkit-optimize-contrast;
}
.title-row .item-icon-slot,
.title-row .item-icon { width: 34px; height: 34px; }
