/* ==========================================================================
   gameweek38 — layout & components
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  /* --bg, and it has to be exactly --bg: `.controls` paints the same token to
     stay opaque while the page scrolls under it, so anything else here leaves
     the strip behind the league and view selectors a visibly different shade
     from the page below it. A darker page was tried, to make the tiles sit
     more obviously ON it — the tiles are --surface, already a step above --bg,
     so they read as raised without it. */
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: var(--step-0);
  line-height: 1.5;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
}

/* 600, not 800: Plex Sans has no 800, and its 600 already carries a heading at
   these sizes. Tracking is far gentler than the old display face wanted —
   Plex is upright and open, and tightening it to -0.035em closes the counters. */
h1, h2 { margin: 0; font-family: var(--font-display); font-weight: 600; line-height: 1.1; letter-spacing: -0.015em; }
p { margin: 0; }
button { font: inherit; color: inherit; }

:where(a, button, select, [tabindex]):focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}

.shell { max-width: var(--shell-max); margin-inline: auto; padding-inline: var(--space-4); }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

/* THE SAME DECLARATIONS, BELOW 720px ONLY — the utility `.pager__btn-word` and
   `.fx-eye__noun` both asked for when they became the second and third copies of
   them. A word clipped this way is still the accessible name, which is what
   keeps WCAG 2.5.3 satisfied at both widths: the Movement view's buttons read
   SHARE IMAGE / SHARE CLIP on a desktop and IMAGE / CLIP on a phone, and
   announce the long form either way.

   `display: none` would NOT do here — it takes the word out of the
   accessibility tree, and the short forms alone name a format rather than an
   action.

   THE THREE EXISTING COPIES ARE LEFT ALONE ON PURPOSE. Two of them share this
   trigger and could use this class, but they are correct as they stand and
   rewriting working rules to tidy them is how a refactor breaks something nobody
   asked about. Use this one for anything NEW. */
@media (max-width: 720px) {
  .sr-only--narrow {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }
}

/* ==========================================================================
   Header — a nameplate, nothing else.
   ========================================================================== */

/* A tight band, not a hero. In a system this restrained the wordmark is the
   only thing carrying identity, so it holds the top-left corner at 28px —
   large enough to anchor the page, small enough that the band costs ~50px. */
/* No rule under the nameplate, and none under the control bar below it. Three
   stacked full-width lines in the first 120px read as banding; the nameplate,
   the bar and the content are separated by tone and spacing instead. */
.hero {
  padding-block: var(--space-3) var(--space-2);
}

/* The wordmark and the theme switch on one line, the switch hard right. It used
   to sit in the controls bar, where it had a row to itself on a phone — 56px of
   a 219px STICKY bar, above every screen. Here it rides a line that already
   exists.
   `align-items: center` and not `baseline`: the switch is a 44px pill with no
   text in it, so there is no baseline of its own to align to, and asking for one
   drops it below the wordmark. The same trap `.section-head` documents. */
.hero__row {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-4);
}

/* The tagline sits beside the wordmark and takes the space the two fixed items
   leave. `flex: 1 1 auto` with `min-width: 0` rather than a width: the wordmark
   and the switch are what they are, and this reads into whatever is left.
   Muted and small — it explains the mark, it does not compete with it. */
.hero__blurb {
  flex: 1 1 auto; min-width: 0;
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.35;
  color: var(--fg-muted);
  text-wrap: pretty;
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--step-3);
  line-height: 1;
  letter-spacing: -0.025em;
  color: var(--fg);
}

/* The 38 is a PLATE, not coloured type — the one change the new mark keeps.
   It stays on `--accent`, which is where the system already puts it: "the title
   numeral" is one of the four sanctioned accent uses, alongside the active view
   segment, the sorted-column header and focus rings. A red plate was tried and
   taken back out; it needed a fifth palette colour sitting one hue from
   `--zone-rel`, which is a colour that carries meaning.

   A fill on a page whose structure is hairlines is still fine here: that rule
   is about structure, and the nameplate is the one thing allowed to be a mark
   rather than an instrument. */
.wordmark__num {
  color: var(--on-accent);
  background: var(--accent);
  /* Optical, not symmetric: Plex's digits carry more side-bearing than the
     block needs, and equal padding leaves the plate looking loose on the right. */
  padding: 0.06em 0.16em 0.1em 0.14em;
  /* Radius 0 like everything else — the plate is a rectangle, not a badge. */
  border-radius: 0;
  /* Sits the plate on the type's baseline rather than letting it grow the line
     box, which would push the status strip down by its own padding. */
  display: inline-block;
  line-height: 1;
}

/* DARK MODE INVERTS THE PLATE: ground and numerals swap, so the mark reads as
   blue type in a dark box rather than a bright blue slab. On a near-black page
   a full-strength --accent plate is the brightest thing on screen, which is the
   wrong weight for a nameplate sitting above the table.

   THE HAIRLINE IS NOT OPTIONAL. Inverted, the fill is --on-accent (#06142E)
   against a #0B0D0E page — 1.06:1, which is no box at all. Straight inversion
   left the 38 as loose blue type with nothing around it. The 1px inset ring is
   what makes it read as a plate again, and it is the same device the club chip
   uses for a white kit: when a fill can't carry an edge, an outline does.

   Declared twice, deliberately — the media query covers "system", the attribute
   covers an explicit choice, and the toggle has to win in both directions. Same
   pairing tokens.css uses; a rule in only one of the two works for exactly one
   way of reaching dark mode. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .wordmark__num {
    color: var(--accent);
    background: var(--on-accent);
    box-shadow: inset 0 0 0 1px var(--accent);
  }
}
:root[data-theme="dark"] .wordmark__num {
  color: var(--accent);
  background: var(--on-accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

/* No status strip under the nameplate, and no rule. The wordmark is a mark, not
   a readout — the matchweek it would have carried is already on the share card
   descriptor, and the design system's "no rule under the nameplate" note stands:
   stacked full-width lines in the first 120px read as banding. */

/* ==========================================================================
   Control bar — quiet. Structure, not spectacle.
   ========================================================================== */

/* No rule under the bar. It is opaque and sits on `--bg`, which is a clear step
   darker than the `--surface` panels that scroll beneath it — that tonal
   difference is what separates them, and it does the job a line was doing
   without adding a third horizontal stripe to the top of the page.

   The bar is deliberately tight. It measured 68px with intrinsic sizing and no
   wrapping — its own padding was doing that, not its contents — which on a
   phone stacked to 184px, 23% of the screen, for three controls. */
.controls {
  position: sticky; top: 0; z-index: 20;
  background: var(--bg);
}
/* The column gap MUST match `.layout`'s. The first group is `--rail-w` wide, so
   the second one starts at rail + gap — exactly where the table starts, but
   only if the two gaps agree. At --space-5 against the layout's --space-6 the
   VIEW group sat 8px left of the table, which reads as a mistake rather than a
   margin. Same token, one edge. */
/* `flex-end`, not `center`, and the VIEW label going `.sr-only` is what forced
   it. These groups are label-above-control columns of UNEQUAL height now — the
   league still shows its label, the view switcher does not — and centring two
   unequal columns aligns their middles, which left the switcher sitting 11px
   above the select it shares a row with. Aligning the ENDS puts the controls on
   one line, because the control is the last child of every group. That stays
   true if a future group has a taller control or no label at all. */
.controls__inner {
  display: flex; flex-wrap: wrap; align-items: flex-end;
  gap: var(--space-2) var(--space-6);
  padding-block: var(--space-2);
  min-height: 0;
}
/* Label above its control, both left-aligned to the control's edge — the same
   arrangement as FILTERS above the filter box, so the whole page labels a
   region the one way. Inline labels also put a fixed gutter to the left of
   every control, which pushed the select and the segmented group out of line
   with each other. */
.controls__group {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--space-1);
}
.controls__label {
  font-size: var(--step--2); font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.14em; color: var(--fg-muted); white-space: nowrap;
}

/* Reads as a field, like the select beside it: same `--surface` ground, same
   hairline, same height. It was transparent, so it sat on `--bg` while the
   select sat on `--surface` — two controls on one row that looked like
   different kinds of thing. */
/* A TONAL TRACK carrying a light THUMB. It was a bordered strip with the
   selected button filled in the accent; the track/thumb idiom reads better at
   this size and matches the rest of the rounded chrome.

   THE THUMB'S BOUNDARY IS THE HARD PART, and the shadow does NOT get to be the
   answer. Thumb and track are both near-white — measured 1.08:1 — so with
   elevation alone the selected state fails the 3:1 floor this project holds any
   control-identifying outline to. `--fg-muted` is the one token that clears it
   in BOTH themes (5.07 light, 6.14 dark), so the thumb carries a real 1px ring
   and the shadow is free to be decoration. `--rule-strong` was the obvious
   choice and is wrong: 16.9 in light, but 2.13 in dark. */
.segmented {
  position: relative;              /* the thumb positions against this */
  display: flex;
  gap: 2px;
  padding: 3px;
  border: 0;
  border-radius: var(--r-pill);
  /* The panel white in light theme, via `--track`. A track is a step off
     WHATEVER IT SITS ON, and this one sits on the page — so it reads as an
     object ON the page rather than as a slightly paler patch of it. The token
     exists because the two themes take that step in opposite directions; the
     numbers are at its definition in `tokens.css`. `.segmented--rail` sits on a
     panel instead and sets its own ground; see below. */
  background: var(--track);
  box-shadow: inset 0 0 0 var(--border-w) var(--rule);
  height: auto;
}
/* The selection, as one object that MOVES. Each button used to paint its own
   accent, so switching was a light going out here and another coming on there —
   nothing travelled, and the control said nothing about where you had come
   from. Sized and placed by positionThumb(); everything here is presentation. */
.segmented__thumb {
  position: absolute;
  inset-block: 3px;
  inset-inline-start: 0;
  border-radius: var(--r-pill);
  background: var(--accent);
  pointer-events: none;
  transition: transform var(--dur) var(--ease),
              width var(--dur) var(--ease),
              opacity var(--dur-fast) var(--ease);
}
/* The thumb is a sibling of the buttons and comes first in the DOM, so the
   labels need lifting above it — no z-index war, just one step. */
.segmented__btn { position: relative; z-index: 1; }
/* The VIEW switcher's items are <a> now (see index.html), so they arrive with a
   UA underline and, once renderChrome() gives them an href, the visited/link
   colour. The two filter toggles remain <button>. One rule covers both, because
   the component is one component — the only thing that differs is which
   attribute says "this one is chosen". */
.segmented__btn { text-decoration: none; }
.segmented__btn {
  appearance: none; background: transparent; color: var(--fg-muted);
  border: 0;
  border-radius: var(--r-pill);
  padding-block: 0; padding-inline: var(--space-4);
  min-height: calc(var(--control-h) - 6px);
  /* Uppercase, in step with the LEAGUE / VIEW / FILTERS micro-labels and with
     the league selector's own options. `text-transform`, never uppercased DOM
     text: the accessible name stays "Table", which is what a screen reader
     announces and what WCAG 2.5.3 wants a speech-input user to be able to say.
     The tracking is opened to 0.08em because caps set tight are harder to read
     than lowercase set tight. */
  font-size: var(--step--1); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em;
  cursor: pointer; white-space: nowrap;
  display: inline-flex; align-items: center; gap: var(--space-2);
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}

/* The BUTTON is the touch target, not the group around it. Pinning the group to
   --control-h left the buttons 2px short once the group's own border was taken
   off, which is 42px against a 44px floor. Let the group size to its buttons. */
@media (pointer: coarse) {
  .segmented__btn { min-height: 44px; }
}
/* The rail variant. Three equal segments filling the panel, because the labels
   are short and unequal — at the bar's own padding "FULL HOME AWAY" measures
   within a few px of the panel's inner width, so one long league name away from
   clipping. `flex: 1` on the BUTTONS is safe: .segmented is a flex ROW, so it
   grows them inline. Never put it on .segmented itself — that sits in
   .filter-group, which is a flex COLUMN, and the same declaration would stretch
   the control down the rail. Same trap the Movement Play button hit. */
.segmented--rail,
/* THE CARD EDITOR'S FORMAT TOGGLE TAKES THE SAME RULE, and the modifier's name
   is about the GROUND rather than the location. `.card-editor__panel` is the
   `.filters__panel` recipe and is `--surface` too, so the base `--track` would
   paint this track the colour of the panel behind it in exactly the same way.
   Listed here rather than copied into a third near-identical block. */
.card-editor__panel .segmented {
  width: 100%;
  /* NOT `--surface`, and this is the whole reason the ground is a modifier.
     This one lives inside a `.filters__panel`, which is already `--surface` — so
     the panel white here would paint the track the colour of the thing behind
     it and leave a hairline outlining nothing. Same rule as the base, applied to
     a different ground: one step off what it sits on. */
  background: var(--surface-2);
}
.segmented--rail .segmented__btn,
.card-editor__panel .segmented__btn {
  flex: 1; min-width: 0; justify-content: center;
  padding-inline: var(--space-2);
}

/* The `:not([aria-pressed="true"])` is load-bearing, and source order does NOT
   substitute for it. `:hover:not(:disabled)` is (0,3,0) against the pressed
   rule's (0,2,0), so hover won wherever it sat in the file — and it overrides
   only `background`, leaving the selected button's `--on-accent` text on
   `--surface-2`: white on #F3F3F0, 1.05:1. The label of whichever view you were
   on vanished under the pointer, which is the click that just got you there.
   Moving the pressed rule down the file fixes nothing; the state has to be
   excluded. */
/* Hover is stronger than it was: 6% of the foreground was close enough to the
   track to be missable, which on a row of destinations makes the control feel
   dead. 12% reads as a definite response and still sits well below the
   selection, so the two states are never confused. */
.segmented__btn:hover:not(:disabled):not([aria-pressed="true"]):not([aria-current="page"]) {
  background: color-mix(in srgb, var(--fg) 12%, transparent);
  color: var(--fg);
}
/* FILLED IN THE ACCENT. It was a light thumb carrying accent text, which is the
   material idiom — and which put the whole selected state on a plate measuring
   1.08:1 against its own track, so it needed a --fg-muted ring and an elevation
   to be legible at all. An accent fill is far the stronger signal and needs
   neither: --on-accent on --accent is 9.17:1 as text, and the fill itself is
   unmistakable against the track. The ring and the lift come off with it. */
/* THE BUTTON PAINTS ITS OWN ACCENT, and the thumb then takes the job over.
   Written this way round because the thumb is created by initSegmentedThumbs()
   and its width is measured there — so with JavaScript off there is no thumb at
   all, and a `background: transparent` pressed button left --on-accent white
   sitting on the near-white --track at 1.05:1. The selected tab's label, and
   FULL in the venue toggle, were simply not on the screen.

   That went unnoticed while a script-free load was a blank page. It stopped
   being one when the forty pre-rendered pages landed (SEO.md item 2), which is
   what made this worth fixing rather than theoretical.

   The flag is a CLASS that initSegmentedThumbs() adds beside the thumb it
   creates. `.segmented:has(.segmented__thumb)` was tried first and is the
   tidier thing to write — no JS change, the thumb's PRESENCE being the fact
   under test — and it did not work: the selector matches (`el.matches()` says
   so) but the declaration never won the cascade, with or without the thumb in
   the DOM. Measured both ways before giving up on it. A class is one line and
   leaves nothing to interpret.

   The button's radius is the same --r-pill the thumb uses, so the fallback and
   the real thing look alike. */
/* TWO ATTRIBUTES, ONE STATE. The view switcher is links and carries
   aria-current="page" — which page you are on; the venue and completed toggles
   are buttons and carry aria-pressed — which way the switch is thrown. Both are
   correct for what they are, and both mean "this segment is the chosen one", so
   every rule about the chosen segment lists the pair. They are the same
   specificity, (0,2,0), so order between them never matters. If a third state
   ever joins, that is the point to swap all of this for one styling class
   written by setView/setSplit/setCompleted, rather than a third selector. */
.segmented__btn[aria-pressed="true"],
.segmented__btn[aria-current="page"] {
  background: var(--accent);
  color: var(--on-accent);
}
.segmented.is-thumbed .segmented__btn[aria-pressed="true"],
.segmented.is-thumbed .segmented__btn[aria-current="page"] {
  background: transparent;         /* the thumb is what you can see */
}
/* The selected button still answers the pointer, in the accent's own hover shade
   rather than the neutral one. Safe in both themes because `--accent-hover`
   moves AWAY from `--on-accent` in each — darker under white text in light,
   lighter under near-black in dark. */
/* Hovering the SELECTED button tints the thumb, not the button — the button is
   transparent now, and painting it would draw a second slab over the first. */
.segmented:has(.segmented__btn[aria-pressed="true"]:hover) .segmented__thumb,
.segmented:has(.segmented__btn[aria-current="page"]:hover) .segmented__thumb {
  background: var(--accent-hover);
}
/* Where the OS substitutes its own palette, shadows and tints are dropped
   entirely — so the state needs a real border to survive. */
@media (forced-colors: active) {
  .segmented__btn[aria-pressed="true"],
  .segmented__btn[aria-current="page"] { border: 2px solid ButtonText; }
}
/* Disabled controls are exempt from WCAG contrast, but "SOON" is information the
   reader wants — keep it legible and let the cursor + pill carry the disabled state. */
.segmented__btn:disabled { color: var(--fg-muted); cursor: not-allowed; }
/* The LEAGUE selector sits between the LEAGUE label and the VIEW segmented
   control, so it takes their type: 700, tracked, uppercase. It used to be 600,
   untracked and sentence case — the geometry already matched (44px, 2px rule,
   square, same surface) and only the lettering gave it away as a stock control.

   text-transform, not uppercased option text: the accessible name stays the
   real label, which is what a screen reader announces and what WCAG 2.5.3
   wants.

   The OPEN LIST is ours now — see `.select__list` below. It was a native
   <select>, and the popup a native select opens is drawn by the OS: its own
   font, its own rounded corners, its own blue highlight, none of it reachable
   from CSS. Every other surface here obeys the design system and that one
   could not, which is exactly what it looked like. */
/* `--fg`, and it MUST stay the page ink. The open option list is a child of this
   shell, so every option inherits from here — setting `--on-accent` to colour the
   chevron painted white text onto the white panel and made the whole current
   season group invisible. Only the archives survived, because
   `.select__group--past` sets a colour of its own. The chevron gets its colour on
   the pseudo-element instead; see `::after` below. */
.select-shell { position: relative; display: inline-flex; color: var(--fg); }
.select-shell::after {
  content: "";
  position: absolute; inset-inline-end: var(--space-3); inset-block-start: 50%;
  translate: 0 -50%;
  width: 13px; height: 9px;
  /* `--on-accent`, NOT `currentColor`: the chevron sits on the button's accent
     fill, while `currentColor` here is the shell's `--fg` — which the option list
     needs to keep inheriting. Colour the mark, never the container. */
  background: var(--on-accent); pointer-events: none;
  /* Stroked chevron, matching the row expander's icon — the house style is line
     art (see the CHEVRON const in app.js and the share icons). This was a solid
     filled triangle, the only one on the page. */
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 10' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 2.5 8 8l6-5.5'/%3E%3C/svg%3E") center / contain no-repeat;
          mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 10' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 2.5 8 8l6-5.5'/%3E%3C/svg%3E") center / contain no-repeat;
  transition: translate var(--dur-fast) var(--ease), rotate var(--dur) var(--ease);
}
/* Flips while the list is open, exactly as `.btn-columns__chev` does — the one
   disclosure idiom this app has. `rotate` is its own property here, so it
   composes with the centring `translate` instead of overwriting it. */
.select-shell:has(.select[aria-expanded="true"])::after { rotate: 180deg; }
/* Sized to the filter rail below it, so one edge runs down the left of the
   page. `pinSelectWidth()` still sets a min-width from the longest league name;
   it acts as a floor, so a name longer than the rail grows the control rather
   than clipping. Matching the box matters less than reading the league.

   SCOPED to the controls bar, and that is not tidiness. `--rail-w` is the width
   of the rail as a whole; the Fixtures club select sits INSIDE a
   `.filters__panel` in that rail, whose padding and border have already taken
   34px out of it. Unscoped, that control was pinned 34px wider than the box
   holding it and its chevron sat on top of the panel's own border. A width
   measured from a container is only ever right for a control that is a sibling
   of it. */
.controls__group .select-shell { width: var(--rail-w); }
/* In the rail it fills whatever the panel leaves. `width`, not `flex: 1`: the
   group is a flex COLUMN, so `flex` would grow this in the BLOCK direction —
   the trap `.btn-filter` and the segmented control have each hit here. */
.filter-group .select-shell { width: 100%; min-width: 0; }
/* A <button>, so it needs the type and box a <select> gave for free.
   `text-align: start` matters: a button centres its text by default and the
   league name has to sit on the same left edge as the label above it. */
/* FILLED IN THE ACCENT, like the view switcher's selected tab beside it. It was
   a `--surface` field with a hairline, which read as an empty box waiting for
   input rather than as a control already holding a choice — and the league it
   holds is a selection exactly as much as the active tab is.
   That makes it the FIFTH sanctioned use of the accent, after the title numeral,
   the active view segment, the sorted-column header and the focus ring. The
   through-line still holds: every one of them marks the thing currently chosen.
   The border goes to the accent too rather than staying `--rule`, or the fill
   sits inside a grey outline that belongs to the old empty-field look. */
