/* ==========================================================================
   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.

   The site is dark, and only dark. It used to carry a full light palette, a
   dark one written out twice, a toggle, and a preference saved against each
   account; the steps below are the dark set, which was always the chosen one
   rather than an automatic inversion of the light set.
   ========================================================================== */

/* --------------------------------------------------------------- font ---
   Plus Jakarta Sans, self-hosted, for the same reason Chart.js is vendored:
   no third-party request at page load, and it renders on a machine with no
   route to the internet - which includes the LAN box this gets tested on.

   One file per subset rather than one per weight. This is the variable cut,
   so `font-weight: 200 800` gives the whole range from a single 27KB face -
   and the range matters, because the current sidebar link is 650 and a
   static instance would round it to 700 and stop it being distinguishable
   from a heading.

   SIL Open Font License 1.1; the licence ships beside the files in
   static/fonts/OFL.txt, which is the condition for redistributing it.
   "Plus Jakarta Sans" is a Reserved Font Name - subsetting is fine, but
   anything that modifies the glyphs has to be renamed.
   ------------------------------------------------------------------------ */
@font-face {
  font-family: "Plus Jakarta Sans";
  src: url("../fonts/plus-jakarta-sans-latin.7660bd9909fb.woff2") format("woff2");
  font-weight: 200 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Plus Jakarta Sans";
  src: url("../fonts/plus-jakarta-sans-latin-ext.341687eeeb6a.woff2") format("woff2");
  font-weight: 200 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

:root {
  color-scheme: dark;

  /* Surfaces and ink */
  --plane: #0d0d0d;
  --surface: #1a1a19;
  --surface-sunken: #131312;
  --text-primary: #ffffff;
  --text-secondary: #c3c2b7;
  /* Tuned against #1a1a19, where it measures 4.85:1 - WCAG AA wants 4.5:1 for
     text this size. Every explanatory subtitle on every dashboard page is one
     of these, so it is the widest-reaching contrast value on the site and the
     one to re-measure if --surface ever moves. */
  --text-muted: #898781;
  --grid: #2c2c2a;
  --axis: #383835;
  --border: rgba(255, 255, 255, 0.1);
  --border-strong: rgba(255, 255, 255, 0.2);
  /* Spark's geometry - 10px down, 30px blur - at an alpha that can actually
     be seen on a dark ground. Theirs is 4% black, which is a light-theme
     device and would be nothing here. This carries the separation that the
     card borders used to, and is the reason they can come off. */
  --shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
  /* Not a border. A raised dark surface has a lit top edge, and this is that
     edge - it rescues the cases the surface step alone cannot carry, like a
     panel sitting on another panel. */
  --card-ring: inset 0 1px 0 rgba(255, 255, 255, 0.055);
  /* Discord's blurple, for Discord's mark and nothing else. Not one of the
     series colours and never used as one: it means "this is Discord", which
     is the whole reason it earns a place outside the palette. */
  --discord-blurple: #7d87f5;
  /* The "this is on / this is a quiet link" colour. Referenced by
     .watch-btn.is-watching and .overflow > summary since both were written,
     and defined nowhere - so both declarations were invalid at
     computed-value time and fell back to `inherit`. A watched metric and an
     unwatched one were therefore the same colour, which made the only
     personalised thing on the site invisible. Tracks --series-1 rather than
     restating it, so the accent and the first plotted series cannot drift. */
  --accent: var(--series-1);
  /* The same purple, but for a filled button, and deliberately NOT the lifted
     value above. The lifted blurple behind white text drops to about 3.7:1;
     the true brand colour holds 5.07:1, and it is what Discord's own button
     looks like. The tint above is lifted so it reads as an icon on this
     ground; a brand colour is not free to move that way. */
  --discord-brand: #5865f2;
  --discord-brand-hover: #4752c4;

  /* Categorical series - fixed order, never cycled past slot 8. */
  --series-1: #3987e5;
  --series-2: #d95926;
  --series-3: #199e70;
  --series-4: #c98500;
  --series-5: #d55181;
  --series-6: #008300;
  --series-7: #9085e9;
  --series-8: #e66767;
  /* 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: #0ca30c;
  /* Status text, as opposed to the status colour itself. A chip sets its own
     tinted ground and the hue that draws that tint cannot also carry the text
     on it. On a dark ground the answer is white, which is why these two are
     not the status hues darkened - darkening runs the wrong way here.

     --status-attention-strong is kept apart because it has a different job
     and a different threshold: these are small chip text needing 4.5:1, that
     is a 1.6rem/700 display number where 3:1 is the bar, and at that size the
     hue itself carries. */
  --status-attention-fg: #ffffff;
  --status-watch-fg: #ffffff;
  --status-attention-strong: var(--status-serious);
  /* Destructive, and deliberately not --status-critical. "This will delete
     something" and "this measurement is bad" are different messages and the
     site says them in different reds on purpose. */
  --status-danger: #b4483c;
  --status-danger-hover: #9c3d33;

  /* The warning amber, deepened until it reads as an edge rather than a glow;
     --status-warning itself spreads too far to show as a crisp 3px rule. */
  --status-warning-strong: #d08c26;

  /* Spacing scale. Every gap, pad and margin below picks from this, so
     rhythm stays consistent instead of drifting into arbitrary pixel values.

     On an 8-grid from --s2 up. The old 22/30/44 were each chosen for one
     layout and landed between steps, which is why gaps that should have
     matched never quite did. */
  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 24px;
  --s6: 32px;
  --s7: 48px;

  /* 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;
  /* The sidebar. Wide enough for "Import history" and a badge without
     wrapping, narrow enough that the content column still gets the room. */
  --sidebar-w: 280px;
  /* Buttons and inputs. */
  --radius: 14px;
  /* Nav items, chips, small controls. */
  --radius-sm: 10px;
  /* Cards and tiles. Larger than --radius because a big surface needs a
     bigger corner to read as the same family as a small one. */
  --radius-lg: 24px;
  /* How much room a card gives its contents. A token rather than a pair of
     spacing steps because eight other surfaces copy the card's recipe by
     hand - the tiles, the hero, the notice, the metric rows - and this is
     the one value that has to move for all of them at once. */
  --card-pad: 28px;
  /* A pill is "however round it takes"; the value is a stand-in for infinity
     rather than a measurement, which is why it is not on the scale above. */
  --radius-pill: 999px;
  /* A second shadow role, and a stronger one. --shadow is the resting lift of
     a card sitting on the page; this is for a menu floating over content that
     is still there behind it, which has to read as detached rather than
     merely raised. */
  --shadow-panel: 0 10px 28px rgba(0, 0, 0, 0.28);
  /* The whole stack stays as a fallback: font-display is swap, so the page
     draws in system-ui first and reflows when the face arrives. */
  --font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto,
    sans-serif;

  /* Breakpoints are NOT here, and cannot be: a custom property is not valid
     inside a media condition, and this project has no build step to fold one
     in. They are written out at each use, so the scale has to be a habit
     rather than a constant. It is:

         1040   wide desktop - tile and hero column counts
          900   nav collapse. PINNED - see below
          680   the main phone break
          600   small phone - hero stacks, action rows become columns
          430   narrow phone

     There were ten values before, including 1080/1040/1023 inside 57px and
     900/880 inside 20px. Each had been added for one grid that wrapped badly,
     which is how a scale becomes a list.

     900 is a contract across three files, not a style choice. The nav
     disclosure is collapsed by CSS at this width, by the pre-paint script in
     templates/base.html, and by initNav() in static/js/dashboard.js. Move it
     in one place and the menu stops agreeing with its own button. */
}

/* ---------------------------------------------------------------- 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); }

/* One focus ring for everything that takes focus.

   There were six :focus rules on the site and no general one, so buttons,
   filter pills, nav links, the theme toggle, the menu summaries and the
   watch stars all fell back to the browser's own ring - which is close to
   invisible on .btn-primary's filled blue and on an inverted
   .pill[aria-current]. Nothing ever set `outline: none`; the ring was simply
   never designed, and a keyboard user could not tell where they were.

   :focus-visible rather than :focus, so a mouse click does not leave a ring
   behind. The few components that already ring themselves keep their own
   rule; this is the floor, not an override. */
:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.muted { color: var(--text-muted); }
.secondary { color: var(--text-secondary); }
.small { font-size: 0.84rem; }
.nowrap { white-space: nowrap; }
.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;
  padding-top: var(--s4);
  /* 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;
}

.brand {
  display: flex;
  align-items: center;
  text-decoration: none;
  /* Allowed to shrink, and that is the point. While this was `flex: none`
     with a 220px logo inside it, the sidebar could not be narrower than the
     wordmark plus the collapse button plus its own padding - 286px - and a
     flex item's automatic min-width meant --sidebar-w was quietly ignored
     below that. The token said 248px and the sidebar measured 286px for as
     long as both existed. */
  flex: 1 1 auto;
  min-width: 0;
}

