/* public/css/site.css

   ============================================================================
   Design system
   ============================================================================

   This file is hand-written, has no build step and no framework. Everything
   below is organised so a future change can find the right lever instead of
   adding another one-off value.

   COLOUR
     --bg / --fg / --muted / --rule / --rule-strong / --control-border /
     --accent / --row-alt / --row-hover / --marker — defined once in `:root`,
     each as `light-dark(light-value, dark-value)`. Every other rule in this
     file reads colour only through these custom properties, so a rule is
     written once and works in both schemes. Which half applies follows the
     element's `color-scheme`, which is `light dark` by default (the operating
     system decides) and is pinned by `[data-theme]` on <html> once a visitor
     uses the theme toggle in the header — see public/js/theme.js.
     `--control-border` (not `--rule`) is used wherever a border is doing a
     control's job — a filter pill, a stat card, a form input — because
     WCAG 1.4.11 wants 3:1 there; `--rule` is decorative-only and is
     deliberately lower contrast (see test/lib/contrast.test.js). `--danger`
     and `--success` are the form-feedback red/green (error/success borders,
     the destructive-action button) — a distinct pair per scheme, like every
     other colour here, rather than a single hard-coded hex that stays too
     dark to read against a dark `--bg`.

   SPACING SCALE
     --space-1 … --space-8, a 4px-based scale (4/8/12/16/24/32/48/64px). Use
     one of these for every margin/padding/gap in this file instead of an
     ad-hoc rem value — it's what keeps the page's rhythm consistent instead
     of "close enough" everywhere.

   TYPE SCALE
     --fs-title / --fs-heading / --fs-subheading / --fs-body / --fs-small,
     paired with --fw-title / --fw-heading / --fw-subheading / --fw-body.
     --fs-title is the page's own name (one per page, the `<h1>`);
     --fs-heading marks off a major section (`<h2>`); --fs-subheading is a
     subdivision within a section (`<h3>`); --fs-body is running text and
     table cells; --fs-small is footnotes, captions and other small print.

   PAGE HEADER
     `.page-header` wraps a page's `<h1>`, its `.subtitle` line of context,
     and — when a page has them — page-level filters or actions, then draws
     one rule underneath to separate that block from the page body. Every
     public page opens with one.

   PANELS
     `.panel` groups a section heading with the table/content it introduces
     into one bordered block, so a long page reads as a stack of distinct
     units rather than a continuous scroll. A `.panel` does not change the
     heading it wraps — no class or id is added to any `<h2>` that a test
     pins literally (`<h2>Most matches</h2>`, `<h2>Cards</h2>`,
     `<h2>Penalties</h2>`, `<h2>Names by season</h2>`); the border and
     spacing come entirely from the wrapping element.
     `.panel-columns` lays a panel's content out as side-by-side columns
     that collapse to one on a narrow viewport, for a section whose content
     is a pair of short lists rather than one long one — the referee page's
     yellow and red card reasons.

   TABLES
     `.table-wrap` still exists purely for the horizontal scroll it enables
     on narrow viewports; tabular numerals, right-aligned numeric columns,
     a caption and scoped headers are unchanged. Layered on top: a visibly
     stronger header (bolder weight, a background tint, and — since the wrap
     only scrolls horizontally, not vertically — a `position: sticky` header
     that stays put while a long table's own rows scroll past it, no JS
     involved), a hover state on data rows, and a zebra stripe. Hover is
     declared after the stripe rule so it wins on both odd and even rows
     rather than the two fighting for the same pixels.

   FILTERS, PILLS, EMPTY STATES
     `.filters` is the one pattern for every filter/sort control on the site
     (referee index, match index, the event indexes, the position switch on the
     alternate referee page). `.empty` is the one pattern for "nothing to
     show here" — a bordered, centred note rather than a lone paragraph
     floating in blank space.

   ADMIN CHROME
     `.admin-shell` wraps the admin nav and every admin page's content
     (see admin/layout.njk). It draws one restrained signal — a coloured top
     rule — so an operator glancing at any admin screenshot can tell they are
     in the operator tools, not the public site, without the admin area
     becoming a visually different product. `.admin-nav` is the operator's
     primary navigation: the same `aria-current="page"` convention as the
     public nav, styled as a row of tabs rather than the public site's pill
     filters, because it is navigation, not a filter.

   FORMS (extended for admin)
     The public site's `.form` (single-column, 26rem, used by login and
     change-password) is unchanged. Admin adds what a data-entry form needs
     that a two-field auth form does not:
       - `fieldset`/`legend` group related controls with a border and a bold
         legend — used for every logical group (a radio choice, a form's
         "details" vs. its metadata, a table's crew positions).
       - `.field-row` (pre-existing) lays a set of fields out side by side,
         wrapping on narrow viewports; the input/select inside keep their own
         intrinsic width rather than stretching to fill the row.
       - Width utility classes `.w-tiny` / `.w-short` / `.w-medium` cap an
         input's width by what it holds (a match minute, a four-character
         club id, a person's given name) instead of letting every field claim
         the same full measure — the club id field is the clearest case: it
         is 1-4 characters and used to render exactly as wide as the club's
         full name field below it.
       - `.field-help` is muted explanatory text directly under a control —
         tighter than `.footnote`, which remains for page-level asides.
       - `.nationality-code` / `.nationality-echo` are the FIFA trigram box and
         the country name written under it. Its own width rather than one of
         the utilities above, because three characters is not a match minute,
         and `.is-unknown` on either marks a code FIFA has never issued.
       - `.form` now also styles `email`/`number`/`date`/`file` inputs and
         `select`, not only `text`/`password` — admin forms use all of these
         and previously got bare, unstyled browser controls for anything
         past a text box.
     `.danger-zone` and `.btn-danger` mark destructive actions (delete a
     match, restore the database) so they read as unlike an ordinary save —
     see COLOUR's `--danger`.
   ============================================================================
*/

/* Each colour is stated once, as `light-dark(light-value, dark-value)`, rather than as a
   light block plus a dark `@media` block that have to be kept in step rule-by-rule. Which
   half is used follows the element's `color-scheme`, so the theme toggle switches every
   colour on the page by setting one property on <html> — see `[data-theme]` below and
   public/js/theme.js. `color-scheme` also tells the browser to render its own furniture —
   form controls, scrollbars, the canvas before the stylesheet lands — to match, which a
   `@media` query alone never did. */