.select {
  appearance: none;
  display: inline-flex; align-items: center;
  width: 100%;
  background: var(--accent); color: var(--on-accent);
  border: var(--border-w) solid var(--accent);
  padding-block: 0;
  padding-inline: var(--space-3) var(--space-7);
  height: var(--control-h);
  font-family: var(--font-ui); font-size: var(--step--1); font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase;
  text-align: start;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
/* `--accent-hover`, the same shade the selected segment answers a pointer with —
   NOT `--surface-2`, which was the old white-field hover and would now flash the
   control back to looking unselected under the cursor. */
.select:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
/* The button never grows past the shell, so a long name truncates rather than
   pushing the VIEW group along. `pinSelectWidth()` normally makes this
   unreachable; it is the backstop for a name longer than the rail. */
.select__value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---- The open list ----
   Same construction as `.col-filter__panel`, the app's other popover: absolute,
   `--surface`, one hairline, z-index 30. Nothing new is invented here. */
.select__list {
  position: absolute; z-index: 30;
  inset-block-start: calc(100% + var(--space-2));
  inset-inline: 0;
  margin: 0; padding: 0; list-style: none;
  border: var(--border-w) solid var(--rule);
  background: var(--surface);
  /* A fifth league would run the list past the fold on a short window. Cap it
     and let it scroll — this is its own scroll container, well away from the
     table's sticky <thead>. */
  max-height: min(60vh, 20rem); overflow-y: auto;
}
/* An author `display` beats the UA [hidden] rule — see the anti-patterns note. */
.select__list[hidden] { display: none; }

.select__option {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: var(--control-h);
  padding-inline: var(--space-3);
  font-size: var(--step--1); font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.select__option + .select__option { border-block-start: var(--border-w) solid var(--rule-soft); }
.select__option:hover { background: var(--surface-2); }

/* ---- Season groups ----
   The list is grouped by season: the leagues being played lead, and the
   archives sit under one "Previous seasons" heading. */
.select__group-list { list-style: none; margin: 0; padding: 0; }
/* The archives are pushed AWAY from the season being played, not merely ruled
   off from it. A hairline alone put the two groups the same distance apart as
   two options within a group, so "Previous seasons" read as one more row rather
   than as the start of somewhere else — and this list exists to keep the
   archives out of the way of the leagues you actually want.

   Space does most of the work and the rule confirms it. The tinted band under
   the heading is the third part: it gives the label a ground of its own, so the
   eye finds the division without having to read the words. */
.select__group + .select__group {
  margin-block-start: var(--space-3);
  border-block-start: var(--border-w) solid var(--rule);
}
.select__group + .select__group .select__group-label {
  background: var(--surface-2);
  padding-block: var(--space-2);
}

/* Reads as a label, not an option: the micro-label idiom used for LEAGUE and
   VIEW above the controls, so it can't be mistaken for something clickable. */
.select__group-label {
  display: block;
  padding: var(--space-2) var(--space-3) var(--space-1);
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--fg-muted);
}
/* No indent, and that is the point of grouping by season: every option is a
   whole league on one left edge, so the list reads as one column broken by a
   heading rather than as two levels. It WAS indented, when an option was a bare
   season sitting under its competition's name. Indenting now would also split
   the two groups onto different left edges, since the current one draws no
   heading to indent from. */

/* Archived seasons are muted, so the eye lands on the season being played first.
   Colour is NOT carrying this on its own — the PREVIOUS SEASONS heading says it
   and every option here ends in its own season — which is what lets the shade
   be a reinforcement rather than the message.

   `:not([aria-selected="true"])` is load-bearing. A selected option is filled
   with `--accent` and its text knocked out in `--on-accent`; muting that would
   put grey on the accent fill, which is both unreadable and a lie about which
   league is loaded. The archive is exactly where this matters, since selecting
   one is how you get there at all. */
.select__group--past .select__option:not([aria-selected="true"]) { color: var(--fg-muted); }

/* Selection is the accent fill the Gameweek grid uses for a checked number, and
   the check icon beside it means colour is not carrying it alone. */
.select__option[aria-selected="true"] {
  background: var(--accent); color: var(--on-accent); font-weight: 700;
}
/* `visibility`, not `display`: the unselected options keep the tick's width, so
   every label starts on the same left edge and the list doesn't jog sideways as
   the selection moves. */
.select__check { flex: none; display: block; width: 14px; height: 14px; visibility: hidden; }
.select__check svg { display: block; width: 100%; height: 100%; }
.select__option[aria-selected="true"] .select__check { visibility: visible; }

/* The keyboard cursor. Focus stays on the button (that is the whole point of
   aria-activedescendant), so the active option cannot use :focus-visible and
   needs its own mark. An inset ring reads on the plain ground AND on the accent
   fill, which a background tint would not — on the selected row a tint is
   invisible against `--accent`. It flips to `--on-accent` there for the same
   reason: an accent ring on an accent fill is nothing at all. */
.select__option.is-active { box-shadow: inset 0 0 0 2px var(--focus); }
.select__option[aria-selected="true"].is-active { box-shadow: inset 0 0 0 2px var(--on-accent); }

/* Disabled while a league loads. Without this the browser paints its own grey,
   which reads as broken rather than busy. Muted text + the wait cursor say it
   plainly, and the border stays put so the control doesn't appear to move. */
/* Disabled has to come off the accent entirely. `--fg-muted` was legible on the
   old white field and is not on a blue one — and a control mid-load should not
   still look like a live selection. It drops back to the surface it used to be,
   which reads as "busy" rather than "chosen". */
.select:disabled {
  background: var(--surface-2); border-color: var(--rule);
  color: var(--fg-muted); cursor: wait;
}
.select-shell:has(.select:disabled) { color: var(--fg-muted); }
.select-shell:has(.select:disabled)::after { background: var(--fg-muted); }

/* Theme control is a switch, not another outlined square — it changes a setting
   rather than performing an action, and should not read like the select beside it. */
.theme-switch {
  appearance: none; background: none; border: 0; padding: 0;
  min-height: var(--control-h);
  display: inline-flex; align-items: center; cursor: pointer;
}
/* The same track-and-thumb the segmented controls use, so the switch and the two
   controls in the bar below it read as one family: a tonal track with an inset
   hairline, and the selection filled in the accent. It was a square --surface box
   with a square knob, which was the only hard-cornered control on the page. */
.theme-switch__track {
  position: relative;
  display: inline-flex; align-items: center;
  padding: 3px;
  border: 0;
  border-radius: var(--r-pill);
  /* `--track`, the same ground the view switcher takes. They no longer sit side by
     side — this one moved up to the header — but both sit on the PAGE, and the
     page is what the token is measured against: one step off `--bg`, in whichever
     direction the theme needs. So the move changed this control's neighbour, not
     its ground, and the token needed nothing. See `--track` in tokens.css. */
  background: var(--track);
  box-shadow: inset 0 0 0 var(--border-w) var(--rule);
}
.theme-switch__knob {
  position: absolute; inset-block: 3px; inset-inline-start: 3px;
  /* SQUARE, so `--r-pill` resolves to a CIRCLE rather than a capsule. It was
     32x28, which is pill-shaped but not round, and it read as a different
     treatment from the COMPLETED FIXTURES eye — the page's other switch, and a
     true circle. The width is what gave: `inset-block: 3px` ties the height to
     the track, so shrinking to 28 squares the knob without touching a single
     vertical metric in the header. The control narrows 70px -> 62px; nothing
     stacks against it, since `.hero__row` right-aligns the whole thing. */
  width: 28px;
  border-radius: var(--r-pill);
  background: var(--accent);
  transition: translate var(--dur) var(--ease), background var(--dur-fast) var(--ease);
}
/* MOVES BY ITS OWN WIDTH, and this has to change with it — the knob travels
   exactly one half, so the two numbers are the same number. Left at 32 it
   overshot into the track's padding and clipped the moon. */
.theme-switch[aria-checked="true"] .theme-switch__knob { translate: 28px 0; }
/* The knob does NOT respond to hover, and that is the fix rather than an
   omission. This is ONE button — a role="switch" wrapping both halves — so
   `:hover` fires wherever the pointer is inside it, and tinting the knob as
   well meant pointing at the moon lit up the sun. Both halves reacted to a
   gesture that can only concern one of them.

   Only the INACTIVE half responds now, which is also the honest affordance:
   this control does one thing, and that thing is "go to the other mode". True
   per-half hover would need two buttons, which would trade the switch role for
   a pair — not worth it for a two-state control. */
/* The INACTIVE half answers the pointer, exactly as an unselected segmented
   button does — same 12% tint, same pill. Without it the only hover response
   was a slight shift in the knob's blue, which is easy to miss and left the
   half you are actually reaching for looking dead.

   WHICH half is inactive depends on the state, so it takes both rules. They
   must not be collapsed into `.theme-switch:hover .theme-switch__icon`: that
   would also repaint the knocked-out icon sitting on the knob, and being later
   in the file at equal specificity it would win.

   THE `:hover` IS ON THE HALF, NOT ON THE BUTTON, and that distinction is the
   whole rule. `.theme-switch:hover .theme-switch__icon--moon` reads as "the
   moon, while the control is hovered" — and the control is ONE button around
   both halves, so pointing at the SUN lit the moon up. Two halves reacting to a
   gesture that was aimed at one of them, which is what a switch must never do.
   `.theme-switch__icon--moon:hover` is the half itself, and the spans are real
   28x28 boxes in the layout, so the pointer resolves to exactly one of them. */
.theme-switch[aria-checked="false"] .theme-switch__icon--moon:hover,
.theme-switch[aria-checked="true"]  .theme-switch__icon--sun:hover {
  background: color-mix(in srgb, var(--fg) 12%, transparent);
  color: var(--fg);
}

.theme-switch__icon {
  position: relative; z-index: 1;
  /* Square, matching the knob — the hover tint takes the knob's shape, so a
     capsule tint under a circular knob would be two shapes for one control. */
  width: 28px; height: 28px;
  display: grid; place-items: center;
  border-radius: var(--r-pill);    /* so the hover tint is the knob's shape */
  color: var(--fg-muted);
  transition: color var(--dur) var(--ease), background var(--dur-fast) var(--ease);
}
.theme-switch__icon svg { width: 16px; height: 16px; }
.theme-switch[aria-checked="false"] .theme-switch__icon--sun,
.theme-switch[aria-checked="true"]  .theme-switch__icon--moon { color: var(--on-accent); }

@media (prefers-reduced-motion: reduce) {
  .segmented__thumb,
  .theme-switch__knob,
  .filters__chev,
  .more-btn svg { transition: none; }
}

/* ==========================================================================
   Table
   ========================================================================== */

main { padding-block: var(--space-4); }
/* Centred on a fixed-height band rather than baseline-aligned: baseline made the
   heading's position depend on whichever buttons happened to sit beside it, which
   is why the rail head and the table head drifted apart. */
.section-head {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--space-3); min-height: var(--head-h); margin-bottom: var(--space-2);
}
/* The table's heading. Sentence case, at body size — it names the table, it is
   not a banner. The rail's heading is deliberately NOT this size; see
   `.filters__heading`, which is the fix for the two competing for first read. */
.section-head h2 { font-size: var(--step-0); text-transform: none; letter-spacing: -0.01em; }
/* The title takes the slack, which is what pushes everything else to the right
   edge — the same edge the table ends on. */
.section-head__title { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap;
  flex: 1 1 auto; min-width: 0; }

/* SHARE IS LAST IN THE BAND, and `order` is what puts it there. It used to sit
   inside `.section-head__title` beside the heading, so it floated wherever the
   heading's length left it. Lifted out, it is a sibling of COLUMNS — and DOM
   order would put it BEFORE, leaving Columns on the table's right edge instead.
   `order: 1` sorts it after, so the accent action ends flush with the table on
   every view, with or without a Columns button beside it.
   Below 1000px Columns pairs off with FILTERS and Share is the only thing in the
   band, which lands on the same right edge without any of this mattering.

   `margin-inline-start: auto` IS NOT REDUNDANT WITH `space-between`, and this is
   the third place in this file to learn it — `.share-pair` and
   `.site-footer__row` both carry the same line for the same reason. The parent
   only pushes items apart while they SHARE a line; the moment the band wraps,
   the Share is alone on the second one, `space-between` has nothing to push it
   against, and it falls hard LEFT. Measured on the table at 360px before the
   fix: right edge 122.3 against the band's 344, so the accent action sat 221.7px
   short of the edge every other Share on the site ends on.

   The band wraps below 363px because the heading is long — it needs 446.3px and
   has 328 — and that wrap is an accepted cost recorded elsewhere. Where the
   button LANDS once it has wrapped is not, and this is that.

   It costs nothing on a single line: `.section-head__title` is `flex: 1 1 auto`
   and absorbs the free space, so the auto margin resolves to 0 and every width
   above the wrap is untouched. */
.section-head > .btn-share { order: 1; margin-inline-start: auto; }

/* ---- Column visibility popover ---- */

.col-filter { position: relative; }

.btn-columns {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--head-h); padding-inline: var(--space-4);
  border: var(--border-w) solid var(--rule);
  background: transparent; color: var(--fg);
  font-size: var(--step--1); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  white-space: nowrap; cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
.btn-columns:hover, .btn-columns[aria-expanded="true"] {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
.btn-columns__count { font-variant-numeric: tabular-nums; opacity: 0.75; }
.btn-columns__chev { width: 14px; height: 14px; transition: rotate var(--dur) var(--ease); }
.btn-columns[aria-expanded="true"] .btn-columns__chev { rotate: 180deg; }

.col-filter__panel {
  position: absolute; z-index: 30;
  inset-block-start: calc(100% + var(--space-2));
  inset-inline-end: 0;
  min-width: 15rem;
  padding: var(--space-4);
  border: var(--border-w) solid var(--rule);
  background: var(--surface);
}
/* An author `display` beats the UA [hidden] rule — see the anti-patterns note. */
.col-filter__panel[hidden] { display: none; }

.filter-group--bare { border: 0; background: none; padding: 0; }
.col-filter__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* Uppercased to match the column headers these switch on and off — the table
   shows PL and PTS, so the filter has to say PL and PTS. */
.col-filter__list .filter-item__name { text-transform: uppercase; }

.btn-share {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--head-h); padding-inline: var(--space-4);
  /* Accent, not --fg — and this is the one of the two head buttons that gets it.
     Share ACTS on the view; Columns describes it, and giving both the accent
     would leave neither leading. In the page's ink Share read as a label beside
     the heading. The icon is stroked in `currentColor`, so it lifts with the
     text. The ground stays transparent: hover is what fills, and a filled
     resting state would compete with the segmented control's selected view.
     Measured as TEXT on the page ground — 5.91 light, 7.92 dark — clearing 4.5
     as type and 3.0 as the border that identifies the control. */
  border: var(--border-w) solid var(--accent);
  background: transparent; color: var(--accent);
  font-size: var(--step--1); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  white-space: nowrap; cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
/* An author `display` beats the UA's [hidden] rule — the trap this file has hit
   five times now. Paired at the point of definition: Fixtures and Movement hide
   their Share button when there is nothing to put on a card. */
.btn-share[hidden] { display: none; }
.btn-share svg { width: 16px; height: 16px; flex: none; }

/* Two formats of one action, held together. `margin-inline-start: auto` rather
   than relying on the parent's `space-between`: on a line the pair shares with
   the heading the two agree, and on a line of its own — which is what happens
   below 720px — `space-between` has nothing to push against and would strand it
   left. The same margin the fixtures toggle uses, for the same reason. */
.share-pair {
  display: flex; align-items: center; gap: var(--space-2);
  margin-inline-start: auto; flex: none;
}

/* The gameweek movement pane's Share, beside a heading that is a MICRO-LABEL and
   not a page heading. At full size a 700-weight accent SHARE next to an 11px
   muted label is the loudest thing in a ~470px pane, and it is not what the pane
   is for — the chart is. So it gives up horizontal padding and a step of type
   and keeps everything that identifies it: the accent ink, the edge, the icon
   and the hover fill.

   `min-height` IS NOT REDUCED AND MUST NOT BE. `--head-h` is 30px on a pointer
   and 44px under `pointer: coarse`, so inheriting it is the whole of what makes
   this a legal touch target. Trimming it to look right on a desktop takes the
   coarse case with it — the blanket-44 anti-pattern, from the other direction. */
.btn-share--sm {
  padding-inline: var(--space-2); gap: var(--space-1);
  font-size: var(--step--2); letter-spacing: 0.06em;
}
.btn-share--sm svg { width: 14px; height: 14px; }

/* The pane's head is the heading and its Share, the button on the pane's right
   edge — `.section-head`'s own `space-between` does that and needs no rule here.
   What it does need is for the heading to be allowed to SHRINK: it is a flex
   item that would otherwise refuse to go below its content and push the button
   onto a second line, which is the `minmax(0, 1fr)` trap in its flexbox form. */
.gwm .section-head .gwm__heading { flex: 1 1 auto; min-width: 0; }
.gwm .section-head .btn-share { flex: none; }
.btn-share:hover:not(:disabled) {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
.btn-share:disabled { color: var(--fg-muted); border-color: var(--rule-soft); cursor: progress; }

/* ==========================================================================
   Layout — filter rail + view
   ========================================================================== */

.layout {
  display: grid;
  grid-template-columns: var(--rail-w) minmax(0, 1fr);
  gap: var(--space-6);
  align-items: start;
  /* CONTAINS THE VIEW TRANSITION. The incoming view starts at translateX(18px),
     and a transform still contributes to scrollable overflow even though it does
     not affect layout — so every forward switch pushed the document 2px wider
     than the viewport and flashed a horizontal scrollbar. Measured: 1280 -> 1282
     mid-animation.

     `clip`, not `hidden`, and the y axis stays visible: `hidden` would make this
     a scroll container and take the sticky filters rail and the sticky <thead>
     down with it — the same trap the table tile hit. `clip` on one axis with
     `visible` on the other is legal precisely because it creates no scroll port.

     This is NOT the `overflow-x: hidden on body` anti-pattern: that one hides a
     layout bug, this contains a transform that is meant to start off-screen. */
  overflow-x: clip;
  overflow-y: visible;
}
/* An author `display` beats the UA [hidden] rule — see the anti-patterns note. */
.layout[hidden] { display: none; }
/* Every view is a grid child in column 2, and exactly one is shown at a time.
   The hidden ones are display:none so they take no track. Paired explicitly
   because anything that later gives .view an author `display` would otherwise
   un-hide all four at once — the trap two rows of the anti-patterns table. */
.view { min-width: 0; }
.view[hidden] { display: none; }

/* ==========================================================================
   Share preview — a distinct screen, not a modal. Pushes history so Back works.
   ========================================================================== */

.share-view[hidden] { display: none; }

.btn-back {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--control-h); margin-block-end: var(--space-4);
  padding: 0; border: 0; background: none; color: var(--fg-muted);
  font-size: var(--step--1); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer;
}
.btn-back svg { width: 16px; height: 16px; }
.btn-back:hover { color: var(--accent); }

/* Editor left, preview right — the half that was held empty for exactly this. The
   preview keeps its 620px so the card stays the size it was, and the editor takes
   the remainder rather than a fixed width. */
/* PREVIEW LEFT, editor right — and the preview takes the flexible track now.
   It was the other way round, with the editor flexible and the image pinned at
   620px: the thing being made was the smaller half of a screen that exists to
   look at it. The editor is a column of short controls and does not need width,
   so it takes a fixed 340px and the image gets everything else.

   DOM order was swapped with it rather than reordered in CSS, so reading and tab
   order still match what is on screen. */
.share-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
  gap: var(--space-6);
  align-items: start;
}
.share-primary { min-width: 0; }

/* ---- The editor ----
   Sticky, because a 20-row card makes the page taller than the viewport and the
   controls have to stay level with the thing they're changing.

   Built as the filter rail is built, and for the same reasons: the BOX is on the
   panel, not on the column, so the CUSTOMISE label stays in the shared `--head-h`
   band and the panel's top edge starts level with `.share-frame` opposite it. */
.card-editor {
  position: sticky;
  top: var(--space-4);
  min-width: 0;
  display: flex; flex-direction: column;
}
/* The `.filters__panel` recipe. Both columns of this screen are then defined
   objects, which is the balance the table screen has and this one didn't — an
   unboxed form beside a boxed image read as a stray fragment of the page. */
.card-editor__panel {
  display: flex; flex-direction: column;
  gap: var(--space-4);
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  box-shadow: var(--shadow);
  background: var(--surface);
  padding: var(--space-4);
}
.card-editor__heading {
  margin: 0;
  font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--fg-muted);
}
/* No margin rule here for the groups: they already carry `.filter-group`, whose
   `.filter-group + .filter-group` rule separates them exactly as it does in the
   rail. A `.card-editor__group` margin used to sit here and never applied —
   `.filter-group { margin: 0 }` is declared later at equal specificity and won. */

.field__label {
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3);
  margin-block-end: var(--space-2);
  font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg);
}
.field__value { font-variant-numeric: tabular-nums; color: var(--fg-muted); }

/* Text and its colour on one line — the swatch reads as part of the field it
   colours rather than as a separate setting further down the panel. */
.field__row { display: flex; gap: var(--space-2); align-items: stretch; }
.field__row .field__input { flex: 1; min-width: 0; }

.field__input {
  width: 100%; min-height: var(--control-h);
  /* No block padding: an input centres its own text, so `--control-h` alone gives
     the exact control height. With padding it came out 3px taller than the colour
     swatch beside it, and `align-self: stretch` can't fix that — a colour input is
     a replaced element and keeps its intrinsic height. */
  padding: 0 var(--space-3);
  border: var(--border-w) solid var(--rule);
  background: var(--surface); color: var(--fg);
  font-family: var(--font-ui); font-size: var(--step--1);
}
.field__input::placeholder { color: var(--fg-faint); }

/* A colour input paints its own swatch with a chunk of built-in padding in every
   engine. Strip the padding so the square fills the control, and keep the frame on
   the outside so it matches the text field beside it. */