/* The wordmark. One file now. There used to be two - the artwork is white on
   transparent and vanished against the light header, so a second copy had its
   white recoloured to ink, and three rules swapped between them for the
   system preference and then for an explicit choice. With one ground to draw
   against, the white original is the only one needed. */
.brand-logo {
  display: block;
  /* Fluid up to its natural size, holding the artwork's own 3.49:1 ratio so
     "Seed The Future" stays legible rather than collapsing into a grey
     smudge. aspect-ratio rather than a fixed height: with `contain` and a
     fixed height, a narrower box letterboxes the image instead of scaling
     it. */
  width: 100%;
  max-width: 220px;
  aspect-ratio: 220 / 63;
  background: url("../img/logo-dark.15e7f851a73d.png") left center / contain no-repeat;
}
@media (max-width: 900px) {
  .brand-logo { max-width: 178px; }
}
@media (max-width: 430px) {
  .brand-logo { max-width: 140px; }
}

/* ---- Search ------------------------------------------------------------ */
/* The box itself is styled where it lives, in .side-search. What was here was
   the top bar's version - a fixed 8.5rem that grew to 13rem on focus, with
   its own background, border and a width transition - and every line of it
   had been overridden since the search moved into the sidebar. */
.nav-search-input::placeholder { color: var(--text-muted); }

.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);
  }
}
/* --------------------------------------------------------------- cards --- */
.card {
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: var(--card-pad);
  /* No border. The surface step plus the shadow does the separating, which
     is what lets a card read as raised rather than outlined. */
  box-shadow: var(--card-ring), 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-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: 1040px) {
  .tiles:has(> :nth-child(5)) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .tiles-small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
  .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
  .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(--s7); }
.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.

   A head with an action beside it - "Write a post", "Add an account" - wraps
   the h1 in .title-row, and is the same head. It has to be named here too,
   because every selector below is a child or sibling combinator and a
   wrapper breaks all four at once: the title lost its top margin, the lead
   paragraph lost the pull that tucks it under the title, and it lost the
   80ch measure, so it ran the full width of the shell. Five pages were
   drawing themselves that way. .title-row itself sets no margins - it is a
   flex row and nothing more - so there was nothing to notice. */
.shell > h1,
.shell > .title-row { margin-top: var(--s5); }
.shell > h1 + p,
.shell > .title-row + p { margin-top: calc(-1 * var(--s2)); }
.shell > h1 + p.secondary,
.shell > .title-row + 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; }
.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,
.back-link + .title-row { 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); }

/* A two-column grid of metric rows wants more room than a two-column grid of
   anything else, and gives up at a different width because of it.

   One of these rows is a name, a trend, a reading, a status chip and a watch
   star - about 380px before the name has said anything. Two of those abreast
   inside the shell needs something over 1100px of window, and below that the
   row had to wrap: the star dropped to a line of its own and that row stood
   twenty pixels taller than the ones around it, for a reason nothing on
   screen explained.

   So this kind of grid drops to one column at 1040 rather than 680, where a
   full-width row has room to stay on one line. Scoped with :has() so the
   chart and text grids, which are happy two-abreast much narrower, keep the
   680 they were tuned for. */
@media (max-width: 1040px) {
  .grid-2:has(.metric-row) { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 680px) {
  .grid-2 { 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. */
}

/* --------------------------------------------------------------- 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(--card-pad) var(--s6);
  background: var(--surface);
  border-radius: var(--radius-lg);
  box-shadow: var(--card-ring), 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; }
/* The hero restructures to a single centred column at this width; the dial
   was the one thing in it still holding its desktop size. */
@media (max-width: 600px) {
  .gauge { width: 108px; height: 108px; }
}
.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: var(--status-attention-strong); }
.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); }