:root {
  color-scheme: light dark;

  --bg: light-dark(#ffffff, #14171a);
  --fg: light-dark(#16191d, #e7eaee);
  --muted: light-dark(#5b6470, #9aa4b0);
  --rule: light-dark(#d8dde3, #2b3138);
  --rule-strong: light-dark(#b3bcc7, #3c444d);
  /* Dedicated border colour for interactive control boundaries (filter/sort pills, stat
     cards) — WCAG 1.4.11 wants 3:1 for these, which --rule (1.37:1 against --bg) does not
     come close to meeting. ~4.0:1 light, ~5.3:1 dark. Decorative table rules keep --rule. */
  --control-border: light-dark(#75808c, #838d99);
  --accent: light-dark(#14507d, #7db6e8);
  --row-alt: light-dark(#f6f8fa, #1a1e22);
  /* Distinct from --row-alt so a hovered row is never colour-identical to a striped one. */
  --row-hover: light-dark(#eaf1f8, #202a35);
  --marker: light-dark(#8a5a00, #e0aa4a);
  /* Form feedback: an invalid field/destructive action (--danger) and a saved one
     (--success). 6.54:1 and 6.28:1 against light --bg, 6.61:1 and 6.56:1 against dark —
     comfortably past the 4.5:1 .btn-danger's own text needs, since its label sits directly
     on this fill. The dark halves are lighter on purpose; reusing the light hexes there
     would sink both well under 3:1. */
  --danger: light-dark(#b3261e, #ff6f64);
  --success: light-dark(#1b6e3c, #4caf6d);
  /* A red card's red. The same pair as --danger, aliased rather than restated so the hex
     stays stated once, and named for what it marks: a second caution is not a danger, and
     a rule reading `var(--danger)` beside a card count would read as a warning. */
  --card-red: var(--danger);
  --measure: 68rem;

  /* Spacing scale — 4px base. Use these, not ad-hoc rem values. */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 3rem;
  --space-8: 4rem;

  /* Type scale. --fs-body matches the existing 15px base body copy. */
  --fs-title: 1.75rem;
  --fs-heading: 1.25rem;
  --fs-subheading: 1rem;
  --fs-body: 0.9375rem;
  --fs-small: 0.8125rem;
  --fw-title: 700;
  --fw-heading: 700;
  --fw-subheading: 600;
  --fw-body: 400;

  --radius: 6px;
}

/* The theme toggle's whole effect: one attribute on <html>, set before first paint by
   public/js/theme.js. Absent — the case for a visitor who has never touched the toggle,
   and for every visitor with JavaScript off — the `light dark` above stands and the
   operating system decides. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font: var(--fs-body)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

a { color: var(--accent); }
a:hover { text-decoration-thickness: 2px; }

/* A visible focus ring beyond the browser default, for keyboard navigation through the
   dense link-heavy tables this site is full of. Additive only — no rule anywhere in this
   file sets outline: none, so the platform default keeps working even where this is not
   supported. */
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, .linklike:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: var(--space-2);
  top: var(--space-2);
  z-index: 10;
  padding: var(--space-2) var(--space-3);
  background: var(--bg);
  border: 2px solid var(--accent);
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ============================== Site chrome ============================== */

.site-header {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  align-items: baseline;
  max-width: var(--measure);
  margin: 0 auto;
  padding: var(--space-4) var(--space-4) var(--space-3);
  border-bottom: 2px solid var(--rule-strong);
}
.site-title { margin: 0; font-size: 1.05rem; font-weight: 700; letter-spacing: -0.01em; }
.site-title a { color: inherit; text-decoration: none; }
.site-header nav ul { display: flex; flex-wrap: wrap; gap: var(--space-4); margin: 0; padding: 0; list-style: none; }
.site-header nav a { text-decoration: none; color: var(--muted); }
.site-header nav a:hover { color: var(--fg); }
.site-header nav a[aria-current="page"] { color: var(--fg); font-weight: 600; }

/* `--space-2`, not `--space-3`. This row holds the theme toggle, a username, Admin, Log out
   and three languages, and in French signed in — "Se déconnecter" against "Log out" — it ran
   past the navigation beside it and the header wrapped onto a second line. A quarter of a rem
   between five items is 1rem back, which with the switcher's own tightening is where the row
   fits again. */
.account { margin-left: auto; display: flex; gap: var(--space-2); align-items: baseline; color: var(--muted); font-size: var(--fs-small); }
.account form.inline { display: inline; margin: 0; }

/* The light/dark toggle. Icon only and unlabelled — it sits among text links and a second
   run of words there would read as another destination. Its name is on the button itself,
   set by theme.js, so a screen reader gets a sentence where a sighted reader gets a glyph.
   `align-self: center` because .account aligns on the baseline, which an icon has none of. */
.theme-toggle {
  align-self: center;
  display: flex; padding: var(--space-1);
  background: none; border: 0; border-radius: var(--radius);
  color: var(--muted); cursor: pointer;
}
.theme-toggle:hover { color: var(--fg); }

/* The button shows where pressing it goes, not where the page is: a moon in daylight, a
   sun at night. Three rules rather than one because CSS can read the chosen theme from the
   attribute but the unchosen one only from the media query. */
.theme-toggle .icon-sun { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; }
  :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
}
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
.linklike { background: none; border: none; padding: 0; font: inherit; color: var(--accent); cursor: pointer; text-decoration: underline; }

main { max-width: var(--measure); margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-7); }

.site-footer {
  max-width: var(--measure);
  margin: 0 auto;
  padding: var(--space-4);
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-size: var(--fs-small);
}

/* ============================== Admin chrome ============================== */

/* One restrained, page-wide signal that this is the operator side of the site — a
   coloured top rule above the nav — rather than a different visual language. Wraps the
   admin nav and every admin page's own content (see admin/layout.njk). */
.admin-shell { border-top: 3px solid var(--accent); padding-top: var(--space-5); }

/* The operator's primary navigation. Same aria-current="page" convention as the public
   site nav and the same underlying list-of-links markup as `.filters`, but shaped like
   tabs — a bottom rule with the current section picked out by an accent underline —
   because this is navigation, not a filter over one page's content. */
.admin-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-5);
  margin: 0 0 var(--space-6);
  padding: 0 var(--space-1);
  border-bottom: 2px solid var(--rule-strong);
}
.admin-nav a {
  padding: var(--space-2) var(--space-1);
  border-bottom: 3px solid transparent;
  margin-bottom: -2px;
  text-decoration: none;
  color: var(--muted);
}
.admin-nav a:hover { color: var(--fg); }
.admin-nav a[aria-current="true"] { color: var(--fg); font-weight: 600; border-bottom-color: var(--accent); }

/* ============================== Forms ============================== */

.form { max-width: 26rem; }
/* The cap belongs to the single-column default; a gridded form sets its own. */
.form.form-grid { max-width: 76rem; }
/* And a form whose content is a table sets none. The bulk fixture form is ten rows of
   eight fields: under the single-column cap the table had a quarter of the window and
   every column in it was too narrow for what it holds. */
.form.form-wide { max-width: none; }
/* The batch's one round, above the table it applies to. Inline, because it is a single
   short control and `.form label` is a block — stacked it opened a two-line gap between
   the panel heading and the table. */
.form-wide .bulk-round { display: flex; align-items: baseline; gap: var(--space-3); }
.form-wide .bulk-round label { margin-bottom: 0; }
.form-wide .bulk-round select { width: auto; }
.form label { display: block; font-weight: 600; margin-bottom: 0.2rem; }
.form input[type="text"], .form input[type="password"], .form input:not([type]),
.form input[type="email"], .form input[type="number"], .form input[type="date"],
.form input[type="file"], .form select {
  width: 100%; padding: var(--space-2) var(--space-3); border: 1px solid var(--control-border);
  border-radius: 4px; background: var(--bg); color: var(--fg); font: inherit;
}
.form p { margin: 0 0 var(--space-3); }
.form p:last-child { margin-bottom: 0; }
.form button {
  padding: var(--space-2) var(--space-4); border: 1px solid var(--accent); border-radius: 4px;
  background: var(--accent); color: var(--bg); font: inherit; font-weight: 600; cursor: pointer;
}
.form button:hover { filter: brightness(1.1); }
/* A button that reads as a link, for an action that only changes what is already on the
   page — "Copy above" beside a date field. It has to out-specify `.form button` above,
   which is the submit style: a filled accent block on a per-row convenience would both
   shout and imply that pressing it submits the form. */
.form .link-button {
  padding: 0; border: 0; background: none; color: var(--accent); font: inherit;
  font-size: var(--fs-small); text-decoration: underline; cursor: pointer;
}
.form .link-button:hover { text-decoration-thickness: 2px; filter: none; }

/* A real button at half the weight, for one sitting beside a line of text rather than at
   the foot of a form — the Run beside each SQL example. The full submit style is a filled
   block sized for the end of a form, and three of them down a list of examples would read
   as the point of the page rather than as a convenience on it. */
.button-small {
  padding: 0.1rem var(--space-2); border: 1px solid var(--control-border); border-radius: 4px;
  background: var(--bg); color: var(--fg); font: inherit; font-size: var(--fs-small);
  cursor: pointer;
}
.button-small:hover { border-color: var(--accent); color: var(--accent); }

/* Each example is a disclosure and a Run button on one line. The disclosure takes the
   width so its summary and the button share a baseline; the query inside it, once opened,
   sits under the summary rather than beside the button. */
.sql-examples { list-style: none; margin: 0; padding: 0; }
.sql-examples li {
  display: flex; align-items: start; gap: var(--space-3);
  padding: var(--space-2) 0; border-bottom: 1px solid var(--rule);
}
.sql-examples li:last-child { border-bottom: 0; }
.sql-examples details { flex: 1; min-width: 0; }
.sql-examples form { margin: 0; }
/* The date cell: "Copy above" to the left of the field, then the field. The link is held
   to a word's width and allowed to wrap onto two lines, so what it costs the row is a
   short column rather than a whole phrase — it is pressed once per row at most, and the
   date beside it is what the operator is aiming at.

   `.form input[type="date"]` is 100% wide, which is right for a column of fields and
   wrong inside a table cell, where it would leave the link nowhere to go. */
/* The link's width and the space after it, named because three things have to agree about
   them: the link itself, the gap beside it, and the DATE heading, which is pushed past
   both so that it sits over the field rather than over the link. */
.bulk-fixtures { --copy-link: 2.8rem; --copy-gap: var(--space-1); }

.form-wide [data-copy-date] { display: flex; align-items: center; gap: var(--copy-gap); }
/* A fixed width, not a share of the cell. The table is `width: 100%`, so a column's stated
   width is really a ratio and whatever space the other eleven do not claim is handed back
   to this one — which left a date field half as wide again as a date needs, growing wider
   the wider the window got. Pinned to what the control actually measures: 9.3rem at this
   font and this padding, plus a little air. Slack now falls to the right of the field,
   inside the cell, where it costs nothing. */
.form-wide [data-copy-date] input { flex: 0 0 var(--date-field); width: var(--date-field); }
.bulk-fixtures { --date-field: 9.5rem; }
/* The side padding the rest of this table's fields use. A date control is mostly its own
   spin buttons and calendar icon, and 12px either side of that was another 16px of nothing
   in the widest field on the row. */
.bulk-fixtures input[type="date"] {
  padding-left: var(--space-1); padding-right: var(--space-1);
}
.copy-above {
  flex: 0 0 var(--copy-link); white-space: normal; text-align: left; line-height: 1.15;
}
/* An empty stand-in for the link, so the DATE heading starts exactly where the fields
   under it start. The same trick the event tables' Opp heading uses: reproducing the thing
   being cleared is exact, where a hand-guessed padding drifts the moment either width
   changes. */
.copy-above-spacer {
  display: inline-block; width: var(--copy-link); margin-right: var(--copy-gap);
}

/* The gap between columns, cut for this table alone. The default 0.55rem either side is
   right for a table of prose; here it was 1.1rem of nothing between boxes, twelve times
   over — nearly a fifth of the table spent on separation that the boxes' own borders
   already provide. */
.bulk-fixtures th,
.bulk-fixtures td { padding-left: 0.2rem; padding-right: 0.2rem; }

/* The fixture table's columns, every one of them sized. The referee used to take whatever
   the others left, which stopped working when the row gained four more figures: left to
   itself it would have been squeezed to a box too narrow to read a name in, which is the
   one field on the row where that costs an operator real time. It is sized here, and its
   width is what was cut to pay for the new columns.

   Each is written as the field width plus the 0.4rem of cell padding above. The four
   counts are `col-score`, not a class of their own: what the owner asked for is that they
   be the same size as the score boxes, and sharing the class is how that stays true.

   The widths are measured rather than guessed. At the 14px these fields render in, the
   longest club id — NYRB — is 38px, so a 3rem box holds it with 2px to spare; the longest
   figure any of these columns takes is two digits, at 18px. */
/* Link, gap, field and the cell's own padding. Stated at what the content measures rather
   than under it: a column narrower than its contents is not honoured anyway — the browser
   widens it to fit — and a figure that lies about the cell is a figure nobody can reason
   from when the next column is added. */
.bulk-fixtures .col-date { width: 12.95rem; }  /* 2.8 link + 0.25 gap + 9.5 field + 0.4 pad */
/* `fixed`, so the widths above are the widths rather than opening bids. Under the default
   `auto` the browser sizes columns from their contents and hands the leftover round
   proportionally, which put 54px of nothing between the date field and Home the moment the
   date field stopped stretching to absorb it. Fixed layout spends that leftover where it
   is stated instead — and the referee column below is stated wide enough to take almost
   all of it, a name being the one thing on the row that can use the room. */
.bulk-fixtures { table-layout: fixed; }
.bulk-fixtures .col-club { width: 3.4rem; }    /* field 3rem — a quarter off, NYRB still fits */
.bulk-fixtures .col-referee { width: 20rem; }  /* takes the table's slack — see table-layout */
.bulk-fixtures .col-score { width: 2.4rem; }   /* field 2rem — scores, yellows, penalties */
.bulk-fixtures .col-fouls { width: 3rem; }     /* field 2.6rem — fouls reach 30-odd */

/* No spinners on the score and foul boxes. Forty of them stacked in four columns is a lot
   of furniture for a control nobody uses: a score is typed, not nudged one at a time, and
   the arrows eat about a third of a box this narrow. `type="number"` stays, so the field
   still takes only digits and still refuses a negative one.

   Two rules because the browsers disagree: WebKit and Blink draw the spinner as a
   pseudo-element, Gecko as the widget's own appearance. Both are needed, and neither
   removes the other's. Scoped to this table — the match form's minute boxes keep theirs,
   where stepping a minute at a time is a real thing to want. */
/* And the padding comes in to match. The form's inputs carry 12px each side, which is
   right for a name and absurd on a two-digit box: it left 6px of usable width inside a
   32px score field, so a single digit was clipped and "14" only fitted because the field
   scrolled. 4px each side leaves 24px, which holds two digits with room to spare.

   Centred, because these are one or two characters in a box sized for exactly that; the
   default `start` parked them against the left edge with the whitespace all on one side.
   The vertical padding is untouched, so these boxes stay the height of every other field
   in the row. */
.bulk-fixtures input[type="number"] {
  appearance: textfield;
  -moz-appearance: textfield;
  padding-left: var(--space-1);
  padding-right: var(--space-1);
  text-align: center;
}

/* The club boxes need it as badly and were quietly failing: at three quarters of their old
   width, 12px each side left 38px of usable space for a three-letter id that wants 51, so
   `NYC` rendered as `NY` with the C scrolled out of sight. Same 4px, and they fit.

   `input.club-id`, not `.club-id`: the club fields carry no `type`, so the rule they are
   already getting their padding from is `.form input:not([type])`, and a bare class does
   not outrank it. Naming the element makes the two equal and this one is written later.

   Centred like the figures beside them, and for the same reason: a three-letter id in a
   box sized for three letters has no long side to align to. */
.bulk-fixtures input.club-id {
  padding-left: var(--space-1);
  padding-right: var(--space-1);
  text-align: center;
}

.bulk-fixtures input[type="number"]::-webkit-outer-spin-button,
.bulk-fixtures input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
/* The event indexes' two aligned columns.

   "@" and "vs" sit in a box of one width and are set against its right edge, so every club
   id in the column starts at the same place whichever it is. Muted, because the mark is
   which ground the match was at and the id is the answer to the column. */
.venue-mark {
  display: inline-block; width: 1.4em; text-align: right; color: var(--muted);
  font-size: var(--fs-small);
}

/* A minute in two halves, so a column of them aligns on the minute rather than on its
   right edge: 69’ and 90+3’ line up on the 69 and the 90, with the added time hanging off
   into a box of its own. Both halves are as wide as their widest content — three digits
   for a minute that runs to 120, and "+20’" for the longest added time anybody has played
   — so the pair holds its shape down the column.

   The two widths are named because the heading is positioned from them: it is one box as
   wide as both halves together, set flush right like them, with the word at its left edge
   — which is exactly where every minute in the column begins. Written as a sum rather than
   as a third number, so the three cannot drift apart.

   `min-width`, not `width`: the words that stand in for a minute — "Shoot-out", "After the
   match" — are all base and wider than any figure, and they widen the box rather than
   spilling out of it. */
/* The base box is exactly two digits wide, which is what makes the heading line up with
   the number rather than with the box around it. The figures are tabular, so two digits
   are always 17.34px at this size — the box holds them with nothing to spare, and its left
   edge IS the left edge of a two-digit minute. Nearly every minute is two digits; the
   handful in extra time are three and hang one digit further left, which is the right
   trade for a heading that sits over the column the other 99% of the time.

   In rem, not em. The heading is set smaller than the cells under it, so an em there is a
   smaller number of pixels than an em here — and the box positioned from these widths
   would land a few pixels right of the column it is meant to sit over. A rem is the same
   length in both. */
.minute-cell { --minute-base: 1.09rem; --minute-added: 1.88rem; text-align: right; }
.minute-base { display: inline-block; min-width: var(--minute-base); text-align: right; }
.minute-added { display: inline-block; min-width: var(--minute-added); text-align: left; }
/* Nothing to hang, so nothing to reserve — but only for the readings that are not a minute.
   The added-time box is a column for "+4’" to sit in, kept at its full width even for a bare
   "47’" so the minutes align on the minute. "Shoot-out", "After full time" and the intervals
   have no minute to align on, and holding 1.88rem open after them left the phrase ending a
   clear 30px short of every figure in the column, floating with blank space to its right.
   Emptied of its reservation the phrase falls flush against the column's own right edge.

   A MISSING MINUTE IS NOT ONE OF THOSE, which is why this keys off `phrase` from
   `minuteParts` rather than off the suffix being empty — both have an empty suffix and they
   want opposite things. The em dash stands in for the number itself, so it belongs where the
   number would be: right-aligned in the minute's own box, ending on the line every other
   minute in the column ends on. Flush right it sat past all of them, which is the same
   complaint the phrases had, in the other direction. */
.minute-phrase .minute-added { min-width: 0; }
/* One box as wide as both halves, flush right like them, with the word at its left edge —
   which is where a two-digit minute begins. "MIN" is wider than the minute under it and
   overhangs the added-time column, which is intended: it heads the whole cell, and what
   matters is where it starts. */
.minute-head {
  display: inline-block; width: calc(var(--minute-base) + var(--minute-added));
  text-align: left;
}

.form-error { padding: var(--space-3) var(--space-4); border-left: 4px solid var(--danger); background: var(--row-alt); border-radius: 0 4px 4px 0; }
.form-success { padding: var(--space-3) var(--space-4); border-left: 4px solid var(--success); background: var(--row-alt); border-radius: 0 4px 4px 0; }

/* A missing-data row saved or refused in place, without the page reloading.
 *
 * The same two colours as the messages above, applied to the row rather than to a banner at
 * the top of the page: with fifty rows on screen the feedback has to be where the operator
 * is looking. `is-saved` is removed again after a moment (public/js/gap-save.js) so a worked
 * table does not end up entirely green; `is-refused` stays until the row is saved, because
 * an unfixed row should not stop looking wrong. A left border rather than a background
 * fill: the row-striping underneath still has to read. */
tr.is-saved > * { box-shadow: inset 4px 0 0 -1px var(--success); }
tr.is-refused > * { box-shadow: inset 4px 0 0 -1px var(--danger); }

/* A form whose sections are independent enough to sit side by side.
 *
 * `.form` alone is one 26rem column, which is right for a login box and wrong for the
 * referee form: eight fieldsets, none of them wider than a name, stacked into a ribbon
 * down the left of a 1400px page with two thirds of it empty and the Save button two
 * screens below the first field.
 *
 * A grid rather than columns: `column-count` would break a fieldset across a column
 * boundary mid-radio-group. `auto-fit` with a 20rem floor means the count follows the
 * viewport — three across on a desktop, two on a laptop, one on a phone — with no
 * breakpoints to keep in step with anything. `align-items: start` stops a short fieldset
 * from stretching to the height of the tall one beside it.
 *
 * Only for a form that says so: the login and settings forms stay the single column they
 * were designed as. */
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: var(--space-5); align-items: start; max-width: 76rem; }
.form-grid > fieldset { margin: 0; }
/* Full width, at the end of the last row: a control that acts on the whole form belongs
   under all of it rather than under whichever column it happened to land in. */
.form-grid > .form-actions { grid-column: 1 / -1; margin: 0; }

/* Two columns for a fieldset that holds paired fields. "Bio details" on the referee form is
   the case: given and common-given belong side by side because one stands in for the other,
   family and suffix because the suffix is an appendage of the family name, and birthplace
   and hometown because the interesting thing about them is whether they differ — and two of
   these fields at their natural width do not fit one column, so in a single column all eight
   stack and the fieldset grows to several times the height of anything beside it.
   Guarded by the media query, not left to `span 2` alone: in the single-column layout a
   narrow viewport falls back to, spanning two would conjure an implicit second column and
   push the form off the side of the screen. */
@media (min-width: 64rem) {
  .form-grid > .field-block-wide { grid-column: span 2; }
}

/* Groups related controls with a visible border and a bold legend — a radio choice
   (sort/alternate name), a form's own sub-section (a club's metadata, a match's crew).
   Unscoped (not `.form fieldset`) because the match form's fieldsets sit in a plain,
   unclassed <form> — see the Forms doc note in the header comment. */
fieldset { border: 1px solid var(--control-border); border-radius: var(--radius); padding: var(--space-4) var(--space-5) var(--space-5); margin: 0 0 var(--space-5); }
fieldset:last-of-type { margin-bottom: 0; }
legend { padding: 0 var(--space-2); font-size: var(--fs-subheading); font-weight: var(--fw-subheading); }

.field-row { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; }
.field-row p { margin: 0 0 var(--space-3); }
.field-row input, .field-row select,
.sides input { padding: 0.3rem 0.4rem; border: 1px solid var(--control-border); border-radius: 4px; background: var(--bg); color: var(--fg); }

/* The two sides of a match: one row each, one column per measure, so home and away line
   up as the pair of like things they are. The number columns are sized to the numbers they
   hold rather than left to `auto`, which would size them to the input's default width and
   leave each figure stranded at the left of a column far wider than itself. */
.sides { display: grid; grid-template-columns: max-content minmax(9rem, 20rem) 4.5rem 4.5rem; gap: var(--space-2) var(--space-4); align-items: center; justify-content: start; }
.sides p { margin: 0; }
.sides input { width: 100%; max-width: none; font: inherit; }
.sides-heading { font-size: var(--fs-small); color: var(--muted); }
.sides-side { font-weight: 600; }

/* Below the width where a club name and two number fields fit side by side, the grid
   collapses to one field per line. The decorative headings would then label nothing, so
   each field falls back to its own label — the one that was visually hidden above. */
@media (max-width: 34rem) {
  .sides { grid-template-columns: 1fr; }
  .sides-heading { display: none; }
  .sides .visually-hidden {
    position: static; width: auto; height: auto;
    margin: 0 0 0.2rem; overflow: visible; clip-path: none; white-space: normal;
    display: block; font-weight: 600;
  }
}
td input, td select { width: 100%; padding: 0.2rem 0.3rem; border: 1px solid var(--control-border); border-radius: 3px; background: var(--bg); color: var(--fg); font: inherit; }

/* Caps a control's width by what it actually holds, instead of every field claiming the
   same full measure — a four-character club id or a match minute does not need the width
   of a person's full name. A max-width, not a width, so each still shrinks further on a
   narrow viewport rather than overflowing it. */
.w-tiny { max-width: 4rem; }
.w-short { max-width: 6rem; }
.w-medium { max-width: 12rem; }
/* The club `<select>` on the cards and penalties tables. A `min-width`, not only a
   max-width: `td select { width: 100% }` fills whatever the table's auto layout gives the
   column, and in a row this wide (minute, added time, club, player, type, reason, issued
   by, note) that algorithm was shrinking the club column below its own content — "NYRB"
   rendered as "NYR" with the arrow crowding the last letter out, the box having been made
   narrower than the four characters it holds rather than merely wider than it needed to
   be. The floor makes the table ask for the width this column actually needs; `.table-wrap`
   already scrolls horizontally, so the row simply gets a little wider rather than the field
   staying too narrow to read. */
.w-club { min-width: 4.5rem; max-width: 6rem; }
/* The minute and added-time boxes in those same tables, squeezed the same way and needing
   the same floor. A number input is not just its digits: the browser draws a spinner inside
   the content box, so "999" (a card after the whistle — see the note on the field) and "13"
   in the narrower added-time column were both being clipped under the arrows. Wide enough
   for the widest value each `max` allows, plus that spinner.

   Scoped to table cells, which is where the squeeze happens: `td input { width: 100% }`
   hands the field whatever the auto layout gives the column, and these columns lose every
   time to the player and note columns beside them. The same class outside a table — the
   score, foul and leg boxes on the fixture form — is laid out by the form instead and
   already gets what it asks for, so it keeps the narrower cap. */
td .w-tiny { min-width: 4.5rem; }
/* A start time. Its own size rather than `.w-short`, which it began as: a native time
   control is not five characters of text but a stepper and, on a browser whose locale says
   so, an AM/PM the page cannot suppress — so it wants more room than the minute boxes that
   share that class and should not drag them wider with it. */
.w-time { max-width: 7.2rem; }

/* Muted explanatory text directly under one control — tighter than `.footnote`, which is
   for a page- or section-level aside instead. */
.field-help { margin: 0.2rem 0 0; color: var(--muted); font-size: var(--fs-small); }

/* The FIFA trigram box, on the referee form and the nationality worklist. Three characters
   wide and no wider — its own width rather than `.w-tiny`, which is the narrowest utility
   above and is still sized for a match minute and its spinner.

   A `max-width`, for the reason given over those utilities: `.form input:not([type])` and
   `td input` both set `width: 100%`, and both out-specify a single class. Capping the
   result needs no specificity fight, and there is nothing here that a cap would stop from
   shrinking further — three characters is three characters at any viewport.

   Uppercased by `text-transform`, not by rewriting `value` on every keystroke: the field
   reads as capitals throughout while the caret stays where the operator put it, and
   public/js/nationality-code.js normalises the value itself when the field is left.
   `letter-spacing` because three capitals set solid read as a word rather than a code.

   `.is-unknown` is what the same file adds once three letters name no FIFA member. Red, the
   same red as `.form-error` and a red card, and paired with the country name going red
   beneath — colour is never the only thing saying so. */
.nationality-code { max-width: 4.5rem; text-transform: uppercase; letter-spacing: 0.08em; }
.nationality-code.is-unknown { border-color: var(--danger); }

/* The country a code names, under the box: the one way a three-letter field can be read
   back. Held open at one line's height so the row below does not jump as it fills in. */
.nationality-echo { display: block; min-height: 1.4em; }
.nationality-echo.is-unknown { color: var(--danger); }

/* The match a worklist row is about: a date, the two clubs, and the two places to watch the
   footage. It was one nowrap line and claimed a third of the table for three facts nobody
   edits, crowding the field the row exists to fill. Capped and allowed to wrap instead —
   the cell grows downwards, which is the cheap dimension on a page of short rows.

   The links are smaller again than the club names under `.field-help`, being the least of
   the three and the one a reader only looks at when they are about to use it. */
.match-cell { max-width: 14rem; white-space: normal; }

/* A cell whose parts go one to a line rather than running on: the match's date, clubs and
   links, and the player's name and club. Left inline they read as prose where they are
   separate facts, and the clubs wrapped mid-fixture — "2026-05-16 Orlando City SC v" /
   "Atlanta United FC". `.field-help` is inline elsewhere on purpose, so this is opt-in. */
.stacked-cell .field-help { display: block; }
.match-cell-links {
  display: block; margin: 0.15rem 0 0;
  font-size: 0.78rem; line-height: 1.35;
}
.match-cell-links a { color: var(--muted); }
.match-cell-links a:hover { color: var(--fg); }

/* A page-level note that is not an error, e.g. context on an admin form. Uses the same
   left-rule shape as .form-error/.form-success but neutral colouring. */
.notice { padding: var(--space-3) var(--space-4); border-left: 4px solid var(--control-border); background: var(--row-alt); border-radius: 0 4px 4px 0; color: var(--muted); }

/* A destructive action (delete a match, restore the database) reads as unlike an
   ordinary save: a fully-bordered red box rather than the neutral/left-rule shapes above,
   and a filled red button rather than the accent-coloured default. */
.danger-zone { padding: var(--space-4) var(--space-5); border: 2px solid var(--danger); border-radius: var(--radius); background: var(--row-alt); }
/* The `.form button` pairing (backup.njk's restore form carries class="form") outranks a
   bare `.btn-danger` on specificity alone, so it is repeated here rather than reached for
   with `!important`. */
.btn-danger, .form button.btn-danger { border-color: var(--danger); background: var(--danger); color: var(--bg); }
.btn-danger:hover { filter: brightness(1.1); }

/* ============================== Type & page header ============================== */

h1 { font-size: var(--fs-title); font-weight: var(--fw-title); margin: 0 0 var(--space-2); letter-spacing: -0.02em; line-height: 1.2; }
h2 { font-size: var(--fs-heading); font-weight: var(--fw-heading); margin: var(--space-6) 0 var(--space-3); padding-bottom: var(--space-1); border-bottom: 1px solid var(--rule); }
h3 { font-size: var(--fs-subheading); font-weight: var(--fw-subheading); margin: var(--space-4) 0 var(--space-2); }
.subtitle { margin: 0; color: var(--muted); font-size: var(--fs-body); }

/* Wraps a page's <h1> + .subtitle (+ optional filters/actions), then draws one rule
   under the whole block. Every public page opens with this. A heading or filter strip
   placed inside needs no class changes of its own — the wrapper supplies the spacing. */
.page-header {
  margin-bottom: var(--space-6);
  padding-bottom: var(--space-4);
  border-bottom: 2px solid var(--rule-strong);
}
.page-header h1 { margin-bottom: var(--space-2); }
.page-header .subtitle { margin-bottom: var(--space-3); }
.page-header .subtitle:last-child { margin-bottom: 0; }
.page-header .filters:last-child { margin-bottom: 0; }
/* A page-level action or link (e.g. "All {club} matches") sitting under the subtitle,
   before any filters. */
/* One line of outward links. `gap` rather than a separator character: two links reading
   "…mlssoccer.com → on FBref →" run together without one, and a bullet between them would
   be a third thing to read. */
.page-actions { margin: var(--space-3) 0 0; display: flex; flex-wrap: wrap; gap: var(--space-4); }

/* A control in the header rather than a link out of it — the match form's second Save.
   Deliberately not `.page-actions`, which is a row of outward links: this is a button, and
   a page with one is not a page that links anywhere.

   Bottom of the header, not the top: the last thing in the column beside it is the contents
   nav, so this lands on the same line as "Match  Officials  Cards" rather than floating
   level with the heading a third of a screen above them. `align-self` rather than a change
   to `.header-split`, which the club page also uses and wants top-aligned. */
.header-action { margin: 0; align-self: flex-end; }

/* A page header in two columns: the page's own block, and a short aside beside it.
 *
 * The club page's former names, so far. `flex-wrap` with a generous basis on the main
 * column means the aside drops underneath rather than squeezing the title, which is what
 * a narrow screen should do with it. */
.header-split { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-4) var(--space-6); align-items: flex-start; }
.header-main { flex: 1 1 30rem; min-width: 0; }
.header-aside { flex: 0 1 auto; font-size: var(--fs-small); }
.header-aside h2 {
  margin: 0 0 var(--space-2); font-size: var(--fs-small); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted);
}
/* Seasons then name, in two columns, so the ranges line up under each other and read as a
   sequence. `tabular-nums` keeps 1996–1997 the same width as 1998–2005. */
.name-eras { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.2rem var(--space-3); }
.name-eras dt { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.name-eras dd { margin: 0; }

/* A short table of contents of in-page anchors, used on pages long enough that jumping
   beats scrolling (the referee page). Plain links to #fragment ids — no JS. */
.contents { margin: var(--space-4) 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--fs-small); }
.contents a { text-decoration: none; }
.contents a:hover { text-decoration: underline; }

/* ============================== Panels ============================== */

/* Groups a section heading with its table/content into one visible block, so a long page
   reads as a stack of distinct units. Does not alter the heading it wraps. */
.panel {
  margin: var(--space-6) 0;
  padding: var(--space-4) var(--space-5) var(--space-5);
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
}
.panel > h2:first-child { margin-top: 0; }
.panel > h2 + p, .panel > h2 + .subtitle { margin-top: 0; }
.panel + .panel { margin-top: var(--space-6); }

/* Two (or more) independent tables side by side inside one panel, for a section whose
   content is a pair of short, lopsided lists rather than one long one — the referee page's
   yellow and red card reasons. Stacked, such a pair costs the sum of both heights on a page
   that is already long, and leaves the shorter list a scroll away from the one it is meant
   to be read against; beside each other they cost the height of the taller alone.

   `auto-fit` with a floor rather than a fixed two-column track, so the columns collapse to
   one when the viewport cannot give each table a usable measure — no media query needed,
   and a section with three tables would behave sensibly without a new rule. `align-items:
   start` keeps a short table at the top of its column instead of stretching its last row
   down to meet a taller neighbour. */
.panel-columns {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: var(--space-3) var(--space-6);
  align-items: start;
}
/* A row label in half the panel's width is not the same problem as one in all of it:
   "Denying a goalscoring opportunity (handball)" is longer than a column here can be, and
   left `nowrap` it turned a table into something to scroll sideways at every viewport
   between the two-column and one-column layouts. It is prose and survives a line break, so
   it wraps. The figures beside it are single tokens and keep the site-wide `nowrap`. */
.panel-columns th.text { white-space: normal; }

/* Cards and Penalties, side by side where the screen has room.
 *
 * Stacked, the two panels put a screen between them and left a great deal of white to the
 * right of each: both are a pair of narrow columns of short lines, and a match's discipline
 * reads as one thing rather than two. `align-items: start` so a match with four bookings
 * and one penalty does not stretch the penalties panel to match.
 *
 * Each panel splits into two club columns of its own at 18rem apiece, so the pair needs
 * roughly twice that plus the gaps before it is worth doing — below which they go back to
 * one panel per row and split their own clubs as before.
 */
.panel-pair { display: grid; gap: 0; align-items: start; }
@media (min-width: 68rem) {
  .panel-pair { grid-template-columns: 1fr 1fr; gap: var(--space-5); }
  /* The panels carry their own bottom margin for the stacked case; side by side it would
     leave the shorter one hanging. */
  .panel-pair > .panel { margin-bottom: 0; height: 100%; }
}

/* A match's cards, grouped by club and then by player.
 *
 * Two nested lists rather than a table: the outer rows are people and the inner ones are
 * events, which is a hierarchy a table can only fake with a repeated first column — which
 * is what the flat card table was. `list-style: none` on both, because the bullet would be
 * a third mark competing with the colour chip that is already doing the work.
 */
.card-list, .card-entries, .event-list { list-style: none; margin: 0; padding: 0; }
.card-list > li { margin: 0 0 var(--space-3); }
.card-list > li:last-child { margin-bottom: 0; }
.card-player { margin: 0; font-weight: 600; }
.card-entries li, .event-list li { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; font-size: var(--fs-small); }

/* "No cards" or "No penalties" under a club that had none: a line in the list's own voice,
   not the dashed placeholder box `.empty` draws. That box is for a whole panel with nothing
   in it, and at the head of a column half a panel wide it made an absence louder than the
   events printed beside it. */
.empty-line { margin: 0; color: var(--muted); font-size: var(--fs-small); }

/* One flat list of events under a club heading, where the cards need two levels because
   several of theirs can belong to one player. A match's penalties are one apiece and want
   no grouping under a name — nobody wins two — so they take the card entries' line and
   skip the list of people above it. Spaced a little, being their own rows rather than
   sub-items of one. */
.event-list li + li { margin-top: var(--space-1); }

/* The penalty spot, drawn as a spot. Decorative, like the card chips beside it: the
   heading says these are penalties and every line reads without it. */
.pen-spot {
  flex: none; width: 0.5rem; height: 0.5rem; border-radius: 50%;
  background: var(--muted);
}

/* The card itself, as a card: a small rectangle in the colour that was shown. Decorative —
   every entry carries the colour in words for a screen reader — so it is aria-hidden and
   the layout does not depend on it. `flex: none` keeps it from being squeezed to nothing
   when a long reason wraps beside it. */
.card-chip { flex: none; width: 0.6rem; height: 0.85rem; border-radius: 1px; border: 1px solid rgb(0 0 0 / 0.35); }
.card-chip-y { background: #f2c200; }
.card-chip-r { background: #d4322c; }

/* A dismissal, beside the name. Small and in the red the card was, so the eye finds it
   without it shouting over the name it qualifies. */
.card-off { font-size: var(--fs-small); font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: light-dark(#b3231e, #ff8a84); }

/* A field whose content is code: the SQL console's query box, and the examples under it.
   Monospaced, because alignment carries meaning in a query and proportional type throws it
   away; and `white-space: pre` on the block so an indented join keeps its shape. */
.code, .code-block { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--fs-small); }
.code { width: 100%; padding: var(--space-2) var(--space-3); border: 1px solid var(--control-border); border-radius: 4px; background: var(--bg); color: var(--fg); }
.code-block { margin: var(--space-2) 0 0; padding: var(--space-3); overflow-x: auto; background: var(--row-alt); border-radius: 4px; white-space: pre; }

/* A statistic's two readings in one card: the total, and the same figure per match in
   brackets after it. The bracketed halves are muted so the card still reads as one number
   at a glance, and the qualifier in the heading lines up with the rate under it. */
.stat-qualifier, .stat-rate { color: var(--muted); font-weight: 400; }
.stat-rate { font-size: var(--fs-small); }

/* ============================== Tables ============================== */

.table-wrap { overflow-x: auto; margin: var(--space-2) 0; }

table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
caption { text-align: left; color: var(--muted); padding-bottom: var(--space-2); font-size: var(--fs-small); }
th, td { padding: 0.3rem 0.55rem; text-align: right; white-space: nowrap; border-bottom: 1px solid var(--rule); }
th:first-child, td:first-child, th.text, td.text { text-align: left; }
/* A text column wraps; a numeric one does not. The `nowrap` above is there for figures —
   a token that must never be broken across lines — and applying it to prose made the widest
   tables wider than the panel holding them, so a reader at an ordinary laptop width had to
   scroll sideways to reach the last column. Club and referee names are the ones that push
   it: "Vancouver Whitecaps FC" claimed 190px of a 900px table for want of permission to
   take two lines. `.nowrap` opts a text cell back out, which the date column uses — an
   ISO date breaks after its hyphens, and half a date on each of two lines is worse than
   anything wrapping was meant to fix. */
th.text, td.text { white-space: normal; }
/* Written to out-specify `th.text` above rather than merely to follow it — a bare
   `.nowrap` loses to it, and the date would wrap after its hyphens anyway. */
.nowrap, th.nowrap, td.nowrap { white-space: nowrap; }
/* Headings wrap where data cells do not. Spelling the numeric columns out ("Opponent
   cautions" rather than "Cau opp") made the widest tables wider than the panel holding
   them, so a reader at an ordinary desktop width had to scroll sideways to reach columns
   that used to be in view. A heading is prose and survives a line break; the figure under
   it is a single token and must not be broken, so the `nowrap` above still governs the
   body. The rate headings are written "Cautions /&nbsp;match" for the same reason: left to
   break freely they put the slash alone on its own line in a squeezed table. */
thead th {
  white-space: normal;
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--row-alt);
  border-bottom: 2px solid var(--rule-strong);
  font-size: var(--fs-small);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
}
thead th a { color: inherit; }
/* A row-labelling `<th scope="row">` in tbody carries the same weight and font as its
   `<td>` neighbours — the semantics are for assistive tech, not a visual emphasis cue. */
tbody th { font-weight: inherit; font-size: inherit; text-transform: none; letter-spacing: normal; color: inherit; }
tbody tr:nth-child(even) { background: var(--row-alt); }
/* Declared after the zebra rule so it wins the same-specificity tie on both odd and even
   rows, rather than a hovered even row keeping its stripe colour underneath the cursor. */
tbody tr:hover { background: var(--row-hover); }
tfoot td, tfoot th { border-bottom: none; color: var(--muted); font-size: var(--fs-small); white-space: normal; font-weight: 600; }
tfoot tr:first-child td, tfoot tr:first-child th { border-top: 2px solid var(--rule-strong); padding-top: var(--space-2); }

/* The per-position match count beside a crewmate's position, e.g. "AR1 (12)" — smaller
   and muted so the position itself (the useful-at-a-glance part) reads first. It sits
   inside the position's link, so it keeps its muted colour rather than inheriting the
   link's: the count is context for the position, not a second thing to click. */
.position-count { font-size: var(--fs-small); color: var(--muted); }

/* The substitution dagger. A <button>, so it is reachable by keyboard and takes focus on a
   tap — which is what makes the explanation available on a touch screen, where there is no
   hover to rely on. Stripped back to look like the character it is rather than a control:
   the dotted underline is the only affordance, and it is the conventional one for "this
   term explains itself". */
.marker {
  color: var(--marker); font-weight: 700; font: inherit; font-weight: 700;
  background: none; border: 0; padding: 0; cursor: help;
  text-decoration: underline dotted; text-underline-offset: 0.15em;
}
.marker:hover, .marker:focus-visible { text-decoration: underline solid; }

/* The explanation. Two of them are rendered once per page by the layout, one per kind of
   dagger; a match page's crew box adds one more beside each of its own daggers, because
   that explanation names the official and the minute and so differs per dagger.
   `position: fixed` is load-bearing rather than decorative: a dagger lives in a table cell
   inside `.table-wrap`, which is `overflow-x: auto`, and an element whose overflow is not
   visible on one axis clips the other too — so anything positioned relative to the dagger
   would be cut off at the edge of the table. Fixed positioning escapes that entirely, at
   the cost of the note appearing at the foot of the viewport rather than beside the
   symbol. For one sentence that always says the same thing, a predictable place is worth
   more than proximity, and it never covers the row being read. */
.marker-note {
  position: fixed;
  left: 50%; bottom: var(--space-4);
  transform: translateX(-50%) translateY(0.4rem);
  z-index: 20;
  max-width: min(44rem, calc(100vw - 2 * var(--space-4)));
  margin: 0;
  padding: var(--space-3) var(--space-4);
  background: var(--bg);
  border: 1px solid var(--rule-strong);
  border-left: 4px solid var(--marker);
  border-radius: var(--radius);
  box-shadow: 0 2px 12px rgb(0 0 0 / 25%);
  color: var(--fg);
  font-size: var(--fs-small);
  opacity: 0; visibility: hidden;
  transition: opacity 120ms ease, transform 120ms ease, visibility 0s linear 120ms;
}

/* `:has()` is what lets a dagger anywhere on the page reveal a note that is not its
   sibling, without a line of JavaScript — every public page here ships none. Hover covers
   a mouse, focus-visible covers the keyboard, and plain focus covers a tap, which is the
   case a hover-only tooltip fails completely. A crew dagger's note *is* its next sibling,
   so it needs no `:has()` at all — which is also what allows one note per dagger, since a
   selector cannot otherwise tell hundreds of identical notes apart. */
body:has(.marker-aggregate:hover) #marker-note-aggregate,
body:has(.marker-aggregate:focus) #marker-note-aggregate,
body:has(.marker-match:hover) #marker-note-match,
body:has(.marker-match:focus) #marker-note-match,
body:has(.marker-2ct:hover) #marker-note-2ct,
body:has(.marker-2ct:focus) #marker-note-2ct,
/* And on hovering the label itself, which is what a reader reaches for when they wonder
   what it means. Hover ONLY: the switch is a control somebody presses, and any focus rule
   here left the note standing open behind every press of it — measured, not guessed. The
   keyboard and the touch screen are the `?`'s job, which is why it sits beside it. */
body:has(.switch:hover) #marker-note-2ct,
.marker-crew:hover + .marker-note,
.marker-crew:focus + .marker-note {
  opacity: 1; visibility: visible;
  transform: translateX(-50%) translateY(0);
  transition: opacity 120ms ease, transform 120ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .marker-note { transition: opacity 1ms linear, visibility 0s linear 1ms; transform: translateX(-50%); }
  body:has(.marker-aggregate:hover) #marker-note-aggregate,
  body:has(.marker-aggregate:focus) #marker-note-aggregate,
  body:has(.marker-2ct:hover) #marker-note-2ct,
  body:has(.marker-2ct:focus) #marker-note-2ct,
  body:has(.switch:hover) #marker-note-2ct,
  body:has(.marker-match:hover) #marker-note-match,
  body:has(.marker-match:focus) #marker-note-match,
  .marker-crew:hover + .marker-note,
  .marker-crew:focus + .marker-note { transform: translateX(-50%); }
}

.footnote { margin: var(--space-3) 0 0; color: var(--muted); font-size: var(--fs-small); }

/* ============================== Stat grid ============================== */

.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--space-3); margin: var(--space-4) 0; }
/* The figures line up across a row whatever their labels do.
 *
 * A grid row's boxes are already the same height — grid items stretch — but the label above
 * each figure is not the same height as its neighbour's: "MATCHES" is one line and "GOALS
 * (PER MATCH)" is two, so 157 sat a line higher than the 501 beside it. Bottom-aligning the
 * figure inside its box fixes it for any label length, which is what a rule has to do here:
 * the widths come from `auto-fit`, so which labels wrap depends on the viewport. */
.stat { display: flex; flex-direction: column; border: 1px solid var(--control-border); border-radius: var(--radius); padding: var(--space-3) var(--space-4); }
.stat dt { margin: 0; font-size: var(--fs-small); text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
.stat dd { margin: auto 0 0; padding-top: 0.15rem; font-size: 1.3rem; font-weight: 600; font-variant-numeric: tabular-nums; }

/* ============================== Filters, pager, empty states ============================== */

.filters { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: baseline; margin: var(--space-3) 0; }

/* One label and the control it names, as a single thing that wraps.
 *
 * The filter forms laid label and field out as siblings of the wrapping row above, so the
 * row broke wherever it ran out of width and left the words on one line with the box they
 * name on the next — "Referee:" over nothing, an unlabelled box beneath it. It was not a
 * narrow-screen problem: the match index orphaned a label at almost every width under
 * 1100px, and which one depended on where the wrap happened to fall. Pairing them makes the
 * pair the unit that wraps, and the row then reads at any width at all.
 *
 * `min-width: 0` on both, or a long field refuses to shrink and pushes the pair past the
 * edge of a phone instead of narrowing inside it. The width utilities (`.w-medium` and the
 * rest) are max-widths, so they cap without preventing that. */
.filter-field { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
.filter-field > label { flex: none; }
.filter-field > input, .filter-field > select, .filter-field > .combobox { min-width: 0; }
.filter-field > .combobox { flex: 1 1 auto; }
.filter-field > .combobox > input { width: 100%; }

/* On a phone, the label goes above its field and the field takes the width.
 *
 * Side by side they fit, but each field then starts wherever its own label ends — "Club:"
 * is short and "Crewmate:" is not — so four boxes sat at four different left edges down the
 * screen. Stacked, they share one, and it is the same shape `.form` gives every other form
 * in this application: label on its own line, control filling the measure below it. */
@media (max-width: 34rem) {
  form.filters > .filter-field {
    flex: 1 1 100%;
    flex-direction: column; align-items: stretch; gap: 0.2rem;
  }
  form.filters > .filter-field > label { font-weight: 600; }
  /* A descendant selector, not a child one: three of these fields have their input inside
     a `.combobox`, and a child rule reached only the fourth — leaving the select filling
     the measure and the three text boxes still capped by `.w-medium` two-thirds across it.
     `max-width: none` overrides that cap, which is there to stop a name field claiming the
     width of the page on a desktop and has nothing to say on a phone. */
  form.filters .filter-field input,
  form.filters .filter-field select { width: 100%; max-width: none; }
}
.filters a {
  text-decoration: none; padding: 0.15rem var(--space-3); border: 1px solid var(--control-border);
  border-radius: 999px; color: var(--muted); transition: background-color 0.1s ease, color 0.1s ease;
}
.filters a:hover { color: var(--fg); background: var(--row-alt); }

/* How many matches a pill leads to, inside the pill and quieter than its label. The label
   is what a reader is choosing between; the count is why they might choose it. */
.pill-count { color: var(--muted); font-size: var(--fs-small); }

/* A panel whose heading shares its line with a control — the match form's Visibility row.
   Wraps rather than squeezes: on a narrow screen the control drops under the heading. */
.panel-head {
  display: flex; flex-wrap: wrap; align-items: baseline;
  justify-content: space-between; gap: var(--space-2) var(--space-4);
}
.panel-head h2 { margin-bottom: 0; }

/* Three radios that read as the pill row they sit beside on every public page.
   `appearance: none` on the input rather than hiding it: a hidden input is unreachable by
   keyboard in some browsers, and the focus ring has to land on something. The label is what
   is styled, and clicking it works because `for` points at the input. */
.visibility-pills { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
.visibility-pills legend {
  float: left; padding: 0 var(--space-2) 0 0;
  font-size: var(--fs-small); color: var(--muted);
}
.pill-choice input {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.pill-choice label {
  display: inline-block; cursor: pointer;
  padding: 0.15rem var(--space-3); border: 1px solid var(--control-border);
  border-radius: 999px; color: var(--muted); font-weight: 400;
  transition: background-color 0.1s ease, color 0.1s ease;
}
.pill-choice label:hover { color: var(--fg); background: var(--row-alt); }
.pill-choice input:checked + label {
  border-color: var(--accent); color: var(--fg); font-weight: 600; background: var(--row-alt);
}
/* The ring the hidden input would have drawn, moved onto the label a reader can see. */
.pill-choice input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
/* An option this match cannot have. Shown rather than dropped, so the row does not silently
   change length — the title says why. */
.pill-choice input:disabled + label {
  cursor: not-allowed; color: var(--rule-strong); border-style: dashed;
}
.pill-choice input:disabled + label:hover { background: none; color: var(--rule-strong); }

/* The crewmate box's suggestion list. A hand-built listbox rather than a <datalist>,
   because a datalist option carries no second column and cannot be disabled, and both are
   the point here: each name comes with the number of matches it would find, and a name at
   nought is shown but not selectable. See public/js/crewmate-filter.js.

   Absolutely positioned under the field, so opening it does not push the filter row's other
   controls down the page. The wrapper is the positioning context and is inline, since it
   sits in a row of labels and inputs. */
.combobox { position: relative; display: inline-block; }
.suggestions {
  position: absolute; z-index: 20; top: 100%; left: 0; min-width: 100%;
  margin: 2px 0 0; padding: var(--space-1) 0;
  list-style: none;
  max-height: 16rem; overflow-y: auto;
  background: var(--bg); border: 1px solid var(--control-border); border-radius: 4px;
  box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
}
.suggestions [role="option"] {
  display: flex; justify-content: space-between; gap: var(--space-4);
  padding: 0.25rem var(--space-3);
  white-space: nowrap; cursor: pointer;
}
.suggestions [role="option"].is-active { background: var(--row-alt); }
.suggestion-count { color: var(--muted); font-size: var(--fs-small); }
/* Nobody this official has shared a match with under the filters in force. Offered, so a
   reader who typed the name in full is told rather than left wondering, and dead, because
   choosing it could only open an empty table. */
.suggestions [role="option"].is-unavailable { color: var(--rule-strong); cursor: default; }
.suggestions [role="option"].is-unavailable .suggestion-count { color: inherit; }
.filters a[aria-current="true"] { border-color: var(--accent); color: var(--fg); font-weight: 600; background: var(--row-alt); }

/* A filter row that is a form rather than a run of links — the match index's club and
   referee boxes, where 33 clubs and 690 officials are both past what pills can show and a
   type-ahead box is the control a reader already knows. The submit button is not optional
   decoration: these have to work with no JavaScript, so a typed value is submitted by
   hand. All three borrow the pill's own border and radius so the row still reads as one
   set of filters and not as a form that wandered onto the page. */
.filters input[type="text"], .filters select, .filters button {
  font: inherit;
  padding: 0.15rem var(--space-2);
  border: 1px solid var(--control-border);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--fg);
}
.filters input[type="text"] { max-width: 12rem; }
.filters button { padding: 0.15rem var(--space-3); border-radius: 999px; cursor: pointer; }
.filters button:hover { background: var(--row-alt); }

.pager { display: flex; gap: var(--space-4); align-items: baseline; margin: var(--space-4) 0; }
.pager .count { color: var(--muted); }

/* A deliberate empty state — a bordered, centred note rather than a lone paragraph
   floating in otherwise-blank space. */
.empty {
  margin: var(--space-4) 0;
  padding: var(--space-5);
  border: 1px dashed var(--rule-strong);
  border-radius: var(--radius);
  color: var(--muted);
  text-align: center;
}

/* The share bar beside a card-minute band. An <svg> whose width attribute carries the
   percentage — see the note in referee.njk for why it cannot be an inline style — so the
   colour is on the rect and the element itself only has to sit on the text's baseline. */
.bar { display: inline-block; vertical-align: middle; }
.bar rect { fill: var(--accent); }

@media (max-width: 34rem) {
  th, td { padding: 0.3rem 0.4rem; }
  body { font-size: 14px; }
  .panel { padding: var(--space-3) var(--space-4) var(--space-4); }
}

/* ===================== Match page: facts and crew ===================== */

/* One club per line. A `<br>` would do it visually, but these are two separate names and
   a screen reader should get a pause between them, not a run-on. */
.match-title span { display: block; }

/* The match's numbers, one row per statistic and one column per club.

   Not inside `.table-wrap`: it is five rows of short figures and never needs to scroll,
   and the wrap's `overflow` would clip the crew note that can appear beside it. The
   site-wide table chrome — sticky header, zebra stripe, hover — is turned off too; those
   earn their keep on a hundred-row match log and only add noise to five rows read as a
   pair of columns. */
.comparison {
  width: auto; min-width: min(28rem, 100%);
  margin: var(--space-4) 0 0;
  border-collapse: collapse;
}
.comparison thead th {
  position: static; background: none;
  padding: 0 var(--space-4) var(--space-2);
  text-align: right; font-size: var(--fs-body); font-weight: var(--fw-subheading);
  white-space: normal; text-transform: none; letter-spacing: normal;
}
.comparison thead th:last-child { padding-right: 0; }
.comparison tbody tr { background: none; }
.comparison tbody tr:hover { background: none; }
/* The label column is the quiet one: a reader scans the figures and refers back to the
   label, not the other way round. */
.comparison tbody th {
  padding: var(--space-2) var(--space-5) var(--space-2) 0;
  text-align: left; font-weight: var(--fw-body); color: var(--muted);
  border-bottom: 1px solid var(--rule);
}
.comparison tbody td {
  padding: var(--space-2) var(--space-4);
  text-align: right; font-variant-numeric: tabular-nums; font-weight: 600;
  border-bottom: 1px solid var(--rule);
}
.comparison tbody td:last-child { padding-right: 0; }
.comparison tbody tr:last-child th, .comparison tbody tr:last-child td { border-bottom: 0; }

/* The match summary and the crew, side by side. The crew is four short lines; below the
   title at full width it cost a screenful to say so. `auto` on the second column lets the
   box be exactly as wide as the longest name rather than a fixed measure that is too
   narrow for "Levon Baladjanian" and too wide for "R Hall".

   The rule that normally closes a `.page-header` moves here, so it runs the full width
   under both columns instead of stopping where the left one ends. */
.match-top {
  display: grid;
  /* The crew column is a share of the page, not a content width: `auto` sized it to the
     longest name, which on a wide screen left a box far narrower than the space beside it.
     A band rather than one figure — never so narrow that names wrap, never so wide that it
     competes with the scoreline. */
  grid-template-columns: minmax(0, 1fr) minmax(40%, 50%);
  gap: var(--space-5) var(--space-6);
  align-items: start;
  margin-bottom: var(--space-6);
  padding-bottom: var(--space-4);
  border-bottom: 2px solid var(--rule-strong);
}
.match-top > .page-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }

/* Fills the column the grid gives it; the width is decided there. */
.crew-panel { margin: 0; }
.crew-panel .footnote { max-width: none; }

/* One official per entry: the position chip, then the name and which appearance in that
   position this match was. */
.crew { list-style: none; margin: 0; padding: 0; }
.crew li {
  display: flex; align-items: baseline; gap: var(--space-2);
  padding: var(--space-1) 0;
  min-width: 0;
}
.crew li + li { border-top: 1px solid var(--rule); }

/* Fixed-width, so the names line up down the box rather than starting wherever "AR1" or
   "4th" happens to end. Not uppercased: these are the position's own spelling, and
   `text-transform` turned the fourth official into "4TH", which is not what it is called
   and read as an acronym. */
.crew-pos {
  flex: 0 0 2.5rem;
  color: var(--muted); font-size: var(--fs-small); font-weight: 600;
}
/* Name, any dagger, and the appearance line, stacked. The stack takes the width the chip
   leaves, so the appearance sentence begins under the name rather than under the chip. */
.crew-who { flex: 1 1 auto; min-width: 0; }
.crew-name { overflow-wrap: break-word; }

/* The appearance count as a sentence — "5th match as referee" — on its own line. A bare
   "5th" at the end of the name's line was ambiguous, and a phrase long enough to resolve
   the ambiguity does not fit beside a name in a column this narrow. The box is short of
   width, not of height, so the second line is the cheap dimension to spend. Nothing left
   to keep `white-space: nowrap` for, now that it is prose rather than a number. */
.crew-app {
  display: block;
  color: var(--muted); font-size: var(--fs-small);
  font-variant-numeric: tabular-nums;
}

/* The full table of appearance counts, in the disclosure below the crew box. Its figures
   are ordinals meant to be compared down a column, so they line up on their digits. */
.crew-table td { font-variant-numeric: tabular-nums; }
/* Where one competition's group of columns begins. Without it the nine figures on an
   overlapping match read as one run, and "which of these is the regular season" is a
   counting exercise. One line on almost every match; two on a July 2020 group-stage
   fixture, which was in two competitions at once.

   An inset shadow rather than `border-left`, which is what this was. In a table with
   `border-collapse: collapse` the browser resolves the borders between neighbouring cells
   into one set and paints them as a layer over the whole table — and opening a tooltip
   below, which makes one row taller, re-lays the table out without repainting that layer
   past the row that changed. The line then stops there and stays stopped, because closing
   the tooltip again dirties the same region and no more. The shadow belongs to the cell
   instead of to the table, so it is repainted whenever the cell is, and 1px inset on the
   left edge of a cell with no left border draws in exactly the same place. */
.crew-table .span-start { box-shadow: inset 1px 0 0 var(--rule-strong); }

/* An assistant referee's figure carries the one-touchline count on its tooltip, so it is
   marked as having something behind it — a dotted underline, the convention every other
   <abbr> on this site uses — and takes the help cursor rather than a text caret. */
.crew-table abbr { text-decoration: underline dotted; text-underline-offset: 0.2em; cursor: help; }
.crew-table abbr:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The same words the tooltip carries, shown under the figure when it is tapped or when
   Enter is pressed on it — a `title` is a hover affordance, and a phone has no hover. Under
   the figure rather than in a floating bubble because the table scrolls horizontally, and
   anything absolutely positioned inside a scrolling box is clipped at its edge.

   Hidden until public/js/tooltip.js adds the class. Without that file the span never
   appears and the tooltip behaves as it always did. */
.tip-text { display: none; color: var(--muted); font-size: var(--fs-small); font-weight: 400; }
.crew-table abbr.is-open .tip-text { display: block; }

/* Below the width where a scoreline and a name can share a line, the box goes back under
   the summary rather than being crushed into a column too narrow for either. */
@media (max-width: 52rem) {
  .match-top { grid-template-columns: 1fr; }
}

/* Home, score and away, read as one scoreline rather than three columns.
   
   They used to inherit the table's ordinary alignment — left, right, left — which put a
   wide gap between the home club and the score and none between the score and the away
   club, so "NE      6–1 CIN" read as two things rather than one. Pointing them inwards
   (right, centre, left) makes the score the middle of a unit.
   
   `width: 1%` is the shrink-to-fit idiom: the table is `width: 100%`, so its slack has to
   land somewhere, and it was landing on these three — HOME was several times the width of
   "NE". A declared width under the content width makes them take only what they need and
   sends the slack to the columns that can use it, the referee name and the date. */
/* The kickoff, beside the date it belongs to. Small and muted so the column still reads as
   a column of dates — the time is the second thing about when a match was, not a rival to
   the first — and unlinked, because the date next to it is the link and two adjacent
   targets to the same page is one too many. */
.match-time { color: var(--muted); font-size: var(--fs-small); }

.matchup-home, .matchup-score, .matchup-away { width: 1%; white-space: nowrap; }

/* Shrunk to its content, the same trick as the three above: under `table-layout: auto` and
   `table { width: 100% }`, a column with no stated width takes a share of whatever space
   the content columns leave over, and a name column's share was visibly wider than any
   name on the page — a gap between Referee and Fouls that was really unclaimed table
   width rather than anything about either column. `width: 1%` tells the browser this
   column wants none of that surplus.
   `nowrap` is stated again and has to be: `th.text, td.text { white-space: normal }`
   further down lets a text column wrap onto a second line, which is right for the match
   cell's club names and wrong here — combined with `width: 1%` it shrank every referee's
   name onto two lines instead of leaving the column at its content width, which is the
   opposite of what a name column should do. `!important` because that rule is later in
   the file and would otherwise win regardless of selector order. */
.referee-col { width: 1%; white-space: nowrap !important; }
.matchup-home { text-align: right; padding-right: var(--space-2); }
.matchup-score { text-align: center; padding-left: var(--space-2); padding-right: var(--space-2); }
.matchup-away { text-align: left; padding-left: var(--space-2); }

/* On a phone, the match index drops its kickoff and its Round column.

   Eleven columns do not fit a narrow screen, and `.table-wrap` answers that by scrolling —
   which is fine for a table a reader is reading across, and wrong for this one, where
   everything that makes a row worth scanning (the score, the away side, the referee, the
   cards) sits to the RIGHT of the two least-asked-for things on it. The reader arrived at a
   list of matches and got a column of dates and the word "Reg" eight times.

   `display: none` rather than `visually-hidden`: the point is the width, and a hidden cell
   that still occupies its column reclaims nothing. It goes for the screen reader too, which
   is the honest trade — both facts remain a tap away on the match's own page, the round is
   a filter directly above this table, and neither is what the list is for. */
@media (max-width: 34rem) {
  .match-time { display: none; }
  .round-col { display: none; }
}

/* The totals / per-match switch above a by-season table. Shaped like the filter pills it
   sits among — same border, same radius — because it does the same job: choosing which of
   two views of one thing to look at. `aria-pressed` is both the state a screen reader
   announces and the hook this styles from, so the two cannot drift apart. */
.figures-switch button[aria-pressed="true"] {
  border-color: var(--accent); color: var(--fg); font-weight: 600; background: var(--row-alt);
}

/* An on/off switch above a table — "Incl. 2CT", and the totals/per-match pair.

   A switch rather than two pills because the choice is one thing being on or off, and a
   pair of buttons where one is always pressed spends twice the width saying so. The track
   and knob are pseudo-elements on the button itself, so the whole control is one focusable
   thing with one accessible name and no wrapper to keep in step with it.

   Driven by `aria-checked`, which the script sets and a screen reader reads. Styling from
   the same attribute rather than a class is what stops the two disagreeing: there is no
   state here that is visible but unannounced. */
/* Label first, knob last. The control sits right-aligned over the column it changes, so
   the thing that must land on the column is the switch itself, not the tail of the label —
   "Incl. 2CT [knob]" puts the knob on the column's edge where the figures line up. */
.switch {
  position: relative;
  display: inline-flex; flex-direction: row-reverse; align-items: center; gap: 0.45rem;
  padding: 0.2rem 0.1rem; border: 0; background: none;
  color: var(--muted); font: inherit; font-size: 0.9rem; cursor: pointer;
}
.switch::before {
  content: ""; flex: none; width: 2rem; height: 1.1rem; border-radius: 0.55rem;
  border: 1px solid var(--border); background: var(--row-alt);
  transition: background-color 120ms ease, border-color 120ms ease;
}
/* The knob sits over the track rather than beside it, and is taken out of the flex flow to
   do it: pulled back with a negative margin it dragged the label left with it, printing the
   knob through the middle of the word. Anchored to the button's right edge, where
   row-reverse puts the track: 0.1rem padding + 2rem track − 0.28rem inset − 0.75rem knob. */
.switch::after {
  content: ""; position: absolute; right: 1.07rem; top: 50%;
  width: 0.75rem; height: 0.75rem; margin-top: -0.375rem; border-radius: 50%;
  background: var(--muted);
  transition: transform 120ms ease, background-color 120ms ease;
}
.switch[aria-checked="true"] { color: var(--fg); }
.switch[aria-checked="true"]::before { background: var(--accent); border-color: var(--accent); }
.switch[aria-checked="true"]::after { transform: translateX(0.85rem); background: var(--bg); }
.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }

/* Reduced motion means no sliding knob; the state still changes, it just arrives. */
@media (prefers-reduced-motion: reduce) {
  .switch::before, .switch::after { transition: none; }
}

/* The nationality worklist. The name column takes whatever the other three leave, so the
   trigram box sits beside its Save button rather than adrift in a quarter of the panel. */
.col-count { width: 8rem; }
/* An ISO date and its heading, neither of which wraps. */
.col-last-match { width: 9rem; }
/* Wide enough for the longest thing the cell ever says, which is not a country name but the
   refusal: "“ZZ9” is not a FIFA country code" beside a three-character box. */
.col-nationality { width: 20rem; }
.col-save { width: 6rem; }

/* Code and country on one line, so an empty row is one line tall. `.nationality-echo` is a
   block on the referee form, where it belongs under the field it captions; here it sits
   beside it and must not reserve a line of its own.

   `td.` on the selector, not the bare class: the cell is also `.text`, and `td.text` is the
   more specific of the two — a bare `.nationality-cell` rule lost to it silently. An
   inline-block rather than an inline, so the refusal wraps within its own box beside the
   field instead of starting its second line underneath it. */
td.nationality-cell .nationality-echo {
  display: inline-block; vertical-align: top; min-height: 0;
  margin-left: 0.5rem; max-width: calc(100% - 6rem);
}

/* A referee's nationality, beside their name. Sized down from the heading it sits in —
   a flag at heading weight competes with the name it belongs to — and given a little air
   on the left so it reads as a mark beside the name rather than part of it.

   `abbr` carries a dotted underline in most browsers, which on a flag is a smudge under
   the glyph rather than a hint that there is a title. Removed here and only here; every
   other abbr on the site keeps it. */
h1 .referee-flag { margin-left: 0.4rem; font-size: 0.8em; text-decoration: none; border: 0; }

/* The bio list under the subtitle: label and value in a row, wrapping to as many rows as
   the facts need. A dl rather than a sentence, so a referee with only a hometown reads as
   well as one with all four. */
.referee-bio {
  display: flex; flex-wrap: wrap; gap: 0.15rem 1.5rem;
  margin: 0.35rem 0 0; color: var(--muted); font-size: 0.95rem;
}
.referee-bio dt { font-weight: 600; }
.referee-bio dt::after { content: ":"; }
.referee-bio dd { margin: 0 0.5rem 0 0; }

/* The +N of a match's yellow-card figure — "4+1" — hangs outside the column's own
   alignment. The columns are right-aligned and the first figure is the one every other
   row's count lines up on; a zero-width box lets the suffix overflow into the gutter
   instead of pushing the count left.

   Red, because what it counts is the red card beside it: a second caution is the yellow
   the dismissal already records, so the +1 and the 1 under RC are the same card seen
   twice. The colour says which card without a legend. It is a hint rather than the
   information — the figure reads correctly in monochrome, and the RC column is where the
   same fact is stated in numbers. */
.caution-extra {
  display: inline-block; width: 0; white-space: nowrap;
  color: var(--card-red); font-size: 0.85em;
}

/* The All/Home/Away tables. Fixed layout, so the widths come from the colgroup rather
   than from whichever view's content the browser measured, and switching views moves
   nothing: every view declares the same eight columns, the name column taking whatever
   the figures leave. The switch column (YC/M) is wide enough for its own control. */
.side-table { table-layout: fixed; width: 100%; min-width: 44rem; }
.side-table col.col-figure { width: 5.2rem; }
.side-table col.col-record { width: 6.5rem; }

/* The thead row both table controls sit in — a controls row, not a heading row. The 2CT
   switch's cell ends at the yellow-card column's edge, right-aligned as the figures under
   it are; the Sides pills, where a table has them, sit in the name column's cell on the
   same line, which is what closes the band of empty space the two rows used to make. */
.switch-row td { padding: 0 0.5rem 0.3rem; text-align: right; border: 0; vertical-align: middle; }
.switch-row td.text { text-align: left; }
.switch-row .figures-switch { margin: 0; }
/* A column's own total, in the controls row directly above it — the season page's Record and
   PPG under Home or Away. Muted and small because it is not one of the table's rows: it
   summarises the column rather than joining it, and at full weight it read as a first row
   that had somehow lost its referee. Tabular figures so it sits on the same digit grid as
   the column under it. */
.side-total {
  color: var(--muted); font-size: var(--fs-small);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}

/* The switch and the "?" that explains it, as one unit, so the two stay together when the
   cell they share is wider than they are. The note needs no anchor here — it is one fixed
   panel per page, revealed by `:has()`; see `.marker-note` above. */
.switch-wrap { display: inline-flex; align-items: center; gap: var(--space-2); }
/* Marked up as `.marker`, which gives it the dotted underline, the help cursor and the
   colour: it does the same job as the dagger and should not look like a different kind of
   thing. Only the size differs, a "?" beside a control being smaller than one in prose. */
.switch-wrap .marker-2ct { font-size: var(--fs-small); }

/* A heading that sorts its column. Created by public/js/table-sort.js, so with no
   JavaScript there is no button — a plain heading promises nothing. The button inherits
   the heading's own type entirely; the arrow is drawn from aria-sort, which the script
   sets and a screen reader announces, so the two cannot disagree. */
.sort-header {
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit; letter-spacing: inherit; text-transform: inherit;
  cursor: pointer;
}
.sort-header:hover { color: var(--fg); }
th[aria-sort] .sort-header { color: var(--fg); }
/* The arrow is a zero-width overhang, like a yellow count's +N: the columns are pinned by
   the colgroup, and " ↓" taking real space wrapped MATCHES onto two lines. Hanging into
   the gutter it costs nothing and lands beside the label wherever the column ends. */
th[aria-sort] .sort-header::after { display: inline-block; width: 0; white-space: nowrap; }
th[aria-sort="descending"] .sort-header::after { content: " ↓"; }
th[aria-sort="ascending"] .sort-header::after { content: " ↑"; }

/* Season/appearance pairs on the alternate referee page, flowed into columns.

   As a table this was a full-width panel eleven rows tall carrying a year and a one- or
   two-digit number per row — the pairs are so narrow that the columns were mostly
   whitespace, and two sections said between them only "how many, and when". Three or four
   across, it is three rows deep. The same reasoning as the match page's crew box: content
   this narrow should cost width, not a screenful of height.

   `auto-fill` rather than a fixed count, so it is four columns on a desktop and one on a
   phone without a media query. */
.season-counts {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: var(--space-1) var(--space-5);
  margin: var(--space-4) 0 0;
}
.season-counts > div {
  display: flex; justify-content: space-between; gap: var(--space-3);
  padding: var(--space-1) 0;
  border-bottom: 1px solid var(--rule);
}
.season-counts dt { color: var(--muted); }
.season-counts dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 600; }

/* A panel heading with an action beside it. The heading keeps the rule the `.panel` draws
   under every `<h2>`, so the action sits on that line rather than starting a row of its
   own — the point of putting it there is that it costs no height. Wraps under the heading
   on a narrow viewport, where two things on one line would collide. */
.panel-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-3); flex-wrap: wrap;
}
.panel-head h2 { flex: 1 1 auto; }
.panel-head-action { font-size: var(--fs-small); white-space: nowrap; }

/* An abbreviation in a table — a club's short form in a match list. No underline and no
   help cursor: the browser's default dotted rule under every one of these would draw more
   attention to the shortening than the shortening is worth, on a column where every cell
   carries one. The title attribute still gives the full name on hover and to assistive
   technology, which is the part that matters. */
abbr[title] { text-decoration: none; }

/* A collapsible block of filters. The browser does the collapsing; this only makes the
   summary look like the label it replaces rather than like a heading with a triangle.

   `list-style: none` plus the WebKit pseudo-element removes the native marker, because it
   sits at the far left where a reader is looking for the word "Season", not beside the
   value the way a control's affordance should. The chevron that replaces it turns with the
   state, which is the whole signal — and it is `aria-hidden`, since a `<summary>` already
   announces expanded/collapsed to assistive technology and a spoken "▾" would be noise. */
.disclosure { margin: var(--space-3) 0; }
.disclosure > summary {
  display: flex; align-items: baseline; gap: var(--space-2);
  width: fit-content; padding: var(--space-1) 0;
  cursor: pointer; list-style: none; color: var(--muted);
}
.disclosure > summary::-webkit-details-marker { display: none; }
.disclosure > summary:hover { color: var(--fg); }
.disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The current season, named in the summary so collapsing the grid never hides which one is
   on — the one thing a collapse must not cost. */
.disclosure-value { color: var(--fg); font-weight: 600; }
.disclosure-chevron { font-size: var(--fs-small); transition: transform 0.12s ease; }
.disclosure[open] > summary .disclosure-chevron { transform: rotate(180deg); }
/* The grid brings its own top margin; inside a disclosure it would double the gap. */
.disclosure > .filters:first-of-type { margin-top: var(--space-1); }
/* A disclosure that is itself a panel — the match page's full appearance table. The summary
   is the panel's heading, so it takes the weight a heading would have rather than the muted
   grey of a control sitting inside one. */
.panel.disclosure { margin: 0 0 var(--space-5); }
.panel.disclosure > summary { color: var(--fg); font-weight: var(--fw-subheading); }

/* The season filter, as a decade grid. Still `.filters`, so the year links keep the pill
   styling every other filter row uses; only the layout changes from a wrapping flex run to
   a grid. One row per decade, ten columns for the final digit, and the columns are shared
   by every row — which is the entire point, since a year is found by looking down a column
   as much as along a row. */
.seasons-grid {
  display: grid;
  grid-template-columns: auto repeat(10, minmax(0, 1fr));
  gap: var(--space-1) var(--space-2);
  align-items: center;
  justify-items: stretch;
}
/* "All" has the first row to itself; the decades start on the next one. It used to share
   that row with a "Season:" label, which moved into the disclosure's summary when the grid
   became collapsible — the label had to go somewhere the reader can see while it is shut. */
.seasons-all { grid-column: 1 / -1; justify-self: start; }
.seasons-decade {
  grid-column: 1;
  color: var(--muted); font-size: var(--fs-small); font-weight: 600;
  padding-right: var(--space-2); white-space: nowrap;
}
.seasons-grid a { text-align: center; }
/* A season the selected round was never played in. Shaped exactly like the year buttons
   beside it — same padding, same radius, same box — so the grid's columns stay aligned and
   the difference reads as "not available" rather than as a hole. Quieter than --muted, and
   `cursor: default` because there is nothing to click. */
.seasons-unavailable {
  padding: 0.15rem var(--space-3);
  border: 1px solid var(--rule);
  border-radius: 999px;
  color: var(--rule-strong);
  text-align: center;
  cursor: default;
}

/* An empty cell, not a missing one: the 1990s begin at 1996, and the six columns before it
   have to stay open or every later decade would shift left and the alignment would be a
   lie. Not announced, because there is nothing there to announce. */
.seasons-gap { visibility: hidden; }

/* Ten year-columns will not fit a phone; fall back to the wrapping run it used to be. */
@media (max-width: 46rem) {
  .seasons-grid { display: flex; flex-wrap: wrap; gap: var(--space-2); }
  .seasons-decade { width: 100%; padding: var(--space-1) 0 0; }
  .seasons-gap { display: none; }
}

/* A heading centred over its column, wherever one is wanted: the bulk form's six boxes,
   and the Min and + pair on the match form's three event tables.

   `th.centred`, not `.centred`. The two rules it has to beat are `th.text` (left) and the
   bare `th, td` (right); a class on its own ties with the first and loses. It also has to
   sit after both, which is why a width utility's usual home further up the file is the
   wrong place for it. */
th.centred { text-align: center; }

/* The added-time box on the cards table, a tenth wider: 37.5px to 41.3px.

   It is the only one of the three that is pinched. The penalties and substitutions tables
   have room to spare, so their boxes sit at the 4rem `.w-tiny` cap, while this column is
   narrow enough that the box falls back to the 37.5px its `size="2"` intrinsically asks
   for — which is a hair under two digits and a spinner.

   No `.form` in front of it. The match form's `<form>` carries no class, so none of the
   `.form input…` rules above apply to any field on that page; a selector written to
   out-specify them would have matched nothing at all. Nothing else sets a width here, so
   the class alone is enough. */
input.added-minute { width: 2.58rem; }

/* ============================== Languages ============================== */

/* The switcher in the header. Flags at text size, each with its language's own name beside
   it — a flag is a country and a language is not, so the name is the label and the flag is
   recognition at a glance. */
/* Tight on purpose. Three flags and three labels sit at the end of a header that already
   carries the theme toggle, a username, Admin and Log out, and the row wrapped onto a second
   line once it was signed in and in French — "Se déconnecter" is half again the width of
   "Log out". The space between the three came out of the gap, the padding and the space
   between a flag and its label rather than out of the flags, which are the part a reader
   aims at. */
.locale-switcher { display: flex; gap: var(--space-1); align-items: center; }
.locale-switcher a {
  display: inline-flex; align-items: center; gap: 0.25rem;
  text-decoration: none; color: var(--muted);
  padding: 0.1rem 0.25rem; border-radius: 4px;
}
.locale-switcher a:hover { color: var(--fg); background: var(--row-alt); }
.locale-switcher a[aria-current="true"] { color: var(--fg); font-weight: 600; }
/* A hairline so a white flag edge (Canada, Québec, Mexico) does not bleed into a light
   page or vanish against a dark one. */
.flag {
  width: 1.25rem; height: auto; display: block; border-radius: 2px;
  box-shadow: 0 0 0 1px var(--control-border);
}
/* Below the width where the header wraps, the names go and the flags stay: three flags are
   still three distinguishable targets, where three words are a second row of navigation. */
@media (max-width: 40rem) {
  .locale-name { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
}

/* The translations panel. One string per row: the English and its notes, then a box for
   each language, then the row's own Save. */
.translation-row {
  display: grid;
  grid-template-columns: minmax(14rem, 2fr) minmax(11rem, 1.5fr) minmax(11rem, 1.5fr) auto;
  gap: var(--space-3) var(--space-4);
  align-items: start;
  padding: var(--space-3) 0; border-top: 1px solid var(--rule);
}
.translation-row:first-of-type { border-top: 0; }
.translation-en { margin: 0 0 var(--space-1); }
.translation-source .field-help { margin: 0 0 var(--space-1); }
.translation-cell label {
  display: block; font-size: var(--fs-small); color: var(--muted); margin-bottom: 0.15rem;
}
.translation-cell textarea { width: 100%; }
.translation-actions {
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  align-items: baseline; margin: var(--space-1) 0 0;
}
.translation-state { font-size: var(--fs-small); color: var(--muted); margin-right: auto; }
/* Approved is the only state that reaches a reader, so it is the only one coloured. */
.translation-state-approved { color: var(--accent); }
.translation-save { margin: 0; }

/* The marks the worklist rows use, on a row that is a grid rather than a <tr>. `is-saved`
   comes off again after a moment (public/js/translations.js) so a worked section does not
   end up entirely green; `is-refused` stays until the row saves. */
/* The live region is empty until a row is saved, and an empty paragraph still takes a line.
   `:empty` rather than leaving the element out: a live region has to be in the document
   before the words arrive for the change to be announced at all. */
[data-translation-status]:empty { display: none; }

.translation-row.is-saved { box-shadow: inset 4px 0 0 -1px var(--success); }
.translation-row.is-refused { box-shadow: inset 4px 0 0 -1px var(--danger); }

/* Below the width where four columns crush, the row becomes a stack: English, then each
   language under it. The boxes stay full width, which is what they want anyway. */
@media (max-width: 64rem) {
  .translation-row { grid-template-columns: 1fr; }
}