.field__colour {
  flex: none;
  width: var(--control-h); min-height: var(--control-h); padding: 3px;
  border: var(--border-w) solid var(--rule);
  background: var(--surface);
  cursor: pointer;
}
.field__colour::-webkit-color-swatch-wrapper { padding: 0; }
.field__colour::-webkit-color-swatch { border: 1px solid var(--rule-soft); }
.field__colour::-moz-color-swatch { border: 1px solid var(--rule-soft); }

/* Only the two UPLOAD hints survive, and they start empty — they exist to report
   which file was picked and to carry a failed read, not to explain the control.
   The explanatory hints under Title, Subtitle and Table opacity are gone: the
   labels say what those do, and five paragraphs of prose made a five-control
   panel read as documentation.

   `:empty` is load-bearing. A hint with no text still takes its margin, which
   left a gap under the two upload buttons exactly where the copy used to be. */
.field__hint {
  margin-block-start: var(--space-2);
  font-size: var(--step--2); color: var(--fg-muted);
}
.field__hint:empty { display: none; }

/* The file input is `.sr-only` inside this label, so the label IS the button — one
   control, natively keyboard-operable, with no click forwarding to fake. */
/* `.filter-group` is a flex column, so this is a flex item and `.btn-filter`'s
   `flex: 1` would grow it in the BLOCK direction. Pin the flex and let the group's
   `align-items: stretch` give it full width — which matches the text inputs above
   it and is what a control panel wants anyway. */
.field__file {
  flex: none;
  display: flex; align-items: center; justify-content: center; gap: var(--space-2);
  min-height: var(--control-h); padding-inline: var(--space-4);
  cursor: pointer;
}
.field__file svg { width: 16px; height: 16px; flex: none; }
/* `:focus-within`, because focus lands on the hidden input rather than the label. */
.field__file:focus-within { outline: 3px solid var(--focus); outline-offset: 2px; }
/* No min-height: it is a `.btn-filter`, so it takes that class's 34px and matches
   the upload button above it and every button in the filter rail. The 36px it used
   to carry was a one-off that matched nothing. */
.field__clear { flex: none; margin-block-start: var(--space-2); }
.field__clear[hidden] { display: none; }

/* ---- Range ----
   Styled explicitly per engine: a default range is a different height and colour in
   each, and the thumb has to clear 3:1 against the track like any other control
   whose outline is what identifies it. */
.field__range {
  appearance: none; -webkit-appearance: none;
  width: 100%; height: 44px;            /* the touch target, not the visible bar */
  background: transparent; cursor: pointer;
}
.field__range::-webkit-slider-runnable-track {
  height: 6px; background: var(--rule-soft); border: 1px solid var(--rule);
}
.field__range::-moz-range-track {
  height: 6px; background: var(--rule-soft); border: 1px solid var(--rule);
}
.field__range::-webkit-slider-thumb {
  appearance: none; -webkit-appearance: none;
  width: 20px; height: 20px; margin-top: -8px;   /* centres on a 6px track */
  background: var(--accent); border: 2px solid var(--surface);
}
.field__range::-moz-range-thumb {
  width: 20px; height: 20px; border-radius: 0;
  background: var(--accent); border: 2px solid var(--surface);
}
.field__range:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }

/* A BARE MOUNT — no padding, no border, no radius, no ground of its own.
   The card IS the object on this screen, and it is already a rectangle with a
   shadow under it; a padded `--surface-2` tile around it drew a second edge
   24px further out and the screen read as a box inside a box.

   What the tile was for is now the shadow's job alone, which is why the one on
   the canvas had to become a TOKEN. It was `0 2px 16px rgb(0 0 0 / 0.15)`, and
   a black shadow is invisible on a near-black ground — the card's own ground is
   `--bg`, the same colour as the page behind it, so in dark theme its edges
   would simply have stopped existing once the tile came off. `--shadow-lift`
   leads with a light hairline in dark for exactly this reason.

   The mount still sizes the image: it fills its column and the canvas fills it,
   so the displayed width is the same whichever league or filter is loaded.
   Height follows the row count — a long table makes a taller page, not a
   smaller card. */
.share-frame {
  display: block;
}
/* The canvas is at 2x its display size, so `width: 100%` scales it down and it
   stays sharp — and `height: auto` keeps the intrinsic ratio as the row count and
   the safe-shape padding change it.

   NO `border-radius` here, however much the rest of the page rounds its tiles.
   The exported PNG has square corners, and rounding the preview alone would
   make this screen show something the downloaded file isn't. */
.share-frame canvas {
  display: block;
  width: 100%; height: auto;
  box-shadow: var(--shadow-lift);
}

/* ---- Actions ----
   Above the image, not below: a 20-row card is taller than the viewport, and buttons
   under it would sit off the bottom of the screen on arrival. */
/* A head band, not a free-floating row: same `--head-h` and same bottom margin as
   `.section-head`, so it sits level with the CUSTOMISE label opposite and the two
   columns below it start at the same y. */
.share-actions {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3);
  min-height: var(--head-h);
  margin-block-end: var(--space-2);
}
/* `.btn-filter` carries `flex: 1` for the Select all / Clear all pair, which here
   would stretch a lone Download button across the column. Size to the label. */
/* `--head-h`, not `--control-h`: these live in a head band, so they take the same
   height as `.btn-share` and `.btn-columns` do in the band on the table screen. */
/* The same treatment `.btn-share` gets in the head band on every other screen:
   accent border, accent text, transparent ground, and the fill arriving on
   hover. These three ARE the share button, one step further in — they should
   not look like a different family of control. */
.share-actions .btn-filter {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--head-h); padding-inline: var(--space-4);
  border: var(--border-w) solid var(--accent);
  background: transparent; color: var(--accent);
  font-size: var(--step--1); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
.share-actions .btn-filter:hover:not(:disabled) {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
.share-actions .btn-filter svg { width: 16px; height: 16px; flex: none; }
/* Encoding the PNG takes ~1.1s. `:disabled` already mutes the button; this says
   "working", not "unavailable". Never swap the label — it would delete the icon. */
.share-actions .btn-filter.is-busy { cursor: progress; }
/* The `display: inline-flex` above is an author display, and it beats the UA
   [hidden] rule — Share is `hidden` wherever the share sheet doesn't exist, and
   without this it showed anyway, next to a note saying the browser has no sheet. */
.share-actions .btn-filter[hidden] { display: none; }
/* `.btn-filter--primary` used to fill Share in the accent so it led the row.
   With all three drawn as `.btn-share` there is no lead: they are three ways to
   take the same image away, and Share is already the leftmost. The modifier is
   still on the button in the markup and now simply adds nothing — kept so the
   class can be given a meaning again without touching index.html. */

/* The only copy on this screen, and the sole carrier of the safe-shape guarantee
   now that the platform tiles are gone. It sits UNDER the image, as the zone
   legend sits under the table — it explains the artefact above it, and keeping it
   out of the action band is what lets that band be one row tall and stay level
   with the editor's label. The buttons stay above the image for their own reason:
   a long card would push them off the bottom of the screen. */
.share-note {
  /* Explicit both ways: a <p> carries a 1em default margin, and the block-end one
     would reopen a gap under the screen's last element. */
  margin: var(--space-3) 0 0;
  font-size: var(--step--2); color: var(--fg-muted);
  max-width: 58ch;
}
/* The note is EMPTY on the ordinary path — a plain image in a browser with a
   share sheet has nothing to say now the padding disclaimer has gone. Without
   this it still occupies its top margin and leaves a gap under the card. Same
   rule, and the same reason, as `.field__hint:empty` in the editor beside it. */
.share-note:empty { display: none; }

/* ==========================================================================
   SCROLLBARS
   The default is a thick grey slab that belongs to no design. These are the
   four scroll ports in the app; they get a thin, rounded thumb in the ACCENT at
   45% — present enough to find, transparent enough not to compete with the data
   it sits beside — going to 70% on hover. The accent carries it rather than a
   neutral, so the bar belongs to the design instead of merely not clashing with
   it, and one value serves both themes.

   Firefox takes `scrollbar-width`/`scrollbar-color`; WebKit and Blink take the
   pseudo-elements. Both are declared, and neither is required for the page to
   work — a browser that supports neither simply keeps its own bar.
   ========================================================================== */
.filters__stack,
.select__list,
.cmp-wrap,
.gwm__frame {
  scrollbar-width: thin;
  scrollbar-color: color-mix(in srgb, var(--accent) 45%, transparent) transparent;
}
.filters__stack::-webkit-scrollbar,
.select__list::-webkit-scrollbar,
.cmp-wrap::-webkit-scrollbar,
.gwm__frame::-webkit-scrollbar { width: 8px; height: 8px; }
.filters__stack::-webkit-scrollbar-track,
.select__list::-webkit-scrollbar-track,
.cmp-wrap::-webkit-scrollbar-track,
.gwm__frame::-webkit-scrollbar-track { background: transparent; }
.filters__stack::-webkit-scrollbar-thumb,
.select__list::-webkit-scrollbar-thumb,
.cmp-wrap::-webkit-scrollbar-thumb,
.gwm__frame::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: var(--r-pill);
  /* Inset from the track by a transparent border, so the thumb reads as
     floating in the gutter rather than filling it.
     
     THE INSET IS ALSO HOW THE BAR IS THINNED, and the track width is left
     alone on purpose. Painted thickness is `width - 2 * border`, so 3px here
     draws a 2px bar where 2px drew 4px — but a transparent border is still part
     of the thumb for hit-testing, so the thing you can grab stays the full 8px.
     Narrowing the track instead would have taken the drag target with it, which
     is the wrong half to spend: at this size the bar is read far more often than
     it is dragged, and a 2px drag target is not one. */
  border: 3px solid transparent;
  background-clip: padding-box;
}
.filters__stack::-webkit-scrollbar-thumb:hover,
.select__list::-webkit-scrollbar-thumb:hover,
.cmp-wrap::-webkit-scrollbar-thumb:hover,
.gwm__frame::-webkit-scrollbar-thumb:hover {
  background: color-mix(in srgb, var(--accent) 70%, transparent);
  background-clip: padding-box;
}

/* ==========================================================================
   ROUNDED CHROME
   One place for the radius the rest of the controls take, so the scale is
   legible rather than scattered through the file.
   ========================================================================== */
.select,
.btn-filter,
.btn-share,
.btn-columns,
.date-input,
.gw-grid button,
/* Missed when the radius scale landed: the pager was the one button pair on the
   page still drawn square, a few centimetres under a rounded VIEW switcher and
   beside a rounded COLUMNS. */
.pager__btn,
/* NOT --r-ctl. The box is ~20px, so a 10px radius is half its width and it
   renders as a circle — which erases the one thing its shape is for: a circle
   means "one of these", a square means "any of these", and the rail puts the
   two within a few centimetres of each other. 6px keeps it legibly square. */
.filter-box { border-radius: 6px; }
.filter-box--radio { border-radius: 50%; }
/* THE LEAGUE LIST MUST NOT BE GIVEN AN `overflow` HERE. It sets
   `overflow-y: auto` and a max-height of its own so ten leagues can scroll, and
   the shorthand above overrode both axes — the list simply stopped scrolling and
   the archives became unreachable by wheel. `border-radius` clips a scroll
   container's contents on its own, so there was nothing to gain either.

   Third time an overflow shorthand has broken something in this file: the sticky
   thead, the view transition, and now this. The rule is that `overflow` is never
   incidental — if a rule sets it, that is the rule's main effect.

   The columns panel is a short fixed list that never scrolls, so it can clip. */
.select__list,
.col-filter__panel {
  border-radius: var(--r-ctl);
  box-shadow: var(--shadow-lift);
}
.col-filter__panel { overflow: hidden; }

/* ==========================================================================
   Filters
   ========================================================================== */

/* The rail is a column; the BOX is on the panel, not on the whole rail.
   ----------------------------------------------------------------------
   Two things depend on that. The FILTERS heading has to stay in the shared
   `--head-h` band so it sits level with the table's heading — boxing the whole
   rail pushed it down by the box's own padding and broke that alignment. And
   the panel then starts exactly where `.table-wrap` starts, because both are
   preceded by a section head of the same height and margin, so the box's top
   edge lines up with the top of the table.

   It stays open at desktop widths on purpose: filtering is most of this table's
   value, and a collapsed drawer states nothing about what the product can do. */
.filters {
  position: sticky;
  top: calc(var(--controls-h, 44px) + var(--space-3));
  max-height: calc(100vh - var(--controls-h, 44px) - var(--space-5));
  display: flex; flex-direction: column;
  overflow: hidden;
}
/* THE STACK scrolls, not each box — that scales as filter types are added,
   where per-box scrolling would give several tiny scroll areas. It draws
   nothing itself: the boxes inside it are the visible objects, and the space
   between them is what separates one filter type from the next. */
.filters__stack {
  display: flex; flex-direction: column;
  min-height: 0; overflow-y: auto;
  gap: var(--space-4);
  /* Content was being guillotined at the scroll port's edge — a hard cut through
     whatever row happened to be there, which is the one thing that reads as
     broken rather than scrollable. It fades instead, at BOTH ends.

     Each end fades only when there is something in that direction to scroll to,
     which is the whole point: a permanent bottom fade dims the last box once you
     have reached it, telling you there is more when there is not. The two
     lengths are variables so the mask itself never changes — at 0 the gradient
     collapses to a flat #000 and nothing is dimmed. initRailFade() sets the
     classes from the scroll position. */
  --fade-top: 0px;
  --fade-bottom: 0px;
  -webkit-mask-image: linear-gradient(to bottom,
    transparent 0, #000 var(--fade-top),
    #000 calc(100% - var(--fade-bottom)), transparent 100%);
          mask-image: linear-gradient(to bottom,
    transparent 0, #000 var(--fade-top),
    #000 calc(100% - var(--fade-bottom)), transparent 100%);
}

.filters__stack.is-fade-top { --fade-top: 28px; }
.filters__stack.is-fade-bottom { --fade-bottom: 28px; }

/* The box. Each filter TYPE is one, and they stack as siblings. The border sits
   here rather than on `.filters` so the first box's top edge starts level with
   the table, with the FILTERS label outside it above.

   Clubs and Form are separate boxes because they are different kinds of
   control: clubs are the same question on every view and the selection carries
   between them, where Form only describes the table's own figures. */
.filters__panel {
  display: flex; flex-direction: column;
  gap: var(--space-4);
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: var(--space-4);
}
/* Paired because of the author `display: flex` above — the FORM box is hidden
   outright on the three views it can't describe, and without this it would
   render anyway. Same trap as `.filter-group[hidden]` twenty lines down. */
.filters__panel[hidden] { display: none; }

/* One group per filter type, separated by a rule rather than boxed. Nothing is
   drawn that isn't separating two things — a border all the way round a group
   that already sits inside a bordered column draws three edges too many. */
/* The author `display: flex` below beats the UA `[hidden]` rule, so hiding the
   FORM group on non-table views did nothing until this pairing existed — it
   reported hidden === true and rendered anyway. Third time this trap has been
   hit in this file; see the anti-patterns table. */
.filter-group[hidden] { display: none; }

.filter-group {
  display: flex; flex-direction: column;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
}
/* Space separates them, not a line — in the filter rail AND in the share
   editor, which is why this is back to the bare selector rather than one rule
   per panel. Both panels are already bordered columns and every group in them
   carries its own label (FORM, CLUBS; TITLE, LOGO, BACKDROP), so a hairline
   between them was a third thing drawing a boundary the label and the gap had
   each already stated. "Nothing is drawn that isn't separating two things"
   cuts against these rules, not for them.

   The padding now does the whole job, so don't trim it back to what looked
   right beside a border: it is the only thing holding the groups apart. */
.filter-group + .filter-group { padding-block-start: var(--space-4); }

/* Two columns of 3-letter codes: 20 clubs fit in 10 rows, so the rail no longer
   runs past the fold and nothing gets clipped at the bottom. */
.filter-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: var(--space-3);
  row-gap: 2px;
}
/* A sub-section inside a filter group — e.g. "Results from" within Form.
   Its own FORM SINCE label already announces the break, so the hairline was the
   second thing saying it. Same reasoning as the group rule above. */
.filter-sub {
  margin-block-start: var(--space-4);
  padding-block-start: var(--space-3);
}
.filter-sub__label {
  margin-block-end: var(--space-2);
  font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--fg-muted);
}

/* The control revealed by the "Results from" choice. */
.filter-control { margin-block-start: var(--space-3); }
.filter-control[hidden] { display: none; }

/* Gameweek picker: 38 numbers read faster as a grid than as a 38-item dropdown. */
.gw-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
  gap: 4px;
}
.gw { position: relative; display: block; }
.gw input {
  position: absolute; width: 1px; height: 1px;
  opacity: 0; margin: 0; pointer-events: none;
}
.gw__num {
  display: grid; place-items: center;
  min-height: 32px;
  border: 1px solid var(--rule-soft);
  background: var(--bg); color: var(--fg);
  font-size: var(--step--2); font-weight: 700;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
.gw:hover .gw__num { border-color: var(--accent); }
.gw input:checked + .gw__num {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
.gw input:focus-visible + .gw__num { outline: 3px solid var(--focus); outline-offset: 2px; }

/* Touch targets are about the pointer, not the viewport. */
@media (pointer: coarse) {
  .gw-grid { grid-template-columns: repeat(auto-fill, minmax(2.75rem, 1fr)); }
  .gw__num { min-height: 44px; }
}

.date-input {
  width: 100%; min-height: 40px;
  padding: var(--space-2) var(--space-3);
  border: var(--border-w) solid var(--rule);
  background: var(--bg); color: var(--fg);
  font-family: var(--font-ui); font-size: var(--step--1); font-weight: 600;
}
@media (pointer: coarse) {
  .date-input { min-height: 44px; }
}

/* What the Form block currently resolves to, in words — shown only while a
   results-from window is set. Paired with the [hidden] rule below: this one has
   no author `display` today, but the hint variant beneath it does, and the next
   person to add one here would hit the trap the buttons keep hitting. */
.filter-summary[hidden] { display: none; }
.filter-summary {
  margin-block-start: var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--bg);
  border-inline-start: 4px solid var(--accent);
  font-size: var(--step--2); font-weight: 600;
  color: var(--fg);
}

/* The hint variant. Same box, but it leads a control instead of reporting on
   one, so it loses the top margin and gains room for the icon. The lightbulb is
   inline SVG in currentColor — emoji-as-icons is banned, and an icon font would
   be a dependency for one glyph. */
.filter-summary--hint {
  margin-block-start: 0; margin-block-end: var(--space-3);
  display: flex; align-items: flex-start; gap: var(--space-2);
  font-weight: 500; color: var(--fg-muted);
}
/* `flex: none` or the bulb squashes as the text wraps; the 1px nudge sits it on
   the first line's cap height rather than its box. */
.filter-summary__icon {
  flex: none; width: 14px; height: 14px;
  margin-block-start: 1px; color: var(--accent);
}

/* A LABEL for a set of controls, not a headline competing with the table's own.
   It used to match the table heading exactly — same face, size and weight —
   which gave a secondary panel the same visual rank as the thing the page is
   for. It is now a micro-label and the table heading leads.

   Note the selector has to out-specify `.section-head h2` (0,1,1); a bare
   `.filters__heading` (0,1,0) silently loses to it. */
.section-head .filters__heading {
  margin: 0;
  font-family: var(--font-ui); font-weight: 600;
  font-size: var(--step--2); letter-spacing: 0.16em; line-height: 1;
  text-transform: uppercase;
  color: var(--fg-muted);
}
.filters__toggle { display: none; }

/* float:left keeps the legend from cutting a gap in the fieldset's top border,
   which is what a default-rendered <legend> does. */
.filter-group__legend {
  float: left; width: 100%;
  padding: 0;
  margin-block-end: var(--space-3);
  font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--fg-muted);
}
.filter-actions {
  display: flex; gap: var(--space-2);
  margin-block-start: var(--space-3);
}

.filter-item {
  display: flex; align-items: center; gap: var(--space-2);
  min-height: 36px;
  cursor: pointer;
}
/* Target size is about the input device, not the screen width. */
@media (pointer: coarse) { .filter-item { min-height: 44px; } }

/* Visually hidden but still focusable and still the real control. */
.filter-item input {
  position: absolute;
  width: 1px; height: 1px; opacity: 0;
  margin: 0; pointer-events: none;
}
/* The box outline is the ONLY thing indicating an unchecked control exists, so it
   uses --fg-muted rather than --rule — it has to clear 3:1 with room to spare. */