@media (max-width: 1040px) {
  .hero { grid-template-columns: auto minmax(0, 1fr); gap: var(--s5); }
  .hero-counts { grid-column: 1 / -1; }
}
@media (max-width: 600px) {
  .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: var(--radius-pill);
  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: var(--status-attention-fg); 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,
.status-planned { color: var(--status-watch-fg); 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, so "planned"
   shares the watch rule above rather than restating it - and declined is
   deliberately quiet rather than red: a reasoned no is not a failure. */
.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); }


/* 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 {
  /* Positioned because the sparkline is taken out of the flow and pinned
     to the top right corner - see .tile .spark below. */
  position: relative;
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: var(--s4) var(--s5);
  box-shadow: var(--card-ring), var(--shadow);
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
.tile-label {
  /* Room for the sparkline in the corner, and two lines reserved whether or
     not this label needs them. Labels here run to "Average daily wage" and
     "Worst-served need", so one tile in a row wraps and its value drops half
     a line below its neighbours'. The reserved line costs a little space on
     the short ones and keeps the row of figures reading as a row.

     The 66px and the corner offset in .tile .spark are measured against this
     tile's padding. Change one of the three and change all three. */
  padding-right: 66px;
  min-height: 2.35em;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--text-muted);
  letter-spacing: 0.01em;
}
.tile-value {
  font-size: 2.1rem;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.025em;
  margin-top: 2px;
}
.tile-foot {
  display: flex;
  align-items: center;
  gap: 7px;
  flex-wrap: wrap;
  margin-top: 2px;
  font-size: 0.8rem;
  color: var(--text-muted);
}
/* The caption, beside the verdict rather than instead of it. This slot used
   to mean "status chip" on the overview and "what this figure is" on the
   other four pages, which taught a reader the wrong thing on their second
   page. It now carries both, and the caption steps back so the chip reads
   first. */
.tile-note { color: var(--text-muted); }
/* The thresholds, in the figure's own units. Written since the metrics app
   shipped and rendered until now on one admin screen. */
.tile-thresholds {
  display: block;
  margin-top: 6px;
  line-height: 1.35;
}

.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(--s3) var(--s4);
  background: var(--surface);
  border-radius: var(--radius-sm);
  box-shadow: var(--card-ring), var(--shadow);
  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;
}
/* Overridden below above 680px, where nothing but the name may shrink. */
.metric-row .trend { flex: 0 1 auto; min-width: 0; }

/* Above a phone, one line per row - always.

   These rows read as a list, and a list whose rows are different heights
   reads as a mistake. The row wrapped, which meant its last item - the watch
   star - could drop to a second line on its own the moment the name, the
   trend, the reading and the chip happened to add up to more than the width.
   One row in a column was then twenty pixels taller than its neighbours,
   with nothing on screen to explain why, and it was whichever row happened
   to carry the longest figure.

   Nothing wraps now; the name gives way instead. At these widths it never
   comes to that - the names here are two words - so this is the valve that
   replaces the wrap rather than something a reader will meet.

   Below 680px the row goes back to wrapping, which is the behaviour that
   block was written for: on a phone the name takes the first line and the
   rest drops beneath it, because the alternative there is a chip pushed off
   the right-hand edge and a page that scrolls sideways. */
.metric-row { flex-wrap: nowrap; }
.metric-row .m-name { flex: 1 1 auto; }
/* And the name is the only thing that gives. Everything after it holds its
   width, because each of them wraps its own text when squeezed - the trend
   reading "21% higher" over two lines makes the row taller just as surely as
   the star dropping did, and is harder to spot as the cause. */
.metric-row > :not(.m-name) { flex: none; }
/* Only where there is no description under the name. A row that carries one
   is two lines by design, and clipping it to one would throw the sentence
   away rather than the overflow. */
.metric-row .m-name:not(:has(.m-desc)) {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
@media (max-width: 680px) {
  .metric-row { flex-wrap: wrap; }
  .metric-row .m-name { flex: 1 1 min(100%, 220px); }
  .metric-row > :not(.m-name) { flex: 0 1 auto; }
  .metric-row .m-name:not(:has(.m-desc)) { white-space: normal; overflow: visible; }
}

/* --------------------------------------------------------------- chart --- */
.chart-card {
  display: flex;
  flex-direction: column;
  border-radius: var(--radius-lg);
}
.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; }
/* A plot keeps its height while the card loses width, so the aspect ratio
   goes with the viewport: 260px tall inside a 340px-wide phone card is a
   near-square box holding a time series. Twenty-six chart cards inherit
   this, so it is one rule rather than twenty-six. */
@media (max-width: 680px) {
  .chart-frame { height: 220px; }
  .chart-frame.tall { height: 270px; }
  .chart-frame.short { height: 130px; }
}
@media (max-width: 430px) {
  .chart-frame { height: 190px; }
  .chart-frame.tall { height: 230px; }
  .chart-frame.short { height: 120px; }
}
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-radius: var(--radius-sm);
  padding: var(--s3) var(--s3) var(--s2);
  background: var(--surface);
  box-shadow: var(--card-ring), var(--shadow);
}
.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; }
/* A row that groups tickboxes is a <fieldset> with a <legend>, so that the
   group has a name a screen reader can reach. Browsers give both a border,
   padding and a margin of their own; stripped here so the grouped row sits
   exactly where an ordinary .form-row does, and the legend reads as the
   label it replaces. */
fieldset.form-row { border: 0; padding: 0; min-width: 0; }
fieldset.form-row > legend { display: block; padding: 0; }
/* Help text sits under its label rather than continuing the sentence it
   started - and as a direct child only, so the flex layouts that place help
   text themselves (.avatar-editor, .add-form) keep their own arrangement.

   There was a second class here, .helptext, on the grounds that it is what
   Django emits. It is - but only from the form templates Django renders
   itself, and no page on this site uses one: there is no {{ form }}, no
   as_p, no as_div, no as_table anywhere. So .helptext was reachable only
   from four hand-written templates that had picked the wrong name, and the
   comment justifying it described a situation that had never arisen here.
   Both went when partials/form_field.html became the only thing that emits
   either. */
.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: 9px 18px;
  font: inherit;
  font-size: 0.85rem;
  font-weight: 700;
  border-radius: var(--radius);
  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; }
