/* Sofaliga — hand-written CSS, no build step
   Layered on top of Pico CSS (self-hosted, loaded in the layouts).
   Uses: CSS custom properties, nesting, light-dark()
   Colour: the Harbor system — docs/color/README.md. Every colour is a
   --pp-* token defined once below; no raw hex and no color-mix() in a
   component rule.
*/

/* Pico is imported into the `pico` layer by components.Stylesheets(), so
   every rule here — in the layers declared after it — beats Pico's own
   unlayered-looking defaults on buttons, links and the root tokens. */
@layer pico, base, components, utilities;

@layer base {
  /* The colour theme. Every colour is worded with light-dark(), which
     resolves against the computed color-scheme property — so overriding that
     one property re-resolves all of them. No second palette, and no
     [data-theme] lookup in any component rule. `js/theme.js` sets the
     attribute; absent it, :root's `color-scheme: light dark` follows the
     operating system, which stays the default. */
  :root[data-theme="light"] { color-scheme: light; }
  :root[data-theme="dark"] { color-scheme: dark; }
}

@layer base {
  :root {
    color-scheme: light dark;

    /* ── Surfaces — stack in one direction, never skip a step ───────── */
    --pp-bg: light-dark(#F7F4F0, #0B1015); /* page ground */
    --pp-surface: light-dark(#FFFFFF, #141C25); /* .card .pool-card .contest-table .menu .action-bar */
    --pp-surface-2: light-dark(#EFEAE3, #1B2530); /* row hover, .sortable-item */
    --pp-surface-3: light-dark(#E7E1D8, #22303D); /* panels nested inside a card */

    /* ── Borders — plain separates, strong is interactive ───────────── */
    --pp-border: light-dark(#DCD4C9, #2C3B4A); /* dividers, card edges, bracket connectors */
    --pp-border-strong: light-dark(#C9BFB1, #3E5265); /* inputs, .updown, radio rings, .row-menu-button */

    /* ── Text — muted is the floor, nothing goes quieter ────────────── */
    --pp-text: light-dark(#1A2430, #E6EDF3); /* headings, player names, points */
    --pp-text-2: light-dark(#4C5A69, #A9B9C7); /* body copy, table cells, .rule */
    --pp-text-muted: light-dark(#5F6C78, #8193A7); /* labels, .section-label, .is-source, ranks
       — the dark value clears 4.5:1 on every surface AND every signal tint it
       lands on (badge-muted, success/select tints), not just the page ground. */

    /* ── Brand — "this is Sofaliga". NOT selection. ────────────────── */
    --pp-brand: light-dark(#8A5A32, #C08A5E); /* .site-title .crown .badge-brand — as INK on the page */
    --pp-brand-hover: light-dark(#6E4626, #D19E74);
    --pp-brand-tint: light-dark(#F2E7DC, #241A11); /* .alert-brand, .badge-brand ground */
    /* -solid is the same role as a FILL under white text, which inverts the
       requirement: the ink token lightens in dark so it reads on the ground,
       and white on that lightened brown is 2.98:1. So -solid stays dark in
       both themes and its hover deepens instead of lightening — exactly what
       --pp-danger-solid already does. Use -solid for any filled control. */
    --pp-brand-solid: light-dark(#8A5A32, #96633A); /* .btn-primary, Pico's button + :checked fill, .avatar-0 */
    --pp-brand-solid-hover: light-dark(#6E4626, #7E5230);

    /* ── Select — "this one is yours / current". NOT the brand. ─────── */
    --pp-select: light-dark(#2C6BB0, #4E8BD1); /* .is-me .is-winner .active [aria-current] :checked */
    --pp-select-text: light-dark(#2C6BB0, #5E9BDE); /* same role, as text on a tint */
    --pp-select-tint: light-dark(#E4EDF8, #12212F);
    --pp-select-solid: light-dark(#2C6BB0, #3175BE); /* white on it: .avatar-1 */
    --pp-select-solid-hover: light-dark(#245A96, #285F9C);

    /* ── Success ────────────────────────────────────────────────────── */
    --pp-success: light-dark(#1F7A4C, #3FA96B); /* .is-hit .rank-up .live-dot .done — as ink */
    --pp-success-hover: light-dark(#19653F, #4FBC7B);
    --pp-success-tint: light-dark(#E4F2EA, #12271C); /* .badge-success, pp-flash keyframe start */
    --pp-success-solid: light-dark(#1F7A4C, #228455); /* .btn-success fill, .avatar-2 */
    --pp-success-solid-hover: light-dark(#19653F, #1B6E44);

    /* ── Danger ─────────────────────────────────────────────────────── */
    --pp-danger: light-dark(#B3392F, #D45B5B); /* .text-danger .rank-down .is-alert .error-text */
    --pp-danger-solid: light-dark(#B3392F, #C0392F); /* .btn-danger fill — white label clears AA */
    --pp-danger-hover: light-dark(#932C24, #A82F27);
    --pp-danger-text: light-dark(#8A2B23, #EFA9A2); /* body copy inside .alert-error */
    --pp-danger-tint: light-dark(#FBE8E6, #2A1618);
    --pp-danger-edge: light-dark(#F0C4BF, #4A2427); /* .btn-danger-ghost border, .field-error border */

    /* ── Warn ───────────────────────────────────────────────────────── */
    --pp-warn: light-dark(#8A5F0F, #D9A03C); /* .text-warn .badge-warn .lock-chip.is-soon LABEL */
    --pp-warn-rule: light-dark(#C89A2E, #D9A03C); /* .alert-warn 4px rule, .is-unanswered edge */
    --pp-warn-text: light-dark(#6E4C0B, #E9C782); /* body copy inside .alert-warn */
    --pp-warn-tint: light-dark(#FBF0DA, #2A2113);

    /* ── Charts — one series, in a hue no signal uses ─────────────────
       The Picks tab's share bars (docs/color/README.md §3.11). The bar is
       plum, OKLCH hue 320: brand is "this is Sofaliga", select "yours",
       and a share of everyone's picks is neither. Checked with the dataviz
       skill's validate_palette.js against --pp-surface in both themes
       (lightness band, chroma floor, 5.6:1 and 4.7:1). The track is the
       same hue a step off the surface: the whole a bar is read against,
       never a mark of its own. */
    --pp-chart-bar: light-dark(#8A5296, #A970B6);
    --pp-chart-track: light-dark(#F4E8F6, #352937);

    /* ── Focus — always blue, never brand, never removed ────────────── */
    --pp-focus: light-dark(#2C6BB0, #5E9BDE);

    /* ── Avatars — hashed by operator id; must not read as a signal ───
       Always a fill under white initials, so these are -solid values: dark
       enough for white in both themes (see --pp-brand-solid). */
    --pp-avatar-0: light-dark(#8A5A32, #96633A);
    --pp-avatar-1: light-dark(#2C6BB0, #3175BE);
    --pp-avatar-2: light-dark(#1F7A4C, #228455);
    --pp-avatar-3: light-dark(#9C3F5C, #A84666);
    --pp-avatar-none: light-dark(#5F6C78, #5A6874); /* unhashed fallback fill */

    /* ── Pico bridge — each family moves together ─────────────────────
       --pico-primary-background/-border and their -hover- pair are a FILL
       under --pico-primary-inverse (white): every classless button and
       Pico's :checked checkbox, radio and switch — so they take -solid.
       --pico-primary/-hover/-underline are INK on the page ground (links,
       summaries) and take --pp-brand. White on the ink brown is 2.98:1 in
       dark mode, 2.37:1 on its hover. */
    --pico-background-color: var(--pp-bg);
    --pico-color: var(--pp-text);
    --pico-muted-color: var(--pp-text-muted);
    --pico-muted-border-color: var(--pp-border);
    --pico-h1-color: var(--pp-text);
    --pico-h2-color: var(--pp-text);
    --pico-h3-color: var(--pp-text);
    --pico-h4-color: var(--pp-text);
    --pico-h5-color: var(--pp-text);
    --pico-h6-color: var(--pp-text);
    --pico-primary: var(--pp-brand);
    --pico-primary-background: var(--pp-brand-solid);
    --pico-primary-border: var(--pp-brand-solid);
    --pico-primary-hover: var(--pp-brand-hover);
    --pico-primary-hover-background: var(--pp-brand-solid-hover);
    --pico-primary-hover-border: var(--pp-brand-solid-hover);
    --pico-primary-underline: color-mix(in srgb, var(--pp-brand) 50%, transparent);
    --pico-primary-focus: var(--pp-focus);
    --pico-primary-inverse: #fff;
    --pico-form-element-background-color: var(--pp-surface);
    --pico-form-element-border-color: var(--pp-border-strong);
    --pico-form-element-color: var(--pp-text);
    --pico-form-element-focus-color: var(--pp-focus);
    --pico-form-element-active-border-color: var(--pp-focus);
    --pico-card-background-color: var(--pp-surface);
    --pico-card-border-color: var(--pp-border);
    --pico-table-border-color: var(--pp-border);
    --pico-code-background-color: var(--pp-surface-2);
    --pico-code-color: var(--pp-text-2);
    --pico-text-selection-color: var(--pp-select-tint);
    --pico-border-radius: 0.5rem;
    --pico-font-family-sans-serif: system-ui, -apple-system, sans-serif;
  }

  /* A fixed 16px base instead of Pico's fluid scale. Pico ramps
     --pico-font-size 100% → 106.25% → 112.5% → 118.75% → 125% → 131.25%
     across its breakpoints, so on a wide monitor every rem in this file
     renders 31% larger than it reads, and `40rem` in a @container query
     means 800px rather than 640px. That ramp suits a prose site; both of
     ours are dense — a leaderboard, a results table, a settle queue — and
     every type scale worth copying assumes a 16px root. The admin was
     already pinned; this is the same call for the player app. */
  /* ── Type scale ───────────────────────────────────────────────────
     Five steps, no in-between values. The sizes are where IBM Carbon's
     "productive" set and GitHub Primer independently landed — both are
     data-dense product systems — and the count is what Learn UI Design
     recommends (a heading, a body, a secondary, a caption).

       xs  12px  Carbon label-01 / helper-text-01, Primer caption
       sm  14px  Carbon body-compact-01, Primer body-medium (their UI default)
       md  16px  Carbon body-02, Primer body-large — the reading size
       lg  20px  Carbon heading-03, Primer subtitle
       xl  28px  Carbon heading-04

     12px is the floor: it is where both systems put captions and labels,
     and nothing here goes under it. Reading text stays at md, which is the
     16px minimum every accessibility guide agrees on. All rem, so browser
     zoom and a user's own default size still scale everything. */
  /* Pico sizes <small> in em, so it multiplies against its parent: 0.833em
     inside a 12px table cell is 10px, and a second nesting is 8px. An
     absolute step cannot compound, and the muted colour already carries the
     demotion that the shrinking was doing. */
  small { font-size: var(--pp-text-xs); }

  :root {
    font-size: 100%;
    --pp-text-xs: 0.75rem;
    --pp-text-sm: 0.875rem;
    --pp-text-md: 1rem;
    --pp-text-lg: 1.25rem;
    --pp-text-xl: 1.75rem;
  }
}

/* ── Layout ───────────────────────────────────────────────────── */

@layer components {
  body {
    background: var(--pp-bg);
    color: var(--pp-text);
    min-height: 100dvh;
    display: grid;
    grid-template-rows: auto 1fr auto;
    /* One column that can never grow past the viewport: an implicit `auto`
       track sizes to its widest child's min-content, so a wide table would
       push the whole page — header included — sideways on a phone. */
    grid-template-columns: minmax(0, 1fr);
  }

  /* Site header. Its own stacking context (position + z-index), so the
     account menu it contains always paints above the page — including
     sticky/positioned content in <main> that would otherwise land on top
     of an unpositioned header purely by DOM order. */
  body > header {
    position: relative;
    z-index: 20;
    border-bottom: 1px solid var(--pp-border);
    padding-block: 0.75rem;

    nav {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 1rem;
    }

    .site-title {
      font-size: var(--pp-text-lg);
      font-weight: 800;
      text-decoration: none;
      color: var(--pp-brand);
      letter-spacing: -0.02em;
    }

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

  /* ── Buttons ──────────────────────────────────────────────────── */

  .btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    /* Pico gives every [type=button] a 1rem margin-bottom; .cluster centres
       margin boxes, so without this reset a button floats above its row. */
    margin: 0;
    padding: 0.45rem 1rem;
    border-radius: var(--pico-border-radius);
    font-size: var(--pp-text-sm);
    font-weight: 500;
    cursor: pointer;
    text-decoration: none;
    border: 1px solid transparent;
    transition: background-color 0.15s, color 0.15s, border-color 0.15s;
    background: none;

    &.btn-primary {
      background: var(--pp-brand-solid);
      color: #fff;
      border-color: var(--pp-brand-solid);
      &:hover { background: var(--pp-brand-solid-hover); border-color: var(--pp-brand-solid-hover); }
    }

    &.btn-ghost {
      color: var(--pp-text-2);
      border-color: var(--pp-border);
      &:hover { background: var(--pp-surface-2); color: var(--pp-text); }
    }

    /* The affirmative: "yes, settle this". Filled in both modes so it keeps
       its rank; its refusing partner is the outline .btn-danger-ghost. */
    &.btn-success {
      background: var(--pp-success-solid);
      color: #fff;
      border-color: var(--pp-success-solid);
      &:hover { background: var(--pp-success-solid-hover); border-color: var(--pp-success-solid-hover); }
    }

    /* Filled danger is for irreversible destruction only (delete a pool,
       delete an account), paired with a .consequence block. */
    &.btn-danger {
      background: var(--pp-danger-solid);
      color: #fff;
      border-color: var(--pp-danger-solid);
      &:hover { background: var(--pp-danger-hover); border-color: var(--pp-danger-hover); }
    }

    &.btn-sm {
      padding: 0.25rem 0.6rem;
      font-size: var(--pp-text-xs);
    }

    /* The one primary action on a player page: thumb-sized. */
    &.btn-lg {
      padding: 0.75rem 1.25rem;
      font-size: var(--pp-text-md);
      min-height: 3rem;
      justify-content: center;
    }

    &:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
  }

  /* ── Cards / Sections ─────────────────────────────────────────── */

  .card {
    background: var(--pp-surface);
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    padding: 1.5rem;
  }

  /* ── Page header (title + actions on one row) ──────────────────── */

  .page-header {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.75rem;

    /* A title here is often one unbreakable word — an account's email, a nick
       with no spaces — and a flex item is never narrower than its longest
       word, so without the wrap a phone pans the whole page sideways. */
    & > :is(h1, h2, h3) { margin: 0; overflow-wrap: anywhere; }
  }

  /* ── Modal (dialog body) ──────────────────────────────────────── */

  .modal-body {
    min-width: min(26rem, 90vw);
  }

  /* ── Bracket tree ─────────────────────────────────────────────── */
  /* components.Bracket (docs/design/bracket-builder-plan.md §5.2). A CSS grid:
     one column per round, every match spanning 2^round equal rows, so a
     match's centre is exactly the row line its two feeders share — the
     connectors are drawn from that fact and nothing else. What breaks it:
     any gap between rows, overflow:hidden on a card, a margin on the <li>,
     a fluid column. tests/bracket/check.js measures the joints. */

  /* The query container is the section, not the viewport: a container query
     styles descendants only, and list mode must switch the viewport's own
     scroller off (below). Section and viewport share one inline size. */
  .bracket-section { container: bracket / inline-size; }
  .bracket-viewport {
    overflow: auto;                       /* the one scroller in grid mode: sticky headings pin to it */
    max-height: min(80vh, 60rem);
    --bracket-gap: 2rem;
    --bracket-col: 12rem;                 /* a FIXED track — the only width authority; what makes the ellipsis work */
    --bracket-line: 1px solid var(--pp-border);
  }
  .bracket {
    display: grid;
    grid-auto-flow: column;               /* definite column + auto row → stacks top-down per round */
    grid-auto-rows: 1fr;                  /* every match row equal, whatever the tallest card is */
    grid-auto-columns: var(--bracket-col);
    justify-content: start;               /* never let tracks stretch to the container */
    column-gap: var(--bracket-gap);
    row-gap: 0;                           /* a row gap breaks "item edge = row line" */
    margin: 0; padding: 0; list-style: none; overflow: visible;
  }
  .bracket:has(> .bracket-round) { grid-template-rows: auto; }   /* the heading row */

  .bracket > [data-col="1"] { grid-column: 1 } .bracket > [data-col="2"] { grid-column: 2 }
  .bracket > [data-col="3"] { grid-column: 3 } .bracket > [data-col="4"] { grid-column: 4 }
  .bracket > [data-col="5"] { grid-column: 5 } .bracket > [data-col="6"] { grid-column: 6 }
  .bracket > [data-col="7"] { grid-column: 7 } .bracket > [data-col="8"] { grid-column: 8 }
  .bracket > [data-span="2"]  { grid-row: span 2 }  .bracket > [data-span="4"]  { grid-row: span 4 }
  .bracket > [data-span="8"]  { grid-row: span 8 }  .bracket > [data-span="16"] { grid-row: span 16 }
  .bracket > [data-span="32"] { grid-row: span 32 } .bracket > [data-span="64"] { grid-row: span 64 }

  /* Headings MUST carry data-col (placed in auto-placement step 1, reserving
     row 1 before the column-locked matches); sticky; Pico gives li a margin. */
  .bracket-round { grid-row: 1; margin: 0; position: sticky; top: 0; z-index: 1; background: var(--pp-bg); min-width: 0; }
  .bracket-round::before { content: ""; position: absolute; inset: 0 calc(var(--bracket-gap) * -1) 0 0; background: var(--pp-bg); z-index: -1; } /* covers the gap so lines do not bleed through */
  .bracket-round-label { margin: 0; text-align: center; font-size: var(--pp-text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--pp-text-muted); padding-block: .25rem; }
  .bracket-title { font-size: var(--pp-text-md); margin: 0 0 .25rem; }

  /* The item stretches to its grid area; the card is centred inside. No margin, border or overflow:hidden here. */
  .bracket-match {
    position: relative; margin: 0; min-width: 0; border: 0; background: none; overflow: visible;
    display: flex; flex-direction: column; justify-content: center;
    padding-block: .375rem;
    scroll-margin-block-start: var(--pico-spacing); /* a #bracket-match-… jump does not land flush */
  }
  .bracket-card { position: relative; display: flex; flex-direction: column; border: 1px solid var(--pp-border); border-radius: var(--pico-border-radius); background: var(--pp-surface); }
  .bracket-card.is-ready { border-color: var(--pp-border-strong); }
  /* Not answered and still answerable: the edge .pick-card uses, so one page
     does not say "you owe me an answer" in two vocabularies. After .is-ready
     on purpose — a ready match you have not answered is the one to point at. */
  .bracket-card.is-unanswered { border-color: var(--pp-warn-rule); }
  .bracket-slot {
    display: flex; align-items: center; gap: .5rem; margin: 0; padding: .4rem .6rem; min-height: 2.25rem;
    font: inherit; font-size: var(--pp-text-sm); line-height: 1.3; color: inherit; background: none; border: 0; border-radius: 0; width: 100%; text-align: left;
    & + & { border-top: 1px solid var(--pp-border); }
    /* Pico switches a button's outline off and rings it with box-shadow; the
       card clips neither, so the ring is drawn inside the slot instead. */
    &:focus-visible { outline: 2px solid var(--pp-focus); outline-offset: -3px; box-shadow: none; }
    &.is-winner { font-weight: 700; color: var(--pp-select-text); background: var(--pp-select-tint); }
    &.is-loser  { color: var(--pp-text-muted); }
    &.is-correct { color: var(--pp-success); background: var(--pp-success-tint); }   /* as .badge-success does; there is no --pp-success-text token */
    &.is-wrong   { color: var(--pp-danger-text);  background: var(--pp-danger-tint); }
    &.is-dead    { color: var(--pp-text-muted); text-decoration: line-through; }
    &.is-stale   { color: var(--pp-warn-text); }
    &.is-me      { outline: 2px solid var(--pp-select); outline-offset: -2px; }
  }
  /* Pick mode: a slot is a <label class="bracket-slot choice"> around a
     radio. .choice brings the pointer and the checked tint; the card brings
     the frame, so the label's own border and box are reset here (both classes
     live in this layer, and .choice is declared later — hence the extra
     class in the selector rather than source order). */
  label.bracket-slot.choice {
    border: 0; border-radius: 0; padding: .4rem .6rem; min-height: 2.25rem;
    &:has(:checked) { font-weight: 700; color: var(--pp-select-text); background: var(--pp-select-tint); }
  }
  .bracket-slot + label.bracket-slot.choice { border-top: 1px solid var(--pp-border); }
  @media (pointer: coarse) { label.bracket-slot.choice { min-height: 2.75rem; } }
  /* Whole-tree picking: the match is one <select> under its two slots, because
     a later round has no two names to choose between yet. Pico gives every
     select a margin-bottom and generous padding; inside a card both are a
     gap, so they are dialled down through Pico's own tokens. */
  .bracket-choose select {
    margin: 0;
    font-size: var(--pp-text-xs);
    --pico-form-element-spacing-vertical: 0.3rem;
    --pico-form-element-spacing-horizontal: 0.5rem;
  }
  .bracket-mark { font-size: var(--pp-text-xs); }
  .bracket-count { font-size: var(--pp-text-xs); color: var(--pp-text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
  .bracket-footer.is-stale { color: var(--pp-warn-text); }
  /* A branch the viewer's pick can no longer reach: greyed, never only
     coloured — the slot carries the strike-through and the words. */
  .bracket-card.is-dead .bracket-name:not(.is-source) { color: var(--pp-text-muted); }

  button.bracket-slot:not(:disabled) { cursor: pointer; &:hover { background: var(--pp-surface-2); } }
  button.bracket-slot.is-winner:not(:disabled):hover { background: var(--pp-select-tint); }
  button.bracket-slot:disabled { opacity: 1; }   /* the state is the text and the class, not a dimmed button */
  @media (pointer: coarse) { .bracket-slot { min-height: 2.75rem; } }
  .bracket-slot .bracket-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .bracket-name.is-source { color: var(--pp-text-muted); font-style: italic; white-space: normal; }  /* sources may wrap: they are the a11y text */
  .bracket-seed { font-size: var(--pp-text-xs); color: var(--pp-text-muted); font-variant-numeric: tabular-nums; min-width: 1.25em; }
  .bracket-undo { font-size: var(--pp-text-sm); color: var(--pp-text-muted); }
  /* A settled match's final score, right of the name (components.slotScore):
     tabular, so a column of cards lines its numbers up. */
  .bracket-score { font-variant-numeric: tabular-nums; min-width: 1.5em; text-align: end; }
  .bracket-no   { position: absolute; top: .15rem; right: .4rem; font-size: var(--pp-text-xs); color: var(--pp-text-muted); }
  .bracket-footer { font-size: var(--pp-text-xs); color: var(--pp-text-muted); padding: .2rem .6rem; border-top: 1px solid var(--pp-border); }
  .bracket-footer:empty { display: none; }   /* revealFooterText says nothing about an unsettled match whose picks are out */
  .bracket-live:empty { display: none; }

  /* Outgoing: from the card centre to the row line shared with the pair sibling, half a gap wide. */
  .bracket-match[data-feed]:not([data-feed="none"])::after {
    content: ""; position: absolute; inset-inline-start: 100%; width: calc(var(--bracket-gap) / 2);
    border-inline-end: var(--bracket-line); box-sizing: border-box;
  }
  .bracket-match[data-feed="top"]::after      { top: 50%; height: 50%; border-top: var(--bracket-line); }
  .bracket-match[data-feed="bottom"]::after   { top: 0;   height: 50%; border-bottom: var(--bracket-line); }
  .bracket-match[data-dashed]::after { border-style: dashed; }
  .bracket-match[data-dashed] .bracket-card { border-style: dashed; }
  /* Incoming: from the gap midpoint into the card at its centre (= the feeders' shared row line). */
  .bracket-match[data-from]:not([data-from="none"])::before {
    content: ""; position: absolute; inset-inline-end: 100%; top: 50%; width: calc(var(--bracket-gap) / 2); border-top: var(--bracket-line);
  }

  .bracket-section + .bracket-section { margin-block-start: var(--stack-gap, 1.5rem); }  /* upper / final */

  /* One card per region on the event page (plan §1.4): the whole card is the
     link into /events/{id}/brackets/{bid}/r/{q}. */
  .region-card {
    padding: 1rem;
    text-decoration: none;
    color: inherit;
    &:hover, &:focus-visible { border-color: var(--pp-brand); }
  }

  /* ── Bracket editor (plan §1.1, §5.2) ────────────────────────────
     The entrants on the left, the shape and the preview on the right;
     one column under 64 rem. The form is .form-compact for its rhythm,
     but the preview needs the width Pico's dense form caps. */
  form.bracket-editor {
    max-width: none;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    @media (min-width: 64rem) { grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr); }
    /* min-inline-size: a fieldset defaults to min-content, which would push
       the rounds table past a phone's column instead of letting it scroll. */
    fieldset { margin: 0; padding: 0; min-inline-size: 0; }
    legend { font-size: var(--pp-text-sm); font-weight: 500; padding: 0; margin-bottom: 0.35rem; }
  }
  /* The seed list: rows are .sortable-item (drag handle, position, name, ▲▼ ✕). */
  .seed-list {
    list-style: none;
    margin: 0;
    padding: 0;
    li { margin: 0 0 0.3rem; }
    .seed-empty { padding: 0.5rem 0.75rem; border: 1px dashed var(--pp-border); border-radius: var(--pico-border-radius); }
    .sortable-name { overflow-wrap: anywhere; }
    /* The name is the field itself now: a typo is repaired in place and the
       form still serialises DOM order. .form-compact already takes Pico's
       bottom margin off inputs; min-width lets the flex row shrink below the
       input's default size instead of pushing ▲▼ ✕ off the line. */
    input.sortable-name { min-width: 0; }
    /* A frozen list: same rows, no handle and no controls — the order cannot
       change, so nothing offers to change it. */
    .seed-frozen {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      padding: 0.35rem 0.75rem;
      border: 1px solid var(--pp-border);
      border-radius: var(--pico-border-radius);
      color: var(--pp-text-muted);
    }
  }
  .updown .seed-remove:hover { color: var(--pp-danger-text); border-color: var(--pp-danger-edge); }
  .bracket-rounds {
    overflow-x: auto;
    table { margin: 0; }
    th, td { padding: 0.25rem 0.4rem; vertical-align: middle; }
    th[scope="row"] { color: var(--pp-text-muted); font-variant-numeric: tabular-nums; }
    input[type="text"] { min-width: 9rem; }
    input[type="datetime-local"] { min-width: 12rem; }
  }

  /* List mode: a single column, each round's heading followed by its
     matches. The markup emits every heading first (the grid needs that), so
     the column is a flex container and `order` interleaves them by data-col;
     DOM order — and the reading order — is unchanged. */
  @container bracket (max-width: 40rem) {
    /* A column of cards scrolls with the page. Capped, the viewport was a
       second touch scroller around every region, and the sticky headings that
       justified it are un-stuck here anyway. */
    .bracket-viewport { max-height: none; overflow: visible; }
    .bracket { display: flex; flex-direction: column; }
    .bracket-match::before, .bracket-match::after { display: none; }
    .bracket-match { margin-block-end: .5rem; }
    /* Un-stick the heading, but keep it a containing block: its ::before is
       absolutely positioned, and a static parent hands that pseudo the
       INITIAL containing block — a viewport-sized opaque --pp-bg rectangle
       painted over the whole page. The mask has no job here anyway (no
       columns, no connector lines to bleed), so it also goes. */
    .bracket-round { position: relative; }
    .bracket-round::before { display: none; }
    .bracket-round-label { text-align: start; padding-block: .5rem .25rem; }
    .bracket > .bracket-round[data-col="1"] { order: 1 }  .bracket > .bracket-match[data-col="1"] { order: 2 }
    .bracket > .bracket-round[data-col="2"] { order: 3 }  .bracket > .bracket-match[data-col="2"] { order: 4 }
    .bracket > .bracket-round[data-col="3"] { order: 5 }  .bracket > .bracket-match[data-col="3"] { order: 6 }
    .bracket > .bracket-round[data-col="4"] { order: 7 }  .bracket > .bracket-match[data-col="4"] { order: 8 }
    .bracket > .bracket-round[data-col="5"] { order: 9 }  .bracket > .bracket-match[data-col="5"] { order: 10 }
    .bracket > .bracket-round[data-col="6"] { order: 11 } .bracket > .bracket-match[data-col="6"] { order: 12 }
    .bracket > .bracket-round[data-col="7"] { order: 13 } .bracket > .bracket-match[data-col="7"] { order: 14 }
    .bracket > .bracket-round[data-col="8"] { order: 15 } .bracket > .bracket-match[data-col="8"] { order: 16 }
  }
  @media (prefers-reduced-motion: no-preference) {
    .bracket-card { transition: background-color 200ms, border-color 200ms; }   /* a fed match turns is-ready: its border is the cue */
  }

  /* ── Tables ───────────────────────────────────────────────────── */

  .leaderboard {
    width: 100%;
    border-collapse: collapse;

    th, td {
      padding: 0.6rem 0.75rem;
      text-align: left;
      border-bottom: 1px solid var(--pp-border);
    }

    td { color: var(--pp-text-2); }

    th {
      font-size: var(--pp-text-xs);
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--pp-text-muted);
    }

    tbody tr {
      transition: background 0.1s;
      &:hover { background: var(--pp-surface-2); }
    }
  }

  /* ── Badges ───────────────────────────────────────────────────── */

  .badge {
    display: inline-flex;
    align-items: center;
    padding: 0.15rem 0.5rem;
    border-radius: 9999px;
    font-size: var(--pp-text-xs);
    font-weight: 600;

    &.badge-success {
      background: var(--pp-success-tint);
      color: var(--pp-success);
    }
    &.badge-warn {
      background: var(--pp-warn-tint);
      color: var(--pp-warn);
    }
    &.badge-brand {
      background: var(--pp-brand-tint);
      color: var(--pp-brand);
    }
    &.badge-muted {
      background: var(--pp-surface-2);
      color: var(--pp-text-muted);
    }
  }

  /* ── Alerts ───────────────────────────────────────────────────── */

  .alert {
    padding: 0.75rem 1rem;
    border-radius: var(--pico-border-radius);
    border-left: 4px solid;
    font-size: var(--pp-text-sm);

    &.alert-error {
      border-color: var(--pp-danger);
      background: var(--pp-danger-tint);
      color: var(--pp-danger-text);
    }
  }

  /* ── Sortable drag handle ─────────────────────────────────────── */

  .sortable-handle {
    cursor: grab;
    color: var(--pp-text-muted);
    user-select: none;
    padding-inline-end: 0.5rem;
    &:active { cursor: grabbing; }
  }

  .sortable-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    background: var(--pp-surface-2);
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    margin-block-end: 0.4rem;
  }

  /* ── Hero / centred splash ────────────────────────────────────── */

  .hero {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding-block: 5rem;
    gap: 1.5rem;

    h1 { font-size: var(--pp-text-xl); }
    /* anywhere: a pool's 404 names the signed-in address, one unbreakable
       word that would otherwise overflow the 36ch measure on a phone. */
    p  { color: var(--pp-text-muted); max-width: 36ch; overflow-wrap: anywhere; }

    .hero-actions {
      display: flex;
      gap: 0.75rem;
      flex-wrap: wrap;
      justify-content: center;
    }
  }

  /* ── Form helpers ─────────────────────────────────────────────── */

  .field {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;

    label { font-size: var(--pp-text-sm); font-weight: 500; }

    .field-error {
      display: none;
      font-size: var(--pp-text-xs);
      color: var(--pp-danger-text);
      background: var(--pp-danger-tint);
      border: 1px solid var(--pp-danger-edge);
      border-radius: 0.4rem;
      padding: 0.3rem 0.5rem;
      line-height: 1.35;
    }

    &.has-error .field-error {
      display: block;
    }

    &.has-error :is(input, select, textarea) {
      /* The border carries the error; the ring stays the focus blue so the
         two edges do not blur into one. */
      border-color: var(--pp-danger);
      box-shadow: 0 0 0 0.15rem var(--pp-focus);
    }
  }


  /* ── Admin chrome ─────────────────────────────────────────────── */

  .admin-nav {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    padding-inline: 1.5rem;
    max-width: 100rem;
    margin-inline: auto;

    .admin-links {
      display: flex;
      gap: 1rem;
      list-style: none;
      margin: 0;
      padding: 0;
      flex: 1;

      a { text-decoration: none; color: var(--pp-text); font-weight: 500; }
      a:hover { color: var(--pp-brand); }
    }

    /* Presence and the account menu stay on the header's one row; the links
       give up the width instead, since they are four short words. Not
       .cluster, for the reason given at .pref-switch. Theme, language and
       Sign out live in the menu, so the row is two small things wide. */
    .admin-actions {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      flex: 0 0 auto;
      flex-wrap: nowrap;

      /* The menu opens from the operator's name, drawn as a quiet button. */
      .nav-menu > summary {
        align-items: center;
        padding: 0.3rem 0.65rem;
        border: 1px solid var(--pp-border-strong);
        border-radius: var(--pico-border-radius);
        font-size: var(--pp-text-sm);
        color: var(--pp-text);
      }
      .admin-menu-name {
        max-width: 12rem;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
    }
  }

  .admin-main {
    width: 100%;
    max-width: 100rem;
    margin-inline: auto;
    padding: 1.5rem;
  }

  .back-link { text-decoration: none; color: var(--pp-text-muted); &:hover { color: var(--pp-brand); } }

  /* ── Avatars, presence, chips ─────────────────────────────────── */

  .avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 50%;
    font-size: var(--pp-text-xs);
    font-weight: 700;
    letter-spacing: 0.02em;
    color: #fff;
    background: var(--pp-avatar-none);
    position: relative;
    flex: 0 0 auto;
    box-shadow: 0 0 0 2px var(--pp-surface);

    &.avatar-0 { background: var(--pp-avatar-0); }
    &.avatar-1 { background: var(--pp-avatar-1); }
    &.avatar-2 { background: var(--pp-avatar-2); }
    &.avatar-3 { background: var(--pp-avatar-3); }
    &.avatar-empty {
      background: transparent;
      color: var(--pp-text-muted);
      border: 1px dashed var(--pp-text-muted);
      box-shadow: none;
    }

    /* presence dot */
    &.is-live::after {
      content: "";
      position: absolute;
      right: -1px;
      bottom: -1px;
      width: 0.55rem;
      height: 0.55rem;
      border-radius: 50%;
      background: var(--pp-success);
      border: 2px solid var(--pp-surface);
    }
  }

  .avatar-stack {
    display: inline-flex;
    .avatar + .avatar { margin-inline-start: -0.5rem; }
  }

  .online-cluster {
    display: inline-flex;
    align-items: center;
    .avatar + .avatar { margin-inline-start: -0.4rem; }
  }

  .live-dot {
    display: inline-block;
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: var(--pp-success);
    margin-inline-end: 0.35rem;
    animation: pp-pulse 2s ease-in-out infinite;
  }

  @keyframes pp-pulse {
    50% { opacity: 0.4; }
  }

  /* ── Popover menus ────────────────────────────────────────────── */

  /* Never set `display` on a [popover] element (no .stack/.cluster on it):
     the UA hides closed popovers with display:none, and any author display
     value overrides that. Lay out the children instead. */
  .menu {
    margin: 0;
    padding: 0.4rem;
    min-width: 12rem;
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);

    /* A menu hangs under the button that opened it. popovertarget makes that
       button the popover's implicit anchor and position-anchor:auto says to
       use it — spelled out because Chrome/Edge 144–150 default to none, which
       would drop the menu into the top-left corner. Every opener ends its
       row, so the menu right-aligns under it and flips when it would leave
       the viewport. */
    position-anchor: auto;
    &:popover-open {
      inset: auto;
      margin: 0.25rem 0;
      position-area: block-end span-inline-start;
      position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    }
    /* Without anchor positioning, take the UA's centring back: margin:0
       above is what used to pin every menu top-left. */
    @supports not (position-area: block-end) {
      &:popover-open { inset: 0; margin: auto; }
    }

    form { margin: 0; padding: 0.3rem; }
    form .menu-heading { padding-inline: 0; }
    select, label { margin-bottom: 0.4rem; }
  }

  /* An item in a popover .menu or in a .nav-menu's .row-menu-panel. */
  .menu-item {
    display: block;
    width: 100%;
    text-align: left;
    padding: 0.45rem 0.7rem;
    border: none;
    border-radius: calc(var(--pico-border-radius) - 2px);
    background: none;
    color: var(--pp-text);
    font: inherit;
    text-decoration: none;
    cursor: pointer;
    margin: 0;
    &:hover { background: var(--pp-surface-2); }
    &.menu-item-danger { color: var(--pp-danger); }
  }

  /* ── Player pool page ─────────────────────────────────────────── */

  /* The line under the pool name: event · when · players. */
  .pool-meta { color: var(--pp-text-muted); font-size: var(--pp-text-sm); margin: 0; }

  /* The stage card's one sentence: what to do now. */
  .next-move { font-size: var(--pp-text-md); font-weight: 500; margin: 0; line-height: 1.4; }
  .stage-card .btn-lg { width: 100%; }
  @media (min-width: 48rem) { .stage-card .btn-lg { width: auto; align-self: flex-start; min-width: 16rem; } }

  /* ── Home: pool cards and the needs-you strip ─────────────────── */

  .alert.alert-brand {
    border-color: var(--pp-brand);
    background: var(--pp-brand-tint);
    color: var(--pp-text);
  }

  .pool-card {
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    &.is-done { opacity: 0.75; }
    .btn { justify-content: center; }
  }
  .pool-card-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.6rem;
  }
  .pool-card-title {
    font-weight: 700;
    font-size: var(--pp-text-lg);
    color: var(--pp-text);
    text-decoration: none;
    &:hover { color: var(--pp-brand); }
  }
  .crown { color: var(--pp-brand); font-size: var(--pp-text-xs); margin-inline-start: 0.25rem; }
  .section-label {
    font-size: var(--pp-text-xs);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--pp-text-muted);
    font-weight: 600;
    margin: 0;
  }
  .empty-pools { padding: 1.5rem; }

  /* The header account menu: a <details> disclosure
     rather than the Popover API, so the panel is positioned relative to the
     avatar that opens it. */
  .nav-menu {
    position: relative;
    margin: 0;
    padding: 0;
    border: none;

    & > summary {
      list-style: none;
      cursor: pointer;
      padding: 0;
      margin: 0;
      border: none;
      background: none;
      width: auto;
      display: inline-flex;
      &::-webkit-details-marker { display: none; }
      &::after { display: none; }
      .avatar { width: 2.1rem; height: 2.1rem; }
    }
  }
  .menu .menu-heading, .row-menu-panel .menu-heading {
    margin: 0;
    padding: 0.35rem 0.7rem;
    font-size: var(--pp-text-xs);
    color: var(--pp-text-muted);
    /* The avatar menu names the signed-in address here, and an address is
       long. The panel is absolutely positioned with no max-width, so without
       a cap a long one stretches it off the side of a phone; 14rem is the
       panel's own 15rem min-width less its padding, so the cap costs the
       short case nothing. */
    max-width: 14rem;
    overflow-wrap: anywhere;
  }

  /* ── Leaderboard list, results, reveal, desktop grid ──────────── */

  .leaderboard-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
  }
  /* The rank track fits "=755", four characters, the largest pool; fixed, so
     the avatars stay in one column down the list. At 1.6rem it broke "=10"
     into "=1 / 0", which read as a tie for first. A name wraps rather than
     being cut: two long nicks that share a prefix ellipsised to the same
     "Ragn…", on the rows an organizer is about to act on. */
  .leaderboard-row {
    display: grid;
    grid-template-columns: 2.75rem 1.75rem minmax(0, 1fr) auto auto auto;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0;
    border-bottom: 1px solid var(--pp-border);
    &:last-child { border-bottom: none; }
    &.is-me { background: var(--pp-select-tint); margin-inline: -0.75rem; padding-inline: 0.75rem; border-radius: var(--pico-border-radius); }
    &.is-me .player-name { color: var(--pp-select-text); }
    .rank { text-align: center; font-weight: 600; color: var(--pp-text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
    .player-name { min-width: 0; color: var(--pp-text); }
    &.medal-1 .rank::before { content: "🥇"; }
    &.medal-2 .rank::before { content: "🥈"; }
    &.medal-3 .rank::before { content: "🥉"; }
    /* Before the first result every rank cell is empty (a medal row holds its
       sr-only ordinal, every other live row its number), so the empty track
       folds away and the name gets its width. */
    &:has(> .rank:empty) {
      grid-template-columns: 1.75rem minmax(0, 1fr) auto auto auto;
      > .rank { display: none; }
    }
  }
  /* A capped or searched board leaves rows out: the last row shown before a
     gap swaps its rule for a dashed one — above the viewer's rows and after
     them. One border, so no double line. */
  .board-standings:not([data-filter-expanded]) .leaderboard-row:not([hidden]):has(+ .leaderboard-row[hidden]) {
    border-bottom-style: dashed;
    border-bottom-color: var(--pp-border-strong);
  }
  /* Top level, not nested in .leaderboard-row: the run strip uses these too. */
  .rank-up { color: var(--pp-success); font-size: var(--pp-text-xs); font-weight: 600; }
  .rank-down { color: var(--pp-danger); font-size: var(--pp-text-xs); font-weight: 600; }
  .pts { font-weight: 700; font-variant-numeric: tabular-nums; }

  /* A row whose place just changed pulses once. */
  @keyframes pp-flash {
    0% { background: var(--pp-success-tint); }
    100% { background: transparent; }
  }
  /* The viewer's own row was left out of the pulse because pp-flash ends on
     transparent, which wipes its select tint. It is the row that matters most,
     so it gets the same pulse landing back where it started. */
  @keyframes pp-flash-me {
    0% { background: var(--pp-success-tint); }
    100% { background: var(--pp-select-tint); }
  }
  /* is-moved, not flash: .flash is the page-level toast, and a row carrying
     that class took its position:fixed and left the table for the bottom of
     the window on exactly the wake that moved it. */
  .leaderboard-row.is-moved:not(.is-me) { animation: pp-flash 1.6s ease-out; }
  .leaderboard-row.is-moved.is-me { animation: pp-flash-me 1.6s ease-out; }
  .result-row { transition: opacity 0.3s, transform 0.3s; }

  .pick-cell {
    &.is-hit { color: var(--pp-success); font-weight: 600; }
    &.is-partial { color: var(--pp-select-text); }
    &.is-miss { color: var(--pp-text-muted); }
    .cell-pts { font-size: var(--pp-text-xs); }
    /* A pick nobody may read yet: faded below the picks that show, so a row
       of them recedes. */
    .cell-hidden { color: var(--pp-text-muted); opacity: 0.7; }
    /* No pick saved: the warning colour, so the players who have not picked
       stand out down a column and across a row. */
    .cell-no-pick { color: var(--pp-warn); font-weight: 600; }
  }
  .matrix {
    th.is-me, td.is-me { background: var(--pp-select-tint); }
    th[scope="row"] { font-weight: 500; text-transform: none; letter-spacing: 0; font-size: var(--pp-text-sm); color: var(--pp-text); }
    /* Rank and points under a player's name: .leaderboard th is uppercase and
       tracked, which a number line with "pts" in it does not want. */
    thead small { display: block; text-transform: none; letter-spacing: 0; font-weight: 400; }

    /* The matrix grows a column per player and a row per contest, and it
       scrolls inside .table-scroll — so both headers have to stay put. At 20
       players the player names left the top of the viewport and every cell
       became an unlabelled mark; scrolling right did the same to the contest
       names. A sticky head pins to its scroll container, which is that
       wrapper: the names hold only where it is height-capped (the rule after
       this block). Both headers need a background, or rows show through. */
    thead th {
      position: sticky;
      inset-block-start: 0;
      z-index: 2;
      background: var(--pp-surface-2);
    }
    th[scope="row"] {
      position: sticky;
      inset-inline-start: 0;
      z-index: 1;
      background: var(--pp-surface);
      /* Without a width the cells squeezed the contest to 74px and its
         subtitle into a 351px-tall column of single words. */
      min-width: 12rem;
    }
    /* The corner belongs to both: it must outrank each of them. */
    thead th:first-child { z-index: 3; }
  }
  /* A sticky head pins to its nearest scroll container, and .table-scroll
     is one — uncapped it never scrolls, so the player names left with the
     page. Capped, it is the one two-axis scroller both sticky headers pin
     to, as .bracket-viewport is. Strictly wider than the card collapse
     below (max-width: 48rem), which a min-width query would overlap at
     exactly 768 px. */
  @media (width > 48rem) {
    .table-scroll:has(> .matrix) { max-height: min(80vh, 60rem); }
  }
  .reveal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; li { padding: 0.25rem 0; } }
  /* One markup at both widths. The matrix used to ship a card-shaped copy of
     every pick beside it and let display:none throw one of the two away: 85 KB
     of cards a desktop reader never sees, 32 KB of table a phone never sees.
     Under 48rem the same table collapses instead — the head is clipped rather
     than removed, so the column headers stay in the accessibility tree; each
     row becomes a card, and each cell prints the player it belongs to from its
     data-player. Changing these display properties strips a table of its
     semantics, which is why every part of the markup names its own role. */
  @media (max-width: 48rem) {
    .matrix {
      display: block;

      thead {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
      }
      tbody, tr, th { display: block; }
      tbody tr {
        padding: 0.5rem 0.75rem;
        border: 1px solid var(--pp-border);
        border-radius: var(--pico-border-radius);
        background: var(--pp-surface);
      }
      tbody tr + tr { margin-block-start: 0.75rem; }

      /* The contest is the card's heading now, not a sticky first column. */
      th[scope="row"] {
        position: static;
        padding: 0 0 0.35rem;
        background: none;
      }

      td {
        display: flex;
        flex-wrap: wrap;
        align-items: baseline;
        column-gap: 0.5rem;
        padding: 0.35rem 0;
        &:last-child { border-bottom: none; }
        /* The label the clipped column header would have given this cell. */
        &::before {
          content: attr(data-player);
          margin-inline-end: auto;
          color: var(--pp-text-muted);
        }
        /* The viewer's own line, tinted edge to edge like .leaderboard-row. */
        &.is-me { margin-inline: -0.75rem; padding-inline: 0.75rem; }
      }
    }
  }

  /* The pool page on a wide screen: the standings pinned in a rail, the
     results beside them. The rail used to hold the organizer tools, which
     never filled it, while the scoreboard sat above the log where it
     scrolled away — so reading a result cost a trip back up. Source order
     is rail-then-results, which is also the right order on a phone. */
  .pool-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    @media (min-width: 64rem) {
      grid-template-columns: minmax(15rem, 22rem) minmax(0, 1fr);
      /* Before the first result there is no second column to fill, and a
         scoreboard stranded in a rail beside 50rem of nothing is the defect
         this layout exists to fix. #pool-results always holds its sr-only
         live region, so the column collapses while that is all it holds; a
         correction note or a bracket keeps it. A lone rail is not sticky and
         does not scroll inside itself — there is nothing beside it to stick
         to, and a viewport-high scroller in a page that scrolls is a trap.
         TestPoolGridCollapseInvariant pins the markup this relies on. */
      &:not(:has(> #pool-results > :not(.sr-only))) {
        grid-template-columns: minmax(0, 1fr);
        .pool-rail { max-width: 34rem; position: static; max-height: none; overflow-y: visible; }
      }
    }
  }
  .pool-rail {
    @media (min-width: 64rem) {
      position: sticky;
      top: 1rem;
      max-height: calc(100dvh - 2rem);
      overflow-y: auto;
    }
  }
  /* The card's 1.5rem padding is sized for full-width cards; in the rail it
     took 48px of the names' width. Only #leaderboard matches. */
  .pool-rail > .card { padding: 1rem; }

  /* One bar for "what now": the sentence and the button that does it. */
  .stage-bar {
    padding: 0.7rem 0.9rem;
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    &.is-urgent { border-color: var(--pp-brand); background: var(--pp-brand-tint); }
    .btn { margin: 0; flex: 0 0 auto; }
  }
  /* The bar's buttons are the pool page's next move: a thumb target on a
     touch screen, like .bracket-slot and .filter-chip. Height, not width —
     full-width would push "Everyone's picks" onto a row of its own. */
  @media (pointer: coarse) { .stage-bar .btn { min-height: 2.75rem; } }
  .board-title { font-size: var(--pp-text-lg); }

  /* The viewer's own position, said once under the standings. */
  .run-strip {
    padding-top: 0.4rem;
    border-top: 1px solid var(--pp-border);
    font-size: var(--pp-text-sm);
    color: var(--pp-text-2);
  }

  .results {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--pp-text-sm);

    th, td {
      padding: 0.35rem 0.45rem;
      text-align: left;
      border-bottom: 1px solid var(--pp-border);
      vertical-align: baseline;
    }
    th:first-child, td:first-child { padding-inline-start: 0; }
    thead th {
      font-size: var(--pp-text-xs);
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--pp-text-muted);
    }
    tbody th[scope="row"] { font-weight: 500; color: var(--pp-text); }
    tbody th small { display: block; font-weight: 400; }
    .col-mark { width: 1.4rem; padding-inline: 0.2rem; text-align: center; font-weight: 700; }
    tbody th[scope="row"] { white-space: nowrap; }
    .col-result { color: var(--pp-text); }
    .col-mine { color: var(--pp-text-2); }
    .col-pts { width: 3rem; text-align: right; font-variant-numeric: tabular-nums; }
    .col-when { text-align: right; white-space: nowrap; padding-inline-end: 0; }
    tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
    /* A ranking result is a whole permutation; let that one row breathe. */
    tr:not(.is-long) .col-result, tr:not(.is-long) .col-mine {
      max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
  }
  /* One markup at both widths, like .matrix. Under 48rem the same table
     is re-laid as compact rows: round, points and when; then the result;
     then the viewer's pick, only when it differs from the result. The
     head is clipped rather than removed, and every part names its role in
     the markup, because changing a table's display strips its semantics. */
  @media (max-width: 48rem) {
    .results {
      display: block;
      line-height: 1.35;

      thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
      tbody { display: block; }
      tbody tr {
        display: grid;
        grid-template-columns: 1.4rem minmax(0, 1fr) auto auto;
        column-gap: 0.5rem;
        align-items: baseline;
        padding-block: 0.3rem;
        border-bottom: 1px solid var(--pp-border);
      }
      tbody tr:last-child { border-bottom: none; }
      th, td { display: block; padding: 0; border-bottom: none; }
      .col-mark { grid-column: 1; grid-row: 1 / span 3; width: auto; }
      tbody th[scope="row"] { grid-column: 2; grid-row: 1; overflow: hidden; text-overflow: ellipsis; }
      /* The upset line under the round wraps; the round's nowrap would clip it. */
      tbody th small { white-space: normal; }
      .col-pts { grid-column: 3; grid-row: 1; width: auto; }
      .col-when { grid-column: 4; grid-row: 1; }
      .col-result { grid-column: 2 / -1; grid-row: 2; }
      .col-mine {
        grid-column: 2 / -1;
        grid-row: 3;
        &::before { content: attr(data-label) ": "; color: var(--pp-text-muted); }
        /* On a hit the pick is the result again. Clipped, not display:none:
           a cell dropped from the accessibility tree moves Pts and When
           under the wrong column headers. */
        &.is-same { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
      }
      /* The wide table ellipsises these at 12rem; a phone row wraps them. */
      tr:not(.is-long) .col-result, tr:not(.is-long) .col-mine { max-width: none; white-space: normal; }
      /* With the pick folded away, the result carries the hit's green. */
      .result-row.is-hit .col-result { color: var(--pp-success); font-weight: 600; }
    }
  }

  /* The verdict mark carries the outcome; .pick-cell's vocabulary, so the
     app has one answer to "how did my pick do". */
  .result-row {
    &.is-hit .col-mark { color: var(--pp-success); }
    &.is-partial .col-mark { color: var(--pp-select-text); }
    &.is-miss .col-mark, &.is-none .col-mark { color: var(--pp-text-muted); }
    &.is-hit .col-mine { color: var(--pp-success); font-weight: 600; }
  }

  #pool-watching:empty { display: none; }

  /* ── The pool's Invite and Settings tabs ──────────────────────── */

  .copy-field {
    display: flex;
    gap: 0.5rem;
    input { margin: 0; font-size: var(--pp-text-xs); text-overflow: ellipsis; }
    .btn { flex: 0 0 auto; }
  }

  tr.is-me td { background: var(--pp-select-tint); }

  /* The ⋯ on a leaderboard row: it opens the page's one shared player menu.
     It used to be a <details> whose panel — a rename form and both role forms
     — was rendered inline on every row; the body is fetched for the row that
     asked now, so this button is the whole control.
     Pico gives every button a margin-bottom and a filled look, and .cluster
     centres margin boxes, so both are reset here (see the pico skill). */
  .row-menu-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    margin: 0;
    padding: 0;
    border: 1px solid var(--pp-border-strong);
    border-radius: var(--pico-border-radius);
    background: none;
    color: var(--pp-text-2);
    font: inherit;
    font-size: var(--pp-text-lg);
    line-height: 1;
    cursor: pointer;
    &:hover { background: var(--pp-surface-2); }
  }
  /* On a leaderboard row the ⋯ is smaller than the 2.5rem target it gets
     in a list of its own — at full size it sets the row's height and
     steals the width the name needs. It keeps its border at rest, though:
     rename, promote, remove and the only way out of a pool all live behind
     this one glyph, and a borderless ⋯ reads as decoration. 2rem still
     sits under the row's avatar, so nothing grows. */
  .leaderboard-row .row-menu-button {
    width: 2rem; height: 2rem; font-size: var(--pp-text-sm);
  }

  /* A preference as one segmented control: System / Light / Dark, and the
     languages. Pico puts a margin-bottom on every button, so .pref-option
     resets margin — .cluster centres margin boxes and the segments would sit
     crooked otherwise. */
  /* Label above the control. Its id is the group's accessible name, so the
     words on screen and the words a screen reader hears are the same one.
     margin: 0, because the language switch's field is a <form>. */
  .pref-field {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    margin: 0;
  }
  .pref-label {
    font-size: var(--pp-text-xs);
    color: var(--pp-text-muted);
  }
  /* Signed out, the language switch sits in the header bar: the two names,
     each in its own language, say what it is, and a label would be in the
     language this visitor may not read. It stays the group's accessible
     name. ">" so the copies inside the avatar menu (also under .nav-actions)
     keep their visible label above the pill. */
  .nav-actions > .pref-field .pref-label {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap;
  }

  /* The layout is declared here rather than with .cluster: .cluster's
     `flex-wrap: wrap` lives in @layer utilities and beats anything this layer
     says, so a .cluster switch breaks itself onto three rows in the admin
     header. */
  .pref-switch {
    display: flex;
    flex: 0 0 auto;
    flex-wrap: nowrap;
    align-self: start;
    /* role="group" is right for the a11y tree, and it also opts this div
       into Pico's form button-group: width:100%, a margin-bottom, and
       flex:1 1 auto on every child. Reset all three — the same discipline
       the pico skill states for buttons and margin. */
    width: auto;
    margin: 0;
    border: 1px solid var(--pp-border-strong);
    border-radius: var(--pico-border-radius);
    overflow: hidden;
    .pref-option {
      flex: 0 0 auto;
      margin: 0;
      padding: 0.3rem 0.5rem;
      border: 0;
      border-radius: 0;
      background: none;
      color: var(--pp-text-2);
      font-size: var(--pp-text-sm);
      font-weight: 500;
      line-height: 1.4;
      cursor: pointer;
      &:hover { background: var(--pp-surface-2); }
      &[aria-pressed="true"] {
        background: var(--pp-select-tint);
        color: var(--pp-select-text);
      }
    }
  }

  /* The dropdown panel of a <details> menu (.nav-menu): opens under its
     trigger, right-aligned to it. */
  .row-menu-panel {
    position: absolute;
    right: 0;
    top: calc(100% + 0.25rem);
    z-index: 3;
    min-width: 15rem;
    padding: 0.5rem;
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
    form { margin: 0; }
    input { margin: 0; }
  }

  .btn.btn-danger-ghost {
    color: var(--pp-danger);
    border-color: var(--pp-danger-edge);
    &:hover { background: var(--pp-danger-tint); border-color: var(--pp-danger); }
  }

  .join-hero { padding-block: 2rem; }
  .join-card { width: min(28rem, 100%); text-align: center; padding: 1.5rem; }
  .join-card .field, .join-card form { text-align: left; width: 100%; }
  /* The note is the pool page's left-ruled note now, so it keeps its own
     alignment inside the centred card — a rule down the side of centred text
     reads as a mistake. */
  .join-card .pool-note { text-align: left; }

  /* ── Picks page ───────────────────────────────────────────────── */

  .section-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.75rem;
    flex-wrap: wrap;
  }

  /* An instant and the zone it is read in (components.LocalTime), kept on one
     line so a zone never wraps away from its clock. */
  .instant { white-space: nowrap; }

  /* A deadline chip: "Locks in 2 days · Sat 16 May 21:00 CEST". */
  .lock-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.15rem 0.6rem;
    border: 1px solid var(--pp-border);
    border-radius: 9999px;
    font-size: var(--pp-text-xs);
    color: var(--pp-text-muted);
    white-space: nowrap;
    &.is-soon {
      color: var(--pp-warn);
      border-color: var(--pp-warn-rule);
      background: var(--pp-warn-tint);
    }
  }

  /* One card per contest; two-up on a wide screen. */
  .cards2 {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 0.75rem;
    @media (min-width: 64rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  .pick-card {
    padding: 0.9rem 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    scroll-margin-block-start: var(--pico-spacing); /* "still to answer →" does not land flush */
    &.is-unanswered { border-color: var(--pp-warn-rule); }
    &.is-done { opacity: 0.85; }
    select, input[type="number"] { margin-bottom: 0; }
  }
  /* An unanswered ranking is a draft until the player moves a name or ticks
     "This is my order": dashed rows and no position numbers, because the
     roster order it has to show is not an answer and must not look like one.
     :checked is live, so the card flips the moment sortable-init.js ticks the
     box. Scoped to .pick-card: the admin settle panel and the seed list keep
     their look. */
  .pick-card:has(.ranking-keep input:not(:checked)) {
    .sortable-item { border-style: dashed; border-color: var(--pp-border-strong); background: transparent; }
    .sortable-pos { visibility: hidden; }
  }
  .pick-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.6rem;
  }
  /* A card's title is a real heading — for the document outline and for
     heading navigation — sized as body copy: promoting one from a <span> or
     a <strong> must not reprint the page. */
  .pick-title { font-weight: 600; font-size: inherit; line-height: inherit; margin: 0; }
  .rule { font-size: var(--pp-text-xs); color: var(--pp-text-2); }

  /* A radio or checkbox row that is a whole tap target — so its edge is the
     control's edge, not a divider: --pp-border-strong, the step the colour
     system gives inputs and radio rings (docs/color/README.md §3.5). At the
     divider step a column of answers read as ruled paper. */
  .choice {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.55rem 0.75rem;
    border: 1px solid var(--pp-border-strong);
    border-radius: var(--pico-border-radius);
    min-height: 3rem;
    margin: 0;
    cursor: pointer;
    input { margin: 0; }
    &:has(:checked) {
      border-color: var(--pp-select);
      background: var(--pp-select-tint);
    }
  }
  .choice-row .choice { flex: 1; }

  /* A bracket's pick-mode pair (components.BracketModeChoice), hung under the
     bracket's tick on the create list and the Pool settings card: indented,
     so it reads as that bracket's answer rather than two more things to take.
     A fieldset, so Pico's margin and border are reset here. */
  .mode-choice {
    display: grid;
    gap: 0.35rem;
    margin: 0.35rem 0 0.35rem 1.75rem;
    padding: 0;
    border: 0;
    .choice { min-height: 2.5rem; }
  }

  /* How a pool scores its matches (components.MatchRuleChoice). The margin's
     bands and bonus and the spread's house rule hang under their own radio,
     indented like .mode-choice, and show only while that radio is chosen:
     the browser's own selection state read by :has(), so no script, and a
     morph of the card cannot leave the wrong one open. No .stack on
     .rule-detail — a utility display would beat this none. */
  .rule-detail {
    display: none;
    gap: 0.35rem;
    margin: 0 0 0.35rem 1.75rem;
    .choice { min-height: 2.5rem; }
  }
  .rule-choice:has([name=match_mode][value=margin]:checked) .rule-detail[data-rule=margin],
  .rule-choice:has([name=match_mode][value=spread]:checked) .rule-detail[data-rule=spread] {
    display: grid;
  }
  .rule-field {
    display: grid;
    gap: 0.2rem;
    margin: 0;
    font-size: var(--pp-text-sm);
    :is(select, input) { margin: 0; }
  }

  /* ── The create flow's event picker ───────────────────────────────
     Choosing what to bet on is the real decision, so it is a page of its
     own drawn as a browse surface rather than a form field: a search box,
     category chips, and short shelves of cards, each card a link to the
     pool form for its event. The chips are real radios inside their
     labels — the browser owns the selection state, :has(:checked) draws
     it, and the same controls are what the picker's form posts. */

  .picker-title { font-size: var(--pp-text-lg); }

  .search-field {
    position: relative;
    display: flex;
    align-items: center;

    /* Pico gives every input a margin-bottom; inside a stack that is a
       second gap. */
    input { margin: 0; padding-inline-start: 2.4rem; }
    /* Safari draws its own clear button on type=search and reserves room
       for a decoration we are not using. */
    input::-webkit-search-decoration { -webkit-appearance: none; }
  }
  .search-icon {
    position: absolute;
    inset-inline-start: 0.75rem;
    width: 1.05rem;
    height: 1.05rem;
    color: var(--pp-text-muted);
    pointer-events: none;
  }

  /* Filter chips — the create page's categories and the admin table's
     stages. Deliberately NOT .chip: that name was already taken by the
     people chip in the admin event header, which is a label, not a
     control. */
  .filter-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
  }
  /* A category's sub-categories: the row under the category chips, set in
     behind a rule so it reads as a refinement of the chip above it — which
     matters on a phone, where both rows wrap. */
  .filter-chips-sub {
    padding-inline-start: 0.7rem;
    border-inline-start: 2px solid var(--pp-border);
  }
  .filter-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    min-height: 2.25rem;
    padding: 0 0.7rem;
    margin: 0;
    border: 1px solid var(--pp-border);
    border-radius: 9999px;
    background: var(--pp-surface);
    font-size: var(--pp-text-sm);
    font-weight: 500;
    color: var(--pp-text-2);
    cursor: pointer;
    text-decoration: none;
    /* Two chips wear this class. On the create page it is a label around a
       radio: the radio is the state, not the picture, and it carries .sr-only
       so it stays focusable and announced while the label draws the state —
       the label then owns the focus ring, since a clipped input cannot show
       one. On the admin events list the chip is the path, so it is a link and
       its state is aria-current. Hence every selected rule is written twice. */
    &:hover { border-color: var(--pp-border-strong); }
    &:has(:focus-visible), &:focus-visible { outline: 2px solid var(--pp-focus); outline-offset: 2px; }
    &:has(:checked), &[aria-current] {
      border-color: var(--pp-select);
      background: var(--pp-select-tint);
      color: var(--pp-select-text);
      font-weight: 700;
    }
  }
  .filter-chip-count { color: var(--pp-text-muted); font-variant-numeric: tabular-nums; }
  .filter-chip:has(:checked) .filter-chip-count,
  .filter-chip[aria-current] .filter-chip-count { color: inherit; }
  @media (pointer: coarse) { .filter-chip { min-height: 2.75rem; } }

  .event-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
    gap: 0.5rem;
  }

  /* A card is a link to the pool form for its event, the whole card the one
     target. Pico colours and underlines every link, and rings its focus with
     a shadow; a card is not running text, so it keeps its own colours and
     draws the outline every other control here draws. */
  .event-card {
    display: flex;
    align-items: flex-start;
    gap: 0.7rem;
    padding: 0.7rem 0.8rem;
    margin: 0;
    min-height: 5rem;
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    color: inherit;
    text-decoration: none;
    &:hover { border-color: var(--pp-border-strong); }
    &:focus-visible { outline: 2px solid var(--pp-focus); outline-offset: 2px; box-shadow: none; }
  }
  .event-card-body { flex: 1; min-width: 0; }
  .event-card-name { font-weight: 600; line-height: 1.3; }
  .event-card-meta {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    margin-top: 0.15rem;
    color: var(--pp-text-2);
  }
  .event-card-sep { color: var(--pp-border-strong); }
  .event-card-pools { color: var(--pp-brand); font-weight: 600; }
  /* "You run Nerds United" — its own line rather than a third item in the
     meta row, which is a nowrap flex line inside a 17rem grid column. */
  .event-card-mine {
    margin-top: 0.15rem;
    color: var(--pp-brand);
    font-weight: 600;
  }

  .event-icon {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    margin-top: 0.1rem;
    border-radius: 50%;
    background: var(--pp-surface-2);
    color: var(--pp-text-2);
    svg { width: 1.05rem; height: 1.05rem; }
  }

  /* The chevron that says a card leads to another page. */
  .event-card-go {
    flex: none;
    align-self: center;
    color: var(--pp-text-muted);
    svg { display: block; width: 1rem; height: 1rem; }
  }
  .event-card:hover .event-card-go { color: var(--pp-brand); }

  .contest-choices .choice { width: auto; }
  /* A contest that can no longer take a pick: listed so nobody wonders where
     it went, but drawn at the divider step and muted so it never reads as a
     choice. Pico already dims the disabled checkbox itself. Scoped to the
     create list, so the picks page's label.bracket-slot.choice is untouched. */
  .contest-choices .choice:has(:disabled) {
    cursor: not-allowed;
    color: var(--pp-text-muted);
    border-color: var(--pp-border);
  }

  /* The contest picker collapsed to one line. .disclosure makes its
     summary a small button; here it is the whole width of the field, so
     the event's size reads before the control does. It is still that button,
     so it keeps .disclosure's edge — this rule used to override it down to
     --pp-border, the divider step, on the widest control on the page. */
  details.contest-disclosure > summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.6rem;
    padding: 0.55rem 0.75rem;
    font-weight: 400;
    &::before { order: 2; }
  }

  /* A bracket on the create page: one tick, with its matches listed
     underneath, indented to the label's text column. */
  .bracket-group {
    margin: 0.15rem 0 0 2.1rem;
    & > summary { padding: 0.15rem 0; }
    ul { margin: 0.15rem 0 0; padding-inline-start: 1.1rem; }
  }

  /* Progress is "how far you are", so it is the select blue — set on the
     pseudo-elements Pico paints rather than accent-color, which Pico's own
     progress styling overrides. */
  .progress-sm {
    height: 0.5rem;
    margin: 0;
    accent-color: var(--pp-select);
    &::-webkit-progress-value { background: var(--pp-select); }
    &::-moz-progress-bar { background: var(--pp-select); }
  }

  /* Rankings: visible positions, a thumb-sized handle, ▲▼ as the no-drag path. */
  .sortable-list .sortable-item { min-height: 3rem; }
  .sortable-pos { min-width: 1.4rem; text-align: center; font-weight: 700; color: var(--pp-text-muted); font-variant-numeric: tabular-nums; }
  .sortable-name { flex: 1; }
  .sortable-list .sortable-handle { font-size: var(--pp-text-lg); min-width: 2rem; touch-action: none; }
  .updown {
    display: inline-flex;
    gap: 2px;
    button {
      width: 2.25rem;
      height: 2.25rem;
      padding: 0;
      margin: 0;
      border: 1px solid var(--pp-border-strong);
      border-radius: 0.4rem;
      background: none;
      color: var(--pp-text-muted);
      font-size: var(--pp-text-xs);
      line-height: 1;
      &:hover { background: var(--pp-surface); color: var(--pp-text); }
    }
  }

  /* The sticky save bar at the foot of the picks page. */
  .action-bar {
    position: sticky;
    bottom: 0;
    z-index: 2;
    margin-inline: calc(-1 * var(--pico-spacing));
    padding: 0.75rem var(--pico-spacing) calc(0.75rem + env(safe-area-inset-bottom));
    background: var(--pp-surface);
    border-top: 1px solid var(--pp-border);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.35rem;
    .btn-lg { width: 100%; max-width: 32rem; }
    /* #picks-reset, the whole-tree preview's sentence above Save: as wide as
       the button, and no flex item at all while empty, so it adds no gap. */
    .picks-reset { width: 100%; max-width: 32rem; }
    .picks-reset:empty { display: none; }
  }

  .alert.alert-warn {
    border-color: var(--pp-warn-rule);
    background: var(--pp-warn-tint);
    color: var(--pp-warn-text);
  }

  /* The pool's standing note. A left rule, not a bubble: it is a field the
     organizer set once, not a line of conversation. */
  .pool-note {
    padding: 0.1rem 0 0.1rem 0.75rem;
    border-inline-start: 3px solid var(--pp-border-strong);
    color: var(--pp-text-2);
  }
  /* A pool with no note still renders the section — the settings and nick
     handlers patch it by id — but it takes no space and no stack gap. */
  #pool-note:empty { display: none; }

  /* The header band's figures line ends with the Assign-to control. */
  .figures .owner-controls { margin-inline-start: auto; }

  .activity-list {
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: var(--pp-text-sm);
    li { padding-block: 0.25rem; border-bottom: 1px solid var(--pp-border); }
    li:last-child { border-bottom: none; }
  }

  .danger-zone {
    padding-top: 1rem;
    border-top: 1px solid var(--pp-border);
  }

  /* ── Tabs (scopes on the events list, sections on one event) ───
     Every tab is a path, so these are links: the back button works, a tab
     is bookmarkable, and the page's stream only patches fragments that tab
     holds. */

  .tabs {
    display: flex;
    /* Pico spreads a <nav>'s children edge to edge; tabs are a group at the
       start of the line. */
    justify-content: flex-start;
    gap: 0.35rem;
    /* One line that scrolls sideways on a phone rather than a second row of
       tabs, which reads as a second set. A scroller clips what overhangs
       it, so the rule under the row is an inset shadow the active tab's own
       border paints over, not a border it has to overlap. */
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    box-shadow: inset 0 -1px 0 var(--pp-border);
  }
  .tab {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.4rem;
    padding: 0.5rem 0.85rem;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    font-size: var(--pp-text-sm);
    font-weight: 500;
    color: var(--pp-text-2);
    text-decoration: none;
    &:hover { color: var(--pp-text); }
    &.is-active {
      border-bottom-color: var(--pp-brand);
      color: var(--pp-text);
      font-weight: 600;
    }
  }
  /* Inside the scroller: Pico's ring is a shadow outside the link, which the
     row would clip at its top and bottom. */
  .tab:focus-visible { box-shadow: inset 0 0 0 2px var(--pp-focus); }
  .tab-count { color: var(--pp-text-muted); font-variant-numeric: tabular-nums; }

  /* ── The events table ──────────────────────────────────────────── */

  .event-table {
    width: 100%;
    border-collapse: collapse;

    th, td {
      padding: 0.55rem 0.75rem;
      text-align: left;
      border-bottom: 1px solid var(--pp-border);
      vertical-align: middle;
    }
    th {
      font-size: var(--pp-text-xs);
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--pp-text-muted);
    }
    /* On the cells, not the row: Pico paints every cell, and a cell's
       background covers the row's wherever the cell is. */
    tbody td { transition: background 0.1s; }
    tbody tr:hover > td { background: var(--pp-surface-2); }
    /* An event wanting an operator is marked by an edge, not by a fill:
       a whole tinted row at every other line reads as decoration. */
    .event-tr.is-alert td:first-child { box-shadow: inset 3px 0 0 var(--pp-warn-rule); }

    .col-starts { width: 15rem; }
    .col-stage  { width: 7rem; }
    .col-needs  { width: 10rem; }
    .col-pools  { width: 9rem; text-align: right; }
    /* Wide enough for the owner's name beside the avatar: a two-letter
       circle is not an answer to "who has this" on the screen where you take
       or reassign an event. The name truncates (.owner-name), so the column
       never grows past this. */
    .col-owner  { width: 12rem; }
    .num { font-variant-numeric: tabular-nums; white-space: nowrap; }
  }
  /* Name over category inside a real table cell. A display:flex <td> is no
     cell: it is only as tall as its content, and a one-line name left an
     unlit strip under it in a hovered row. */
  .cell-name {
    a, small { display: block; }
  }
  /* Owner cell: avatar, the owner's name, then whoever else is present.
     Not .cluster — that wraps, and a long name would drop to a second line
     rather than truncate inside the fixed column. */
  .owner-cell {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
    .owner-name {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
  /* The row's link is the name, stretched over the whole row: an operator
     clicking a table row expects to open it, not to hit the word. */
  .row-link {
    font-weight: 600;
    text-decoration: none;
    color: var(--pp-text);
    &::after { content: ""; position: absolute; inset: 0; }
    &:hover { color: var(--pp-brand); }
  }
  .event-table tbody tr { position: relative; }
  /* Anything clickable inside a linked row must sit above the stretched
     link, or a click on it opens the event instead. */
  .owner-controls { position: relative; z-index: 1; }
  .needs-cell { font-weight: 600; }
  .empty-note {
    padding: 1.5rem;
    border: 1px dashed var(--pp-border);
    border-radius: var(--pico-border-radius);
    text-align: center;
    color: var(--pp-text-muted);
  }

  /* ── Pick statistics: an event's Picks tab ─────────────────────────
     docs/design/pick-stats-plan.md §7. The tiles are a <dl>; every chart is
     a table first, and a bar is an inline SVG under its answer whose widths
     are attributes — no style attribute anywhere. Values keep the text
     tokens; only the bar carries the chart hue. */
  .stat-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
    gap: 1rem;
    margin: 0;
  }
  .stat-tile {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    dt { font-size: var(--pp-text-sm); font-weight: 500; color: var(--pp-text-muted); }
    dd { margin: 0; }
  }
  .stat-value {
    font-size: var(--pp-text-xl);
    font-weight: 600;
    line-height: 1.2;
    color: var(--pp-text);
  }
  .stat-note { font-size: var(--pp-text-sm); color: var(--pp-text-2); }

  /* Cards side by side while there is room, one column on a phone. */
  .picks-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
    gap: 1rem;
    align-items: start;
  }
  /* The two lists of results against the picks share the row between them. */
  .surprise-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(26rem, 100%), 1fr));
    gap: 1rem;
    align-items: start;
  }
  .picks-table .num { text-align: right; }
  .split-title { font-size: var(--pp-text-md); }
  .split-way {
    font-size: var(--pp-text-sm);
    font-weight: 600;
    small { font-weight: 400; margin-inline-start: 0.35rem; }
  }

  .share-table {
    width: 100%;
    margin: 0;
    border-collapse: collapse;

    th, td {
      padding: 0.35rem 0.5rem;
      border-bottom: 1px solid var(--pp-border);
      background: none;
      vertical-align: top;
      text-align: left;
    }
    th {
      font-size: var(--pp-text-xs);
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--pp-text-muted);
    }
    :is(th, td):first-child { padding-inline-start: 0; }
    :is(th, td):last-child { padding-inline-end: 0; }
    tbody tr:last-child td { border-bottom: none; }
    .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
    .share-answer { width: 100%; color: var(--pp-text-2); }
    .is-result .share-text { font-weight: 600; color: var(--pp-text); }
    .is-others .share-text { color: var(--pp-text-muted); }
    .badge { vertical-align: 0.1em; }
  }
  .share-bar {
    display: block;
    width: 100%;
    height: 0.5rem;
    margin-top: 0.3rem;
  }
  .share-track { fill: var(--pp-chart-track); }
  .share-fill { fill: var(--pp-chart-bar); }

  /* The Statistics page's new picks by day: one series of columns in an SVG
     that keeps its aspect as it scales with the card, so a column's round
     top stays round. The baseline stays a hairline at any width. */
  .day-chart { display: block; width: 100%; height: auto; }
  .day-col { fill: var(--pp-chart-bar); }
  .day-base { stroke: var(--pp-border); stroke-width: 1; vector-effect: non-scaling-stroke; }
  .day-axis {
    display: flex;
    justify-content: space-between;
    margin-top: 0.25rem;
    font-size: var(--pp-text-xs);
  }

  /* ── The event header band ─────────────────────────────────────── */

  /* The figures line replaces the stepper card: the same facts, one row. */
  .figures {
    font-size: var(--pp-text-sm);
    color: var(--pp-text-2);
  }
  .figures-sep { color: var(--pp-border-strong); }

  /* ── Disclosures (setup forms behind a button-like summary) ───── */

  .disclosure {
    margin: 0;
    padding: 0;
    border: none;

    & > summary {
      display: inline-flex;
      align-items: center;
      gap: 0.4rem;
      padding: 0.35rem 0.8rem;
      border: 1px solid var(--pp-border-strong);
      border-radius: var(--pico-border-radius);
      font-size: var(--pp-text-sm);
      font-weight: 500;
      cursor: pointer;
      list-style: none;
      &::before { content: "+"; color: var(--pp-text-muted); }
      &:hover { background: var(--pp-surface-2); }
    }
    & > summary::-webkit-details-marker { display: none; }
    & > summary::after { display: none; }
    &[open] > summary::before { content: "−"; }
    &[open] { padding-bottom: 0.5rem; }
  }

  /* ── Contest rows and inline panels ───────────────────────────── */

  /* One table, ordered so the contests still wanting a result are at the
     top. Each contest is a <tbody>: the row, plus a full-width row holding
     the settle and edit panels it opens. */
  .contest-table {
    width: 100%;
    border-collapse: collapse;
    border: 1px solid var(--pp-border);
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    /* Rounded corners on a table need the overflow clipped somewhere the
       panels can still expand: the border-radius alone does not clip cells. */
    overflow: hidden;

    th {
      padding: 0.5rem 0.9rem;
      text-align: left;
      border-bottom: 1px solid var(--pp-border);
      font-size: var(--pp-text-xs);
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--pp-text-muted);
    }
    td { padding: 0.55rem 0.9rem; vertical-align: middle; }
    .contest-tr td { border-top: 1px solid var(--pp-border); }
    thead + tbody .contest-tr td { border-top: none; }
    /* On the cells, not the row: Pico paints every cell, and a cell's
       background covers the row's wherever the cell is. */
    tbody:hover .contest-tr > td { background: var(--pp-surface-2); }

    /* Fixed, so the columns never move under a stream patch. Under auto
       widths the first settle on an event moved every Settle button 190–360
       px sideways, and one long ranking result squeezed every contest name
       to two or three lines. */
    .col-when { width: 16rem; }
    .col-status { width: 22rem; }
  }

  /* Under 48rem the same table collapses into one card per contest. As a
     table a phone got contest names seven lines tall in a 95 px column and a
     settle form 1.5 screens wide, scrolled sideways inside its box. Same
     approach as .matrix: one markup, the head clipped rather than removed so
     the column headers stay in the accessibility tree, and every part of the
     markup names its own role because these display values strip a table of
     its semantics. No cell needs a label: each already says what it is
     ("picks lock …", "Winner: …", Settle/Edit). */
  @media (max-width: 48rem) {
    .contest-table {
      display: block;
      border: none;
      border-radius: 0;
      background: none;
      overflow: visible;

      thead {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
      }
      tbody, tr, .contest-tr > td, .contest-panels > td { display: block; }
      /* The desktop columns' fixed widths would overflow a 326 px card. */
      .col-when, .col-status { width: auto; }
      /* Pico rules under every cell and paints each one --pp-bg, which would
         hide the card's surface. */
      td { border: none; background: none; }

      .contest-tbody {
        border: 1px solid var(--pp-border);
        border-radius: var(--pico-border-radius);
        background: var(--pp-surface);
        overflow: hidden;
      }
      .contest-tbody + .contest-tbody { margin-block-start: 0.5rem; }

      .contest-tr td { border-top: none; padding: 0.15rem 0.75rem; }
      .contest-tr td:first-child { padding-block-start: 0.6rem; }
      .contest-tr td:last-child { padding-block-end: 0.6rem; }
    }
  }

  /* Name over details inside a real table cell. A display:flex <td> is no
     cell: it is only as tall as its content, and the row's hover showed
     through the empty cell the browser wraps around it. The status cell
     lays its buttons out with a .cluster inside for the same reason. */
  .contest-cell-name {
    strong, small { display: block; }
  }
  .contest-tbody.is-settled .contest-cell-name strong { color: var(--pp-text-muted); }
  .contest-result { color: var(--pp-success); font-weight: 500; }

  /* The panels row carries no padding of its own: it is empty until a panel
     is open, and an empty cell with padding is a gap in the table. */
  .contest-panels > td { padding: 0; }

  .contest-panel {
    margin: 0;
    padding: 0;
    border: none;
    & > summary { display: none; }
    &[open] { border-top: 1px dashed var(--pp-border); background: var(--pp-surface-3); }
  }

  .settle-panel {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(14rem, 1fr);
    gap: 1.25rem;
    padding: 0.75rem 0.9rem;

    form { margin: 0; }
    label { margin-bottom: 0; }
  }

  .consequence {
    padding: 0.6rem 0.75rem;
    border-radius: var(--pico-border-radius);
    border: 1px solid var(--pp-warn-rule);
    background: var(--pp-warn-tint);
    color: var(--pp-warn-text);
  }

  .edit-panel {
    padding: 0.75rem 0.9rem;
    margin: 0;
  }

  /* Dense forms for the operator tool: Pico's spacing tokens dialled down on
     the form itself, so every control inside inherits the tighter rhythm. */
  .form-compact {
    --pico-form-element-spacing-vertical: 0.35rem;
    --pico-form-element-spacing-horizontal: 0.6rem;
    --pico-spacing: 0.5rem;
    --pico-typography-spacing-vertical: 0.5rem;
    font-size: var(--pp-text-sm);
    max-width: 48rem;

    label, fieldset { margin-bottom: 0.5rem; }
    input, select, textarea { font-size: var(--pp-text-sm); margin-bottom: 0; }
    label > input:not([type=checkbox]), label > select, label > textarea { margin-top: 0.15rem; }
    .grid { gap: 0.6rem; }
    .btn { margin-top: 0.25rem; }
    textarea { min-height: 0; }
  }

  /* ── Dashboard ────────────────────────────────────────────────── */

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

  .event-card {
    padding: 1rem;
    &.is-alert { border-color: var(--pp-danger); }
    .event-card-title { text-decoration: none; color: inherit; }
    .event-card-title:hover { color: var(--pp-brand); }
  }

  /* position: relative makes the scroller the containing block of the
     .sr-only (position:absolute) text inside its table. Without it that text
     escapes the overflow clip and widens the page: 577 cell labels made the
     reveal 1858 px wide in a 1280 px window, and one sr-only header made
     /users 581 px wide on a 375 px phone. */
  .table-scroll { overflow-x: auto; position: relative; }
  /* A suspended account and an archived pool are marked by the badge in the
     row's own status cell. Dimming repeated that badge and charged the whole
     row for it: 0.6 on --pp-text-2 lands under the AA floor, so the email,
     the counts and the suspension reason — the words an operator opens this
     table to read — got quieter than the muted text the system calls its
     floor. A surface step says "set aside" and takes nothing with it. */
  tr.is-banned, tr.is-archived { background: var(--pp-surface-2); }

  /* ── Responsive: the settle panel stacks below 1280px ──────────── */

  @media (max-width: 80rem) {
    /* One column stacks the aside after the form — the "What happens" box
       below the very button it explains, on any laptop. order puts it back
       above without moving it in the DOM, where it belongs after the
       controls: the form is what the operator came here to fill in. */
    .settle-panel {
      grid-template-columns: 1fr;
      .consequence { order: -1; }
    }
  }

  /* On a phone both apps' headers wrap, and the admin page's padding
     tightens. */
  @media (max-width: 48rem) {
    /* The player app's header: wordmark on one line, actions on the next. */
    body.app > header nav { flex-wrap: wrap; }
    body.app > header .nav-actions { flex-basis: 100%; justify-content: flex-end; }
    .admin-nav { padding-inline: 1rem; gap: 0.75rem; flex-wrap: wrap; }
    /* Wraps: four links in Norwegian ("Oversikt Arrangementer Puljer
       Brukere") are 386 px, and even the English ones overflow at 320 px. */
    .admin-nav .admin-links { flex-basis: 100%; order: 3; flex-wrap: wrap; row-gap: 0.25rem; }
    .admin-main { padding: 1rem; }
  }

  /* Honeypot */
  #xorf { display: none; }
}

/* ── Utilities ────────────────────────────────────────────────── */

@layer components {
  /* An instant rendered by components.LocalTime: js/time.js shows it in the
     viewer's zone and keeps the event's own time as the tooltip. */
  time[data-local] { text-decoration: underline dotted; text-decoration-color: var(--pp-text-muted); cursor: help; }

  /* "Live updates have stopped": the one thing a frozen page cannot say for
     itself. js/stream.js sets data-stream-stale on <html> when Datastar
     reports the page's stream over for good; a reload takes it away by
     starting a new one. Nothing else reveals this bar.

     .request-failed shares the bar: an action that never reached the app (a
     proxy 502, a lost network), revealed by the same script. One bar at a
     time, and the stale bar wins — it already says reload. Not the bottom
     edge: .flash and the picks page's sticky .action-bar live there, and a
     fixed bar would cover the Save the note asks them to press again. */
  .stream-stale,
  .request-failed {
    display: none;
    position: fixed;
    inset-block-start: 0;
    inset-inline: 0;
    z-index: 60;
    margin: 0;
    padding: 0.4rem var(--pico-spacing, 1rem);
    text-align: center;
    font-size: var(--pp-text-sm);
    color: var(--pp-warn-text);
    background: var(--pp-warn-tint);
    border-block-end: 1px solid var(--pp-warn-rule);
  }
  :root[data-stream-stale] .stream-stale { display: block; }
  :root[data-request-failed]:not([data-stream-stale]) .request-failed { display: block; }

  /* Flash: the page-level slot for a refusal that has no card of its own
     (a lost permission, a vanished pool). Patched by handlers.refuse; empty
     means hidden. */
  .flash {
    position: fixed;
    inset-block-end: var(--pico-spacing, 1rem);
    inset-inline: 0;
    display: grid;
    justify-items: center;
    padding-inline: var(--pico-spacing, 1rem);
    pointer-events: none;
    z-index: 50;

    &:empty { display: none; }
    & .alert { pointer-events: auto; max-width: min(100%, 40rem); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); }
    /* The × that gets the toast off the screen. .btn already resets Pico's
       margin-bottom; this only sizes the glyph. */
    .flash-close {
      padding: 0 0.35rem;
      font-size: var(--pp-text-lg);
      line-height: 1;
    }
  }

  /* The picks page pins its Save bar to the bottom of the viewport, and a
     toast parked on top of it covers the only control on the page. Clear the
     bar: one button, its padding, the "Saved · just now" line under it (Save's
     top edge is then about 5.75rem up) and the safe-area inset it also adds.
     Only rare refusals reach #flash here; the whole-tree preview's reset
     sentence sits inside the bar (#picks-reset), since a bar holding a save
     report grows past any fixed offset. */
  body:has(.action-bar) .flash {
    inset-block-end: calc(var(--pico-spacing, 1rem) + 5rem + env(safe-area-inset-bottom));
  }
}

@layer utilities {
  .stack   { display: flex; flex-direction: column; gap: var(--stack-gap, 1rem); }
  .cluster { display: flex; flex-wrap: wrap; gap: var(--cluster-gap, 0.75rem); align-items: center; }
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }

  /* Gap scale — overrides the gap on .stack / .cluster */
  .gap-0  { --stack-gap: 0;      --cluster-gap: 0; }
  .gap-xs { --stack-gap: .5rem;  --cluster-gap: .5rem; }
  .gap-sm { --stack-gap: .75rem; --cluster-gap: .75rem; }
  .gap-md { --stack-gap: 1rem;   --cluster-gap: 1rem; }
  .gap-lg { --stack-gap: 1.5rem; --cluster-gap: 1.5rem; }
  .gap-xl { --stack-gap: 2rem;   --cluster-gap: 2rem; }

  /* Flex alignment (for .stack / .cluster) */
  .between     { justify-content: space-between; }
  .end         { justify-content: flex-end; }
  .start       { justify-content: flex-start; }  /* a <nav> cluster: Pico spreads nav children edge to edge */
  .items-start { align-items: flex-start; }

  /* Margins */
  .m-0   { margin: 0; }
  .mt-xs { margin-top: 0.5rem; }
  .mt-sm { margin-top: 0.75rem; }
  .mt-lg { margin-top: 1.5rem; }
  .mt-xl { margin-top: 4rem; }
  .mb-md { margin-bottom: 1rem; }
  .mb-lg { margin-bottom: 1.5rem; }

  /* Sizing */
  .input-narrow { max-width: 8rem; }
  .input-wide   { min-width: 14rem; }
  .narrow       { max-width: 32rem; }
  .measure      { max-width: 42rem; }
  .center-block { margin-inline: auto; }

  /* Display */
  .inline { display: inline; }
  .hidden { display: none; }

  /* Text */
  .text-sm    { font-size: var(--pp-text-sm); }
  .error-text { color: var(--pp-danger); }

  .text-muted   { color: var(--pp-text-muted); }
  .text-body    { color: var(--pp-text-2); }
  .text-success { color: var(--pp-success); }
  .text-danger  { color: var(--pp-danger); }
  .text-warn    { color: var(--pp-warn); }
  .text-brand   { color: var(--pp-brand); }

  /* Animate elements patched in by Datastar */
  @starting-style {
    .fade-in { opacity: 0; transform: translateY(-4px); }
  }
  .fade-in {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 0.2s, transform 0.2s;
  }
}

/* ── View transitions ─────────────────────────────────────────── */
/* Cross-document: same-origin navigations cross-fade instead of flashing.
   No SSE patch uses one — every response carries several fragments, and
   Datastar would start (and abort) one transition per patch. Browsers
   without support simply navigate as before. */
@view-transition {
  navigation: auto;
}

/* The site chrome stays put; only the content cross-fades. */
body > header { view-transition-name: site-header; }
main           { view-transition-name: main; }

/* A tab is a page (components.Tabs), so a tab click cross-faded the tab strip
   and the band above it along with everything else: the old tab stayed lit
   for a quarter of a second over chrome that had not changed. Both are
   captured on their own, and between two pages that have them they switch at
   once; only what is under them fades. The band is whatever sits directly
   above the strip — the event's header, a pool's name, a page header. */
.tabs         { view-transition-name: page-tabs; }
:has(+ .tabs) { view-transition-name: page-band; }

::view-transition-group(page-tabs),
::view-transition-group(page-band),
::view-transition-new(page-tabs),
::view-transition-new(page-band) {
  animation: none;
}
::view-transition-old(page-tabs),
::view-transition-old(page-band) {
  animation: none;
  opacity: 0;
}

/* Leaving for a page without tabs, or arriving from one, they fade with the
   rest: shown at once, they would sit over the other page's content for the
   length of its fade. Outside the reduced-motion block below on purpose — a
   named pseudo-element outranks its (*) rules — so the fade asks for motion. */
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(page-tabs):only-child,
  ::view-transition-old(page-band):only-child {
    opacity: 1;
    animation: 0.25s both pp-fade-out;
  }
  ::view-transition-new(page-tabs):only-child,
  ::view-transition-new(page-band):only-child {
    animation: 0.25s both pp-fade-in;
  }
}
@keyframes pp-fade-in  { from { opacity: 0; } }
@keyframes pp-fade-out { to { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
  /* The patch-in fade and the moved-row pulse are motion as well. This block
     is outside every layer, so these win over the rules that declare them. */
  .fade-in { transition: none; }
  .leaderboard-row.is-moved { animation: none; }
}

/* ── Print ────────────────────────────────────────────────────── */
/* A bracket prints as a tree: the viewport stops scrolling, the headings
   stop sticking, the winner tint survives, and the controls go. Only the
   pages a tree lands on turn landscape (a named page — browsers without
   named-page support print it portrait, like everything else). */
@media print {
  .bracket-viewport { overflow: visible; max-height: none; page: bracket; }
  .table-scroll:has(> .matrix) { max-height: none; overflow: visible; }
  .bracket-round { position: static; }
  .bracket-slot.is-winner { print-color-adjust: exact; }
  dialog, [popover], .btn, .updown { display: none; }
  /* One region per landscape page (plan §5.2 note 7): a whole 32+ tree
     fragments mid-tree and orphans its headings, so what prints is a region
     — the region path draws one, and the picks page stacks them, each
     starting its own sheet. */
  .bracket-section, .bracket-match { break-inside: avoid; }
  .bracket-region { break-before: page; }
  .bracket-region:first-of-type { break-before: auto; }
}
@page bracket { size: A4 landscape; margin: 1cm; }

@layer components {
  /* The skip link: the first thing a keyboard user meets, and invisible until
     they need it. Without one, every page starts with the header's links —
     the wordmark, My pools, the account menu — before any content. */
  .skip-link {
    position: absolute;
    inset-inline-start: 0.5rem;
    inset-block-start: -3rem;
    z-index: 100;
    padding: 0.5rem 0.9rem;
    border-radius: var(--pico-border-radius);
    background: var(--pp-surface);
    color: var(--pp-text);
    border: 1px solid var(--pp-border-strong);
    text-decoration: none;
    transition: inset-block-start 0.12s;

    /* :focus, not :focus-visible — the link is invisible until focused, so
       any focus must reveal it, including a programmatic one. */
    &:focus { inset-block-start: 0.5rem; }
  }
}

@layer components {
  /* A scroll container that is focusable needs a visible focus ring — it is
     now a tab stop, and a keyboard user has to be able to see where they are
     before they use the arrow keys to pan it. */
  .bracket-viewport:focus-visible,
  .table-scroll:focus-visible {
    outline: 2px solid var(--pp-focus);
    outline-offset: 2px;
  }
}