.filter-box {
  flex: none;
  width: 18px; height: 18px;
  display: grid; place-items: center;
  border: var(--border-w) solid var(--fg-muted);
  background: var(--bg);
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.filter-box svg { width: 12px; height: 12px; color: var(--on-accent); opacity: 0; }
.filter-item input:checked + .filter-box { background: var(--accent); border-color: var(--accent); }
.filter-item input:checked + .filter-box svg { opacity: 1; }

/* Radios read as round so "one of these" is obvious at a glance, even though
   everything else in this design has hard corners. */
.filter-box--radio { border-radius: 50%; }
.filter-box--radio svg { width: 100%; height: 100%; }
.filter-item input:focus-visible + .filter-box { outline: 3px solid var(--focus); outline-offset: 2px; }
.filter-item:hover .filter-box { border-color: var(--accent); }

/* `.chip.chip--sm`, not a bare `.chip--sm`, and that is not tidiness. The base
   `.chip` rule is declared LATER in this file at equal specificity, so a bare
   modifier loses its radius to it — the modifier-before-base trap in the
   anti-patterns table, which this rule walked into on the way in and reported
   2px as 3px. The doubled class outranks it wherever either sits. */
.chip.chip--sm { width: 7px; height: 18px; border-radius: 2px; }
.filter-item__name {
  min-width: 0;
  font-size: var(--step--1); font-weight: 700; letter-spacing: 0.03em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.btn-filter {
  flex: 1;
  min-height: 34px; padding-inline: var(--space-2);
  border: var(--border-w) solid var(--rule);
  background: transparent; color: var(--fg);
  font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.06em; text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
@media (pointer: coarse) { .btn-filter { min-height: 44px; } }
.btn-filter:hover:not(:disabled) {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}
/* Disabled = "this would do nothing", which is the honest state at 20/20 and 0/20. */
.btn-filter:disabled { color: var(--fg-muted); border-color: var(--rule-soft); cursor: not-allowed; }

/* Empty state when every club has been filtered out */
.table-empty { padding: var(--space-6) var(--space-4); text-align: center; color: var(--fg-muted); }
.table-empty button {
  margin-top: var(--space-3);
  border: var(--border-w) solid var(--rule); background: transparent; color: var(--fg);
  min-height: 44px; padding-inline: var(--space-5);
  font-size: var(--step--1); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  cursor: pointer;
}
.table-empty button:hover { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }

/* No overflow container: an `overflow-x: auto` ancestor would become the scroll
   container and silently break the sticky <thead>. Columns collapse instead. */
/* No frame. The table is defined by its own rules and by the rail's hairline —
   a border round the outside is a fourth edge nobody needed. */
/* The table is a tile too. `overflow: CLIP`, never `hidden` — clip rounds the
   corners without becoming a scroll container, so the sticky <thead> keeps
   pinning to `--controls-h`. Measured all three: `visible` and `clip` both hold
   the header at 69px; `hidden` drops it to 5px, riding up with the wrapper.
   That is the anti-patterns entry for `overflow-x: auto` in a second costume,
   and it is exactly what rounding this element walks into. */
.table-wrap {
  border: 0;
  border-radius: var(--r-card);
  background: var(--surface);
  box-shadow: var(--shadow);
  overflow: clip;
  /* Keeps the first and last rows off the rounded corners, so nothing has to be
     clipped in the first place.

     The bottom is DEEPER than the top on purpose, and the depth is the header
     band's: `--thead-h` plus the same --space-2. The table now closes the way it
     opens — a --rule-strong line, then a band of the same height before the
     tile's edge. Before this the last row ended on a light hairline with 9px
     under it, so the table opened deliberately and stopped abruptly. */
  padding-block-start: var(--space-2);
  padding-block-end: calc(var(--space-2) + var(--thead-h));
}

.table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
/* The table CLOSES on the same rule it opens on. The thead's --rule-strong is
   "labels stop, data starts"; this one is "data stops", and it is the same
   weight because it marks the same kind of boundary. Every other row keeps its
   light hairline, so the strong line reads as the table's edge rather than as
   one more separator.

   ON THE TBODY, not on `tr:last-child td`. Every club row is followed by its
   hidden detail row, so the last child of tbody is a `display: none` row and a
   :last-child rule matches nothing you can see — it silently did nothing. The
   tbody has one bottom edge wherever its rows end, which is the thing being
   drawn. Same reason the stagger index had to count visible rows only. */
.table tbody, .form-table tbody {
  border-block-end: var(--border-w) solid var(--rule-strong);
}
.col-team { width: 100%; }

/* The single heavy rule in the design. It marks where the labels stop and the
   data starts, which is the one division in the table worth drawing firmly. */
.table thead th {
  position: sticky; top: var(--controls-h, 44px); z-index: 5;
  background: var(--surface);
  border-bottom: 1px solid var(--rule-strong);
  padding: 0;
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--fg-muted);
  white-space: nowrap;
}

/* `.th-btn` sets the header row's height, not the `th` — it stacks its own
   padding and min-height on top. Sizing the th alone does nothing; that trap
   cost a 64px header row. The 44px floor is restored for coarse pointers via
   --thead-h, because target size is a property of the input device. */
.th-btn {
  appearance: none; background: none; border: 0; width: 100%;
  padding: 0 var(--space-3);
  min-height: var(--thead-h);
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit;
  cursor: pointer; display: flex; align-items: center; gap: 4px;
  transition: color var(--dur-fast) var(--ease);
}
.th-btn:hover { color: var(--fg); }
.th-btn__arrow { opacity: 0; font-size: 0.8em; }
th[aria-sort] .th-btn { color: var(--accent); }
th[aria-sort] .th-btn__arrow { opacity: 1; }

/* Numeric headers centre on the column. The arrow is taken out of flow so its
   width can't shift the label off-centre from the figures below it. */
.col-num .th-btn { position: relative; justify-content: center; }
.col-num .th-btn__arrow {
  position: absolute;
  inset-inline-end: 2px; inset-block-start: 50%;
  translate: 0 -50%;
}

/* Left-aligned headers keep the arrow in flow — nothing is being centred. */
.col-team .th-btn, .col-pos .th-btn { justify-content: flex-start; }

.table tbody tr.row { border-bottom: 1px solid var(--rule-soft); }
.table tbody tr.row:hover { background: var(--surface-2); cursor: pointer; }
/* Every figure is mono. A league table is a readout: Plex Mono makes each
   column align by construction rather than by luck, and it is the single
   strongest signal that this is an instrument and not a page of prose. */
.table td {
  padding: 0 var(--space-3);
  height: var(--row-h);
  text-align: center;
  font-family: var(--font-figure);
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
}
.table td.col-team { text-align: start; font-family: var(--font-ui); }
.table tbody td { border-bottom: 1px solid var(--rule-soft); }

/* Selected rows — any number, click again to drop one. The whole row floods with
   a light shade of the accent blue; no border. */
.table tbody tr.is-selected,
.table tbody tr.is-selected:hover {
  background: var(--row-selected);
}
/* The tint is strong enough to eat the muted data colours, so they darken on it.
   Ovr is muted for the same reason and needs the same treatment — bold 17px is
   not "large text" by WCAG, so 4.5:1 applies to it in full. */
.table tbody tr.is-selected .gd--pos { color: var(--pos-on-selected); }
.table tbody tr.is-selected .gd--neg { color: var(--neg-on-selected); }
.table tbody tr.is-selected td.col-ovr { color: var(--ovr-on-selected); }
.table tbody tr.is-selected + .row-detail.is-open td { background: var(--row-selected); }

.col-pos {
  position: relative;
  width: 3.5rem;
  text-align: start !important;
  font-family: var(--font-figure); font-weight: 600;
  font-size: var(--step--1); letter-spacing: 0;
}
/* Qualification/relegation marker. Colour alone never carries it — each row also
   has a visually-hidden zone label, and the legend sits under the table. */
td.col-pos::before {
  content: "";
  position: absolute; inset-block: 0; inset-inline-start: 0;
  width: var(--zone-bar-w, 4px);
  background: var(--zone, transparent);
}
/* Indent the cell and the header's inner button separately. Setting it on
   `.col-pos` alone hits the <th> too, and .th-btn then stacks its own padding
   on top — which is what pushed "Pos" 12px right of the numbers. */
td.col-pos { padding-inline-start: var(--space-5); }
th.col-pos .th-btn { padding-inline-start: var(--space-5); }

/* The real control for row selection: keyboard-reachable, announces its pressed
   state. Mouse users can click anywhere on the row; both route to one handler. */
.club {
  display: flex; align-items: center; gap: var(--space-3); min-width: 0;
  width: 100%;
  appearance: none; background: none; border: 0; padding: 0;
  font: inherit; color: inherit; text-align: start; cursor: pointer;
}

/* Colour chip in club colours: two-tone 60/40 across, or SOLID.
   --------------------------------------------------------------
   `--c2` is optional and a club that doesn't set it gets one colour across the
   whole chip. Some clubs are a single colour and that is the identity —
   Liverpool red, Chelsea blue, Fiorentina viola — and inventing a second band
   for them states something about the club that isn't true. The chip is
   editorial, so it follows the club rather than the convenience of the layout.

   The cost is real and accepted: the second colour was doing work. Clubs share
   primaries — Man Utd and Bournemouth are the same red to ΔE 0.0 — and where
   both go solid their chips become identical. That is bounded by the club NAME
   sitting immediately to the right of the chip, which is the actual identifier;
   the chip is `aria-hidden` and always was a secondary cue. `check-clubs.py`
   reports same-league solid collisions so the trade stays visible.

   Note the fallback is `var(--c2, var(--c1))` and the JS must OMIT `--c2`
   rather than set it empty — an invalid custom-property value computes to
   something the fallback never sees, and the chip loses its second band either
   way but for the wrong reason.

   Narrowed from 14×30 to 9×24 for this design's lighter weight. That is the
   floor: at 9px the two bands are 5.4px and 3.6px, and going narrower is what
   made an earlier 3px version unreadable (1.8px and 1.2px bands).

   The inset hairline is NOT optional — it is what stops a white or pale chip
   vanishing into the surface, which is why club colours are exempt from the
   3:1 rule that governs zone bars. It matters MORE now: an all-white solid
   chip is nothing but its hairline. It flips in dark mode. */
/* ROUNDED, at a THIRD of the chip's width — 3px on the 9px chip, 2px on the 7px
   one in the filter list. The chip was the last square object left on a page
   whose panels, buttons, popovers and switches all round, so it read as a
   different vocabulary sitting beside them.

   THE RADIUS IS DERIVED FROM THE WIDTH, NOT PICKED, and that is what keeps it
   from becoming a capsule: the chip's narrow axis is 9px, so `--r-ctl`'s 10px
   would exceed the whole width and round it to a lozenge. A third is roughly
   what `--r-ctl` is to a 32px control, which is why the two read as the same
   amount of rounding at very different sizes. Same reasoning as `.filter-box`,
   which takes a documented literal for the same reason a few rules above.

   The gradient and the inset hairline both follow the radius on their own, so
   the two bands still meet on a straight seam and the edge still traces the
   corner. Nothing needed clipping. */
.chip {
  flex: none;
  width: 9px; height: 24px;
  border-radius: 3px;
  background: linear-gradient(to right, var(--c1) 0 60%, var(--c2, var(--c1)) 60% 100%);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.28);
}
:root[data-theme="dark"] .chip { box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.22); }

/* display:block matters — text-overflow does nothing on a non-replaced inline element. */
.club__name {
  display: block; min-width: 0;
  font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.club__name-short { display: none; }

/* Points is the figure the table exists to rank on, so it is the only one that
   steps up a size. Everything else stays at the readout size. */
.col-pts { font-family: var(--font-figure); font-weight: 600; font-size: var(--step-0); }
.gd--pos { color: var(--pos-fg); font-weight: 500; }
.gd--neg { color: var(--neg-fg); font-weight: 500; }

/* Overall position — only meaningful when the table isn't the overall table.
   Same face as Pos but muted: it is the reference figure beside the ranking
   one, not a second ranking. */
.col-ovr { display: none; width: 3.75rem; }
.table--has-overall .col-ovr { display: table-cell; }

/* Position when the window opened. Hidden the same way Ovrl is, but gated on the
   since-filter rather than on any derived view: a club or home/away filter
   derives a table without giving a "when" to report from. Muted like Ovrl —
   it is a reference figure, not a ranking. */
.col-start { display: none; width: 3.75rem; }
.table--has-start .col-start { display: table-cell; }
td.col-start {
  color: var(--fg-muted);
  font-family: var(--font-figure); font-weight: 400;
  font-size: var(--step--1); letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}
.table tbody tr.is-selected td.col-start { color: var(--ovr-on-selected); }

/* The Final column widens to carry its bracketed marker, and must not wrap:
   `table-layout` is fixed, so a cell too narrow for "16 (▲1)" breaks it onto a
   second line and that row alone grows taller than the rest. */
.table--has-start .col-ovr { width: 7.75rem; }
.table--has-start td.col-ovr { white-space: nowrap; }

/* The movement marker inside the Final cell. The triangle carries direction, the
   colour reinforces it, the figure gives magnitude — three channels agreeing,
   the same construction the GD column uses with its +/- sign. Never colour
   alone. Smaller and set off by a space, so it annotates the position rather
   than competing with it. */
.mvmt {
  display: inline-flex; align-items: baseline; gap: 0.05em;
  margin-inline-start: 0.4em;
  font-family: var(--font-figure);
  font-variant-numeric: tabular-nums;
  font-size: 0.85em;
}
/* Fixed width so the digits form a straight column whichever way the triangle
   points — without it, "▲" and "–" set different advances and the numbers jog. */
.mvmt__tri { width: 0.7em; font-size: 0.72em; line-height: 1; }
.mvmt--up   { color: var(--pos-fg); }
.mvmt--down { color: var(--neg-fg); }
/* An en dash, muted: no movement is not a small rise, and a direction colour
   here would claim it was. */
.mvmt--flat { color: var(--fg-muted); }
/* Same trap as Ovr and GD: --row-selected is strong enough to eat these. */
.table tbody tr.is-selected .mvmt--up   { color: var(--pos-on-selected); }
.table tbody tr.is-selected .mvmt--down { color: var(--neg-on-selected); }
.table tbody tr.is-selected .mvmt--flat { color: var(--ovr-on-selected); }
td.col-ovr {
  color: var(--fg-muted);
  font-family: var(--font-figure); font-weight: 400;
  font-size: var(--step--1); letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}

/* The header is what sets this column's width, and "OVRL" measures 32px at the
   thead's 11px/0.1em — so 3.75rem leaves room for the 12px `.th-btn` padding each
   side with clearance for the absolutely-positioned sort arrow. This replaced a
   stacked "OVR" over "POS", which was narrower but needed its own line-height and
   padding-block overrides to keep the <thead> the same height as every other
   header. One line needs neither. */

/* User-hidden columns. These only ever hide, so they compose with the responsive
   rules rather than fighting them — both sides set display:none. The two rank
   columns need the extra `.table` weight to beat `.table--has-overall .col-ovr`. */
.table.hide-pos .col-pos { display: none; }
.table.hide-ovr .col-ovr { display: none; }
.table.hide-start .col-start { display: none; }
.table.hide-pl  .col-pl,  .table.hide-pl  .detail-pl  { display: none; }
.table.hide-w   .col-w,   .table.hide-w   .detail-w   { display: none; }
.table.hide-d   .col-d,   .table.hide-d   .detail-d   { display: none; }
.table.hide-l   .col-l,   .table.hide-l   .detail-l   { display: none; }
.table.hide-gf  .col-gf,  .table.hide-gf  .detail-gf  { display: none; }
.table.hide-ga  .col-ga,  .table.hide-ga  .detail-ga  { display: none; }
.table.hide-gd  .col-gd,  .table.hide-gd  .detail-gd  { display: none; }
.table.hide-pts .col-pts, .table.hide-pts .detail-pts { display: none; }

/* Expand control — mobile only */
.col-more { width: 3rem; display: none; }
.more-btn {
  appearance: none; background: none; border: 0;
  width: 44px; height: 44px; display: inline-grid; place-items: center;
  cursor: pointer; color: var(--fg-muted);
}
.more-btn svg { width: 16px; height: 16px; transition: rotate var(--dur) var(--ease); }
.more-btn[aria-expanded="true"] svg { rotate: 180deg; }

.row-detail { display: none; }
.row-detail td {
  padding: var(--space-3) var(--space-4);
  background: var(--surface-2);
  border-bottom: 1px solid var(--rule-soft);
}
.row-detail.is-open { display: table-row; }
.detail-grid {
  margin: 0;                                /* <dl> ships with a 1em block margin */
  /* FOUR, not `auto-fit`. The detail holds eight figures, and auto-fit packed as
     many as would go — six at 375px — which left GD and Pts orphaned on a second
     row beside four empty cells. Four columns is the same eight as two even rows
     of four, at every width this expander appears at. */
  display: grid; grid-template-columns: repeat(4, 1fr);
  gap: var(--space-2);
  text-align: center;
}
.detail-grid dt {
  font-size: var(--step--2); font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.12em; color: var(--fg-muted);
}
.detail-grid dd { margin: 2px 0 0; font-weight: 600; font-variant-numeric: tabular-nums; }

/* Row reveal — first paint only. Re-running this on every sort is nauseating.
   The FILTER case is handled by the view/settle motion below, which fades the
   figures in place rather than moving them. */
@media (prefers-reduced-motion: no-preference) {
  .table tbody tr.reveal {
    animation: rowIn var(--dur-slow) var(--ease-out-back) both;
    animation-delay: calc(var(--i, 0) * 24ms);
  }
  @keyframes rowIn {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: none; }
  }
}

/* ==========================================================================
   VIEW TRANSITIONS
   ==========================================================================
   The four views are one surface changing rather than four pages, so the
   incoming one enters from the direction you travelled along the tab strip:
   Table -> Movement enters from the right, back again from the left. `setView()`
   sets `--view-dir` to +1 or -1 and stamps `data-entering` on the view; the
   attribute is removed when the animation ends, so nothing is left mid-state.

   DIRECTION IS THE WHOLE POINT. A fade would be motion for its own sake; travel
   that matches the control you just used is the part that tells you where you
   are. 240ms, and it does not delay anything — the view is already rendered
   when this runs.
   ========================================================================== */
@media (prefers-reduced-motion: no-preference) {
  .view[data-entering] {
    animation: viewIn var(--dur) var(--ease) both;
  }
  @keyframes viewIn {
    from { opacity: 0; transform: translateX(calc(var(--view-dir, 1) * 18px)); }
    to   { opacity: 1; transform: none; }
  }

  /* A filter change settles the figures IN PLACE. They must not travel: moving
     data is data you cannot read while it moves, and this table exists to be
     compared against itself across filter changes. */
  .table-wrap[data-settling] { animation: settleIn var(--dur) var(--ease) both; }
  @keyframes settleIn {
    from { opacity: 0.5; }
    to   { opacity: 1; }
  }
}

/* ==========================================================================
   Notes + footer
   ========================================================================== */

/* ---- Zone legend ---- */
.zone-legend {
  display: flex; flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  align-items: center;
  padding-block: var(--space-4) 0;
  font-size: var(--step--1); color: var(--fg-muted);
}
/* Paired with the author `display` above, or the UA rule loses and a hidden
   legend still renders — the trap this project has now hit four times. The
   movement chart's key is hidden in points mode, where there are no bands. */
.zone-legend[hidden] { display: none; }
/* And empty, which is how the movement key ships in the pre-rendered pages:
   those draw the fallback table rather than the chart, so there are no bands to
   explain and renderChrome() has not run yet. Without this it would hold its top
   padding open as a gap. Same rule and reason as `.field__hint:empty`. */
.zone-legend:empty { display: none; }

/* `.mv-zone` was here — the tinted qualification bands behind the Movement
   chart's lines, at 0.14. Removed 2026-08-28 along with that chart's own key and
   share.js's copy; the chart is up to twenty-four lines over a grid and the
   bands were a fifth thing in the same space. The TABLE's zone bars are a
   different rule (`td.col-pos::before`) and are untouched. */
/* An author `display` beats the UA's [hidden] rule, so hiding has to be explicit. */
.zone-legend[hidden] { display: none; }
.zone-legend__item { display: inline-flex; align-items: center; gap: var(--space-2); }
.zone-legend__swatch {
  width: 6px; height: 18px;
  background: var(--swatch);
  flex: none;
}
/* Name and social sit as one group at the RIGHT edge: `justify-content: flex-end`
   rather than `text-align: right`, because the icon has to sit beside the text
   and share its baseline, which alignment on the block alone can't arrange. */
/* A COLUMN now, right-aligned: the name and its icons on one line, the policy
   link under them. Both rows end on the page's right edge, so the footer still
   reads as one block rather than as two stray items. */
/* The league index, above the wordmark row.

   LEFT-ALIGNED, against a footer whose other two lines are right-aligned. The
   index is a LIST — several columns of names read down — and a right-aligned
   list has a ragged left edge exactly where the eye needs a straight one to
   scan. The wordmark row and the privacy link are single items and keep the
   right edge they have always had, so the footer reads as one block with the
   navigation on the left and the identity on the right.

   ONE COLUMN PER SEASON, each headed by the season. That is the league
   selector's own information architecture — the season is the coarser question
   and the leagues being played lead — and it is what lets the link text drop the
   year: under a "2026/27" heading, "Premier League 2026/27" would print the
   season twice.

   Underlined, like the privacy link and unlike the social icons — words among
   words in the footer's own muted ink, where without the underline the only
   thing marking them as links would be a colour they do not have to themselves.
   Hover lifts to --fg, which is what everything clickable down here does. */
.site-index {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--space-3);
  /* No block-end margin: it is a sibling in the row now, not a block above one. */
}
.site-index__label,
.site-index__season {
  margin: 0;
  font-size: var(--step--2); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--fg-muted);
}
/* The columns WRAP rather than scroll or squeeze. Two seasons fit side by side
   at every width the site supports; a third would drop to a second row instead
   of narrowing the first two past their longest league name. */
.site-index__groups {
  display: flex; flex-wrap: wrap;
  gap: var(--space-3) var(--space-7);
}
.site-index__group {
  display: flex; flex-direction: column; gap: var(--space-1);
}
.site-index__list {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column;
}
.site-index__link {
  color: inherit; text-decoration: underline; text-underline-offset: 0.2em;
  /* A line of text is not a target — the socials' box, for the same reason.
     inline-flex with a min-height keeps the underline off the box's full height,
     and `align-self: start` keeps the target the width of the WORD rather than
     the width of the column, so the underline does not run past the name. */
  display: inline-flex; align-items: center; align-self: flex-start;
  min-height: 28px;
}
.site-index__link:hover { color: var(--fg); }
@media (pointer: coarse) {
  .site-index__link { min-height: 44px; }
}