/* A button that hands somebody over to Discord wears Discord's colour rather
   than the site's. It is the one control here whose job is to be recognised
   before it is read, and on the sign-in page it sits beside the ordinary
   password button - where being a different colour is the whole point. */
.btn-discord { background: var(--discord-brand); color: #fff; }
.btn-discord:hover { background: var(--discord-brand-hover); color: #fff; }
.btn-quiet {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text-secondary);
}
.btn-quiet:hover { color: var(--text-primary); background: var(--surface-sunken); }

/* ------------------------------------------------------------- filters --- */
/* One filter row above everything it scopes - never inside a chart card.

   There are two shapes of filter bar and they are not interchangeable:

     a <div> of .pill-row links, when the filters are addresses that change
     what the page shows - each link carries the page's other parameters so
     picking one does not silently reset the rest;

     a <form method="get" action=""> of .field wrappers with a submit button,
     when they are controls somebody fills in. Selects that stand alone take
     data-autosubmit; a Reset link appears only when something is set.

   Both take role="group" and an aria-label, because a bare row of links has
   no name and a screen reader meets them as loose anchors. The box itself is
   partials/search_field.html - the bar around it is not a partial and should
   not become one, since the fields differ per page and each Reset guard is
   an expression, which {% include with %} cannot carry. */
.filterbar {
  display: flex;
  gap: var(--s3);
  align-items: flex-end;
  flex-wrap: wrap;
  padding: var(--s3) var(--s4);
  background: var(--surface);
  border-radius: var(--radius);
  box-shadow: var(--card-ring), var(--shadow);
  /* 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: var(--radius-pill);
  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-info { background: var(--surface-sunken); }

.freshness {
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
  align-items: center;
  color: var(--text-secondary);
  font-size: 0.82rem;
  padding: 10px 0 0;
}

/* Nothing here, at three scales. The one to reach for follows the thing that
   is empty, not the page it is on:

     a table's       - a full-width muted <tr> inside its own <tbody>, so the
                       header still explains what is missing. Its colspan must
                       match the header, which is checked in tests;
     a section's     - this, .empty, a panel where the panel would have been;
     a whole page's  - .not-found, which is the page.

   A fourth had grown up alongside them - a bare <p class="card-sub"> inside a
   card - which is not wrong so much as unplaceable: it reads as a caption for
   content that is not there. Say which of the three you mean.

   Some tables answer this with an {% if rows %} one level up instead of an
   {% empty %}. That is the same thing said differently and is left alone. */
.empty {
  text-align: center;
  padding: 48px 20px;
  color: var(--text-muted);
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
}

/* "Since you were last here". Sits between the hero and the pinned posts, so
   it wants to read as a note rather than as another panel of numbers - hence
   `.card` and not `.banner`, which on this site is the vocabulary of warnings
   and would give a friendly update the weight of the stale-data notice. */
/* The same rhythm as every other block. This was var(--s4), which left it
   sitting half a step closer to its neighbour than anything else on the
   page - it is the only block that is a bare .card rather than a .section
   wrapping one, so it never picked up .section's margin. */
.since { margin-top: var(--s6); }
.since-lead { margin: 0 0 var(--s2); }
/* Columns, not one long list. As a single column this was five rows of about
   400px of ink on a 1700px card - a ragged clump against the left edge of a
   mostly empty panel, which is what it looked like. whats_new.MAX_CHANGES is
   5, so three columns lay a full block out as 3 + 2.

   max-width queries on the scale documented at the top of this file, because
   every other query here is desktop-first and a lone min-width would quietly
   break that. */
.since-changes {
  list-style: none;
  margin: 0 0 var(--s4);
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s4) var(--s5);
}
@media (max-width: 1040px) {
  .since-changes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 680px) {
  .since-changes { grid-template-columns: minmax(0, 1fr); }
}
/* One row per indicator that moved: arrow, name, the band it left, the band
   it is in. Wraps rather than truncates, because the band labels are the
   point - a change shown only by a colour going from green to amber is a
   change a colour-blind member does not see. */
/* One change, as two lines that cannot come apart.

   This was five things in a wrapping flex row - arrow, name, old band, new
   chip, reading - laid into a third of a card. There was never room, so it
   wrapped; and because the reading was pinned with `margin-left: auto` it
   then flew to the right-hand edge of whichever line it had landed on. What
   a member actually saw was a name, and somewhere off to the right of the
   line below it, a number belonging to it.

   A grid fixes the pieces in place. First line: what moved, and what it
   reads now. Second: which band it moved into, indented under the name so
   the column of names stays scannable. */
.since-change {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  grid-template-areas:
    "arrow name  value"
    ".     bands bands";
  align-items: baseline;
  column-gap: 7px;
  row-gap: 4px;
  font-size: 0.9rem;
}
.since-change > .trend { grid-area: arrow; }
.since-change-label {
  grid-area: name;
  min-width: 0;
  font-weight: 600;
}
/* The reading. Tabular figures so the numbers in a column line up on the
   decimal rather than drifting with the width of their glyphs. */
/* Beside the name, not pinned to the far edge of the column.

   It used to be pushed right so the readings lined up down the page. On a
   card this wide that put two hundred pixels of nothing between a name and
   its number, which is the same disconnection the wrapping caused, only
   tidier. Two or three items to a column is not enough of a column for the
   alignment to buy anything. */
.since-change-value {
  grid-area: value;
  justify-self: start;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.since-change-bands {
  grid-area: bands;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  font-size: 0.84rem;
}
.since-change-from { color: var(--text-muted); }
.since-change-bands > [aria-hidden] { color: var(--text-muted); }

/* Links in here keep both of their cues - the accent colour and an underline
   - but the underline is thinned and dropped clear of the text. At full
   weight, against a card this quiet, a plain browser underline was the
   loudest thing in the panel. */
.since a {
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
}
.since a:hover { text-decoration-color: currentColor; }
.since-watch { margin: 0 0 var(--s2); font-size: 0.9rem; }
.since-community { margin: 0; }

/* The 404. A centred column like the welcome page rather than the dashed box
   `.empty` gives every other empty state, because the two are not the same
   thing: `.empty` is a panel inside a page that worked, and this is the whole
   page, for somebody who has lost their place. It should read as a full stop
   and a set of ways onward. */
.not-found {
  max-width: 44rem;
  margin: var(--s7) auto 0;
  text-align: center;
}
/* Big, and deliberately the border colour rather than a text one: it is
   decoration repeating what the heading already says, and it is aria-hidden
   for the same reason. */
.not-found-code {
  font-size: clamp(3.5rem, 12vw, 6rem);
  line-height: 1;
  font-weight: 700;
  letter-spacing: -0.04em;
  color: var(--border-strong);
  margin-bottom: var(--s2);
}
.not-found h1 {
  font-size: clamp(1.4rem, 3vw, 1.9rem);
  line-height: 1.2;
  margin-bottom: var(--s3);
}
.not-found .lead {
  color: var(--text-secondary);
  line-height: 1.6;
  margin-bottom: var(--s5);
}
/* A path is the one string on this page that may be long and unbreakable, and
   a phone is where somebody mistypes one. */
.not-found-path {
  word-break: break-all;
  background: var(--surface-sunken);
  border-radius: 4px;
  padding: 1px 5px;
  font-size: 0.9em;
}
.not-found-search {
  display: flex;
  gap: var(--s2);
  max-width: 26rem;
  margin: 0 auto var(--s4);
}
.not-found-search .form-control { flex: 1 1 auto; min-width: 0; }
/* Four destinations of equal weight, all `.btn-quiet`. The search box above
   is this page's one emphasised control, and a bare `.btn` among three
   outlined ones only looked like a mistake. */
.not-found-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  justify-content: center;
}
.not-found-note { margin-top: var(--s4); }
/* The same breakpoint and the same reason as `.welcome-actions`: four buttons
   wrap into a ragged block on a phone, and a column reads as a list. */
@media (max-width: 600px) {
  .not-found-links { flex-direction: column; align-items: stretch; }
}

/* 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 --- */
/* Drawn only by partials/pager.html. */
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 16px; flex-wrap: wrap; }