/* A ROW, not a column: the league index on the left and the policy link with
   the social icons on the right, LEVEL with it rather than stacked underneath.
   The right-hand pair is two items and was never worth a band of its own — put
   beside the index it uses the space the index's short second column leaves,
   and the footer becomes one object instead of three stacked ones.

   `align-items: flex-start` so the right-hand block sits on the index's first
   line. IT STAYS THERE ON A PHONE TOO, as of 2026-08-27 — see the `nowrap`
   below. It used to wrap to its own line at 720px, because two columns of
   leagues plus a link on one line is more than a phone's width holds; stacking
   that block into a column took it down to the width of two icons, which the
   index can spare. */
.site-footer {
  margin-top: var(--space-7);
  border-top: var(--border-w) solid var(--rule);
  padding-block: var(--space-5) var(--space-7);
  font-size: var(--step--1); color: var(--fg-muted);
  display: flex; flex-wrap: wrap;
  justify-content: space-between; align-items: flex-start;
  gap: var(--space-4) var(--space-6);
}

/* A COLUMN at the footer's right — the social icons, with the policy link under
   them. It was a row of the three on one line until 2026-08-27.

   Stacking is what tells the two apart: the icons are accounts to follow and
   the link is somewhere to read, and side by side the link read as a third
   social item. Under them it is a line of words again, which is what its
   underline has always said it was.

   `align-items: flex-end` keeps both on the footer's one right edge — the edge
   the index's rows and the wordmark row above already end on.

   `margin-inline-start: auto` rather than relying on the parent's
   space-between: if this ever wraps to its own line it is the only item on it,
   and space-between has nothing to push it against. */
.site-footer__row {
  display: flex; flex-direction: column; align-items: flex-end;
  gap: var(--space-1);
  margin-inline-start: auto;
}

/* Muted and lifting to `--fg` on hover, exactly as the icons beside it do — one
   hover response for everything in this footer that can be clicked. Underlined
   at rest, because unlike the icons it is a word among words: without it the
   only thing marking it as a link would be a colour it does not have to itself.
   Same treatment as the league links on the other side of the row. */
.site-footer__link {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  /* The socials' rule: a line of text is not a target. It gets the same 32px
     desktop / 44px coarse box they do, laid out inline-flex so the box is real
     without the underline growing with it. */
  display: inline-flex; align-items: center;
  min-height: 32px;
  transition: color var(--dur-fast) var(--ease);
}
.site-footer__link:hover { color: var(--fg); }
@media (pointer: coarse) {
  .site-footer__link { min-height: 44px; }
}

/* Tighter than the footer's own gap, so the pair reads as one cluster belonging
   to the name rather than as separate items. The 32px targets already carry
   their own padding, so the icons stay comfortably apart at 4px. */
.site-footer__socials {
  display: inline-flex; align-items: center; gap: var(--space-1);
}

.site-footer__social {
  display: inline-flex; align-items: center; justify-content: center;
  color: inherit;
  /* Square, so the target is the same in both directions. It is the only thing
     in the footer that can be clicked, and a bare 16px icon is not a target. */
  width: 32px; height: 32px;
  transition: color var(--dur-fast) var(--ease);
}
.site-footer__social:hover { color: var(--fg); }
.site-footer__social svg { width: 18px; height: 18px; display: block; }
/* Target size is a property of the INPUT DEVICE, not the viewport — the rule the
   control bar already follows. Gated, never blanket. */
@media (pointer: coarse) {
  .site-footer__social { width: 44px; height: 44px; }
}

/* THE TWO BLOCKS STAY ON ONE LINE ON A PHONE. The footer wraps by default and
   this is where it used to give, dropping the socials and the policy link to a
   line of their own under the leagues. Stacking that pair into a column is what
   made keeping them level affordable: the block is now as wide as two icons
   rather than as wide as two icons plus "Privacy policy", so the index can give
   up the difference and still show its columns.
   `min-width: 0` on the index is what lets it: a flex item will not shrink
   below its content otherwise, and the index would push the pair off the row it
   is being held on. Its own `flex-wrap` then takes the seasons onto two rows
   instead of one, which is the give. */
@media (max-width: 720px) {
  .site-footer { flex-wrap: nowrap; gap: var(--space-4); }
  .site-index { min-width: 0; }
}

/* ==========================================================================
   Responsive — mobile is a first-class target, not a fallback.
   Below 720px: Pos / Club / Pl / GD / Pts, with the rest behind a row toggle.
   ========================================================================== */

/* Below 1000px there isn't room for a rail beside the table, so the filters
   become a disclosure above it — same controls, no new interaction to learn. */
@media (max-width: 1000px) {
  .layout { grid-template-columns: 1fr; gap: var(--space-4); }
  /* Same breakpoint as the table view: no room for controls beside content. The
     editor goes above the preview and loses its sticky — there is nothing beside
     it to stay level with, and stuck to the top it would cover the card. */
  .share-layout { grid-template-columns: 1fr; gap: var(--space-5); }
  .card-editor { position: static; }

  /* The rail becomes a disclosure widget here. It keeps the box, but drops the
     desktop panel's padding: the toggle and the panel each carry their own, and
     stacking the two made the collapsed bar ~160px tall with the label floating
     in the middle of it. */
  /* `relative`, so the open panel can anchor to this bar — see the overlay rule
     below. It used to be `static` here and was only switched to `relative` on
     the Table view, which is what left the other three views with an in-flow,
     GROUNDLESS panel.
     `top: auto` is NOT redundant: `.filters` is `position: sticky` with
     `top: calc(var(--controls-h) + var(--space-3))` on the desktop rule above,
     and `static` makes that offset INERT rather than removing it. Anything
     other than `static` wakes it back up — see the overlay rule's own note for
     what that cost. */
  .filters {
    position: relative; top: auto;
    max-height: none; overflow: visible;
    border: var(--border-w) solid var(--rule);
    padding: 0;
  }
  .filters__head { margin: 0; }
  .filters__heading { display: none; }

  /* ---- FILTERS and COLUMNS, paired ----
     The page's two disclosure bars share the layout's first row here. Stacked
     they were two full-width bars plus the gap between them — 56px of an 812px
     screen, spent on two controls that between them just narrow the table.

     The grid grows a SECOND COLUMN and every view spans both. `.view` must take
     `1 / -1` explicitly: without it the table would drop into column 1 and
     render at half width, which is the obvious way to break this.

     `align-items: start` so a tall open FILTERS panel doesn't stretch the
     COLUMNS button down beside it. */
  /* The second column exists ONLY while COLUMNS is actually in the row, which is
     what `:has()` is testing. COLUMNS is table-only, so on Form, Fixtures and
     Movement it is `hidden` — and an unconditional two-column grid would leave
     FILTERS at half width on those views with nothing beside it.

     `minmax(0, 1fr)`, not a bare `1fr`: a bare `1fr` is `minmax(auto, 1fr)`, so
     a track never shrinks below its content's min-content width, and COLUMNS is
     the wider label — that gave 152/175 instead of two equal halves. */
  .layout:has(> .col-filter--paired:not([hidden])) {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: start;
  }
  .filters { grid-column: 1; }
  .col-filter--paired { grid-column: 2; }
  .view { grid-column: 1 / -1; }

  /* The two bars match in height because they are read as a pair, and FILTERS
     sets the number: its toggle's `min-height` of 48, plus the hairline the box
     around it adds top and bottom. COLUMNS has no such box — its border is the
     button's own, and `box-sizing: border-box` counts it — so it takes the sum
     directly. The `+ 2 *` is what makes that relationship visible rather than
     leaving a 50 here to be re-derived later.
     (It was briefly 49 + 2: the FILTERS badge was wrapping onto a second line
     and pushing its toggle a pixel past the declared minimum. Fixing the wrap
     fixed the height, which is the better order to do it in.) */
  .col-filter--paired .btn-columns {
    min-height: calc(48px + 2 * var(--border-w));
    width: 100%;
    /* Half a screen is 163.5px at 375, and the label, the count and the chevron
       came to more than that at the full-width padding — the chevron was the
       thing that got squeezed, since it is the only flexible box in the row.
       The gutters come in and the gap tightens; nothing is abbreviated. */
    padding-inline: var(--space-3);
    gap: var(--space-2);
  }
  .col-filter--paired .btn-columns__chev { flex: none; }

  /* Third instance of this trap in this file, and the first where the element
     is moved between parents: `.col-filter` gets an author `display` from the
     rules above, which beats the UA [hidden] rule. Without this, hiding COLUMNS
     on Form/Fixtures/Movement would set the attribute and render it anyway. */
  .col-filter[hidden] { display: none; }

  /* The PANEL spans the whole row, not just its own column. Half a screen is too
     narrow for a two-column checkbox list, and the panel is anchored right, so
     it opens across both bars: 200% of the button plus the grid gap between
     them. Re-derive it from `.layout`'s gap if that ever changes. */
  .col-filter--paired .col-filter__panel {
    inset-inline: auto 0;
    width: calc(200% + var(--space-4));
  }

  /* AND THE PAIR STOPS PAIRING WHERE IT STOPS FITTING. Half a row is
     `(viewport - 32 - 16) / 2`, so COLUMNS' 153px of content needs a 354px
     viewport; at 320 it gets 134, and the overflow lands on the chevron —
     `flex: none` above keeps it from being squeezed, so instead it is pushed
     clean past the button's edge to x=322 and clipped at the screen's. Below
     that width each bar takes the whole row again, which is what they did
     before the pairing and costs the 28px of vertical space the pairing bought.
     Abbreviating the label is the other way out and is not one: the two words
     ARE the control. */
  @media (max-width: 359px) {
    .layout:has(> .col-filter--paired:not([hidden])) {
      grid-template-columns: minmax(0, 1fr);
    }
    .col-filter--paired { grid-column: 1; }
    /* The panel is the button's width again, not twice it — anchored right, a
       200% panel would run off the left edge of the screen. */
    .col-filter--paired .col-filter__panel { width: 100%; }
  }

  .filters__toggle {
    display: flex; align-items: center; gap: var(--space-3);
    width: 100%; min-height: 48px;
    padding: var(--space-2) var(--space-4);
    border: 0; background: transparent; color: var(--fg);
    font-size: var(--step--1); font-weight: 700;
    letter-spacing: 0.08em; text-transform: uppercase;
    cursor: pointer;
  }
  .filters__badge {
    margin-inline-start: auto;
    font-size: var(--step--2); font-weight: 700; letter-spacing: 0.06em;
    color: var(--fg-muted); font-variant-numeric: tabular-nums;
    text-transform: none;
    /* "All 20" broke onto two lines once this bar was half the screen, which
       made the FILTERS bar taller than the COLUMNS bar beside it — two words
       that are one label, and the pair stopped matching. */
    white-space: nowrap;
  }
  /* Same squeeze as COLUMNS, same answer: tighter gutters, nothing shortened. */
  .layout:has(> .col-filter--paired:not([hidden])) .filters__toggle {
    padding-inline: var(--space-3);
    gap: var(--space-2);
  }
  .filters__chev { width: 16px; height: 16px; transition: rotate var(--dur) var(--ease); }
  .filters__toggle[aria-expanded="true"] .filters__chev { rotate: 180deg; }

  /* The disclosure itself is the box here, so the boxes inside give theirs back
     — otherwise they draw a second and third box inside the first. The stack is
     what the toggle shows and hides. */
  .filters__stack {
    display: none;
    padding: 0 var(--space-4) var(--space-4);
    overflow: visible;
    gap: var(--space-5);
    /* No scroll port here, so nothing to fade out of — and left on, the mask
       would dim the bottom of a fully expanded panel for no reason. */
    -webkit-mask-image: none;
            mask-image: none;
  }
  .filters.is-open .filters__stack { display: flex; }

  /* ---- Open FILTERS overlays, exactly as COLUMNS does ----
     IT NEEDS ITS OWN BOX, unlike the in-flow version, where `.filters` was the
     box and the stack sat inside it. Out of flow it would otherwise be a
     transparent column of controls over whatever the view is showing — which is
     precisely what the other three views were getting; see below.

     AND IT NEEDS A SCROLL PORT, which the COLUMNS panel never did at 301px tall.
     An absolutely positioned element does not grow the page, so at 676px on a
     shorter phone the bottom would simply be unreachable. `70dvh` leaves the
     bar and some page visible above it; `dvh` rather than `vh` because mobile
     browser chrome moves.

     ON ALL FOUR VIEWS, as of 2026-08-27. This whole block used to hang off
     `.layout:has(> .col-filter--paired:not([hidden]))` — the COLUMNS pairing —
     which is a test for BEING ON THE TABLE VIEW, since COLUMNS is table-only.
     So Form, Fixtures and Movement got the in-flow panel: no ground, no border,
     no shadow, no scroll port, and neither way out. The bug was invisible from
     the Table view, where everything worked.
     The pairing decides the panel's WIDTH and nothing else; that is the one
     thing left scoped to it, below. */
  .filters.is-open .filters__stack {
    position: absolute; z-index: 30;
    inset-block-start: calc(100% + var(--space-2));
    /* The border corrections are not fudge factors. A percentage width on an
       absolutely positioned box resolves against its containing block's PADDING
       box, and `.filters` is a bordered box here — so `100%` lands the panel 2px
       narrow and 1px inboard. `-1 * border-w` puts its left edge back on the
       bar's own, and `2 * border-w` gives back the width its two borders took.
       Measured: 16 → 359, the same edges as the content beside it. */
    inset-inline-start: calc(-1 * var(--border-w));
    width: calc(100% + 2 * var(--border-w));
    max-height: 70dvh; overflow-y: auto;
    padding: var(--space-4);
    border: var(--border-w) solid var(--rule);
    border-radius: var(--r-ctl);
    background: var(--surface);
    box-shadow: var(--shadow-lift);
  }
  /* PAIRED, THE BAR IS HALF THE ROW AND THE PANEL IS NOT. Anchored to `.filters`
     in grid column 1 and grown rightwards across both tracks — the mirror of the
     COLUMNS rule, which grows leftwards from the right column. Measured at 375
     before this existed: 162px wide and 1320px tall, one squeezed column of
     controls, where the same panel at the full 343px is 676px.
     `4 * border-w` rather than the `2 *` above, because the doubling counts
     `.filters`' own two borders twice. Re-derive both from `.layout`'s gap if
     that ever changes. */
  .layout:has(> .col-filter--paired:not([hidden])) .filters.is-open .filters__stack {
    width: calc(200% + var(--space-4) + 4 * var(--border-w));
  }
  .filters__panel {
    padding: 0;
    border: 0; background: transparent;
  }

  /* The disclosure is already a box — don't nest another one inside it. */
  .filter-group { border: 0; background: none; padding: 0; }
  /* This re-added the hairline the base sheet no longer draws, so the same rail
     grew its lines back below 1000px. The base rule already sets the padding,
     so there is nothing left for this breakpoint to say about groups. */

  /* Codes are short, so narrow screens get more than two columns rather than
     a two-column list with a lot of dead space. */
  .filter-list {
    grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
    column-gap: var(--space-4);
    overflow: visible;
  }
}

@media (max-width: 720px) {
  .hero { padding-block: var(--space-6) var(--space-5); }

  /* THE TAGLINE IS DESKTOP-ONLY. Beside the wordmark it had 106px to work with
     against the 607px it needs — a 2.3px font — and on its own line it cost two
     lines and 49px of a screen whose header is the thing this project keeps
     buying back. The tabs name the four views one row below, which is the same
     answer in less space. `display: none` rather than `.sr-only`: what the site
     is also reaches a crawler through `<meta name="description">` and
     `og:description` — different wording, same ground — so hiding it here costs
     nothing that reads the page rather than looks at it. */
  .hero__blurb { display: none; }

  /* Nothing to override: the base `flex: 0 0 auto` sizes each button to its label
     and wraps the row when they stop fitting. An earlier `flex: 1 1 auto` split the
     row evenly, which was right for two buttons and wrong for three — the odd one
     out stretched full width under the other two. Never grow; just wrap. */

  /* The section head wraps here, putting the Columns button on the left — so a
     right-anchored popover would hang off-screen. Go full width instead. */
  .col-filter { width: 100%; }
  .btn-columns { width: 100%; justify-content: space-between; }
  .col-filter__panel { inset-inline: 0; min-width: 0; }

  /* Labels stay above their controls here too. Stacking actually costs nothing:
     it removes the 4.75rem gutter each inline label used to reserve, which the
     select and the segmented group get back as width. */
  .controls__inner { gap: var(--space-3) var(--space-4); }
  .controls__group { width: 100%; }
  .controls__group:has(.segmented) { width: auto; flex: 1 1 auto; min-width: 0; }
  .controls__label { flex: none; width: auto; }
  /* `flex: 1` here would grow the control in the BLOCK direction, because
     `.controls__group` is a flex COLUMN — the same trap as a `.btn-filter`
     dropped into a `.filter-group`. It collapsed the segmented control to 22px
     with 20px touch targets. Stretch to the group's width instead. */
  /* NOWRAP, AND THAT IS A CORRECTNESS RULE RATHER THAN A LAYOUT PREFERENCE.
     It was `flex-wrap: wrap` until 2026-09-04, and at 360px the four view tabs
     came to 335.6px against 322 — so MOVEMENT dropped to a second row and the
     control started LYING ABOUT WHICH VIEW WAS ON.

     THE THUMB CANNOT EXPRESS TWO ROWS, in either axis. `positionThumb()` writes
     `translateX(offsetLeft)` and a width and nothing else, so it stayed on row
     one; and `.segmented__thumb` is `inset-block: 3px`, which on a group grown
     to 96px makes the thumb 90px tall and spans BOTH rows. Together those drew
     one accent block over the whole control: on /movement at 360 the three tabs
     on row one were sitting on the fill in `--on-accent` and read as selected,
     while MOVEMENT sat below it unmarked.

     So the single row is the component's invariant, and this is where it is
     stated. The floor that makes it hold at every supported width is the
     padding below — see there. */
  .segmented { flex: none; align-self: stretch; flex-wrap: nowrap; }
  /* The rail-matching width is a desktop idea; on a phone the control takes the
     full column, and the rail is full width too, so they still agree. */
  .controls__group .select-shell { width: 100%; }
  /* `--space-1`, and the padding is doing something different here than it looks
     like it is. These are `flex: 1 1 auto`, so they GROW to fill the track
     whenever there is room — the padding never sets their drawn width, it sets
     their MINIMUM, which is what decides whether the row fits at all. At 375 and
     360 the tabs are the same size as before this change; all that moved is the
     width below which the row can no longer hold them.
     Measured natural need for the four labels: 335.6px at `--space-3`, 271.6px
     here. The narrowest supported width offers 282px of track, so the row now
     holds down to 320 with 10.4px to spare, where it used to break at 375. */
  .segmented__btn { flex: 1 1 auto; justify-content: center; padding-inline: var(--space-1); }
  .controls__group .select-shell { flex: 1; min-width: 0; }
  .select { width: 100%; }

  .table .col-w, .table .col-d, .table .col-l,
  .table .col-gf, .table .col-ga { display: none; }
  /* Overall position earns its place ahead of Played on a narrow screen; Played
     moves into the row detail so nothing is actually lost. Only when Ovr is
     actually on screen, though — hidden from the column filter, it displaces
     nothing. */
  .table--has-overall:not(.hide-ovr) .col-pl { display: none; }
  /* Narrower than desktop, but the label is the same 32px and the sort arrow is
     absolutely positioned 2px off the trailing edge — so the padding comes in
     rather than the width, and 3.5rem is the floor at which the arrow still
     clears the "L". 3rem overlapped it by 2px. */
  .col-ovr { width: 3.5rem; }
  th.col-ovr .th-btn { padding-inline: var(--space-1); }
  .col-more { display: table-cell; }
  .club__name-full { display: none; }
  .club__name-short { display: inline; }
  .club__name { font-size: 0.9375rem; }   /* fits the longest short name (Bournemouth) */

  /* Fixed layout so the club name truncates instead of forcing the table wider
     than the viewport and pushing Pts off-screen. */
  .table { table-layout: fixed; }
  .table td, .th-btn { padding-inline: var(--space-2); }
  .club { gap: var(--space-2); }
  .col-pos { width: 2.4rem; }
  td.col-pos { padding-inline-start: var(--space-3); }
  th.col-pos .th-btn {
    padding-inline: var(--space-3) var(--space-1);
    letter-spacing: 0.04em;
  }
  /* col-team keeps width:100% from the base rules — in a fixed-layout table one
     column has to absorb the remainder, and `auto` collapses to min-content. */
  .col-pl { width: 2.4rem; }
  .col-gd { width: 2.9rem; }
  .col-pts { width: 2.7rem; }
  .col-more { width: 2.25rem; }
  /* The expander's coarse-pointer target is 44px and the column it sits in is
     36, so it HAS to overhang something. Overhanging the trailing edge put it
     outside `.table-wrap`'s `overflow: clip`: measured, the button ran 331 -> 375
     against a card edge at 359, so a third of the tap target was cut off and the
     chevron sat 6px from the card's edge instead of centred in its column.
     Overhang INWARD instead — the space it borrows is the Pts cell's own
     padding, which holds nothing. -0.5rem is 44px minus the 2.25rem column;
     re-derive it if either number moves. */
  td.col-more { padding-inline: 0; }
  .more-btn { margin-inline-start: -0.5rem; }
  /* Slightly shorter to suit the tighter row, but the WIDTH holds at 9px: that
     is the floor at which both bands of the 60/40 split stay readable. */
  .chip { width: 9px; height: 22px; }
}

/* ===========================================================================
   FORM & FIXTURES
   ---------------------------------------------------------------------------
   Deliberately built from the table's own parts — .table-wrap, .col-pos with
   its zone bar, .chip, .club__name — rather than a second visual language for
   a second view. Only what genuinely differs is written here.

   The whole view is a sibling of #tableView, so switching hides the filter rail
   with it and nothing here has to reason about filter state.
   =========================================================================== */