/* --------------------------------------------------------------- print --- */
/* Print, which is the one surface that is not dark.

   This used to be three declarations, and that was enough while the palette
   had a light half to fall back on. It does not any more: a dark-only sheet
   prints either a solid black page or, with background graphics switched off
   - which is the browser default - white paper carrying white text. Neither
   is a page anybody can read.

   So the roles are flipped back here rather than anywhere else. Everything
   downstream reads from these tokens, so the whole site follows without a
   single component needing a print rule of its own. The borders come back
   too: the shadow that separates cards on screen does not survive a printer,
   and `box-shadow: none` on a borderless card leaves nothing at all. */
@media print {
  :root {
    color-scheme: light;
    --plane: #ffffff;
    --surface: #ffffff;
    --surface-sunken: #f4f4f2;
    --text-primary: #000000;
    --text-secondary: #333333;
    --text-muted: #555555;
    --border: rgba(0, 0, 0, 0.25);
    --border-strong: rgba(0, 0, 0, 0.4);
    --grid: #d9d9d4;
    --axis: #9a9a95;
    --shadow: none;
    --card-ring: none;
  }
  .chart-tools, .filterbar, .footer { display: none; }
  /* The sidebar is navigation; paper has none. */
  .nav-disclosure { display: none; }
  .card,
  .tile {
    break-inside: avoid;
    border: 1px solid var(--border);
    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"; }
}
/* 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: 600px) {
  .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: var(--shadow-panel);
}
.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);
  /* A row in a menu, not a link in a sentence. The underline the document
     default gives it made the one link in here look like the odd one out
     beside the button below it, when the two are the same kind of thing. */
  text-decoration: none;
  font: inherit;
  font-size: 0.9rem;
  cursor: pointer;
  white-space: nowrap;
}
/* --surface-2 and --bg were both undefined, so this whole declaration was
   invalid and every item in the Manage and account menus had no hover
   highlight at all - only the colour change below it landed. That matters
   more now that Blog and Patch notes live in here. */
.menu-panel a:hover,
.menu-signout:hover { background: var(--surface-sunken); color: var(--text-primary); }
.menu-panel a[aria-current="page"] { color: var(--text-primary); font-weight: 600; }
.menu-panel form { margin: 0; }
/* A rule above anything that starts a new part of the menu - the Manage
   heading, and Sign out. With Manage in here the panel runs to ten rows, and
   ten rows of identical spacing is a list you have to read rather than scan.
   Not before the first heading: that one is the top of the panel and already
   has an edge. */
.menu-panel > .menu-head:not(:first-child),
.menu-panel > form {
  margin-top: var(--s1);
  padding-top: var(--s2);
  border-top: 1px solid var(--border);
}
.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: var(--radius-pill);
  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); }

/* Discord's mark keeps Discord's colour. Every other icon here is a house
   stroke that inherits the text around it, which is right for an icon
   standing in for a word - but this one stands in for somewhere else you
   can go, and it is spotted by its colour before it is read. Members
   scanning a page for the way into the server find it faster in purple. */
.icon--discord { color: var(--discord-blurple); }
/* Except on a filled button, where the background is already solid and the
   brand purple on top of it is neither legible nor the logo: there it stays
   white, the way Discord's own button is drawn. */
.btn-discord .icon--discord,
.btn-primary .icon--discord { color: currentColor; }

/* ---- Data freshness ---------------------------------------------------- */
.freshness.is-stale { color: var(--status-attention-fg); 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 var(--status-warning-strong);
  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. */
/* A reading column. Named for the shape, not for the three pages it started
   on: the terms and the privacy notice were the first things here that were
   prose rather than numbers, so the class was called .legal - and then the
   blog, the post editor and the search results all wanted the same 46rem and
   inherited a name that described none of them. The measure is the point.
   Anything whose content is sentences rather than a grid belongs in it. */
.prose-page { max-width: 46rem; margin-inline: auto; }
.prose-page 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 var(--status-danger); }
.btn-danger {
  background: var(--status-danger);
  border-color: var(--status-danger);
  color: #fff;
}
.btn-danger:hover { background: var(--status-danger-hover); border-color: var(--status-danger-hover); }

/* ---- 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: var(--radius-pill);
  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; }

/* ------------------------------------------------- dashboard layout --- */
/* The editor for the member's own arrangement of the overview. Everything
   here is scoped to the edit mode, which is a querystring flag rather than a
   stored one, so none of it reaches a normal page load. */

/* A counter in the hero whose block is not on the page. The <a> has no href
   in that case - so it is already not focusable and not a link - and this
   only removes the affordance that would otherwise promise something. */
.count.is-plain { cursor: default; }
.count.is-plain:hover { border-color: var(--border); background: none; }

/* Right-aligned and compact. Reading down the page from the top you should
   meet your dashboard, not the control for rearranging it - this used to be
   the first thing in the content block, flush left. */
.layout-enter { display: flex; justify-content: flex-end; margin: var(--s4) 0 0; }

/* The first icon-only control on the site, so two things in .icon that are
   built for icon-beside-text have to be undone: a margin-right that would
   push the glyph off-centre in a square button, and a hard-coded
   --text-muted that would leave it dead grey while the button around it
   brightens on hover. */
.btn-icon { padding: 5px; gap: 0; }
.btn-icon .icon { margin-right: 0; color: currentColor; }

.layout-bar {
  margin-top: var(--s5);
  padding: var(--s5);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface);
}
/* The editor's controls follow you down the page. What they arrange is
   fifteen dashboard blocks tall now, and Save sitting three thousand pixels
   above the block you just dragged is not a trip anybody makes twice. Kept
   short on purpose: the indicator list moved out from under here into
   _edit_tiles.html precisely so that this could stick without eating the
   screen. The shadow is what separates it from what scrolls underneath; in
   dark mode --shadow is none and the strong border does that job instead. */
.layout-bar.is-sticky {
  position: sticky;
  top: 0;
  z-index: 4;
  box-shadow: var(--shadow);
}
.layout-intro h2 { margin-top: 0; }

/* ---- Arranging the blocks themselves ----------------------------------- */
/* The editor used to be a list of fifteen names with the real page below it,
   unchanged, so you arranged one thing while looking at another. Now the page
   *is* the list: every block stays where it is in the document and gains a
   frame carrying its name and its controls.

   The frame is not decoration. Nine of the fifteen blocks draw nothing at all
   when there is nothing to report, and a zero-height row cannot be grabbed,
   reordered, or switched back on. The frame is what gives those a body. */
.layout-canvas {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  margin-top: var(--s4);
}
.layout-block {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--plane);
  overflow: hidden;
}
.layout-block:focus-within { border-color: var(--border-strong); }

.layout-block-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2) var(--s3);
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

/* The block, shrunk to a card. A cap rather than a scaled-down copy: the
   numbers stay their real size and stay readable, and you see the top of each
   one. Enough to recognise a block by, which is the whole job here. */
.layout-block-preview {
  position: relative;
  max-height: 180px;
  overflow: hidden;
  padding: 0 var(--s3);
  /* Everything in here is live markup - links, watch buttons, chart canvases.
     Clicking one mid-arrange would navigate away from an unsaved layout, so
     the preview takes no pointer events at all and the whole card becomes
     grab surface instead. */
  pointer-events: none;
}
/* Except a chart, which needs most of itself to be worth previewing. A plot
   is a title, an axis, and then the part that carries the meaning, and that
   part is the bottom two thirds - so 180px of a chart block is a heading over
   an empty grid. The one kind of block the cap made *less* recognisable
   rather than more, which is the opposite of what it is for. */
.layout-block-preview:has(canvas) { max-height: 330px; }

/* Faded rather than cut, so a clipped block reads as continuing below rather
   than as ending badly. */
.layout-block-preview::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 48px;
  background: linear-gradient(to bottom, transparent, var(--plane));
}
/* Undo the top margin the first thing in a block brings with it: inside a
   frame that is a gap under the name bar, not separation from a neighbour. */
.layout-block-preview > :first-child { margin-top: var(--s3); }

/* Shown only when the block drew nothing at all. :has() is doing real work
   here - it is the only way to ask "did that template produce anything?" from
   a stylesheet, and this file already leans on it in five other places. If
   the include emitted any element, this is not the thing to say. */
.layout-block-empty {
  margin: var(--s3) 0;
  color: var(--text-muted);
  font-size: 0.86rem;
  font-style: italic;
}
.layout-block-preview:has(> :not(.layout-block-empty)) .layout-block-empty {
  display: none;
}
/* And an empty frame has nothing to fade out. */
.layout-block-preview:not(:has(> :not(.layout-block-empty)))::after {
  display: none;
}

/* Hidden, and unavailable until an import lands. Both stay legible: these are
   blocks you are being invited to switch back on, not disabled controls.
   Dimmed in place rather than moved to a tray, so "where does it come back
   to" is answered by looking at it. */
.layout-block.is-off { background: var(--surface-sunken); }
.layout-block.is-off .layout-block-bar { background: var(--surface-sunken); }
.layout-block.is-off .layout-name { color: var(--text-muted); }
.layout-block.is-off .layout-block-preview { opacity: 0.4; }
.layout-block.is-off .layout-block-preview::after {
  background: linear-gradient(to bottom, transparent, var(--surface-sunken));
}
.layout-block.is-unavailable .layout-hint em { color: var(--status-watch-fg); }

/* ---- The indicator list ------------------------------------------------ */
.layout-list {
  list-style: none;
  margin: var(--s4) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}
.layout-item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2) var(--s3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--plane);
}
/* The list is operated by the buttons; a container cue is enough to say
   which row you are in. The global :focus-visible already rings the button
   itself, so there is no second focus rule here. */
.layout-item:focus-within { border-color: var(--border-strong); }

/* The cut between the headline tiles and the watchlist. Drawn on the sixth
   row rather than inserted as a row of its own, because a node does not move
   when the rows around it are dragged - the old one went on marking a
   boundary the server was no longer going to cut at. This one is a property
   of position, so it is always in the right place and needs no script. */
.layout-list[data-tile-line] .layout-item:nth-child(6) {
  position: relative;
  margin-top: calc(var(--s5) + var(--s3));
}
.layout-list[data-tile-line] .layout-item:nth-child(6)::before {
  /* Two spaces, and the first one is not a typo: a space terminates a CSS
     hex escape and is eaten by it, so "\2014 shown" comes out with the dash
     jammed against the word after it. */
  content: "below the line \2014  shown in your watchlist";
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(100% + var(--s2));
  padding-top: var(--s2);
  border-top: 1px dashed var(--border-strong);
  color: var(--text-muted);
  font-size: 0.8rem;
  text-align: center;
}