.form-table {
  width: 100%; border-collapse: collapse;
  font-variant-numeric: tabular-nums;
}

/* Same one heavy rule under the header as the table, and the same sticky offset
   — the control bar is still overhead on this view. */
.form-table thead th {
  position: sticky; top: var(--controls-h, 44px); z-index: 5;
  background: var(--surface);
  border-bottom: 1px solid var(--rule-strong);
  height: var(--thead-h);
  padding: 0 var(--space-3);
  text-align: start;
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--fg-muted);
  white-space: nowrap;
}
/* No .th-btn here: nothing on this view sorts, so the header is a label and not
   a control. Height sits on the th because there is no inner button to carry it
   — the opposite of the table, and only because the button is genuinely absent. */

.form-table td {
  height: var(--row-h);
  padding: 0 var(--space-3);
  font-family: var(--font-ui);
  font-size: var(--step--1);
  border-bottom: 1px solid var(--rule-soft);
}
.form-table tbody tr.row { border-bottom: 1px solid var(--rule-soft); }

/* The club column absorbs the remainder; the other three are content-sized.
   `width: 100%` rather than `auto`, which collapses to min-content. */
.form-table .col-team { width: 100%; }
.form-table .col-form { width: 1%; white-space: nowrap; }

/* A since-window puts every match in the window in this column — up to 37 of
   them — so the flexible column swaps over and the strip wraps. Without the
   swap the club column keeps `width: 100%` and squeezes the strip to one
   unreadable line; `table-layout` is fixed here, so one of the two has to give
   the width up explicitly. */
.form-table.is-windowed .col-team { width: 1%; white-space: nowrap; }
.form-table.is-windowed .col-form { width: 100%; white-space: normal; }
.form-table.is-windowed .form-strip { flex-wrap: wrap; row-gap: 3px; }
/* Cells stop being single-row once the strip wraps, so the row grows instead of
   clipping. `height` on a td is a minimum, but the padding has to come back. */
.form-table.is-windowed td { height: auto; padding-block: var(--space-2); }
.form-table .col-formpts { width: 1%; white-space: nowrap; text-align: center; }

/* ---- the rank columns ----
   `.col-start` / `.col-ovr` are `display: none` from the bare class the table
   shares, so each needs its own gate here. Two classes, not one: Ovrl/Final is
   earned by any Form filter, but "position when the window opened" needs a
   window to have opened. Both out-specify the bare class. */
.form-table--has-overall .col-ovr { display: table-cell; }
.form-table--has-start .col-start { display: table-cell; }
.form-table .col-start, .form-table .col-ovr { width: 1%; white-space: nowrap; }

/* Muted and lighter than Rank beside them — reference figures, not a second and
   third ranking. Matches td.col-start on the table. */
.form-table td.col-start, .form-table td.col-ovr {
  color: var(--fg-muted);
  font-family: var(--font-figure); font-weight: 400;
  font-size: var(--step--1); letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}

/* Points from the last five. Mono and 600 like the table's Pts, because it is
   the same kind of figure — the one the row is really about — just over a
   shorter window. */
.form-table td.col-formpts {
  font-family: var(--font-figure); font-weight: 600;
  font-size: var(--step-0);
}
.form-table th.col-formpts { text-align: center; }

/* Static — this view has no row selection, so the club is text, not a button.
   Resetting cursor and pointer feedback matters: inheriting .club's `cursor:
   pointer` would promise a click that does nothing. */
.club--static { cursor: default; }
.form-table tbody tr.row:hover { background: var(--surface-2); }

/* ---- the last-5 strip ---- */

.form-strip { display: flex; gap: var(--space-1); align-items: center; }

/* A hairline box with the letter in its data colour — not a filled pill. The
   system's rule is that structure is hairlines and colour is meaning, and the
   GD column already sets the precedent: the SIGN carries the fact and the
   colour reinforces it. Here the LETTER carries it, so a filled block would be
   spending weight to repeat something already stated.

   Mono, so five of them line up as a strip rather than jostling by glyph width. */
.pill {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px;
  border: 1px solid var(--rule);
  font-family: var(--font-figure);
  font-size: var(--step--1); font-weight: 600;
  line-height: 1;
}
.pill--w { color: var(--pos-fg); border-color: currentColor; }
.pill--l { color: var(--neg-fg); border-color: currentColor; }
/* A draw is the absence of a result, and muted says that better than a third
   hue would. Adding one here would put three colours in a five-cell strip.

   `border-color: currentColor` like the other two, which it was missing — so an
   away draw drew its box in `--rule` while its letter was muted. Two reasons it
   had to change: the recency bar takes the BOX's colour, and a 1px bar in
   `--rule` is a hairline nobody can see, which is the whole signal gone; and
   share.js has always stroked this border in muted, so the screen and the card
   disagreed about what a drawn away game looks like. */
.pill--d { color: var(--fg-muted); border-color: currentColor; }

/* VENUE is fill, RESULT is the letter and its colour, RECENCY is the bar below.
   Three channels, none of them doing two jobs — which is what the old scheme
   did, spending CASE on venue and INVERSION on recency. Lower-case `w` against
   upper-case `W` is a fine distinction to ask of a 22px box, and it was reported
   as confusing.

   Home FILLS. The knocked-out letter is `--surface`, not `--bg`: the strip sits
   on the table's own ground. A filled DRAW darkens to `--fg` first — `--fg-muted`
   is not strong enough to knock text out of, the same trap `--pos-on-selected`
   documents. */
.pill--home { background: currentColor; border-color: currentColor; }
.pill--home > [aria-hidden] { color: var(--surface); }
.pill--d.pill--home { background: var(--fg); border-color: var(--fg); }
/* Away is the outline, which is the base .pill already — declared for the reader
   rather than the cascade, so the pair is legible in one place. */
.pill--away { background: transparent; }

/* The most recent match, UNDERSCORED. Inversion used to carry this, and venue
   has taken it — so recency moves to a channel neither of the others uses.
   Chronological order alone does not say which end is "now": both ends of a
   five-cell strip look like ends.

   Absolutely positioned, so the five boxes stay identical in size and the strip
   still reads as one row of equal things. It draws the same over a filled pill
   and an outlined one, which a shade or an inversion could not.

   The bar takes the BOX's colour, so the chip reads as one object rather than a
   box with a foreign rule under it. Recency is still not carried by colour — the
   bar's PRESENCE is the signal, and it is the only pill that has one — so this
   costs nothing that the earlier neutral bar was buying. The .sr-only sentence
   still opens with "Most recent:".

   As a non-text graphic the bar answers to the 3:1 floor, not 4.5. It clears it
   on every result in both themes, because it is the same ink the outlined pill
   already uses as TEXT there, which is held to the higher bar. */
/* The most recent match is HATCHED — diagonal rule across the chip, with the
   letter left clear. The box keeps its full 22px and its type size: the marker
   is a texture laid over the chip rather than a change to its shape, so the five
   boxes stay identical and the strip reads as one row of equal things.

   It replaced an underline, which was the same idea in less space and read as a
   fourth edge rather than as a mark on the chip.

   THE HOLE IS A MASK, not a plate drawn on top. A radial mask subtracts the
   centre of the hatch, so the letter sits on the chip's own ground — whatever
   that is — instead of on a patch that has to be told what colour to be. That is
   what lets one rule serve the filled chip and the outlined one.

   Not colour-only: the hatch's PRESENCE is the signal, and it is the only chip
   that carries one. The .sr-only sentence still opens with "Most recent:". */
.pill--latest { position: relative; }
.pill--latest::after {
  content: ''; position: absolute; inset: 0;
  background: repeating-linear-gradient(
    45deg, currentColor 0 1.5px, transparent 1.5px 5px);
  -webkit-mask: radial-gradient(circle at 50% 50%, transparent 0 7px, #000 8.5px);
          mask: radial-gradient(circle at 50% 50%, transparent 0 7px, #000 8.5px);
  pointer-events: none;
}
/* A filled chip is already its own colour, so the hatch has to be the knocked-out
   one — the same ink the letter uses — or it would be drawn in the colour it is
   drawn ON and disappear. Covers the filled draw too, whose box darkens to --fg
   while its own colour stays muted. */
.pill--home.pill--latest::after {
  background: repeating-linear-gradient(
    45deg, var(--surface) 0 1.5px, transparent 1.5px 5px);
}
/* The muted draw is not strong enough to knock text out of, so the inverted
   draw darkens to the full foreground. Same trap as --pos-on-selected: a colour
   that works as text does not automatically work as a ground. */

/* Same construction as the zone legend under the table: it explains the thing
   directly above it, so it sits between the strip and the note. */
.form-legend {
  display: flex; flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  align-items: center;
  padding-block: var(--space-4) 0;
  font-size: var(--step--1); color: var(--fg-muted);
}
.form-legend__item { display: inline-flex; align-items: center; gap: var(--space-2); }

.form-none { color: var(--fg-muted); font-size: var(--step--1); }

/* ---- postponed ----
   What is left of the old per-club "next fixture" line, which went when Form and
   Fixtures were split into two views. The flag outlived it: all three fixture
   surfaces write it. */
/* Not a colour-only flag: it is the word itself. */
.fixture__flag {
  font-size: var(--step--2); text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--neg-fg);
}

.form-note {
  margin: var(--space-4) 0 0;
  color: var(--fg-muted);
  font-size: var(--step--1);
}

/* ---- the fixtures list ----
   Grouped by date, because a fixture list is read as "what's on Saturday" and
   not club by club. Rows are a three-part grid rather than flex, so the kick-off
   times form a true centre column down the page instead of drifting with the
   length of the club names either side. */

/* Width of the fixture row's centre column — see `.fx`. One token so the value
   can't drift between the base rule and the narrow breakpoint. */
.fixtures-view { --fx-mid-w: 6.5rem; }

/* ---- The two panes ----
   The fixture list, and the gameweek-movement chart beside it. `minmax(0, …)` on
   both, or the fixture rows' `nowrap` centre column makes the first track refuse
   to shrink and pushes the whole grid past the viewport. */
/* One column by default, side by side only once there is genuinely room —
   measured, not guessed. The fixture list needs ~460px before club names start
   ellipsising and the chart needs ~440 to render its text at size, so the two
   want ~930px of content between them. At a 1024px viewport the rail is STILL a
   sidebar (it only becomes a disclosure at 1000px), which left the fixture pane
   206px wide and clipped all twenty club names. Hence 1280px: below it the two
   panes stack, which costs nothing but scrolling. */
/* The list column is a tile too. It was the one pane left flat on the page after
   the rollout — Table, Form and Movement all became tiles and Fixtures did not,
   so this view alone read as unfinished. No `overflow` needed: nothing inside is
   sticky, and the padding keeps the rows off the corners. */
/* The pane's head holds only its Share, so it needs pushing to the right edge:
   `.section-head`'s `space-between` has nothing to push against with one child.
   The band keeps its `--head-h` height either way, which is what holds this
   pane's top level with the movement pane's. */

.fixtures-main {
  border-radius: var(--r-card);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: var(--space-4);
  min-width: 0;
}

.fixtures-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-5);
  align-items: start;
}
/* The chart column's width, named because TWO rules need it: the grid below, and
   the section head above the grid, which has to reserve the same amount to keep
   the view's Share over the LIST pane. A literal in both places is the kind of
   pair that drifts. */
.fixtures-view { --gwm-col: 470px; }

@media (min-width: 1280px) {
  .fixtures-layout { grid-template-columns: minmax(0, 1fr) minmax(0, var(--gwm-col)); }
  /* THE VIEW'S SHARE ENDS ON THE LIST PANE'S RIGHT EDGE, NOT THE CONTENT'S, and
     that is what makes "above the box" true at this width rather than only at
     narrow ones. The head spans both panes, so with no reservation the button
     landed on the far right — above the CHART, 25px from that chart's own Share,
     while sharing the fixture list on the opposite side of the screen. That is
     the exact objection that moved this button into the pane on 2026-09-03, and
     reserving the second column answers it without giving up the position.
     `:has()` because the head is an earlier SIBLING of the grid and CSS has no
     previous-sibling combinator. Solo — a club picked, off the active gameweek,
     or a bound that says nothing — reserves nothing, because there is no second
     column to sit clear of. */
  /* `>` IS LOAD-BEARING. The movement pane's head is a `.section-head` too, and
     it is a descendant of this view — so without the child combinator this rule
     reserved a column inside the CHART's head as well and dragged that pane's
     own Share 377px off its right edge. Caught by measuring both, not by
     reading the selector. */
  .fixtures-view:has(.fixtures-layout:not(.fixtures-layout--solo)) > .section-head {
    padding-inline-end: calc(var(--gwm-col) + var(--space-5));
  }
  /* The chart is dropped whenever a club is picked in "Show fixtures for" — club
     and compare mode are one club's question, which a ladder of the whole league
     cannot answer — or a season is too young to have moved. A `display: none`
     aside would leave its grid TRACK and the gap behind, so the column COUNT has
     to change, not just the column's contents. */
  .fixtures-layout--solo { grid-template-columns: minmax(0, 1fr); }
}
.gwm[hidden] { display: none; }

/* Boxed like `.filters__panel`, which is what the rail and the share editor use:
   this is a second object on the page, not a stray drawing beside the list. */
/* Deliberately NOT sticky. A 20-club chart is ~560px of drawing plus its heading,
   scrubber and note — taller than a short viewport, and a sticky element that
   doesn't fit cannot scroll its own overflow into view, so the bottom rows would
   be unreachable. The pane it sits beside is one gameweek, about ten rows, so
   the two are a similar height and there is nothing for a sticky to buy. */
.gwm {
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: var(--space-4);
}
/* THE MICRO-LABEL, not a heading — restyled 2026-08-28. It was an `<h3>` with a
   size and nothing else, so it inherited the UA's bold sentence case and drew as
   15px bold "Gameweek movement": the one title on the page in a voice nothing
   else uses.

   `.gwm` is a boxed pane sitting beside the fixture list, which is the same KIND
   of object as `.filters__panel` beside the table — so it takes the same label
   that FILTERS does, and the same one the rail's own groups take (MOVEMENT BY,
   HIGHLIGHT, SHOW FIXTURES FOR). Uppercase, tracked, `--step--2`, muted. Every
   value here is `.section-head .filters__heading`'s; the two are deliberately
   identical rather than merely similar.

   SCOPED TO `.section-head`, like that rule and for its reason. `.section-head
   h2` is (0,1,1) and outranks a bare class — this is an `<h3>` so nothing
   reaches it today, but the trap is one `.section-head h3` away and the fix
   costs nothing now.

   It keeps the `--head-h` band the `.section-head` gives it, so the title still
   sits level with the fixtures pager beside it rather than 6px off. */
.section-head .gwm__heading {
  margin: 0;
  font-family: var(--font-ui); font-weight: 600;
  font-size: var(--step--2); letter-spacing: 0.16em; line-height: 1;
  text-transform: uppercase;
  color: var(--fg-muted);
}
/* `.gwm__scrub` was here, spacing the reference-gameweek scrubber. The chart
   looks forward now and has no reference to pick, so the control and this rule
   went together. The heading sits directly above the frame. */
.gwm__heading + .gwm__frame,
.section-head + .gwm__frame { margin-block-start: var(--space-3); }
/* The chart SCROLLS sideways rather than shrinking, and this is the one place in
   the project that gets a horizontal scroller — for a reason the anti-patterns
   table's warning doesn't cover. That warning is about `.table-wrap`, where the
   scroller becomes the containing block and kills the sticky `<thead>`; there is
   nothing sticky here.
   Twenty numbered circles need ~16px of axis each, so the drawing has a hard
   floor of 430px and cannot be made narrower without either overlapping the
   discs or dropping the figures out of them. Scaling it down instead is what put
   the club names at 9.13px on a phone. Below the floor the reader pans; above it
   the SVG fills the pane as normal. */
.gwm__frame { overflow-x: auto; }
/* THE PAN AFFORDANCE. Below the 430px floor above, this frame is a window onto a
   wider drawing and nothing said so: on a phone the arrows simply ran off the
   right edge, which reads as a chart that has been cut off rather than one you
   can pan. Desktop has the scrollbar to say it; a touch screen has nothing.

   `mask-image`, NOT a gradient overlay, and the difference is not stylistic. A
   background gradient is painted BEHIND the content, so the very arrows it is
   meant to soften would be drawn on top of it at full strength — the fade would
   be invisible exactly where it matters. A gradient drawn in FRONT needs an
   element outside the scroller, since anything inside it scrolls away with the
   content. A mask is applied to the frame after its content has scrolled, so it
   fades whatever is actually at the edge.

   Which edge fades is set from the scroll position by syncPanFade(), and a fade
   at the end of the pan is removed rather than left standing: a permanent one
   claims there is more to see when there is not, which is the same fault as a
   control that cannot act. */
.gwm__frame[data-pan] {
  --pan-w: 28px;
  -webkit-mask-image: var(--pan-mask);
          mask-image: var(--pan-mask);
}
.gwm__frame[data-pan="right"] {
  --pan-mask: linear-gradient(to right, #000 calc(100% - var(--pan-w)), transparent);
}
.gwm__frame[data-pan="left"] {
  --pan-mask: linear-gradient(to left, #000 calc(100% - var(--pan-w)), transparent);
}
.gwm__frame[data-pan="both"] {
  --pan-mask: linear-gradient(to right, transparent, #000 var(--pan-w),
                              #000 calc(100% - var(--pan-w)), transparent);
}
.gwm__chart svg { display: block; width: 100%; min-width: 430px; height: auto; }

.gwm-grid__line { stroke: var(--rule-soft); stroke-width: 1; }

/* The row's direction lives in `color`, and every part of the mark inherits from
   it — shaft, arrowhead and the filled disc. One declaration per row and the
   three can't fall out of step, which is the `.pill--latest` construction. */
.gwm-row--up   { color: var(--pos-fg); }
.gwm-row--down { color: var(--neg-fg); }
.gwm-row--flat { color: var(--fg-muted); }

/* A ROW NOW HAS BOTH DIRECTIONS AT ONCE — a span out to the best case and out to
   the worst — so the direction moved off the row and onto the two segments.
   `.gwm-row--flat` stays on the row, which is what keeps the CURRENT position's
   hollow disc neutral between them.

   The colour REINFORCES the direction, it does not carry it: the axis is
   reversed, so best is always the right-hand segment and worst always the left,
   and the two ends are labelled with their positions. Ignore colour entirely and
   the drawing still reads. */
.gwm-span--up   { color: var(--pos-fg); }
.gwm-span--down { color: var(--neg-fg); }

.gwm-row__name {
  fill: var(--fg); font-family: var(--font-ui);
  font-size: 13px; dominant-baseline: middle;
}
.gwm-row__shaft { stroke: currentColor; stroke-width: 1.5; }
.gwm-row__head { fill: currentColor; }

/* The reference end is hollow on the page ground — it is where the club WAS, and
   an outline reads as the empty slot it left. */
.gwm-row__from { fill: var(--surface); stroke: var(--rule-strong); stroke-width: 1.5; }
/* The current end is flooded and the figure knocked out, exactly as the most
   recent form pill inverts. `--fg-muted` is not strong enough to knock text out
   of, so a club that hasn't moved never gets this treatment — it keeps the
   hollow circle alone, which is also the honest drawing: nothing happened. */
.gwm-row__to { fill: currentColor; stroke: currentColor; stroke-width: 1.5; }

/* 10px, not 11: "20" has to sit inside a circle of r=8 with its ring visible. */
.gwm-row__num {
  font-family: var(--font-figure); font-size: 10px; font-weight: 600;
  text-anchor: middle; dominant-baseline: middle;
  font-variant-numeric: tabular-nums;
}
.gwm-row__num--from { fill: var(--fg); }
.gwm-row__num--to { fill: var(--surface); }

.gwm-axis__tick {
  fill: var(--fg-muted); font-family: var(--font-figure);
  font-size: 10px; text-anchor: middle;
}

/* ---- one club's season ----
   A five-column grid rather than the three-column fixture row: this list answers
   "when does my club play", so the gameweek and the result are columns in their
   own right instead of a centred kick-off between two club names. Fixed tracks
   for everything except the opponent, so the columns line up down the page — the
   same reason `--fx-mid-w` is fixed on the other list. */
.fx-season { list-style: none; margin: 0; padding: 0; }
.fx-row {
  display: grid;
  grid-template-columns: 2.5rem 10.5rem 1.75rem minmax(0, 1fr) 4.5rem;
  align-items: center; gap: var(--space-3);
  min-height: 44px;
  padding-inline: var(--space-3);
  border-bottom: 1px solid var(--rule-soft);
  background: var(--surface);
}
/* A played match recedes: the season's story is what is coming. Dimmed, not
   hidden — "Hide completed" is the control for hiding. */
.fx-row--played { background: var(--bg); }
.fx-row__gw {
  font-family: var(--font-figure); font-size: var(--step--1);
  color: var(--fg-muted); font-variant-numeric: tabular-nums;
}
.fx-row__when { display: flex; align-items: baseline; gap: var(--space-2); white-space: nowrap; }
.fx-row__date { font-size: var(--step--1); }
.fx-row__time {
  font-family: var(--font-figure); font-size: var(--step--2); color: var(--fg-muted);
  font-variant-numeric: tabular-nums;
}
/* H and A, not "Home"/"Away": the column repeats 38 times and the letter is
   unambiguous beside the opponent's name. */
.fx-row__venue {
  font-size: var(--step--2); font-weight: 600; color: var(--fg-muted);
  letter-spacing: 0.06em;
}
.fx-row__opp { display: inline-flex; align-items: center; gap: var(--space-2); min-width: 0; }
.fx-row__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fx-row__result { text-align: end; font-family: var(--font-figure); font-variant-numeric: tabular-nums; }
/* The score carries the result in its own colour, the same three-channel rule
   the form strip follows — the figures say what happened, the colour agrees. */
.fx-score { font-weight: 600; }
.fx-score--w { color: var(--pos-fg); }
.fx-score--l { color: var(--neg-fg); }
.fx-score--d { color: var(--fg-muted); }

/* Sits where Next does in gameweek mode. */
/* COMPLETED [ SHOW | HIDE ]. It was a "Hide completed" checkbox: one bit, stated
   by a mark you have to notice and then read against a verb, with only one of the
   two states ever named. The label now names the SUBJECT and the segments name
   both VALUES, so the filled one is the current status — the same trade the venue
   toggle made when FULL replaced "neither radio checked". */
/* The row's trailing pair: the COMPLETED switch and, below 720px, the Share.
   THE AUTO MARGIN IS ON THE GROUP, not on either control, and that is the whole
   point of the group existing — the two have to reach the right edge together
   and wrap together. Appended straight to `.pager`, the Share dropped to a
   second line ALONE and sat hard left, because that row packs from the start
   (`justify-content: flex-start`); club mode's long label is enough to make it
   give at 375px. Same fault the table's section head had, same remedy
   `.share-pair` uses on the Movement view.
   The margin is inert while the row fits — the pager's items absorb the space
   first — and is what right-aligns the group on a line of its own once it does
   not. */
.fx-actions {
  display: flex; align-items: center; gap: var(--space-2);
  margin-inline-start: auto;
}

.fx-toggle {
  display: inline-flex; align-items: center; gap: var(--space-3);
}
/* Paired at the point of definition — an author `display` beats the UA [hidden]
   rule, which this file has now hit six times. */
.fx-toggle[hidden] { display: none; }
/* ---- the COMPLETED FIXTURES eye ----
   A `role="switch"` carrying one eye that gains a slash when off. It replaced a
   SHOW | HIDE segmented pair on 2026-08-26; the markup comment in
   `tools/shell.html` carries the reasoning, including why the visible label had
   to survive the change.

   THREE CHANNELS, no channel doing two jobs: the SLASH is the shape, the FILL is
   the emphasis, and `aria-checked` is the announcement. The slash alone is
   sufficient — ignore colour entirely and the two states are still different
   drawings, which is what the non-negotiable about colour requires.

   Sized from `--control-h`, like every other control in this row, so it sits
   level with `.pager__btn` without a literal anywhere. That token is already
   44px under `pointer: coarse`, so the touch target comes for free — do NOT add
   a blanket 44px here, which is this file's own documented anti-pattern. */
.fx-eye {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-2);
  /* The floor, not the size: the word makes this a pill on desktop and the
     `min-width` only matters below 720px, where the word goes `.sr-only` and the
     button collapses back to the glyph. Square there, so `--r-pill` gives a
     circle; a capsule here, which is the same shape rule either way. */
  min-width: var(--control-h); min-height: var(--control-h);
  padding-block: 0; padding-inline: var(--space-3);
  /* `--r-pill`, the token the theme switch's track and knob take and the one the
     SHOW | HIDE track it replaced took before it. In this system the pill IS the
     switch shape — segmented tracks, their thumbs, and the light/dark control —
     so a square here read as a different KIND of control from the one it is. The
     box is square, so the pill resolves to a circle at both sizes.
     NOT the shared 6px chrome radius: that group is buttons, inputs and
     popovers, which this is not. */
  border-radius: var(--r-pill);
  border: var(--border-w) solid var(--rule);
  /* `--surface-2`, NOT `--surface`, and this is the `--rail` case the old
     SHOW | HIDE track documented before it: the pager sits on `.fixtures-main`,
     which is ALREADY `--surface`, so a `--surface` ground here paints the
     control the exact colour of the thing behind it and leaves the hairline
     outlining nothing. Measured on the way in: `--surface` came to 1.00:1
     against the pane, so the OFF state had no boundary at all. One step off
     whatever it sits on — see `--track` in `tokens.css`. */
  background: var(--surface-2);
  color: var(--fg-muted);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease);
}
/* Paired at the point of definition — an author `display` beats the UA [hidden]
   rule, and this file has now hit that trap eight times. */
.fx-eye[hidden] { display: none; }
.fx-eye svg { width: 18px; height: 18px; flex: none; }
/* The micro-label idiom the bar's LEAGUE and VIEW take, at the pager label's
   tracking so the two muted words either side of the list still match. It sits
   INSIDE the control now, so it takes the button's colour rather than its own —
   which is what lets it invert to `--on-accent` on the accent fill for free. */
.fx-eye__word {
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase;
  white-space: nowrap;
}
/* The long/short pair, swapped at 720px — see the ≤720 block. `display: none`
   and not a clip, so exactly one of them is in the accessibility tree and the
   announced name is whichever is on screen. */
.fx-eye__short { display: none; }
/* ON — completed fixtures are showing. A TINT, NOT A SOLID FILL, and the reason is
   what this state MEANS: showing everything is the resting state, the one the view
   boots in and sits in almost always. A solid accent pill made the most ordinary
   condition on the screen the loudest thing in the pane, competing with SHARE and
   the selected view tab for a control nobody needs pointing at. It was solid until
   2026-08-26.

   Quieter is not fainter: the ink and the edge stay full-strength `--accent`, so
   the control still reads as switched on and still clears the text floor. Only
   the GROUND softens. 12% is the tint the theme switch's hover already uses, so
   this is the file's existing mix rather than a new one.

   The slash is unaffected either way — shape carries the state, the fill only
   reinforces it, which is what lets the ground go this quiet at all. */
.fx-eye[aria-checked="true"] {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-color: var(--accent);
  color: var(--accent);
}
/* The slash is the OFF mark, so it is absent when on. `display` rather than
   opacity: a 0-opacity line still antialiases against the accent fill on some
   engines and leaves a faint ghost diagonal across the eye. */
.fx-eye[aria-checked="true"] .fx-eye__slash { display: none; }
/* HOVER MUST EXCLUDE THE CHECKED STATE, and this is the trap the segmented
   control already documents: a hover rule that overrides only PART of what a
   state rule sets wins on specificity wherever it sits in the file, so a bare
   `.fx-eye:hover { background: var(--surface-2) }` would flash the ON switch
   back to looking off under the pointer — on the very click that turned it on. */
/* The ground is already `--surface-2`, so hover strengthens the EDGE and the ink
   rather than the fill — there is no lighter step to take toward the pane. */
.fx-eye:hover:not([aria-checked="true"]) {
  border-color: var(--fg-muted); color: var(--fg);
}
/* Hover deepens the same tint rather than jumping to a solid — a control that
   fills in under the pointer would put the loudness back on the gesture most
   likely to be a near-miss on the way to something else. */
.fx-eye[aria-checked="true"]:hover {
  background: color-mix(in srgb, var(--accent) 20%, transparent);
}

@media (max-width: 720px) {
  /* The date column is the one that can give: "Sat 22 Aug" fits in less than the
     desktop track, and the opponent needs the difference. */
  /* THE OPPONENT'S NAME IS WHAT THIS ROW IS FOR, and until 2026-09-04 this grid
     starved it to 8px — one character — on EVERY row of club mode at 375px.
     Measured on the Championship: 46 of 46 names clipped, and on the Premier
     League 38 of 38, so it was not about long names. "Coventry" rendered as "C".

     THE CAUSE WAS SLACK IN THE WRONG COLUMN. The date was given 7.5rem (120px)
     against the 74.5px the widest date ("Wed 22 Sept") needs — 45.5px idle —
     while the flexible column next to it collapsed. Every fixed column here is
     now sized from what it actually holds, measured at this font size:
       gw "46" 18px -> 1.5rem   date 74.5px -> 5rem
       venue "A" 8.1px -> 1rem  result "0-0" 26.8px -> 2rem
     which leaves 119px for the club, against the 115px "Middlesbrough" plus its
     chip and gap need. Verified 0 of 46 clipped where it was 46 of 46.

     THE PADDING CAME IN TOO, and that is a fix rather than a saving: these rows
     sat at `--space-3` while the GAMEWEEK list's `.fx` rows sit at `--space-1`,
     so the two modes of one view drew their rows on different left edges.
     Matched, they share one inset — measured, both at x=50 at 375px — and the
     16px it frees is what makes "Middlesbrough" fit rather than ellipsise. */
  .fx-row {
    grid-template-columns: 1.5rem 5rem 1rem minmax(0, 1fr) 2rem;
    gap: var(--space-2);
    padding-inline: var(--space-1);
  }
  /* THE DATE AND THE KICK-OFF STACK HERE, and that is what makes the 5rem above
     honest. `.fx-row__when` is a flex ROW carrying both, so an UNPLAYED row needs
     117px where the date alone needs 74.5 — size the column from the date and 41
     of 46 rows overflow, which is exactly what happened on the way in: the time
     ran out of its cell and collided with the venue letter beside it. A played
     row shows no kick-off and never had the problem, which is why it hides in a
     season that has started.
     Stacked, the cell needs only its widest LINE (63px) and costs no height that
     the row did not already have: two lines at 19.5px is 36px inside a 44px row,
     so every row stays 44px and the column can stay narrow enough to leave the
     club its 119px. Verified 0 of 46 overflowing, horizontally or vertically. */
  .fx-row__when { flex-direction: column; align-items: flex-start; gap: 0; }

  /* THE TOGGLE IS BACK ON THE PAGER'S OWN LINE as of 2026-08-26, and the
     `width: 100%` that used to give it a row of its own is gone. It was there
     because the control could not fit: PREVIOUS + GAMEWEEK N + NEXT + a
     two-segment SHOW | HIDE came to 520px inside a 311px pager. Four changes
     have paid for it — the segments became one eye, GAMEWEEK N became GW N, the
     label merged into the button, and PREVIOUS and NEXT gave up their words to
     their arrows (2026-08-27), which is what bought the eye's own label back.
     The row measures 281px against 311. **`width: 100%` FORCED THE WRAP ON ITS
     OWN**, independently of how wide the contents were, so it had to come off in
     the same change or none of the rest would have shown. */

  /* ONLY THE NOUN IS CLIPPED HERE — the whole word used to be, and the button
     collapsed to its glyph. See the markup comment for what paid for putting it
     back: PREV and NEXT gave up their words to their arrows, which hands this
     row 146px where it had 63. The button keeps its inline padding and its
     capsule; it is the pager's buttons that square up at this width.
     Clipped, not `display: none`, so the accessible name stays "Completed
     fixtures" and only the screen gives the noun up. */
  .fx-eye__noun {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }
}

/* THE SAME CLIP, ON A SECOND TRIGGER: the chart is beside the list, so the
   fixtures pane is ~470px rather than the full width, and COMPLETED FIXTURES no
   longer fits on the pager's line. It wrapped to a second line, right-aligned —
   correct, and 32px of pane height spent on a control that fits once its noun
   goes. The row is one line again at 1440.

   THE GUARD IS THE COLUMN COUNT, NOT A WIDTH ALONE. `--solo` is toggled by
   `renderGameweekMovement()` at every width, so `:not(--solo)` on its own would
   also match a phone with the chart stacked BELOW the list, where the pane is
   full width and the noun should stay. The two-column grid only exists at
   ≥1280px, so that is where this belongs — the media query and
   `.fixtures-layout`'s own are the same one, deliberately.

   THIRD COPY OF THESE SIX DECLARATIONS in this file (`.pager__btn-word` and the
   rule above are the others). CSS has no mixin and the three have different
   triggers, so they cannot be one rule; if a fourth appears, that is the point
   to reach for a utility class written by JS rather than a fourth copy. */
@media (min-width: 1280px) {
  .fixtures-layout:not(.fixtures-layout--solo) .fx-eye__noun {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }
}

/* ---- compare fixtures ----
   `.btn-compare` lived here, styling a "Compare fixtures" toggle that switched a
   mode the club selection can state by itself. It went with the merge, and so
   did `.filter-hint`, which only ever dressed that box's counter — the hint is
   now `.filter-summary--hint` and sits with the component it borrows from. */

/* Two columns like the CLUBS list, so the rail keeps one rhythm. */
.filter-list--compare { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-3); }
/* At the cap the unchosen are disabled, not merely ignored — a box that ticks
   and immediately unticks reads as broken. Muted so the reason is visible. */
.filter-item.is-disabled { opacity: 0.42; cursor: not-allowed; }

/* Six columns of dates need more width than a phone has, so this scrolls
   sideways — and that is exactly why its header is NOT sticky.

   `overflow-x: auto` makes this element the scroll container, which makes it the
   containing block for anything sticky inside it. The header then pins itself
   `top` pixels from the WRAPPER rather than the viewport and lands on top of the
   first row. I wrote the sticky and a comment claiming it was safe here, and it
   was not: this is the `.table-wrap` anti-pattern with a different element.
   Scroll OR stick, not both. */
.cmp-wrap { overflow-x: auto; }
.cmp { width: 100%; border-collapse: collapse; }
.cmp thead th {
  background: var(--surface);
  border-bottom: 1px solid var(--rule-strong);
  padding: var(--space-2) var(--space-3);
  text-align: start; vertical-align: bottom;
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-muted);
}
.cmp__club { display: inline-flex; align-items: center; gap: var(--space-2); }
.cmp__name { white-space: nowrap; }
.cmp__gw {
  width: 3rem; text-align: center;
  font-family: var(--font-figure); font-size: var(--step--1); font-weight: 600;
  color: var(--fg-muted); font-variant-numeric: tabular-nums;
}
.cmp tbody tr { border-bottom: 1px solid var(--rule-soft); }
.cmp__cell {
  padding: var(--space-2) var(--space-3);
  vertical-align: top;
  font-size: var(--step--1);
  /* Sized from the DATE LINE, which is the cell's hard floor: it is `nowrap` and
     cannot give, where the opponent line ellipsises. "Sun 13 Sept 12:00 AM"
     measures 121px, and the opponent line runs to 127px on the longest name —
     both against the 128px of content `9.5rem` left after padding, which is how
     a late kick-off came to wrap its second line and make that row alone taller
     than the rest. 10.5rem leaves 144px.
     Six columns then exceed the pane at 1280 and `.cmp-wrap` scrolls, which is
     what it is for; five still fit. */
  min-width: 10.5rem;
}
/* A played match recedes, as it does in the club list — the comparison is
   mostly about what is still to come. */
.cmp__cell--played { background: var(--bg); }
.cmp__cell--none { color: var(--fg-faint); text-align: center; }

/* The opponent leads the cell, the date follows it — and it used to be the other
   way round: the date at --step--1 in --fg over a muted --step--2 "Home @
   Coventry", which made a column of near-identical dates the loudest thing in
   the pane and buried the one fact the cell exists to state.
   The devices are the single-club list's, deliberately: the same H/A letter, the
   same chip, the same muted meta line, so the two fixture views read as one
   thing. Hierarchy is size AND colour, as it is there — the name at the cell's
   own step--1 in --fg against step--2 in --fg-muted — not weight, which would
   make six columns of bold. */
.cmp__match {
  display: flex; align-items: center; gap: var(--space-2);
  min-width: 0;
}
/* H and A rather than "Home"/"Away", the club list's reasoning at six times the
   repetition: the letter is unambiguous beside the opponent's name, and the note
   under the table carries the legend. */
.cmp__venue {
  flex: none;
  font-size: var(--step--2); font-weight: 600; color: var(--fg-muted);
  letter-spacing: 0.06em;
}
/* Must be block-ish for the ellipsis to apply at all — the anti-patterns table's
   inline-span trap. `flex` makes it a flex item, which is enough. */
.cmp__oppname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* THE DATE LINE IS A ROW NOW, because the score sits on it — see the markup.
   `baseline`, not `center`: two sizes only read as one line when they share a
   baseline, the rule the share card's title/descriptor pair already follows. */
.cmp__when {
  display: flex; align-items: baseline; gap: var(--space-2);
  margin-block-start: 2px;
  font-size: var(--step--2); color: var(--fg-muted);
  font-variant-numeric: tabular-nums;
  /* One line, as it is in the club list. A wrapped date makes one cell taller
     than its neighbours, and every row in a comparison is read across. */
  white-space: nowrap;
}
.cmp__time { font-family: var(--font-figure); }
/* THE SCORE IS THE LOUDEST THING IN THE CELL, and deliberately: it is the
   outcome, where everything around it is context. `--step-0` against the
   opponent's `--step--1` and the date's `--step--2`, so the cell reads
   result → who → when by size alone.

   THIS IS THE ONE PLACE THE "size and colour, never weight" RULE BENDS, and the
   bend is bounded: 600 on a score is the club list's own treatment, and there is
   exactly one score per cell where a bold NAME would have been six columns of
   it. The colour is the club's own point of view — this column is one club, so
   win/loss/draw is a claim it is entitled to make, unlike the gameweek list
   where a row names both sides and takes neither.

   `margin-inline-start: auto` pushes it to the cell's right edge, which lines
   the scores up down each column. That is the half that makes them scannable
   across a row, not just bigger. */
.cmp__score {
  margin-inline-start: auto;
  font-family: var(--font-figure); font-size: var(--step-0); font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* ---- Matchweek pager ----
   One page per matchweek. Sits above the list for the reason the Movement
   scrubber does: a matchweek is up to ten rows tall, so a control underneath is
   off the bottom of the screen exactly when it is wanted. */
/* WRAPS, at every width. The COMPLETED toggle names both of its states, which
   costs width a tickbox did not, and the row runs out of room in two places
   rather than one: at 375px, and again at 1280px — where the movement chart
   takes the second column and hands this pane ~470px less than it had at 1279.
   Neither shows up as a page-level scrollbar, because the pager sits in a panel
   that clips, so the toggle simply ran 90px past the pane's right edge.

   THE THREE PAGER CONTROLS PACK LEFT AS ONE GROUP — `flex-start`, where this was
   `space-between`. Spread across the pane, PREVIOUS and NEXT sat at opposite
   edges with the gameweek stranded between them, so the pair that steps back and
   forward through the season read as two unrelated controls rather than as one
   pager. Read together they are a single object: back, where you are, forward.

   How the toggle stays right: `.fx-toggle` keeps `margin-inline-start: auto`,
   which does the job `space-between` used to. That margin USED to be inert on a
   row that fits, because `.pager__label` grew and left it nothing to take —
   dropping that growth is what hands it the free space and puts the toggle back
   on the pane's right edge. The two changes are one change; don't undo half.
   On a wrapped line the toggle is the only item and the same margin right-aligns
   it, exactly as before. */
.pager {
  display: flex; align-items: center; justify-content: flex-start;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-block-end: var(--space-4);
}
/* An author `display` beats the UA [hidden] rule — the trap this file has hit
   four times now. Paired at the point of definition. */
.pager[hidden] { display: none; }

.pager__btn {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: var(--control-h);
  padding-inline: var(--space-3);
  border: var(--border-w) solid var(--rule);
  background: var(--surface);
  color: var(--fg);
  font: inherit; font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
/* SEVENTH instance of this trap in this file. `.pager__btn` sets
   `display: inline-flex`, which beats the UA [hidden] rule, so club mode's
   `fxPrev.hidden = true` left the button on screen. Paired at the point of
   definition, and asserted by computed style rather than the property. */
.pager__btn[hidden] { display: none; }
.pager__btn svg { width: 14px; height: 14px; flex: none; }
.pager__btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--fg-muted); }
/* Explicit muted text and a default cursor at the ends of the range. Left to
   the browser it paints its own grey, which reads as broken rather than as
   "there is nothing before matchweek 1". */
.pager__btn:disabled { color: var(--fg-faint); cursor: default; opacity: 1; }

/* The pager's long/short word pair. PREVIOUS and GAMEWEEK N are what this row is
   called with room to say it; PREV and GW N are what it is called without. One
   pair of rules serves the button and the label, so the two can never disagree
   about which width they are at.

   THE MEDIA QUERY IS DECLARED HERE, immediately after the base rule, rather than
   in the `@media (max-width: 720px)` block further up with the rest of the
   fixtures overrides. That block sits EARLIER in the file than this section, and
   both rules are a single class — equal specificity, so the later one wins
   whatever the viewport is. Put there, `.pager__long { display: none }` lost to
   this rule and BOTH halves ended up hidden: the label measured 0px wide and the
   button collapsed to its chevron. Same family as the modifier-before-base trap
   in the anti-patterns table, arriving from the other direction. Keep the pair
   adjacent and neither can be outranked by where it happens to sit. */