/* ---- Shared between the two -------------------------------------------- */
.layout-handle {
  color: var(--text-muted);
  cursor: grab;
  /* Only a grab affordance once something can actually be grabbed. layout.js
     sets draggable and this class together; without the script the handle is
     decoration and says so by not inviting a drag. */
  display: none;
}
.layout-list.is-draggable .layout-handle,
.layout-canvas.is-draggable .layout-handle { display: inline; }
.layout-list.is-draggable .layout-item,
.layout-canvas.is-draggable .layout-block-bar { cursor: grab; }

.layout-text { flex: 1 1 auto; min-width: 0; }
.layout-name { display: block; font-weight: 600; }
.layout-hint {
  display: block;
  color: var(--text-muted);
  font-size: 0.82rem;
}
.layout-show { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.layout-moves { display: flex; gap: 4px; }

.layout-item.is-off { background: var(--surface-sunken); }
.layout-item.is-off .layout-name { color: var(--text-muted); }
.layout-item.is-unavailable .layout-hint em { color: var(--status-watch-fg); }

/* Drag cues are classes on existing rows - no placeholder node is inserted,
   so the tree a screen reader walks does not gain a phantom item mid-drag.
   And no transition anywhere: there is then nothing for prefers-reduced-motion
   to have to honour. */
.layout-item.is-dragging,
.layout-block.is-dragging { opacity: 0.4; }
.layout-item.is-drop-target,
.layout-block.is-drop-target { border-color: var(--series-1); }

@media (max-width: 680px) {
  .layout-item { flex-wrap: wrap; }
  .layout-text { flex: 1 1 100%; }
  /* A frame bar wraps differently from a tile row. The rule above drops the
     name to its own line, which on a tile leaves the handle beside the
     buttons - fine, they are all controls. On a frame it strands the handle
     alone on the first line, a grab target with nothing next to it saying
     what it grabs. Here the handle stays with the name and only the controls
     wrap underneath. */
  .layout-block-bar { flex-wrap: wrap; }
  .layout-block-bar .layout-text { flex: 1 1 auto; }
  .layout-block-bar .layout-show { margin-left: auto; }
  .layout-block-preview,
  .layout-block-preview:has(canvas) { max-height: 140px; }
}

/* ==========================================================================
   Sidebar chrome.

   The top bar became a left column. It had been at its ceiling for a while -
   seven flat tabs, two overflow menus, and a comment explaining which links
   had been demoted to keep the row under nine. A column has no such ceiling,
   so the blog, the patch notes and the suggestion board come back as
   destinations rather than menu entries, grouped instead of hidden.

   Still the same <details class="nav-disclosure">, so the pre-paint script
   and initNav() are untouched: above 900px the summary is hidden and this
   lays out as a column; below it, it is a menu button again.
   ========================================================================== */
.app { display: flex; align-items: flex-start; min-height: 100vh; }
.app-body { flex: 1 1 auto; min-width: 0; }

.nav-disclosure {
  flex: 0 0 var(--sidebar-w);
  position: sticky;
  top: 0;
  max-height: 100vh;
}
.sidebar {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  height: 100vh;
  padding: var(--s6) var(--s5);
  background: var(--surface);
  border-right: 1px solid var(--border);
  /* No scroll here: the brand and the foot are pinned and the link list
     scrolls between them. Nineteen destinations plus four group headings is
     taller than a laptop viewport, and scrolling the whole column put Sign
     out and the theme below the fold - two controls a member should never
     have to go looking for. */
  overflow: hidden;
}
.sidebar .brand { margin-bottom: var(--s2); }

.side-nav {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}
/* Section headings. Not an <h2>: these label a list of links, not a region of
   the document, and a screen reader walking the headings of a page should
   find the page's own content rather than three copies of the furniture. The
   list is pointed at them with aria-labelledby instead. */
.side-group {
  margin: var(--s4) 0 var(--s1);
  padding: 0 var(--s3);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.side-group:first-child { margin-top: 0; }
.side-list { list-style: none; margin: 0; padding: 0; }
.side-list a {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: 8px var(--s3);
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  text-decoration: none;
  font-size: 0.95rem;
  font-weight: 500;
}
.side-list a:hover { background: var(--surface-sunken); color: var(--text-primary); }
/* The current page. Background and weight rather than colour alone, and the
   aria-current attribute carries it for anybody not looking at the colour. */
.side-list a[aria-current="page"] {
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-weight: 650;
}
/* Ahead of the word they sit beside, rather than matching it at 1em like
   every other icon on the site. In a nav they carry more than decoration -
   collapse the column to a rail and they are the only thing left to read
   it by. */
.side-list .icon {
  width: 1.2em;
  height: 1.2em;
  margin-right: 0;
  color: currentColor;
}
/* The count of bug reports waiting. Was .side-badge, on a sidebar link; the
   name went with the link when Manage moved into the account menu. */
.menu-badge {
  margin-left: auto;
  min-width: 20px;
  padding: 1px 6px;
  border-radius: var(--radius-pill);
  background: var(--status-critical);
  color: #fff;
  font-size: 0.72rem;
  font-weight: 700;
  text-align: center;
}
/* Only the row carrying one becomes a flex row, so the badge can sit out at
   the right edge; every other row in the panel stays a plain block. */
.menu-panel a:has(.menu-badge) {
  display: flex;
  align-items: center;
  gap: var(--s2);
}

/* The same count, marking the avatar. Inside a menu the badge is behind a
   click, and an administrator not noticing that reports are waiting is the
   thing that badge is for - so the closed menu carries the signal too.
   Decoration: the summary's own label says the number. */
.menu-dot {
  position: absolute;
  top: 3px;
  left: 25px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--status-critical);
  /* Ringed in the page colour so it reads as a mark cut into the avatar
     rather than a dot floating on top of whatever colour that is. */
  box-shadow: 0 0 0 2px var(--plane);
}


/* ---- The account menu, top right --------------------------------------- */
/* The summary is the avatar and nothing else, so the padding the header menu
   gives a worded summary would sit as a ring of empty space around a circle.
   The chevron stays: it is the only thing saying this opens rather than
   navigates. */
.topstrip-account { flex: none; align-self: center; }
.topstrip-account > summary {
  position: relative;
  gap: 4px;
  padding: 4px 6px 4px 4px;
}
.topstrip-account .avatar {
  width: 30px;
  height: 30px;
  font-size: 0.78rem;
  margin: 0;
}
/* Wider than the 13rem default: this panel carries a username and a role on
   one line, and those are as long as somebody's name happens to be. */
.topstrip-account .menu-panel { min-width: 15rem; }

/* ---- The strip above the content -------------------------------------- */
.topstrip {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
  padding: var(--s5) clamp(14px, 1.8vw, 36px) 0;
}
.topstrip-greet { flex: 1 1 auto; min-width: 0; }
.topstrip-hello {
  margin: 0;
  font-size: 1.35rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}
.topstrip-sub { margin: 2px 0 0; color: var(--text-muted); font-size: 0.86rem; }

@media (max-width: 900px) {
  .app { display: block; }
  .nav-disclosure { position: static; max-height: none; }
  .sidebar {
    height: auto;
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }
  .topstrip { padding-top: var(--s4); }
}

/* ==========================================================================
   The sidebar, narrowed - and the pieces that came with it.

   This used to be a layer called "Surfaces, after the overhaul": a second
   :root and a second copy of .card, .tile, .section and .chart-card, sitting
   two thousand lines below the originals and quietly winning. A changelog
   written as an architecture, and the reason "I edited .card and nothing
   happened" was possible in this file. Those duplicates are folded back into
   the rules they were overriding.

   What is left here never overrode anything - the sparkline's corner, the
   chart legend, and the collapsed sidebar were simply written later and filed
   under the nearest banner. Add to this section only things that belong to
   the sidebar; a surface belongs with the other surfaces.
   ========================================================================== */
.tile .spark {
  position: absolute;
  top: var(--s4);
  right: var(--s4);
  width: 58px;
  height: 22px;
  opacity: 0.85;
}
.tile-thresholds { margin-top: 8px; opacity: 0.85; }

.chart-card .card-head {
  align-items: baseline;
  margin-bottom: var(--s2);
}
.chart-legend {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-left: auto;
  font-size: 0.78rem;
  color: var(--text-muted);
}
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.chart-legend i {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
  flex: none;
}

/* ---- The sidebar, narrowed ---------------------------------------------
   Icons only. Every label stays in the DOM as .visually-hidden rather than
   being display:none - the links still need accessible names, and a column
   of unnamed icons is exactly the control this codebase refuses to ship
   elsewhere (see partials/icon.html on why an icon-only button needs text).
   The title attribute gives a sighted reader the same word on hover. */
:root[data-sidebar="collapsed"] { --sidebar-w: 74px; }

.side-head { display: flex; align-items: center; gap: var(--s2); }
.side-head .brand { flex: 1 1 auto; min-width: 0; margin: 0; }
.side-collapse {
  flex: none;
  padding: 5px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text-muted);
  cursor: pointer;
}
.side-collapse:hover { color: var(--text-primary); border-color: var(--border); }
.side-collapse .icon { margin-right: 0; color: currentColor; }