.pager__short { display: none; }
@media (max-width: 720px) {
  .pager__long { display: none; }
  .pager__short { display: inline; }

  /* THE BUTTONS LOSE THEIR WORDS ENTIRELY AND KEEP THEIR ARROWS, as of
     2026-08-27. They carried PREV and NEXT here, which is 79px and 55px of a
     311px row to repeat what the chevron either side of the gameweek already
     says — and the COMPLETED FIXTURES switch, which cannot be read without its
     label, had none. So the width moved: the pager is 153px where it was 236,
     and the eye gets its word back. Measured at 375: 44 + 41 + 44 with two gaps
     is 153, leaving 146 for a 128px button.
     `.sr-only`, NOT `display: none`, and that is the difference from the pair
     above: the label's two halves swap, so whichever is on screen names it,
     while these two have nothing left on screen to name them. Clipped text is
     still in the accessibility tree.
     THIS PAIR IS SCOPED TO `.pager__btn`. `#fxLabel` uses the same long/short
     idiom and must stay VISIBLE — an unscoped rule would blank the gameweek
     number as well, which is the failure this section's own comment above
     records from the other direction. */
  .pager__btn-word {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  /* Nothing but the chevron left, so the button squares up to the 44px touch
     floor `--control-h` already carries at this pointer type — no literal, and
     no blanket 44 anywhere. */
  .pager__btn {
    min-width: var(--control-h);
    justify-content: center;
    padding-inline: 0; gap: 0;
  }

  /* THE FIXTURES SHARE JOINS THIS ROW HERE — moved into the pager by
     `syncFixturesSharePlacement()`, whose threshold is this media query's and
     must stay so. It squares up to the same touch floor the two arrows beside
     it take, from the same token: nothing but the glyph is left once
     `.sr-only--narrow` has clipped the word, and a labelled 76px control is
     what made this row refuse it before.
     It keeps `.btn-share`'s accent ink and hairline — it is still the action on
     this pane and still the one control here that acts on the view rather than
     paging it, which is the whole reason Share and Columns are not styled
     alike. */
  .btn-share--pager {
    min-width: var(--control-h);
    justify-content: center;
    padding-inline: 0; gap: 0;
  }

  /* THE WORD SHORTENS, THE GLYPH STAYS — the ~29px the Share needs to join this
     row, settled 2026-09-04.

     THE EYE IS NOT AVAILABLE TO SPEND, and that is the whole shape of this
     decision. The slash is the SHAPE channel: take the svg away and the only
     thing separating on from off is the accent tint, which is colour as the sole
     carrier of meaning — the one flat non-negotiable here. It was briefly built
     that way, with a strikethrough on the word standing in for the slash, and
     that is a compensation for a rule being broken rather than a rule being
     kept. So the label gives up its length instead: "Comp." says what the
     control acts on in five characters, and the eye goes on saying which way it
     is set.

     Measured: the button is 127.6px with the full word, 85.4px like this — the
     row lands at 281.9 of 311 at 375px and of 296 at 360px. */
  .fx-eye__long { display: none; }
  .fx-eye__short { display: inline; }
  .fx-eye { padding-inline: var(--space-2); }

  /* The gap comes in with it. Five controls share this row now where four did,
     so the row carries a fourth gap; at `--space-3` that fourth one is most of
     what the glyph just handed back. `--space-2` is the step the eye's own inner
     gap already used, not a new value. */
  .pager { gap: var(--space-2); }

}

.pager__label {
  margin: 0;
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--fg-muted);
  /* The floor that makes the COMPLETED toggle WRAP rather than this label
     shrink, when the two are on the row together. */
  white-space: nowrap;
  /* SIZED TO ITS TEXT, so the three controls sit together at the pane's left
     edge. It used to take `flex: 1 1 auto` and centre, which spread PREVIOUS and
     NEXT to opposite edges of the pane.
     That growth was also what kept `.fx-toggle`'s auto margin inert on a row
     that fits. Giving it up is deliberate and is what re-arms that margin, which
     is now the only thing right-aligning the toggle — see the `.pager` comment.
     No `text-align` needed: the box is its own width now, so there is nothing
     for the text to align within. */
  flex: 0 0 auto;
}

.fx-group + .fx-group { margin-block-start: var(--space-5); }

.fx-group__date {
  margin: 0 0 var(--space-2);
  font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--fg-muted);
}

.fx-group__list { list-style: none; margin: 0; padding: 0; background: var(--surface); }

/* The centre column is a FIXED width, not `auto`. Each row is its own grid, so
   `auto` resolves per row — "11:00 PM" is wider than "1:15 AM", which made the
   1fr columns either side different widths on different rows and walked the
   club chips left and right down the page.

   Wide enough for the longest kick-off ("11:00 PM") and for "Postponed", which
   sits under it in the same column. `tabular-nums` on the time is not enough on
   its own: it equalises digit widths, not string lengths. */
.fx {
  display: grid;
  grid-template-columns: 1fr var(--fx-mid-w) 1fr;
  align-items: center; gap: var(--space-3);
  min-height: var(--row-h);
  padding: 0 var(--space-3);
  border-bottom: 1px solid var(--rule-soft);
}
.fx:last-child { border-bottom: 0; }

.fx__club { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
/* The home club reads toward the centre, the away club away from it, so the two
   names meet at the kick-off time the way a fixture is written. */
.fx__club--home { justify-content: flex-end; }
.fx__club--home .chip { order: 2; }
.fx__club--away { justify-content: flex-start; }
.fx__name { font-weight: 600; font-size: var(--step--1); white-space: nowrap;
            overflow: hidden; text-overflow: ellipsis; }

.fx__mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.fx__time {
  font-family: var(--font-figure); font-size: var(--step--1);
  font-variant-numeric: tabular-nums; color: var(--fg-muted);
}
.fx__time--tbc { font-family: var(--font-ui); font-size: var(--step--2);
                 letter-spacing: 0.08em; }
/* The score takes the kick-off's slot on a played row and the kick-off drops
   beneath it. Neutral — no win/loss colour — because this row names both clubs
   and takes neither side; the club list colours its score because there the
   whole list is one club's point of view. */
.fx__score {
  font-family: var(--font-figure); font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--fg);
}
/* A played row recedes, as it does in the club list — a gameweek page is read
   forwards. Dimmed, not hidden: "Hide completed" is the control for hiding. */
.fx--played { background: var(--bg); }

/* A postponed fixture is dimmed AND says the word — the dimming alone would be
   colour carrying meaning on its own. */
.fx--off .fx__name { color: var(--fg-muted); }

/* ===========================================================================
   MOVEMENT
   ---------------------------------------------------------------------------
   Position per matchweek, drawn as SVG with no library — the same
   zero-dependency rule as the rest of the site.

   THIS IS THE ONE VIEW WHERE MOTION IS THE CONTENT, and so the one deliberate
   exception to "motion only on first paint". The exception is bounded: nothing
   else here animates, and under prefers-reduced-motion the playback steps whole
   matchweeks rather than gliding between them (handled in app.js) — the same
   information, without the sliding.
   =========================================================================== */

.movement-frame {
  background: var(--surface);
  border-radius: var(--r-card);
  box-shadow: var(--shadow);
  padding: var(--space-3);
}
/* Capped at 62vh, the same ceiling the share preview uses and for the same
   reason: the whole thing should be takeable in at a glance without scrolling.
   `meet` keeps the aspect ratio and centres, so a wide viewport gets margins
   rather than a stretched chart. */
.movement-chart svg {
  display: block; width: 100%; height: auto;
  /* Raised from 62vh with the taller viewBox. The cap is what actually decides
     the drawn size on a wide screen — the viewBox alone just changes the shape
     it scales into — so growing one without the other buys nothing. */
  max-height: 72vh;
  cursor: crosshair;
}
/* NO CAP ON A PHONE, and this is half of the readable-text fix rather than a
   loosening. Below 720px the viewBox tracks the element so one unit is one
   pixel; a max-height would scale that back down and put the club codes right
   back where they were. The chart is as tall as its club count needs and the
   page scrolls — which this view already assumes, its panel being deliberately
   non-sticky because a full ladder is taller than a short viewport. */
@media (max-width: 720px) {
  .movement-chart svg { max-height: none; }
}

/* No top margin: this is the first thing under the section head, so it takes
   the same start as `.table-wrap` does on the table view and the rail's first
   box lines up with it. A margin here put it 8px below. */
.movement-scrub {
  display: flex; align-items: center; gap: var(--space-3);
  margin-block: 0 var(--space-3);
}

.mv-grid__line { stroke: var(--rule); stroke-width: 1; }
.mv-grid__num {
  fill: var(--fg-muted); font-family: var(--font-figure);
  font-size: 15px; font-weight: 600; text-anchor: end; dominant-baseline: middle;
}
.mv-axis__tick {
  fill: var(--fg-muted); font-family: var(--font-figure);
  font-size: 14px; text-anchor: middle;
}

/* THE KNOCKOUT, added 2026-08-28, and it is a DIFFERENT JOB from the casing
   below — the two look alike and are easy to collapse into one another.

   It is the chart's own ground, drawn at full opacity and wider than everything
   above it, so each club's line CUTS a gap through every club drawn before it.
   That is what turns twenty crossings from a woven mesh into lines that pass
   over and under each other. Groups are emitted one club at a time — knockout,
   casing, line — so the interleaving is what produces the depth; drawing all
   twenty knockouts first and then all twenty colours would only ring the bundle
   and leave the crossings exactly as they were.

   `var(--surface)` rather than a literal: the chart sits on the panel, and the
   knockout has to be invisible against it in both themes. A literal white would
   draw a bright halo through the dark theme.

   The DRAWING ORDER therefore decides what sits on top, which is worth knowing
   before anything reorders `drawn`. */
.mv-line__knockout {
  fill: none; stroke: var(--surface);
  stroke-width: 6; stroke-linejoin: round; stroke-linecap: round;
}
/* The casing is what the club chip's inset hairline is for elsewhere: club
   colours are exempt from the contrast floor BECAUSE something else carries
   their edge. Without it a white or pale kit is an invisible line.
   It sits ON the knockout, so a crossing reads ground → edge → colour. */
.mv-line__casing {
  fill: none; stroke: var(--rule-strong); stroke-opacity: 0.32;
  stroke-width: 4.5; stroke-linejoin: round; stroke-linecap: round;
}
.mv-line {
  fill: none; stroke-width: 2.5;
  stroke-linejoin: round; stroke-linecap: round;
}

/* --font-ui, not --font-figure. These are the same three-letter club codes the
   Clubs filter lists, and it draws them in the UI face — a code is a name, not a
   figure. The axis numbers above stay in the figure face for the same reason the
   table's do. */
.mv-head__code {
  fill: var(--fg); font-family: var(--font-ui);
  font-size: 14px; font-weight: 600;
}
/* Dimmed, not hidden: an unhighlighted club still shows where it ran, so the
   highlighted lines are read AGAINST the league rather than in isolation. That
   is the whole difference between this and the Clubs filter, which removes.

   Opacity rather than a grey stroke, so the club's own colour is never
   overwritten and comes back untouched when the highlight clears. The casing
   fades further than the line — at 20 clubs the casings merge into a grey wash
   long before the lines do. */
.mv-club.is-dim .mv-line { opacity: 0.16; }
.mv-club.is-dim .mv-line__casing { opacity: 0.06; }
/* A dimmed club must not keep cutting a full-strength gap through the clubs you
   asked to see — the knockout is opaque and would punch a ground-coloured hole
   straight across a highlighted line. It fades with its own group. */
.mv-club.is-dim .mv-line__knockout { opacity: 0.06; }
.mv-head.is-dim { opacity: 0.28; }
.mv-playhead { stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; }

/* The pointer marker. Accent and DOTTED against the playhead's accent and
   DASHED — the two are on screen together and say different things: the playhead
   is the week the chart is DRAWN to, this is a week being pointed at. It used to
   be neutral and solid, which separated them by colour as well as pattern; the
   pattern is now carrying it alone, so keep 1/4 well clear of the playhead's
   3/3 if either is ever retuned.

   `visibility`, not `display: none` — the geometry is set before it is shown and
   a display-none SVG element has no box to measure. Never `hidden`, which is the
   attribute half of the anti-pattern the buttons keep hitting. */
.mv-hover { visibility: hidden; }
.mv-hover.is-on { visibility: visible; }
/* Round caps are what make 1/4 read as dots rather than very short dashes. */
.mv-hover__line {
  stroke: var(--accent); stroke-width: 1.5;
  stroke-dasharray: 1 4; stroke-linecap: round;
}
/* THE MICRO-LABEL, IN THE ACCENT — restyled 2026-08-28. It was 14px `--fg`
   sentence case, the only text on this view in that voice; every other small
   label here is uppercase, tracked and 11-scale.

   `--accent` ties the word to the dotted line it names. The two are one marker
   and were drawn in two colours, so the label read as a stray caption rather
   than as part of the thing under the pointer.

   THIS IS NOT A SIXTH ACCENT FILL — the rule that Play just failed. That one is
   about a solid GROUND reading as "selected"; this is ink on a moving marker
   that is already accent-coloured, and it names a pointer position rather than a
   chosen state. Nothing here is clickable.

   Contrast is not a floor case either: it rides over the chart's own lines at
   whatever they happen to be, which is why the marker is a DOTTED line the label
   sits above rather than text laid across the data. */
.mv-hover__label {
  fill: var(--accent); font-family: var(--font-ui);
  font-size: 11px; font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase;
  text-anchor: middle;
}

/* ---- Play ----
   `.movement-actions` and its `.btn-filter` override lived here, sizing this
   button to `--head-h` for the section head it used to sit in. Both are gone:
   the button moved into the scrubber row on 2026-08-28 and is styled on its own
   rather than borrowing the filter button, which drags `flex: 1` and a 34px
   `min-height` floor along with it — the two traps that override existed to
   undo.

   IT IS `.btn-share`'S RECIPE, NOT A FILL — changed back on 2026-08-28, and the
   reversal is the point of this comment.

   It shipped as a solid `--accent` pill, on the argument that Play is the one
   control here that ACTS on the chart and "more pronounced" was the ask. That
   was answered in the wrong currency: **a solid accent is this system's SELECTED
   signal** — the active view segment, the league selector, the sorted column —
   so a filled Play read as a button that was already switched on, next to a
   chart sitting still. The state it appeared to announce was not one it had.

   Accent text and an accent border on a transparent ground is what this project
   already uses for "acts on the view rather than describing it", and the note on
   `.btn-share` spells out why the ground stays transparent: a filled resting
   state competes with the segmented control's selected view. Hover is what
   fills. Play is still the loudest thing in its row — the range beside it is a
   hairline track and the readout is muted — without claiming to be chosen.

   SO THE ACCENT IS BACK TO FIVE SANCTIONED FILLS, and the sixth that was argued
   here is withdrawn. "Marks the thing to DO" turned out not to survive contact
   with the screen; if it is ever argued again, this is the counter-example.

   Sized from `--control-h`, so 32px desktop and 44px under `pointer: coarse`
   with no literal anywhere — the row's other control is a range input on the
   same line. */
.movement__play {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-2);
  flex: none;
  min-height: var(--control-h);
  padding-inline: var(--space-4);
  border: var(--border-w) solid var(--accent);
  border-radius: var(--r-ctl);
  background: transparent;
  color: var(--accent);
  font: inherit; font-size: var(--step--2); font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease);
}
.movement__play svg { width: 16px; height: 16px; flex: none; }
/* The glyph swaps with the state, one <svg> and two paths — the COMPLETED eye's
   construction. `display`, not `opacity`: a 0-opacity stroke still antialiases
   against the fill on some engines and leaves a ghost. */
.movement__pause-glyph { display: none; }
.movement__play[aria-pressed="true"] .movement__play-glyph { display: none; }
.movement__play[aria-pressed="true"] .movement__pause-glyph { display: inline; }
/* Hover is what fills — `.btn-share`'s response, so the two actions on this view
   answer a pointer the same way. */
.movement__play:hover:not(:disabled) {
  background: var(--accent); border-color: var(--accent); color: var(--on-accent);
}
/* OFF THE ACCENT ENTIRELY when there is nothing to play: a control that cannot
   act should not still be the loudest thing on the view. It keeps the
   transparent ground the resting state has and gives up the accent ink and edge,
   which is the whole of what marked it. */
.movement__play:disabled {
  border-color: var(--rule); color: var(--fg-muted); cursor: default;
}
/* The label is what names the button — the icon is aria-hidden — so it never
   goes `.sr-only` at any width. The row has the space: the range input beside it
   is the only thing that needs any, and it flexes. */

.movement-scrub input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
/* IT IS `#fxLabel`'S LABEL, NOT MERELY ITS TYPEFACE — finished 2026-08-28.
   It already took `--font-ui` "to match #fxLabel, the other Gameweek N readout
   on the site", and matched the family and nothing else: 13px sentence case
   against that one's tracked, uppercase 11px. Two readouts saying the same kind
   of thing in two voices, which is the drift this project closes everywhere. It
   is the same micro-label now, so "GAMEWEEK 3" reads the same here as it does in
   the fixtures pager.
   tabular-nums stays, and is the one thing that was always right: the number
   changes as the scrubber moves and the label must not jitter. */
.movement-scrub__out {
  font-family: var(--font-ui); font-size: var(--step--2); font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase;
  font-variant-numeric: tabular-nums; color: var(--fg-muted);
  white-space: nowrap; min-width: 9.5rem; text-align: end;
}

@media (max-width: 720px) {
  /* Tighter centre column: a fixed width buys chip alignment, and it is bought
     out of the club columns either side. At 6.5rem on a 375px screen that took
     enough to ellipsise "Bournemouth" on 38 rows.

     5rem DID NOT HAND BACK ENOUGH, and the reason was in the note that set it:
     it sized the column against "11:00 PM", the longest kick-off at 62.4px, when
     the widest thing in that column is "Postponed" at 70.9px. So the club
     columns came out 99.5px, the name box 82.5, and "Bournemouth" needs 82.6 —
     ellipsised by a tenth of a pixel, which is the one club the earlier note
     names. 4.75rem clears "Postponed" by 5px, and the gutters come in with it:
     between the mid column's own slack and the gap either side there was more
     air around the kick-off than around the names being cut off. The three
     together hand back 20px, 10px a column, which fits every club short name in
     the registry at this width — "Middlesbrough", the longest at 89.9px,
     included. Re-measure against IT, not against the kick-off. */
  .fixtures-view { --fx-mid-w: 4.75rem; }
  .fx { padding-inline: var(--space-1); gap: var(--space-1); }

  .form-table td, .form-table thead th { padding-inline: var(--space-2); }
  .form-table { table-layout: auto; }

  /* Six columns don't fit 375px: measured at 388px against a 343px wrap, which
     scrolled the whole PAGE sideways. The middle one goes, exactly as the table
     drops `Pl` once its rank columns appear — and least is lost here, because
     Final still carries the movement that Pos was the baseline for.

     `table-layout: fixed` was the other candidate and is worse: it holds the
     table at 343 and pushes the strip out instead, measuring 388px of document
     scroll. The width has to come out of the columns, not be hidden by them. */
  .form-table .col-start { display: none; }
  /* The rank columns are sized by their headers, not their figures. Trimming
     their gutters buys the last few px and lands the table at exactly 343. */
  .form-table td.col-ovr, .form-table th.col-ovr { padding-inline: var(--space-1); }
  /* Pos keeps a real LEADING gutter, where the trim above would give it 4px —
     and the zone bar is 4px wide, drawn on that same leading edge, so the
     position sat directly on its own bar with nothing between them. 12px is what
     the standings table gives it at this width (`td.col-pos` in the block
     above), so the two views indent the number identically. Measured: the table
     still lands at exactly 343 in the worst case (a since-window, both rank
     columns), because the Results strip wraps and absorbs it. */
  .form-table td.col-pos, .form-table th.col-pos { padding-inline: var(--space-3) var(--space-1); }
  .pill { width: 19px; height: 19px; font-size: var(--step--2); }
  .form-strip { gap: 2px; }

  /* NO min-height and no max-height here. The narrow viewBox is already
     portrait (see MV_NARROW in app.js), so the SVG arrives at the right
     proportions and scales itself. Constraining the box instead is what
     letterboxes it: `meet` fits the whole viewBox in and centres it, so a
     forced height just adds blank bands above and below the drawing. */
  .movement-chart svg { max-height: none; }
  .movement-scrub__out { min-width: 7rem; }
}

/* ==========================================================================
   AD SLOTS — RESERVED SPACE
   ==========================================================================
   Auto ads chooses its own placement, so the page cannot know WHERE a unit
   will land. What it can do is make sure the space is already there when one
   FILLS, which is the half of the shift that actually costs.

   An injected ad shifts the page twice: once when the empty `<ins>` is
   inserted, and again when the creative arrives and the box grows from 0 to
   ~250px. The second is the expensive one — it happens seconds after paint,
   long past the 500ms window that would excuse it as input-driven, and it
   displaces everything below it. A `min-height` collapses that second shift to
   whatever the creative exceeds the reservation by.

   Measured on 2026-08-26: field CLS was 1.06 against a 0.1 threshold while
   every other Web Vital was green, and the site's own code measured ZERO —
   hydration, view switches, the font swap and both breakpoints. Ads are the
   only thing left that injects layout.

   SCOPED TO `main`, and that matters. The overlay formats — anchor, vignette
   and the side rails — are appended to `<body>`, are `position: fixed`, and
   cost no CLS at all because they float over the page rather than displacing
   it. A blanket `ins.adsbygoogle` rule would reach inside those fixed
   containers and make the anchor bar 280px tall.

   `min-height`, never `height`: a unit may only ever grow into its box. A
   fixed height would crop a taller creative, which is a policy problem as well
   as a visual one.

   THE UNFILLED CASE HAS TO BE PAIRED, exactly like every other author
   `display` in this file. AdSense marks a unit it could not fill with
   `data-ad-status="unfilled"` and collapses it; without the second rule the
   reservation would hold 280px of empty page open underneath the table, which
   is a worse outcome than the shift it was fixing. Verified `unfilled` is the
   attribute AdSense actually writes — it is on the live page's own unit.

   `display: block` IS LOAD-BEARING, not tidying. `<ins>` is an inline element
   by default, and `min-height` does not apply to a non-replaced inline box — so
   the reservation silently did nothing at all until this was added. AdSense's
   own script sets the display itself once it processes a unit, which is far too
   late: the whole point is to hold the space open BEFORE the creative lands.
   Caught by measuring the reserved height and getting 0.

   UNVERIFIED LOCALLY, and it is the one thing here that could not be: ads
   report `unfilled` in an automated browser, so no creative ever arrives to
   measure. This is field-checked in Speed Insights, not on this machine. */
main ins.adsbygoogle { display: block; min-height: 280px; }
main ins.adsbygoogle[data-ad-status="unfilled"] { display: none; }

@media print {
  .controls, .col-more { display: none; }
}