.side-search {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 var(--s2);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--plane);
}
.side-search .icon { margin-right: 0; flex: none; }
.side-search .nav-search-input {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  background: none;
  padding: 7px 0;
  color: inherit;
  font: inherit;
  font-size: 0.9rem;
}
/* The ring belongs to the bar, not to the box inside it.

   The global :focus-visible draws a 2px --series-1 outline two pixels clear
   of whatever it is on, which is right for a bare control. This input sits
   inside a pill that has its own border and a 999px radius, so that ring
   landed *within* the pill, in the wrong shape and the wrong colour - a hard
   blue rectangle floating inside a soft grey capsule.

   So the box gives its outline up and the pill takes it, as a halo that
   follows the pill's own corners. Quieter than an outline and attached to
   the thing you are actually typing into. */
.side-search .nav-search-input:focus,
.side-search .nav-search-input:focus-visible { outline: none; }
.side-search:focus-within {
  border-color: color-mix(in srgb, var(--accent) 50%, var(--border-strong));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}

/* A folding group. The marker is ours, so it can sit at the end of the row
   rather than in front of the word. */
.side-fold > summary { cursor: pointer; list-style: none; display: flex; align-items: center; }
.side-fold > summary::-webkit-details-marker { display: none; }
.side-fold > summary::after {
  content: "";
  margin-left: auto;
  width: 6px;
  height: 6px;
  border-right: 1.6px solid currentColor;
  border-bottom: 1.6px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
}
.side-fold[open] > summary::after { transform: rotate(-135deg) translate(-3px, -3px); }

:root[data-sidebar="collapsed"] .side-list a { justify-content: center; padding-inline: 0; }
:root[data-sidebar="collapsed"] .side-list a > span,
:root[data-sidebar="collapsed"] .side-search .nav-search-input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}
:root[data-sidebar="collapsed"] .side-group,
:root[data-sidebar="collapsed"] .brand { display: none; }
/* The folding group, with the column narrowed. Hiding the summary would
   otherwise take the group with it: a closed <details> hides its own
   contents, so Help, How this works and Report a bug would vanish and the
   control for getting them back would have gone with them. Forcing the list
   visible overrides the user-agent rule that hides it, so collapsing the
   sidebar shows every icon whether or not the group was folded. */
:root[data-sidebar="collapsed"] .side-fold > summary { display: none; }
:root[data-sidebar="collapsed"] .side-fold > .side-list { display: block; }
:root[data-sidebar="collapsed"] .side-search { justify-content: center; padding: 6px; }
:root[data-sidebar="collapsed"] .sidebar { padding-inline: var(--s2); align-items: stretch; }
:root[data-sidebar="collapsed"] .side-head { justify-content: center; }

/* Below the mobile break the column is already a menu; narrowing it as well
   would be two answers to one question. */
@media (max-width: 900px) {
  :root[data-sidebar="collapsed"] { --sidebar-w: auto; }
  .side-collapse { display: none; }
  :root[data-sidebar="collapsed"] .side-group,
  :root[data-sidebar="collapsed"] .brand,
  :root[data-sidebar="collapsed"] .side-fold > summary { display: revert; }
  :root[data-sidebar="collapsed"] .side-list a > span,
  :root[data-sidebar="collapsed"] .side-search .nav-search-input {
    position: static;
    width: auto;
    height: auto;
    clip: auto;
  }
}
