/* ---- Draft Nova design tokens ----
   Arena-at-night palette: cool dark grey surfaces with an electric blue as the
   primary brand/identity color, a scoreboard-gold reserved strictly for live
   score digits, and a buzzer-red reserved for reveals/alerts. Win/loss keep
   green/red because that reading is semantic, not thematic. Numeric displays
   use a monospace "scoreboard digit" treatment; headlines use a heavy
   condensed-feeling sans.

   --accent-rgb carries the accent as raw channels so the translucent glows
   and gradients below can share the one source of truth instead of hardcoding
   the brand color again in every rgba().

   The four --accent* tokens are the ONE thing that changes per sport:
   basketball leather orange, football field blue. js/sports/<id>/index.js owns
   the values and applyTheme() in js/main.js writes them onto the root element
   when the sport changes. The values here are the default sport's, so the
   first paint is already the right color rather than flashing blue and
   correcting itself. Anything wanting to follow the sport must go through
   var(--accent*) - a hardcoded hex is a color that stays basketball forever. */
:root {
  /* The page's side gutter, in one place so anything wanting to sit flush
     with the content edge can cancel exactly this much and no more. It used
     to be hardcoded in both #app-root and .home-top's negative margin;
     when the phone breakpoint narrowed the gutter and the outdent didn't
     follow, the How to Play pill and the online counter hung 6px off the
     left edge of the screen and got clipped. */
  --page-gutter: 1.25rem;
  --bg: #0d1117;
  /* AN ALIAS, NOT A SECOND SCALE. --panel is what 28 rules in this file were
     already written against, from long before the surface scale below existed.
     Repointing it at --surface-2 puts every one of those on the scale in one
     line, with a colour shift of two hex points that nothing can perceive -
     rather than 28 edits, each one a chance to change something that was
     fine. New rules should name the surface they mean; --panel stays because
     rewriting a working rule to say the same thing differently is churn. */
  --panel: var(--surface-2);
  --line: #2a323e;
  --accent: #d9741f;
  --accent-bright: #ff9d4d;
  --accent-rgb: 217, 116, 31;
  --accent-contrast: #1d0c01;
  --scoreboard: #ffd166;
  --buzzer: #e8433d;
  --text: #eef2f8;
  --muted: #93a1b5;
  --win: #3fcf8e;

  /* ---- Type floor ----
     Not a full scale, deliberately. 102 of the 237 font-size declarations in
     this file were under 13px, and the mobile baseline
     (docs/audits/2026-09-02-mobile-baseline) counted 1,089 readings of text
     too small to read at arm's length on a couch - the posture this game is
     actually played in.

     Remapping all 237 onto a scale would have flattened real hierarchy (an
     0.5rem superscript and an 0.78rem label are not the same thing) and
     changed sizes nothing was complaining about. These two tokens raise the
     bottom of the range and touch nothing above it, so every size that was
     already fine is byte-for-byte unchanged.

     --text-micro is for UPPERCASE, letter-spaced micro-labels - a column
     header, a stat abbreviation - where the letterforms read larger than
     their size. --text-xs is the floor for anything sentence-case. Nothing
     in this file should declare a font-size below --text-micro; if a design
     seems to need one, the container is too small, not the text. */
  --text-micro: 0.75rem;   /* 12px */
  --text-xs: 0.8125rem;    /* 13px */

  /* ---- Surfaces and shape ----
     Three depths and three radii, so a card is a card everywhere. Five screens
     previously picked five different treatments; together they read as five
     different products.

     -1 is page furniture, -2 is a card on the page, -3 is something inside a
     card. Step through the scale rather than reaching into it at random -
     nesting one value twice is what makes a card-in-a-card invisible.
     --line-soft divides WITHIN a surface; --line is the edge OF one. */
  --surface-1: #12171f;
  --surface-2: #171d27;
  --surface-3: #1d2532;
  --line-soft: rgba(255, 255, 255, 0.07);
  --radius-card: 14px;
  --radius-inner: 10px;
  --radius-pill: 999px;

  /* Depth, not decoration: one shadow for a card at rest and one for a card
     being pressed or hovered, so nothing invents its own. The inset top
     highlight is what makes a dark card read as lit from above rather than as
     a hole cut in the page. */
  --shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 6px 18px rgba(0, 0, 0, 0.34);
  --shadow-lift: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 10px 26px rgba(0, 0, 0, 0.44);

  /* ---- Motion ----
     One duration for a state change you should barely notice, one for a
     transition you should. Anything longer than --motion-slow on an
     interaction is an animation the player is waiting on rather than one that
     is answering them. */
  --motion-fast: 140ms;
  --motion-base: 220ms;
  --motion-slow: 320ms;
  --ease-out: cubic-bezier(0.22, 0.68, 0.36, 1);

  --font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
}

* {
  box-sizing: border-box;
}

/* ---- Page depth ----
   Accent wash in the ACTIVE SPORT's colour (the one place the whole page
   follows the sport), the cool blue-grey that keeps it from tinting
   everything, and fine noise so flat areas do not band. All very low contrast:
   the failure mode here is a neon esports template. The SVG is inline because
   the CSP forbids external images - see scripts/verify-csp.mjs. */
body {
  margin: 0;
  background:
    radial-gradient(ellipse 90% 55% at 50% -8%, rgba(var(--accent-rgb), 0.07) 0%, transparent 70%),
    radial-gradient(ellipse at 50% -10%, #17253d 0%, transparent 55%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.028'/%3E%3C/svg%3E"),
    var(--bg);
  /* The two gradients are fixed so the lighting belongs to the SCREEN: sized
     against a 2,400px profile page instead, a "90% x 55% at the top" ellipse
     is enormous and washes half the document. The noise scrolls with the page,
     which is both correct (it is a texture on the paper, not on the lamp) and
     cheaper - a fixed tiled layer repaints the whole viewport on every scroll
     frame, and this one has nothing to gain from it. */
  background-attachment: fixed, fixed, scroll, scroll;
  color: var(--text);
  font-family: var(--font-body);
  /* dvh, not vh, with vh underneath for anything too old to know the unit.
     
     On a phone `100vh` is the viewport with the browser's own chrome HIDDEN -
     the tallest it ever gets - so a "full height" page is permanently 60-80px
     taller than what is actually on screen, and every screen has a phantom
     scroll at the bottom that goes nowhere. dvh is the viewport as it is right
     now, which is the one the reader has. */
  min-height: 100vh;
  min-height: 100dvh;
}

button, input {
  font-family: inherit;
}

/* ---- Top nav ---- */
.top-nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.25rem;
  border-bottom: 1px solid var(--line);
}

.brand {
  background: none;
  border: none;
  color: var(--text);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.25rem;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0;
}

.auth-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  margin-bottom: 1rem;
}

.auth-brand-mark {
  max-width: 12rem;
  width: 100%;
  height: auto;
  object-fit: contain;
}

.auth-brand-word {
  font-size: 1.25rem;
  font-weight: 800;
  letter-spacing: 0.02em;
}

.brand-mark {
  height: 1.6em;
  width: auto;
  max-width: 8rem;
  object-fit: contain;
  vertical-align: middle;
}

.brand-word {
  vertical-align: middle;
}

.brand-ball {
  filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.6));
}

.nav-tabs {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}

/* The four destinations, and the two utilities. On a desktop they are one
   row and this split does nothing; on a phone the destinations leave the
   header entirely. See the bottom-bar block in the phone breakpoint. */
.nav-primary,
.nav-utility {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

/* The glyph exists for the bottom bar, where a 4-across row of words alone
   is a wall of text to scan mid-game. In the desktop header the word carries
   it and the glyph would just be noise. */
.nav-tab-glyph {
  display: none;
}

.nav-tab {
  background: transparent;
  border: 1px solid transparent;
  color: var(--muted);
  font-weight: 700;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  padding: 0.45rem 0.9rem;
  border-radius: var(--radius-pill);
  cursor: pointer;
}

.nav-tab.active {
  color: var(--bg);
  background: var(--accent);
}

.nav-tab:not(.active):hover {
  border-color: var(--line);
  color: var(--text);
}

/* Sign Out sits in the same row as the navigation tabs but isn't one - it
   throws away your session. Colouring it red separates "where do I want to
   go" from "get me out of here" so it isn't tapped by reflex. */
.nav-tab-danger {
  color: var(--buzzer);
}

.nav-tab-danger:hover {
  color: var(--text);
  background: var(--buzzer);
  border-color: var(--buzzer);
}

/* ---- Subtabs (sport switcher inside a screen) ---- */
.subtabs {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin-bottom: 0.9rem;
}

.subtab {
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--muted);
  border-radius: var(--radius-pill);
  padding: 0.4rem 0.85rem;
  font-size: var(--text-xs);
  font-weight: 700;
  cursor: pointer;
}

.subtab:hover {
  color: var(--text);
  border-color: var(--accent);
}

.subtab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}

.subtab.locked:not(.active) {
  opacity: 0.55;
}

#app-root {
  max-width: 1100px;
  margin: 0 auto;
  padding: 1.5rem var(--page-gutter) 3rem;
}

.screen.hidden {
  display: none;
}

.hidden,
[hidden] {
  display: none !important;
}

/* ---- Shared bits ---- */
.card {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  padding: 1.25rem;
  margin-bottom: 1rem;
  /* Lit from above rather than cut out of the page. Every card in the app
     picks this up, which is most of what made the screens read as five
     different products: the profile's cards were flat 10px panels, the sport
     cards 14px with a shadow, the badge tiles 12px with neither. */
  box-shadow: var(--shadow-card);
}

/* ---- Typography ----
   Six voices that have to be distinguishable. Almost everything here was
   0.85-1rem body text in --text or --muted, so a page title, a card title and
   a caption differed by two pixels and a colour.
     page title .section-heading 1.15rem + accent rule | card title .card h2/h3
     1.25/1.05rem | primary stat mono 1.2-1.6rem | secondary body 0.85-0.95rem
     | metadata --text-micro uppercase --muted
   New here is the display face and size on headings, which previously used the
   browser's default h2/h3 sizing. */
.card h2, .card h3 {
  margin-top: 0;
  font-family: var(--font-display);
  letter-spacing: 0.01em;
}

.card h2 {
  font-size: 1.25rem;
}

.card h3 {
  font-size: 1.05rem;
}

/* The page's own title. It was --muted at 1rem, which is the same voice the
   captions inside the cards below it speak in - so the loudest thing on the
   Rewards screen was a subtab pill. */
.section-heading {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 1.15rem;
  color: var(--text);
  margin: 0 0 0.85rem;
  border-bottom: 2px solid var(--accent);
  padding-bottom: 0.5rem;
  display: inline-block;
}

.field-row {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-bottom: 0.9rem;
}

/* A setting is a label and its control side by side, not stacked: unlike the
   text fields above it, the control is a fixed-width button and stacking it
   leaves a row of empty space beside a short word. */
.field-row-setting {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.field-row-setting > span {
  color: var(--muted);
  font-size: var(--text-sm);
}

#setting-sound {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.field-row label {
  font-size: 0.85rem;
  color: var(--muted);
}

/* EVERY TEXT FIELD, not just type="text".
   
   This rule was scoped to one input type, and the app has four: the sign-in
   PASSWORD field, the recovery EMAIL field, the squad SEARCH box and every
   textarea were rendering in the browser's own default - a white box with a
   black border - on a dark screen. The password field on the sign-in card is
   the second control anybody sees, directly under a correctly-styled one, and
   it was white.
   
   The min-height floor further down this file already listed all four; only
   the paint did not. */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="number"],
select,
textarea {
  background: var(--surface-1);
  border: 1px solid var(--line);
  color: var(--text);
  border-radius: 8px;
  padding: 0.55rem 0.75rem;
  font-size: 1rem;
  font-family: inherit;
  width: 100%;
}

input[type="text"]:focus-visible,
input[type="email"]:focus-visible,
input[type="password"]:focus-visible,
input[type="search"]:focus-visible,
input[type="number"]:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent);
}

::placeholder {
  color: var(--muted);
  opacity: 1;
}

/* A small heading over a picker. The mode row, the era row and the button
   under them were three unlabelled blocks in a column, so which pill row did
   what had to be inferred from its contents. */
.field-heading {
  margin: 1.1rem 0 0.5rem;
  font-family: var(--font-display);
  font-size: var(--text-micro);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
}

.field-heading:first-of-type {
  margin-top: 0.6rem;
}

/* --- Mode cards ---
   Three deliberate choices, not three rows of text. The tick matters more than
   it looks: selected was a border colour plus a background tint, both of them
   the accent, so on the football theme "selected" and "hovered" read the
   same. */
.mode-toggle {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

.mode-btn {
  display: flex;
  align-items: flex-start;
  gap: 0.85rem;
  width: 100%;
  text-align: left;
  padding: 0.85rem 1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  background: var(--surface-2);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 0.95rem;
  cursor: pointer;
  transition: border-color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
}

.mode-icon {
  font-size: 1.5rem;
  line-height: 1.2;
  flex: 0 0 auto;
}

.mode-text {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
  flex: 1;
}

.mode-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.mode-label {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.02rem;
  letter-spacing: 0.01em;
}

/* Only Ranked has one, and it is the one thing about this screen that is worth
   being unmissable: it is the mode where the result is kept. */
.mode-tag {
  font-family: var(--font-display);
  font-size: var(--text-micro);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  padding: 0.1rem 0.5rem;
  border-radius: var(--radius-pill);
  background: rgba(232, 67, 61, 0.18);
  border: 1px solid rgba(232, 67, 61, 0.6);
  color: #ff8b86;
}

.mode-blurb {
  color: var(--muted);
  font-size: var(--text-xs);
  line-height: 1.45;
}

/* Present but invisible until selected, so selecting a mode does not reflow
   the card it is on. */
.mode-check {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  border: 1px solid var(--line);
  color: transparent;
  font-size: 0.8rem;
  font-weight: 800;
  transition: background var(--motion-fast) var(--ease-out),
              border-color var(--motion-fast) var(--ease-out),
              color var(--motion-fast) var(--ease-out);
}

@media (hover: hover) {
  .mode-btn:hover:not(.active) {
    border-color: rgba(var(--accent-rgb), 0.55);
    background: var(--surface-3);
  }
}

/* No :active rule here. Press feedback is app-wide - see the "Press feedback"
   block further down, which already scales .mode-btn on :active - and a second
   transform declared closer to the top of the file would just be the one that
   loses. */
.mode-btn.active {
  border-color: var(--accent-bright);
  background:
    linear-gradient(180deg, rgba(var(--accent-rgb), 0.2) 0%, rgba(var(--accent-rgb), 0.07) 100%),
    var(--surface-2);
  box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.35), var(--shadow-card);
}

.mode-btn.active .mode-check {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}

.hint-text {
  color: var(--muted);
  font-size: var(--text-xs);
  margin: 0.25rem 0 0.9rem;
}

.search-status {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  margin-top: 0.75rem;
  color: var(--muted);
  font-size: 0.9rem;
}

.search-spinner {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* WHICH RULES ARE IN FORCE, as a chip rather than a shout.
   
   It was full-width uppercase accent text, which for the strict-mode sentence
   ("No player list - draft from memory. 3+ letters to search.") is a rule
   worth stating and for the practice-mode one ("Practice mode - full squad and
   stats shown, no clock.") is a two-line orange banner announcing that nothing
   is at stake. The phone override below already dropped the uppercase, which
   is the file saying the same thing.
   
   inline-block, so it is as wide as what it says. */
.knowledge-hint {
  display: inline-block;
  color: var(--accent-bright);
  font-size: var(--text-xs);
  font-weight: 600;
  margin: 0 0 0.7rem;
  letter-spacing: 0.01em;
  padding: 0.25rem 0.65rem;
  border-radius: var(--radius-pill);
  background: rgba(var(--accent-rgb), 0.1);
  border: 1px solid rgba(var(--accent-rgb), 0.32);
}

/* Nothing to say, nothing to draw. An empty chip is a stray pill on the
   board. */
.knowledge-hint:empty {
  display: none;
}

.btn {
  border: none;
  border-radius: 8px;
  padding: 0.65rem 1.2rem;
  font-size: 0.95rem;
  font-weight: 700;
  cursor: pointer;
  transition: transform 0.05s ease, opacity 0.15s ease, filter 0.15s ease;
}

.btn:active {
  transform: scale(0.98);
}

.btn-block {
  width: 100%;
}

.btn-primary {
  background: var(--accent);
  color: var(--accent-contrast);
}

.btn-primary:hover {
  filter: brightness(1.08);
}

.btn-secondary {
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--line);
}

.btn-secondary:hover {
  border-color: var(--accent);
}

:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
}

/* ---- Sport select ---- */
.sport-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.75rem;
  margin-bottom: 1.25rem;
}

/* Section heading with an action beside it. The heading keeps its accent
   underline, so the row aligns on the baseline rather than on the box. */
.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

/* The home screen's sport list: where you stand in each sport, its ladder, its
   rules, and the way in. It was a 56px strip with a 12px triangle doing the
   whole job of "this is how you play" - the hub's most important decision as
   its least prominent element.

   --card-accent is the sport's own colour, set per card in JS. The hub is
   sport-neutral; the cards are not, because colour is how you recognise a
   sport before you read the word. */
.sport-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
  gap: 0.9rem;
  margin-bottom: 1.25rem;
}

.sport-card {
  --card-accent: var(--accent);
  display: flex;
  flex-direction: column;
  background:
    /* The sport's colour bleeding up out of the card's own floor, rather than
       a flat panel. Very low opacity on purpose: this is a tint that tells you
       which sport you are looking at, not a coloured card. */
    radial-gradient(120% 80% at 50% 120%, rgba(var(--accent-rgb), 0.1), transparent 70%),
    var(--surface-2);
  border: 1px solid var(--line);
  border-top: 3px solid var(--card-accent);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  overflow: hidden;
  transition: border-color var(--motion-fast) var(--ease-out),
              box-shadow var(--motion-base) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
}

/* Only where there is a pointer to hover with. On a touchscreen the press
   state below is the feedback. */
@media (hover: hover) {
  .sport-card:hover:not(.locked) {
    border-color: var(--card-accent);
    box-shadow: var(--shadow-lift);
    transform: translateY(-2px);
  }
}

.sport-card:has(.sport-card-open:active) {
  transform: translateY(1px);
  box-shadow: var(--shadow-card);
}

/* Locked sports stay legible rather than being dimmed into unreadability -
   they are advertising what is coming, which only works if you can read it. */
.sport-card.locked {
  opacity: 0.55;
  --card-accent: var(--line);
}

.sport-card-open {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  flex: 1;
  padding: 1.1rem 1.1rem 0.9rem;
  background: none;
  border: none;
  color: inherit;
  text-align: left;
  font: inherit;
  cursor: pointer;
}

.sport-card-open:disabled {
  cursor: default;
}

.sport-card-open:focus-visible {
  outline: 2px solid var(--card-accent);
  outline-offset: -3px;
}

.sport-card-head {
  display: flex;
  align-items: center;
  gap: 0.85rem;
}

.sport-card-icon {
  font-size: 2.2rem;
  line-height: 1;
  flex: 0 0 auto;
}

.sport-card-text {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  /* min-width:0 is what lets a long rank line ellipsize instead of forcing the
     whole card wider than the screen. */
  min-width: 0;
}

.sport-card-name {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.35rem;
  letter-spacing: 0.02em;
}

.sport-card-rank {
  color: var(--card-accent);
  font-family: var(--font-display);
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sport-card.locked .sport-card-rank {
  color: var(--muted);
}

/* Rating, ranked record and games, in even columns so the two sports can be
   compared against each other rather than each being read on its own. Absent
   entirely for a sport that has never been played - see renderHomeSportCards
   on why a row of zeroes is worse than no row. */
.sport-card-figures {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  overflow: hidden;
}

.sport-card-figure {
  display: flex;
  flex-direction: column;
  gap: 0.05rem;
  padding: 0.5rem 0.6rem;
  background: var(--surface-3);
  min-width: 0;
}

.sport-card-figure-value {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1.05rem;
  color: var(--text);
}

.sport-card-figure-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
}

/* The call to action, inside the card's own button. It reads as a button
   because it is the visible half of one - the whole card body is the control,
   and this is where the eye is meant to land. */
.sport-card-cta {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  margin-top: auto;
  padding: 0.6rem 1rem;
  border-radius: var(--radius-pill);
  background: var(--card-accent);
  color: var(--accent-contrast);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.95rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transition: filter var(--motion-fast) var(--ease-out);
}

/* --accent-contrast follows the APP's sport, and this card carries its own -
   so the football card's label would print football's white on basketball's
   orange whenever basketball was the selected sport. Locked and preview cards
   have no accent to print on at all. */
.sport-card[data-sport="nba"] .sport-card-cta { color: #1d0c01; }
.sport-card[data-sport="nfl"] .sport-card-cta { color: #ffffff; }

.sport-card.locked .sport-card-cta,
.sport-card.preview .sport-card-cta {
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid var(--line);
  color: var(--muted);
  text-shadow: none;
}

.sport-card-open:not(:disabled):hover .sport-card-cta {
  filter: brightness(1.1);
}

.sport-card-cta-glyph {
  font-size: 0.85em;
}

/* The two reference buttons sit under the card's own button, not inside it:
   nesting a button in a button is invalid, and a click on How to Play must not
   also change sport and walk you into the Play screen. */
.sport-card-actions {
  display: flex;
  gap: 0.5rem;
  padding: 0 1.1rem 1.1rem;
}

.sport-card-action {
  padding: 0.45rem 0.9rem;
  background: rgba(var(--accent-rgb), 0.08);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  color: var(--text);
  font-family: var(--font-display);
  font-size: var(--text-xs);
  font-weight: 700;
  cursor: pointer;
  white-space: nowrap;
  transition: border-color var(--motion-fast) var(--ease-out),
              color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out);
}

.sport-card-action:hover {
  border-color: var(--card-accent);
  color: var(--card-accent);
  background: rgba(var(--accent-rgb), 0.14);
}

.sport-card-action:active {
  transform: translateY(1px);
}

/* Inside a card the two pills share the row evenly; the loose ones at the top
   of the screen (Customize, Overall Rank) size to their own labels. */
.sport-card-actions .sport-card-action {
  flex: 1;
}

/* Play screen header: back out, and which sport you are in - the sport was
   chosen on the previous screen, so this is the only thing that says so. */
.play-head {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.play-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.25rem;
}

.play-title-icon {
  font-size: 1.4rem;
}

@media (max-width: 700px) {
  .sport-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

.sport-tile {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.1rem 0.5rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  cursor: pointer;
  color: var(--text);
}

.sport-tile .sport-icon {
  font-size: 1.8rem;
}

.sport-tile .sport-name {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: 0.9rem;
}

.sport-tile .sport-status {
  font-size: var(--text-micro);
  color: var(--muted);
}

.sport-tile.active {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent), 0 0 18px rgba(var(--accent-rgb), 0.35);
}

.sport-tile.active .sport-status {
  color: var(--accent-bright);
}

.sport-tile.locked {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Selectable but not playable. Deliberately NOT the dimmed locked treatment -
   a preview tile is a real destination you can click, and dimming it would
   say the opposite. A dashed border reads as "under construction" rather than
   "unavailable", which is exactly the difference. */
.sport-tile.preview {
  border-style: dashed;
  border-color: rgba(var(--accent-rgb), 0.55);
  opacity: 0.9;
}

.sport-tile.preview .sport-status {
  color: var(--accent-bright);
}

.sport-tile.preview.active {
  border-style: solid;
  opacity: 1;
}

/* The line under the era picker explaining why Start Draft is dead. Warned
   rather than errored: nothing is broken, this sport just isn't finished. */
.sport-preview-note {
  border-left: 3px solid rgba(var(--accent-rgb), 0.55);
  padding-left: 0.7rem;
  margin-bottom: 0.75rem;
}

.sport-preview-note[hidden] {
  display: none;
}

/* ---- Draft screen ---- */
/* The squad banner carries the whole run-up to a pick: which round, which
   era, which squad, whose turn, and how long is left. Those used to be five
   separate full-width rows stacked down the page - on a phone they filled the
   screen and pushed the search box below the fold with a clock running. As
   one banner they are three short lines, and the search box is the next thing
   underneath at every width. */
.squad-banner {
  background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.04));
  border: 1px solid var(--accent);
  border-radius: 12px;
  padding: 0.75rem 1rem;
  margin-bottom: 0.75rem;
  text-align: center;
}

.squad-banner-top {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-bottom: 0.25rem;
}

.round-label {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.03em;
}

/* The era bracket in play. It reads as a chip rather than as more text
   because it is a setting, not part of the squad's name - "Bulls 1990s" is
   the squad, "Unc Status" is the rules you asked for. */
.squad-banner-era {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent-bright);
  border: 1px solid rgba(var(--accent-rgb), 0.55);
  border-radius: var(--radius-pill);
  padding: 0.15rem 0.6rem;
  white-space: nowrap;
}

.squad-banner-main {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.squad-banner-team {
  font-family: var(--font-display);
  font-weight: 900;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: 1.55rem;
  line-height: 1.1;
  color: var(--text);
}

.squad-banner-decade {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1.1rem;
  color: var(--accent-bright);
}

/* Whose pick and how long left, on one line inside the banner rather than as
   two more full-width rows under it. */
.squad-banner-status {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin-top: 0.4rem;
  min-height: 1.6rem;
}

.squad-banner-status:empty {
  display: none;
}

.position-selector {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
  flex-wrap: wrap;
}

/* Desktop keeps the reading it has always had: the slot, and a tick to say it
   is taken. The name span is emitted by the same render (see
   renderPositionSelector) and hidden here, because at this width the roster
   panel is a column away and already carries every name. */
.position-btn-name {
  display: none;
}

/* A <button> when the slot can be chosen, a <span> when it is only telling
   you something - see renderPositionSelector. One rule styles both, because
   they are the same chip; what differs is whether it does anything. */
.position-btn {
  display: inline-block;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--muted);
  border-radius: 8px;
  padding: 0.5rem 1rem;
  font-weight: 700;
  font-family: var(--font-mono);
}

button.position-btn {
  cursor: pointer;
}

/* Awaiting a slot choice for a just-clicked player: their eligible slots
   glow and invite a click, everything else (including other open slots
   that don't fit this player) dims out of the way. */
.position-btn.eligible {
  border-color: var(--accent);
  color: var(--text);
  background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.3), transparent);
  animation: pulse-eligible 1.2s ease-in-out infinite;
}

@keyframes pulse-eligible {
  0%, 100% { box-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
  50% { box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.45); }
}

.position-btn.awaiting-dim {
  opacity: 0.35;
  cursor: not-allowed;
}


/* A SIDEWAYS PHONE IS STILL A PHONE, and the roster strip has to know it.
   These rules first lived in the max-width:700px block, so a phone in
   landscape - 740x360 - fell back to the desktop chip row: full-size buttons,
   no drafted names, the reading the strip exists to replace. The roster panels
   are just as far off-screen at 360px TALL as they are at 360px wide, so the
   condition is the same pair the tap-target floors already use in this file:
   narrow, or short and being touched. */
@media (max-width: 700px), ((max-height: 520px) and (pointer: coarse)) {
/* THE ROSTER, ON ONE LINE, WHILE YOU ARE READING THE POOL.
   ------------------------------------------------------------------
   This used to be `display: none`, and the note explaining why said it was
   "a whole row of chrome buying nothing" that "sat between the squad banner
   and the search box - exactly where it hurt". That was right about the
   POSITION and about the size, and wrong about the value.

   Measured at 360x780 mid-draft: 293px of banner, search and hint sit above
   the pool, the pool list is 384px, and the two roster panels begin at
   710px - below the fold. So while you are doing the only thing this screen
   is for, reading names and choosing one, you cannot see what you have
   already drafted or what you still need. Hiding the read-out did not solve
   that; it removed the only thing on the screen that could have answered it.

   What it costs is the whole argument. As a wrapping row of ten full-size
   buttons it was ~130px, which is a third of the pool. As one line that
   scrolls sideways it is ~34px, and it earns that by carrying the drafted
   NAMES rather than just ticks - see renderPositionSelector, which emits
   both densities so this is a styling decision rather than a second
   component.

   The horizontal scroll is contained (overflow-x on the strip, not the
   page), so the layout gate's h-overflow check still means what it says. */
.position-selector {
  display: flex;
  flex-wrap: nowrap;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  gap: 0.3rem;
  margin-bottom: 0.5rem;
  padding-bottom: 0.2rem;
  /* No scrollbar gutter eating a line of a 34px strip. */
  scrollbar-width: none;
}

.position-selector::-webkit-scrollbar {
  display: none;
}

.position-selector .position-btn {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: 0.25rem;
  padding: 0.3rem 0.45rem;
  border-radius: 6px;
  font-size: var(--text-micro);
  /* A drafted unit is "Carolina Panthers Defensive Line" and a person can be
     "Giannis Antetokounmpo". Neither gets to set the width of the strip;
     both are reachable through the button's title. */
  max-width: 8.5rem;
  white-space: nowrap;
}

.position-selector .position-btn-slot {
  color: var(--muted);
  font-weight: 700;
}

.position-selector .position-btn-name {
  display: inline;
  color: var(--text);
  font-family: var(--font-sans, inherit);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

/* The tick is what the name replaces. Showing both says the same thing
   twice in a strip whose entire budget is one line. */
.position-selector .position-btn-check {
  display: none;
}

/* A filled slot is settled; an open one is the question. Border weight does
   the sorting so the strip scans without colour carrying the meaning on its
   own - see the contrast note in the accessibility pass. */
.position-selector .position-btn.filled {
  border-color: var(--line);
  opacity: 0.85;
}

.position-selector .position-btn:not(.filled) {
  border-style: dashed;
  color: var(--text);
}

/* The one chip in the strip that is genuinely a control gets a real target.
   Only the slots a pending player is eligible for are buttons at all (the
   rest are spans - see renderPositionSelector), so this grows the strip
   ONLY while a slot choice is actually on offer, which is exactly when it
   has stopped being a read-out. The rest of the time it stays one line. */
.position-selector button.position-btn {
  min-height: 44px;
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
}
}

/* --- Matchup intro: the ~7s "you've been matched" beat before an online
   draft starts (see playMatchupIntro in main.js) - both banners fly in from
   the edges with a little overshoot, an impact flash lands as they settle,
   then a 3-2-1 countdown and a buzzer-red "GO!". --- */
/* STACKED BELOW 900px, FACING EACH OTHER ABOVE IT.
   
   Two whole player cards in a row are two narrow columns where the stats wrap
   and the badges drop - true at 700px, not true at 1280, where two 28rem cards
   plus a VS fit in 1060px of content width. The breakpoint decides rather than
   the layout, and the fly-in directions already match both.
   
   Checked, not assumed: verify-matchup-intro.mjs asserts nothing is cropped
   and nothing falls below the fold at desktop, 360 portrait and 640x360. */
.matchup-intro {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  min-height: 60vh;
  min-height: 60dvh;
  padding: 2rem 1rem;
  /* So the drifting light below is clipped to the stage rather than washing
     over the nav. */
  overflow: hidden;
}

@media (min-width: 900px) {
  .matchup-intro {
    flex-direction: row;
    gap: 1.75rem;
  }

  /* Between two 28rem cards, 2.2rem of VS reads as a separator rather than as
     the collision the animation is selling. */
  .matchup-vs {
    font-size: 3.6rem;
  }

  .matchup-side {
    flex: 1 1 0;
    min-width: 0;
    max-width: 28rem;
  }

  /* Not quite a flat row: one card rides a little high and the other a little
     low, so the pair reads as two players squaring up rather than as a table
     with two columns. Margin rather than transform, deliberately - transform
     is what the fly-in animates, and a resting offset written there would be
     wiped the moment .fly-in set translateX(0). */
  .matchup-side-a {
    margin-bottom: 2.4rem;
  }

  .matchup-side-b {
    margin-top: 2.4rem;
  }
}

/* A brief radial glow timed to the banners landing - sells the two sides
   actually meeting, rather than just sliding to a stop. Opacity-only, same
   restrained language as pulse-lock/pulse-buzzer elsewhere - no color
   cycling. */
.matchup-intro::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at center, rgba(var(--accent-rgb), 0.35), transparent 65%);
  opacity: 0;
  pointer-events: none;
}

.matchup-intro.impact-flash::before {
  animation: matchup-impact-flash 0.7s ease-out;
}

/* The stage's own light, drifting. A completely still background under a
   timed animation makes the two seconds where you are reading the opponent's
   name feel like a frozen frame; a slow off-centre wash makes the screen read
   as a place rather than a screenshot.
   
   Two very wide, very low-opacity radials on one layer, moved together. Cheap
   enough to be a compositor-only transform, and far too slow to be noticed as
   an animation - which is the point. Off entirely under reduced motion. */
.matchup-intro::after {
  content: "";
  position: absolute;
  inset: -25%;
  background:
    radial-gradient(circle at 30% 35%, rgba(var(--accent-rgb), 0.16), transparent 42%),
    radial-gradient(circle at 72% 68%, rgba(120, 170, 255, 0.12), transparent 46%);
  pointer-events: none;
  z-index: 0;
}

.matchup-intro.intro-lit::after {
  animation: matchup-stage-drift 18s ease-in-out infinite alternate;
}

@keyframes matchup-stage-drift {
  0% { transform: translate3d(-2%, -1.5%, 0) scale(1); }
  100% { transform: translate3d(2.5%, 2%, 0) scale(1.08); }
}

/* Everything the player is actually looking at sits above the wash. The
   countdown gets the z-index only - it is absolutely positioned further down
   this file, and re-declaring `position` here would leave that working by
   source order alone. */
.matchup-side,
.matchup-vs {
  position: relative;
  z-index: 1;
}

.matchup-countdown {
  z-index: 2;
}

@keyframes matchup-impact-flash {
  0% { opacity: 0; }
  30% { opacity: 1; }
  100% { opacity: 0; }
}

.matchup-side {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.6rem;
  width: 100%;
  max-width: 34rem;
  opacity: 0;
  /* A back-out curve on the way in - the side overshoots slightly and
     settles, a small bounce rather than a flat slide-and-stop. */
  transition: transform 0.9s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.6s ease;
}

.matchup-side-a {
  transform: translateX(-140%);
}

.matchup-side-b {
  transform: translateX(140%);
}

.matchup-side.fly-in {
  opacity: 1;
  transform: translateX(0);
}

.matchup-card-slot {
  width: 100%;
}

/* The card at intro scale. Everything on it is the home card's - only the
   sizes step down, because two cards and a VS have to fit one screen without
   the player scrolling to see who they are playing. Written as overrides on
   the shared rules rather than as a second set of card styles: a copy would be
   the same drift in CSS that the markup was fixed to avoid.

   Every selector here carries `.player-banner` as well as `.matchup-card`,
   which is not decoration: the rules being overridden are further down this
   file and tie on class count without it, so the later one would win and the
   star would go on rendering at 5rem over a card two-thirds the height. */
.player-banner.matchup-card {
  padding: 0.9rem 1rem;
  gap: 0.6rem;
}

.player-banner.matchup-card .pb-badge {
  width: 3.4rem;
  height: 3.4rem;
  font-size: 1.7rem;
  border-radius: 10px;
}

.player-banner.matchup-card .pb-badge.empty {
  font-size: 1.3rem;
}

.player-banner.matchup-card .pb-badge-tier {
  font-size: 0.95rem;
}

.player-banner.matchup-card .pb-stat-value {
  font-size: 1.05rem;
}

.player-banner.matchup-card .player-avatar {
  width: 2.5rem;
  height: 2.5rem;
  font-size: 1.6rem;
}

.player-banner.matchup-card.has-banner::before {
  font-size: 6rem;
}

.player-banner.matchup-card.has-banner > .pb-banner-emblem {
  font-size: 3.4rem;
  right: 1.1rem;
}

.player-banner.matchup-card.has-banner > .pb-banner-label {
  font-size: var(--text-xs);
  right: 1rem;
  bottom: 0.7rem;
}

/* Real artwork sits on top of the colour gradient already painted on
   .banner-art, so a missing or slow file degrades to the banner's own two
   colours instead of a hole. object-fit: cover because the art is a texture -
   cropping it is fine, distorting it is not.

   THE LEADING `.matchup-banner-slot` THAT USED TO BE HERE WAS A TYPO (that
   class is now .matchup-card-slot - the intro renders whole player cards), and CSS
   comments are whitespace, so the selector silently parsed as
   `.matchup-banner-slot .banner-art-img` - a descendant rule. Every banner
   image outside the matchup intro therefore had no positioning at all and
   rendered at its natural 2120x720, cropped by .banner-art's overflow to a
   hugely zoomed top-left corner. The Rewards grid has been showing a magnified
   fragment of every piece of artwork rather than the artwork. */
.banner-art-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

.matchup-vs {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 2.2rem;
  letter-spacing: 0.04em;
  color: var(--accent-bright);
  flex-shrink: 0;
  filter: drop-shadow(0 0 10px rgba(var(--accent-rgb), 0.5));
  transition: opacity 0.4s ease;
}

/* "VS" and the countdown are both dead-center by design - once the
   countdown starts, VS has to get out of the way or the two render on top
   of each other. */
.matchup-vs.vs-fade {
  opacity: 0;
}

.matchup-countdown {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 5rem;
  line-height: 1;
  color: var(--accent-bright);
  text-shadow: 0 0 24px rgba(var(--accent-rgb), 0.6);
  pointer-events: none;
}

.matchup-countdown.pulse {
  animation: matchup-countdown-pulse 1s ease-out;
}

@keyframes matchup-countdown-pulse {
  0% {
    transform: translate(-50%, -50%) scale(1.7);
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  100% {
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
  }
}

/* The payoff beat - buzzer-red (the same color the pick timer already uses
   for urgency) and a bigger pop than the 3-2-1 numbers, so "GO!" reads as
   the moment the countdown was building to, not just a fourth number. */
.matchup-countdown.go {
  color: var(--buzzer);
  text-shadow: 0 0 28px rgba(232, 67, 61, 0.65);
  animation: matchup-go-pulse 0.6s ease-out;
}

@keyframes matchup-go-pulse {
  0% {
    transform: translate(-50%, -50%) scale(1.4);
    opacity: 0.4;
  }
  100% {
    transform: translate(-50%, -50%) scale(2.1);
    opacity: 1;
  }
}

/* A phone held sideways: 360px of HEIGHT for two cards, a VS and a countdown.
   The intro is a timed animation - it cannot be scrolled through while it
   plays - so anything that does not fit is simply missed, which is how the
   opponent's card ends up below the fold on the one screen that exists to
   show it. Everything steps down together rather than any part being dropped:
   a card missing its badges or its rating is the thin intro again, just at a
   different width. */
@media (max-height: 560px) and (orientation: landscape) {
  .matchup-intro {
    min-height: 0;
    gap: 0.25rem;
    /* The last few pixels come out of the stage's own padding rather than out
       of anything on the cards. At this height the intro is two cards, a VS
       and a countdown against 360px, and every one of those is content. */
    padding: 0.15rem 0.75rem;
  }
  /* Tightened to pay for the type floor. Raising every sub-13px label added
     a few pixels per card, and two stacked cards put the pair 4px below the
     fold at 640x360 - verify-matchup-intro.mjs catches exactly this. The
     height comes back out of padding rather than out of the text, because
     the text being readable is the point and the padding is not. */
  .player-banner.matchup-card {
    padding: 0.3rem 0.7rem;
    gap: 0.25rem;
  }
  .player-banner.matchup-card .player-name {
    font-size: 1.05rem;
  }
  .player-banner.matchup-card .player-avatar {
    width: 2rem;
    height: 2rem;
    font-size: 1.3rem;
  }
  .player-banner.matchup-card .pb-badge {
    width: 2.1rem;
    height: 2.1rem;
    font-size: 1.05rem;
  }
  .player-banner.matchup-card .pb-badges {
    gap: 0.4rem;
  }
  .player-banner.matchup-card .pb-stat-value {
    font-size: 0.95rem;
  }
  /* THE STATS PLATE COSTS HEIGHT, AND HERE THERE IS NONE. Its ~18px per card,
     doubled by two stacked cards, put the pair 27px below the fold at 640x360.
     The plate stays - its contrast argument holds here too - it just stops
     being padded at a height where the padding is the difference between
     seeing your opponent and not. verify-matchup-intro.mjs caught it. */
  .player-banner.matchup-card .pb-stats {
    padding: 0.1rem 0.45rem;
    gap: 0.9rem;
  }
  .player-banner.matchup-card.has-banner::before {
    font-size: 4rem;
  }
  .player-banner.matchup-card.has-banner > .pb-banner-emblem {
    font-size: 2.2rem;
  }
  .matchup-vs {
    font-size: 1.6rem;
  }
  .matchup-countdown {
    font-size: 3.4rem;
  }
}

/* On a phone the cards are already the full column width, so the fly-in comes
   from above and below instead of the sides - a card sliding in from off-screen
   left would be travelling most of the viewport to arrive somewhere it barely
   moved. */
@media (max-width: 560px) {
  .matchup-intro {
    gap: 0.75rem;
    padding: 1rem 0.75rem;
  }
  .matchup-side-a {
    transform: translateY(-140%);
  }
  .matchup-side-b {
    transform: translateY(140%);
  }
  .player-banner.matchup-card {
    padding: 0.75rem 0.85rem;
  }
  .player-banner.matchup-card .pb-badge {
    width: 2.9rem;
    height: 2.9rem;
    font-size: 1.45rem;
  }
  .player-banner.matchup-card.has-banner::before {
    font-size: 4.5rem;
  }
  .player-banner.matchup-card.has-banner > .pb-banner-emblem {
    font-size: 2.6rem;
    top: 1.1rem;
    right: 0.8rem;
    transform: none;
  }
}

/* Inline inside .squad-banner-status now, so no block margins and no
   full-width backdrop - the banner around it already provides both. */
.turn-banner {
  padding: 0.2rem 0.55rem;
  border-radius: var(--radius-pill);
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--line);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: var(--text-xs);
  color: var(--accent-bright);
}

.turn-banner:empty {
  display: none;
}

.pick-timer {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  font-size: 1.05rem;
  color: var(--accent-bright);
}

.pick-timer.timer-warning {
  color: var(--buzzer);
  animation: pulse-buzzer 0.6s ease-in-out infinite;
}

@keyframes pulse-buzzer {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}

/* THE POOL IS THE READING SURFACE, and it had 420px of a 1060px page while two
   roster panels holding six short rows each took the other 640 - the narrowest
   thing on screen with the widest content in it. The rosters are sized in rem
   because what they need is fixed (a position tag and a name); everything left
   over belongs to the pool. */
.draft-body {
  display: grid;
  grid-template-columns: minmax(12rem, 15rem) minmax(0, 1fr) minmax(12rem, 15rem);
  gap: 1rem;
  align-items: start;
}

/* Narrow screens reorder the draft board rather than restacking the markup.
   The DOM order is roster-A, phases, pool, roster-B, which is right for the
   desktop three-column layout but puts the search box under a ten-slot roster
   panel on a phone - roughly 850px down, with a 30-second pick clock running.
   `order` fixes the reading order without moving anything renderPool and
   renderRosterPanel target by id.

   Two columns, not one: the rosters sit side by side underneath so both teams
   are on screen at once, which is the other thing that was lost at this
   width. */
@media (max-width: 860px) {
  .draft-body {
    grid-template-columns: 1fr 1fr;
  }

  /* The strategy phases replace the pool once drafting ends, so they take the
     same slot at the top. */
  .draft-body .tactic-phase {
    order: 0;
    grid-column: 1 / -1;
  }

  /* Directly under the squad banner: pick a name without scrolling. */
  #draft-pool-panel {
    order: 1;
    grid-column: 1 / -1;
  }

  #roster-panel-a {
    order: 2;
  }

  #roster-panel-b {
    order: 3;
  }

  /* Half-width panels can't afford the desktop padding or a wrapping name. */
  .draft-body .roster-panel {
    padding: 0.7rem 0.6rem;
    min-width: 0;
  }

  .draft-body .roster-slot {
    padding: 0.4rem 0.15rem;
    font-size: var(--text-xs);
    gap: 0.3rem;
  }

  .draft-body .slot-tag {
    min-width: 2.1rem;
    font-size: var(--text-micro);
  }

  /* Truncate rather than wrap: a wrapped name makes its row twice as tall as
     the others and the column stops scanning as a lineup. */
  .draft-body .slot-filled {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

.roster-panel {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  padding: 1rem;
}

.roster-panel h3 {
  margin-top: 0;
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 0.95rem;
}

/* A LINEUP, NOT A LIST OF FIELDS. Six rows of "PG ......... open" between
   hairlines is the shape of a table, and the two things that matter - which
   slots are open, and who is in the ones that are not - carried no weight.
   Each slot is its own surface now: filled reads as settled, open as a
   question. Same information, same order, same markup. */
.roster-slot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.45rem 0.55rem;
  margin-bottom: 0.3rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-1);
  font-size: 0.88rem;
  gap: 0.5rem;
  transition: border-color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out);
}

.roster-slot:last-child {
  margin-bottom: 0;
}

/* Filled: lit, and the position tag takes the accent because that slot is now
   a thing you have rather than a thing you need. */
.roster-slot:has(.slot-filled) {
  background: var(--surface-3);
  border-color: rgba(var(--accent-rgb), 0.35);
}

.roster-slot:has(.slot-filled) .slot-tag {
  color: var(--accent-bright);
}

.slot-tag {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.04em;
  min-width: 2.8rem;
}

.slot-filled {
  color: var(--text);
  text-align: right;
  /* .roster-slot is a flex row and a flex item defaults to min-width:auto, so
   * this column refuses to shrink below its content unless told to. Below
   * 860px it is also truncated with an ellipsis (see the media block above),
   * which is the deliberate choice - a wrapped football name makes its row
   * twice as tall and the column stops scanning as a lineup. These two are
   * what keep the untruncated case, above 860px, from pushing the row wide. */
  min-width: 0;
  overflow-wrap: anywhere;
}

.slot-empty {
  color: var(--muted);
  font-style: italic;
}

.slot-locked {
  color: var(--accent-bright);
  font-weight: 700;
  letter-spacing: 0.03em;
  /* "🔒 Locked in" is a status, and a status that breaks after the padlock
     reads as two things. The position tag beside it is allowed to wrap
     instead - "6th Man" over two lines is still "6th Man". */
  white-space: nowrap;
  animation: pulse-lock 1.4s ease-in-out infinite;
}

@keyframes pulse-lock {
  0%, 100% { opacity: 0.65; }
  50% { opacity: 1; }
}

/* Shared by offline (the bot's pick, via renderRoundReveal) and online (the
   opponent's pick, via renderOnlineDraftRound) - one animation, so a reveal
   reads identically in both modes. The flip alone read as a reveal; the
   glow on top is what makes it a HIGHLIGHT - worth a beat to actually look
   at and compare against your own pick, not just a flicker. */
.slot-reveal {
  display: inline-block;
  border-radius: 4px;
  padding: 0.05em 0.35em;
  animation: flip-reveal 1s ease, slot-highlight-glow 2.4s ease-out;
}

@keyframes flip-reveal {
  0% { transform: rotateX(90deg); opacity: 0; }
  60% { transform: rotateX(-12deg); opacity: 1; }
  100% { transform: rotateX(0deg); opacity: 1; }
}

@keyframes slot-highlight-glow {
  0% { background: rgba(var(--accent-rgb), 0.6); box-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
  20% { background: rgba(var(--accent-rgb), 0.45); box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.6); }
  100% { background: rgba(var(--accent-rgb), 0); box-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
}

.draft-pool-panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1rem;
}

#pool-search {
  width: 100%;
  margin-bottom: 0.5rem;
}

.pool-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-height: 380px;
  overflow-y: auto;
  /* Momentum scrolling on iOS - without it the list scrolls a fixed distance
     and stops dead on each swipe, which is most of what "frozen" describes. */
  -webkit-overflow-scrolling: touch;
  /* Keeps a flick that reaches the end of the list from scrolling the page
     underneath, so the board doesn't jump while you're picking. */
  overscroll-behavior: contain;
  /* Each card is its own layout/paint scope, so the browser doesn't re-layout
     the whole list (10-15 cards with borders and chips) on every scroll
     frame. This is the difference between smooth and stuttering on a phone. */
  contain: layout paint;
}

.player-card {
  /* --surface-3, not --bg: this is a card sitting inside the pool panel, and
     painting it in the PAGE's colour made it a hole in the panel rather than a
     thing on it - fifteen holes in a row, which is why the pool read as a list
     of rows rather than a set of cards you are choosing between. */
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  padding: 0.65rem 0.8rem;
  cursor: pointer;
  transition: border-color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out),
              box-shadow var(--motion-fast) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
  display: flex;
  justify-content: space-between;
  align-items: center;
  /* An eligible card is a <button> so it can be reached and pressed from a
     keyboard (renderPlayerCard in js/ui.js). These four undo what the UA
     stylesheet gives a button and the surrounding rules never expected: its
     own font, centred text, and a full-width block that the flex row above
     already handles. Declared on .player-card rather than on button.player-card
     so both element types stay byte-identical on screen. */
  font: inherit;
  color: inherit;
  text-align: left;
  width: 100%;
}

/* The whole point of the button: a visible focus ring. Without this the card
   is reachable by keyboard and invisible while you are on it, which is worse
   than not being reachable - you cannot tell what Enter would draft. */
.player-card:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
  border-color: var(--accent);
}

/* Only on devices that actually hover. On a touchscreen there's no hover to
   leave, so the transform stuck to the last-touched card and every scroll
   frame had to repaint a moved element - the jank this rule used to cause on
   a phone was worse than the polish it added on a desktop. */
@media (hover: hover) {
  .player-card:hover {
    border-color: var(--accent);
    /* Sideways rather than up: this is a vertical list being scanned, and a
       card that rises overlaps the one above it. The lift is in the shadow. */
    transform: translateX(3px);
    box-shadow: var(--shadow-card);
  }
}

/* Immediate feedback on tap, replacing the hover state above on touch. */
.player-card:active {
  border-color: var(--accent);
  transform: translateX(1px) scale(0.995);
}

.player-card.disabled {
  opacity: 0.3;
  cursor: not-allowed;
}

.player-card.disabled:hover {
  transform: none;
  border-color: var(--line);
}

.player-card.pending {
  border-color: var(--accent);
  background: rgba(var(--accent-rgb), 0.12);
}

.player-card-name {
  font-weight: 700;
}

.pos-chip {
  display: inline-block;
  background: var(--panel);
  color: var(--accent-bright);
  border: 1px solid var(--line);
  border-radius: 5px;
  padding: 0.05rem 0.4rem;
  font-size: var(--text-micro);
  font-family: var(--font-mono);
  margin-left: 0.35rem;
}

#btn-skip-round {
  margin-top: 0.6rem;
}

/* ---- Live scoreboard / game screen ----
   Modeled on a broadcast score bug: big score up top for drama, a
   quarter-by-quarter grid underneath that fills in as the game plays out -
   the recognizable "NBA on TV" scoreboard shape. */
/* ---- The board ----
   An arena light board rather than a panel with numbers on it. The court used
   to be the stage during play and it was the wrong thing to look at: 510px of
   mostly-empty rectangle carrying less information than the scoreboard under
   it. This is what a person watching actually reads, so it is what gets the
   room - and the shot chart moved to the recap, where a chart is studied
   rather than glanced at.

   The bulb texture is a 6px dot grid over a dark gradient. Cheap - one
   repeating radial-gradient, no image - and it is what stops the panel reading
   as a div with a big font. */
.scoreboard {
  position: relative;
  background:
    radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.045) 1px, transparent 1px) 0 0 / 6px 6px,
    linear-gradient(180deg, #12161d, #080a0e);
  border: 2px solid var(--line);
  border-radius: 12px;
  padding: 1.4rem 1.6rem;
  margin-bottom: 1rem;
  text-align: center;
  box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.85), 0 10px 30px rgba(0, 0, 0, 0.4);
}

/* Three columns: each side owns a name-over-score stack, and the dash keeps
   the middle. Sides are equal columns so a long team name cannot shove the
   scores off centre. */
.scoreboard-teams {
  display: grid;
  grid-template-columns: 1fr minmax(6ch, auto) 1fr;
  margin-bottom: 1.1rem;
  align-items: center;
  gap: 0.75rem;
}

.scoreboard-side {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  min-width: 0;
}

.scoreboard-side-a { color: var(--team-a-ink, var(--text)); }
.scoreboard-side-b { color: var(--team-b-ink, var(--text)); }

.scoreboard-side .scoreboard-team-name {
  color: inherit;
  /* A drafted squad's label can be long. Truncate rather than wrap: two lines
     under one score and one under the other puts the numbers on different
     baselines, which looks broken in a way a clipped name does not. */
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.scoreboard-team-name {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-weight: 800;
  font-size: 1rem;
  letter-spacing: 0.1em;
  color: var(--text);
}

.scoreboard-score {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 3.2rem;
  color: var(--scoreboard);
  font-variant-numeric: tabular-nums;
  min-width: 3.5ch;
  /* currentColor, so the glow follows whatever the team's colour turns out to
     be instead of being a second place the colour has to be kept in step. */
  text-shadow: 0 0 18px currentColor, 0 0 44px currentColor;
}

/* The kits, finally on the board. --team-a-ink/--team-b-ink are set on
   #game-stage by dressStage() in js/main.js, and the scoreboard lives inside
   it, so they arrive by inheritance with nothing to wire. The fallback is the
   old single colour, which is what a sport that dresses no kits still gets. */
.scoreboard-score-a { color: var(--team-a-ink, var(--scoreboard)); }
.scoreboard-score-b { color: var(--team-b-ink, var(--scoreboard)); }
.scoreboard-team-a { color: var(--team-a-ink, var(--text)); }
.scoreboard-team-b { color: var(--team-b-ink, var(--text)); }

/* While the game's actually playing (renderScoreboard's isLive flag), same
   idea as .scoreboard-period.live's blink - a constant, low-key "this is
   live" tell instead of digits that just sit there between updates. */
.scoreboard-score.pulse {
  animation: score-glow-pulse 0.9s ease-in-out infinite;
}

@keyframes score-glow-pulse {
  0%, 100% { text-shadow: 0 0 16px rgba(255, 209, 102, 0.35); }
  50% { text-shadow: 0 0 28px rgba(255, 209, 102, 0.75), 0 0 8px rgba(255, 209, 102, 0.9); }
}

.scoreboard-dash {
  font-family: var(--font-mono);
  color: var(--muted);
  font-size: 1.6rem;
}

/* On a phone the centre column is competing with two glowing scores for a
   390px row, so the status gives ground first - it is the least urgent of the
   three and the only one that can shrink without becoming unreadable. */
@media (max-width: 560px) {
  .scoreboard { padding: 1.1rem 0.9rem; }
  .scoreboard-score { font-size: 2.4rem; }
  .scoreboard-period { font-size: var(--text-micro); letter-spacing: 0.04em; }
  .scoreboard-middle { min-width: 5ch; }
}

.scoreboard-middle {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 7ch;
}

.scoreboard-period {
  margin: 0;
  font-family: var(--font-mono);
  color: var(--muted);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-size: var(--text-xs);
}

.scoreboard-period.live {
  color: var(--buzzer);
  animation: blink-live 1s steps(2, start) infinite;
}

/* Football's clock counts down in here, and a blinking clock is unreadable -
   the digits would be at 0.35 opacity half the time they are on screen. The
   movement of the clock says "live" on its own, which is the whole job the
   blink was doing. Set by setScoreboardStatus in js/ui.js; a sport with no
   play clock never gets the class and keeps the blink. */
.scoreboard-period.live.ticking {
  animation: none;
  color: var(--scoreboard);
  opacity: 1;
}

@keyframes blink-live {
  50% { opacity: 0.35; }
}

/* Fired (via js/main.js's flashClass helper) right as tickScoreTo's count-up
   lands on a period's real total - period-flash for an ordinary quarter
   end, the bigger buzzer-red lead-flash (reusing final-banner's own
   buzzer-flash keyframe) when that period also flipped the lead. */
/* The split-flap's motion without its cream tiles. Keyed to the classes the
   period tick already flashes, so it fires ONCE when a period lands rather
   than on every frame of the count-up - the count-up is its own motion, and
   flipping under it would be noise on top of noise. */
.scoreboard.period-flash .scoreboard-score,
.scoreboard.lead-flash .scoreboard-score {
  animation: score-flip 420ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes score-flip {
  0% { transform: perspective(320px) rotateX(0deg); }
  45% { transform: perspective(320px) rotateX(-88deg); filter: brightness(0.55); }
  55% { transform: perspective(320px) rotateX(88deg); filter: brightness(0.55); }
  100% { transform: perspective(320px) rotateX(0deg); }
}

.scoreboard.period-flash {
  animation: period-flash 0.6s ease;
}

@keyframes period-flash {
  0% { box-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
  30% { box-shadow: 0 0 26px rgba(var(--accent-rgb), 0.55); }
  100% { box-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
}

.scoreboard.lead-flash {
  animation: buzzer-flash 0.7s ease;
}

.scoreboard-grid {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.scoreboard-grid th, .scoreboard-grid td {
  padding: 0.4rem 0.5rem;
  text-align: center;
  border-bottom: 1px solid var(--line);
  border-left: 1px solid var(--line);
}

.scoreboard-grid th:first-child, .scoreboard-grid td:first-child {
  border-left: none;
}

.scoreboard-grid thead th {
  color: var(--muted);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-weight: 600;
}

.scoreboard-grid td.team-col, .scoreboard-grid th.team-col {
  text-align: left;
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  color: var(--text);
  letter-spacing: 0.02em;
}

.scoreboard-grid td.period-pending {
  color: var(--line);
}

.scoreboard-grid th.period-current {
  color: var(--buzzer);
}

/* The totals repeat the score, so they wear the same colour as the digits
   above them - an amber total under an orange score reads as two numbers
   rather than the same one twice. */
.scoreboard-grid tbody tr:first-child td.grid-total { color: var(--team-a-ink, var(--scoreboard)); }
.scoreboard-grid tbody tr:last-child td.grid-total { color: var(--team-b-ink, var(--scoreboard)); }

.scoreboard-grid td.grid-total {
  color: var(--scoreboard);
  font-weight: 700;
}

.final-banner {
  text-align: center;
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 1.3rem;
  font-weight: 800;
  padding: 1rem;
  border-radius: 10px;
  background: var(--panel);
  border: 1px solid var(--buzzer);
  margin-bottom: 0.75rem;
  animation: buzzer-flash 0.6s ease;
}

@keyframes buzzer-flash {
  0% { box-shadow: 0 0 0 rgba(232, 67, 61, 0); }
  30% { box-shadow: 0 0 24px rgba(232, 67, 61, 0.5); }
  100% { box-shadow: 0 0 0 rgba(232, 67, 61, 0); }
}

/* The MVP card. It was one line of orange text with no box around it, sitting
   between two bordered panels - the runt of the stack, and the one thing on
   the screen a person turns the phone round to show somebody. */
.mvp-callout {
  text-align: center;
  margin-bottom: 1rem;
  padding: 0.85rem 1rem 0.95rem;
  border-radius: 12px;
  background: linear-gradient(160deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.03));
  border: 1px solid var(--accent);
  animation: mvp-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.mvp-tag {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent-bright);
}

.mvp-name {
  font-family: var(--font-display);
  font-weight: 800;
  /* Fluid rather than a phone override: the name is the biggest thing on the
     card at every width, and a long one ("Giannis Antetokounmpo") has to fit a
     360px screen without a second rule deciding when it stops growing. */
  font-size: clamp(1.35rem, 6.5vw, 1.9rem);
  line-height: 1.1;
  margin-top: 0.15rem;
  color: var(--text);
}

.mvp-team {
  font-size: var(--text-sm);
  color: var(--muted);
  margin-top: 0.1rem;
}

/* WHY he was named, between the team and the numbers. Sits below the name it
   explains and above the line that proves it, because the sentence is what
   makes an unexpected choice - a kicker, a secondary - read as a verdict. */
.mvp-note {
  font-size: var(--text-sm);
  line-height: 1.35;
  color: var(--text);
  margin-top: 0.45rem;
  /* The one place on this card a phrase is allowed to wrap: it is a clause,
     not a label, and truncating it would lose the half that explains. */
  max-width: 26rem;
  margin-left: auto;
  margin-right: auto;
}

.mvp-stats {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.7rem;
}

/* A chip is a number and a word, sized differently: across a couch the number
   is what gets read and the word is what makes it mean something. */
.mvp-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 4.2rem;
  padding: 0.35rem 0.5rem;
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid var(--line);
}

.mvp-stat-value {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.15rem;
  color: var(--accent-bright);
  line-height: 1.1;
}

.mvp-stat-label {
  font-size: var(--text-micro);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

@keyframes mvp-pop {
  0% { opacity: 0; transform: scale(0.85) translateY(6px); }
  100% { opacity: 1; transform: scale(1) translateY(0); }
}

.game-actions {
  display: flex;
  gap: 0.75rem;
  margin-top: 1rem;
}

.game-actions .btn {
  flex: 1;
}

/* Always-available way out of a finished game, under the primary actions -
   Play Again and View Profile both commit you to something, and there was no
   plain "I'm done" until now. */
.game-home-btn {
  margin-top: 0.75rem;
}

table.box-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 1.25rem;
  font-size: 0.85rem;
  font-family: var(--font-mono);
}

table.box-table th, table.box-table td {
  padding: 0.45rem 0.6rem;
  text-align: left;
  border-bottom: 1px solid var(--line);
}

table.box-table th {
  color: var(--muted);
  font-weight: 600;
  text-transform: uppercase;
  font-size: var(--text-micro);
  letter-spacing: 0.03em;
}

/* Team + era under a player's name in the box score. */
.box-meta {
  font-size: var(--text-micro);
  color: var(--muted);
  margin-top: 0.1rem;
}

.team-heading {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--accent-bright);
  margin: 1rem 0 0.4rem;
}


/* ---- Profile screen ---- */

/* --- The identity hero ---
   The shared .player-banner card plus a footer bar carrying the equipped title
   and the two actions. One object visually: the seam between them reads as a
   fold rather than as two stacked panels. Nothing here repaints the card - see
   the long note above .player-banner - this is only the frame it lands in. */
.profile-hero {
  margin-bottom: 1rem;
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  /* So the card's artwork cannot paint over the rounded corner. */
  overflow: hidden;
}

.profile-hero-card {
  margin: 0;
  border-radius: var(--radius-card) var(--radius-card) 0 0;
  border-bottom: none;
  /* The profile is the one place this card is the page's headline rather than
     one element on a screen, so it gets more room to breathe than the home
     screen's copy of it. */
  padding: 1.35rem 1.4rem 1.15rem;
  box-shadow: none;
}

/* Rank progress, on the card. Only the profile's copy of the card has this:
   the home screen and the matchup intro draw the same component without it,
   because how close you are to Gold is a thing you look at deliberately, not
   something an opponent needs during a five-second intro. */
.pb-progress {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* A card heading and the sentence under it are ONE unit. Left to their own
   default margins an <h3> and a <p> put 1.4rem between two lines that belong
   together, and the hint then reads as loose body copy rather than as the
   heading's own caption. */
.card-head {
  margin-bottom: 0.9rem;
}

.card-head h3 {
  margin: 0 0 0.15rem;
  font-family: var(--font-display);
  font-size: 1.05rem;
  letter-spacing: 0.01em;
}

.card-head .hint-text {
  margin: 0;
}

.pb-progress-track .progress-bar-track {
  margin: 0;
  /* On a banner rather than on a panel, so the empty half of the bar has to be
     a hole in the artwork rather than the panel colour - which on a pale
     banner disappeared entirely. */
  background: rgba(0, 0, 0, 0.6);
  border-color: rgba(255, 255, 255, 0.35);
  height: 12px;
  /* Its own shadow, because the thing behind it is a player's artwork and
     some of that artwork is nearly white - Arctic Stripe put a pale bar on a
     pale field and the whole track disappeared. */
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}

.pb-progress-caption {
  font-size: var(--text-xs);
  color: rgba(255, 255, 255, 0.82);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
  line-height: 1.4;
}

/* The fold: title on the left, actions on the right. */
.profile-hero-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.7rem 1rem;
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-top: none;
  border-radius: 0 0 var(--radius-card) var(--radius-card);
}

.profile-hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-left: auto;
}

/* --- The career strip ------------------------------------------------------
   Four tiles that must look like four of the same thing, so they are a grid of
   equal columns rather than flex items sized by their content: "12-4" and
   "100%" are different widths and as flex items the tiles would be too, which
   makes the row read as four unrelated boxes. */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.6rem;
  margin-bottom: 1rem;
}

.stat-tile {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.85rem 0.9rem;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  box-shadow: var(--shadow-card);
  min-width: 0;
}

.stat-tile-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
}

.stat-tile-value {
  font-family: var(--font-mono);
  font-size: 1.5rem;
  font-weight: 700;
  line-height: 1.1;
  color: var(--scoreboard);
}

.stat-tile-note {
  font-size: var(--text-micro);
  color: var(--muted);
}

/* 2x2 on a phone, as specified - four tiles across a 360px screen gives each
   one 78px, which cannot hold "Practice Record" or a mono "128". */
@media (max-width: 720px) {
  .stat-strip {
    grid-template-columns: 1fr 1fr;
  }
}

/* --- Team colours ----------------------------------------------------------
   The head and the swatches used to stack, so a card whose entire content is
   one row of 28px swatches stood three times taller than it needed to. */
.card-tight {
  padding: 0.9rem 1rem;
}

.profile-kits {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
}

.profile-kits-head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.profile-kits .kit-swatches {
  margin: 0;
}

/* The hint goes underneath the row rather than beside it - it is a sentence,
   and a sentence competing for a row with a colour picker gets neither. */
.profile-kits-hint {
  flex: 1 0 100%;
  margin: 0;
}

/* --- The career divider ---
   Everything below belongs to ONE sport, and the divider has to say so - the
   subtab row used to float four cards below the last thing it governed. */
.career-section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin: 1.6rem 0 0.9rem;
  padding-bottom: 0.7rem;
  border-bottom: 2px solid rgba(var(--accent-rgb), 0.5);
}

.career-section-head .section-heading {
  margin: 0;
  /* The section rule is on the head now, so the heading's own underline would
     be a second line 4px above it. */
  border-bottom: none;
  padding-bottom: 0;
  font-size: 1.05rem;
  color: var(--text);
}

.career-section-head .subtabs {
  margin: 0;
}

/* --- The sport toggle ---
   The active sport is a filled accent chip and the inactive one a quiet
   outline, so which sport you are reading is answerable from across the room
   rather than by comparing two pills. */
.sport-toggle {
  gap: 0.35rem;
  padding: 0.25rem;
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}

.sport-toggle .subtab {
  border: none;
  background: none;
  padding: 0.45rem 1rem;
  font-size: 0.9rem;
  letter-spacing: 0.02em;
  transition: background var(--motion-fast) var(--ease-out),
              color var(--motion-fast) var(--ease-out);
}

.sport-toggle .subtab.active {
  background: var(--accent);
  color: var(--accent-contrast);
  box-shadow: 0 2px 10px rgba(var(--accent-rgb), 0.35);
}

.sport-toggle .subtab:not(.active):hover {
  background: rgba(255, 255, 255, 0.06);
  color: var(--text);
}

/* --- The per-sport summary strip ------------------------------------------- */
.sport-summary {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  /* One-pixel gaps over a line-coloured ground: the cells are a single object
     divided up, not six cards with six borders. Six borders at this size is
     more line than content. */
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
  overflow: hidden;
  margin-bottom: 1rem;
}

.summary-cell {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  padding: 0.85rem 0.95rem;
  background: var(--surface-2);
  min-width: 0;
}

/* Rank and Most Drafted carry a sentence rather than a number, so they take
   two columns. Everything else is a figure and fits one. */
.summary-cell-wide {
  grid-column: span 2;
}

.summary-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
}

.summary-value {
  font-family: var(--font-mono);
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--text);
  line-height: 1.2;
  overflow-wrap: anywhere;
}

/* A player's name and a tier name are words, not readings off a scoreboard. */
.summary-cell-text .summary-value,
.summary-cell-wide .summary-value {
  font-family: var(--font-display);
  font-size: 1.1rem;
  letter-spacing: 0.01em;
}

.summary-note {
  font-size: var(--text-micro);
  color: var(--muted);
  line-height: 1.35;
}

@media (max-width: 720px) {
  .sport-summary {
    grid-template-columns: 1fr 1fr;
  }
}

/* --- Records by Era ---
   A table on a desktop, a stack of cards on a phone, from one set of elements.
   The header row and every era row share the grid's column widths, which is
   what makes the numbers line up - the old flex rows did not, so the brackets
   could not be compared, which is the only reason to break them out. */
.era-records {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  overflow: hidden;
}

.era-row {
  display: grid;
  grid-template-columns: minmax(9rem, 1.6fr) 1fr 1fr 1fr;
  align-items: center;
  gap: 0.5rem 1rem;
  padding: 0.6rem 0.9rem;
  background: var(--surface-2);
  border-bottom: 1px solid var(--line-soft);
}

.era-row:last-child {
  border-bottom: none;
}

.era-head {
  background: var(--surface-1);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
}

/* An unplayed bracket is dimmed, not hidden: which brackets exist is
   information, and a list that grows as you play them reads as broken. */
.era-row-empty .era-cell-value,
.era-row-empty .era-rank-chip {
  opacity: 0.5;
}

.era-name {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  font-weight: 600;
  min-width: 0;
}

.era-label {
  overflow-wrap: anywhere;
}

.era-cell {
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
  min-width: 0;
}

/* On a desktop the column header says what each cell is, so repeating it in
   every cell would be three extra words per row times five brackets. On a
   phone there is no header row, and the label is the only thing that says
   which record is which - see the phone block below. */
.era-cell-label {
  display: none;
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  font-weight: 700;
}

.era-cell-value {
  font-family: var(--font-mono);
  font-weight: 700;
  white-space: nowrap;
}

.era-rank-chip {
  display: inline-block;
  font-size: var(--text-micro);
  padding: 0.15rem 0.55rem;
  border-radius: var(--radius-pill);
  background: var(--surface-3);
  border: 1px solid var(--line);
  color: var(--muted);
  white-space: nowrap;
}

@media (max-width: 720px) {
  /* Each bracket becomes its own card: name across the top, the three readings
     as labelled cells beneath it. A four-column table at 360px gives each
     column 80px, and "Grandpa's Game" does not fit in 80px. */
  .era-records {
    border: none;
    gap: 0.5rem;
  }

  .era-head {
    display: none;
  }

  .era-row {
    grid-template-columns: 1fr 1fr;
    border: 1px solid var(--line);
    border-radius: var(--radius-inner);
    padding: 0.7rem 0.85rem;
    gap: 0.5rem 0.75rem;
  }

  .era-name {
    grid-column: 1 / -1;
  }

  .era-cell {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.1rem;
  }

  /* The rank is a chip, not a figure, so it does not want the stacked
     label-over-value shape the two record cells take - as a third cell in a
     two-column grid it took a whole row for one short pill with a heading
     floating above it. Side by side across the full width instead. */
  .era-cell-rank {
    grid-column: 1 / -1;
    flex-direction: row;
    align-items: center;
    gap: 0.45rem;
  }

  .era-cell-label {
    display: block;
  }
}

/* --- Top Performances: the record board ---
   Grouped headings, then rows of what / who / how much / when. It was one
   undivided column of every tracked stat - twelve for football, each stacking
   into two lines on a phone. Twenty-four lines of prose where a board goes. */
.record-board {
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}

.record-group-heading {
  margin: 0 0 0.4rem;
  font-family: var(--font-display);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--accent-bright);
  font-weight: 800;
}

.record-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  gap: 0.3rem 1rem;
  width: 100%;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--line-soft);
  text-align: left;
  font: inherit;
  color: inherit;
  background: none;
  border-left: none;
  border-right: none;
  border-top: none;
}

.record-group .record-row:last-child {
  border-bottom: none;
}

.record-what {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

.record-label {
  font-weight: 600;
  font-size: 0.9rem;
}

/* Who holds it - a player, a unit, or the opponent a game record was set
   against. Quieter than the label, because the label is what you scan for and
   the holder is what you read once you have found it. */
.record-holder {
  font-size: var(--text-micro);
  color: var(--muted);
  overflow-wrap: anywhere;
}

.record-figure {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.1rem;
  text-align: right;
}

.record-value {
  font-family: var(--font-mono);
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--scoreboard);
  white-space: nowrap;
}

.record-date {
  font-size: var(--text-micro);
  color: var(--muted);
  white-space: nowrap;
}

/* An unset record keeps its row - showing what WILL be tracked is more useful
   than an empty card - but it should not look like an achievement. */
.record-row-empty .record-value {
  color: var(--muted);
}

/* A row is only a button when there is a box score behind it. */
.record-row-link {
  cursor: pointer;
  border-radius: 6px;
  padding-left: 0.4rem;
  padding-right: 0.4rem;
  margin-left: -0.4rem;
  margin-right: -0.4rem;
  transition: background var(--motion-fast) var(--ease-out);
}

.record-row-link:hover {
  background: rgba(var(--accent-rgb), 0.12);
}

.record-row-link:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 1px;
}

/* --- Recent Games: match history ---
   One grid per game, reflowing into a card under 640px. The W/L chip leads the
   row and is read out first: the result is why anyone opens this panel. */
.match-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.match-row {
  display: grid;
  grid-template-columns: 2.4rem 1fr auto auto;
  align-items: center;
  gap: 0.35rem 0.9rem;
  padding: 0.6rem 0.8rem;
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-left: 3px solid var(--line);
  border-radius: var(--radius-inner);
}

/* Colour is the SECOND reading, never the only one - the chip already says W
   or L in a letter. */
.match-won { border-left-color: var(--win); }
.match-lost { border-left-color: var(--buzzer); }

.match-result {
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  border-radius: 8px;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 1.1rem;
  line-height: 1;
}

.match-won .match-result {
  background: rgba(63, 207, 142, 0.16);
  border: 1px solid rgba(63, 207, 142, 0.55);
  color: var(--win);
}

.match-lost .match-result {
  background: rgba(232, 67, 61, 0.16);
  border: 1px solid rgba(232, 67, 61, 0.55);
  color: var(--buzzer);
}

.match-main {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
}

.match-opponent {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.match-tags {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  font-size: var(--text-micro);
  color: var(--muted);
}

.match-tag {
  padding: 0.1rem 0.45rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--line);
  white-space: nowrap;
}

.match-mvp b {
  color: var(--text);
  font-weight: 600;
}

.match-score {
  font-family: var(--font-mono);
  font-size: 1.15rem;
  font-weight: 700;
  white-space: nowrap;
}

.match-date {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  color: var(--muted);
  white-space: nowrap;
}

@media (max-width: 640px) {
  /* Chip down the left, everything else in a column beside it, score and date
     on their own line under the tags. Four columns at 360px put the score and
     the date in ~50px each and broke "128-119" across two lines. */
  .match-row {
    grid-template-columns: 2.4rem 1fr;
  }

  .match-result {
    grid-row: span 2;
    align-self: start;
  }

  .match-score {
    grid-column: 2;
    font-size: 1.05rem;
  }

  .match-date {
    grid-column: 2;
    margin-top: -0.2rem;
  }
}
.tier-badge {
  display: inline-block;
  background: var(--accent);
  color: var(--accent-contrast);
  border-radius: var(--radius-pill);
  padding: 0.3rem 0.9rem;
  font-weight: 800;
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  margin-bottom: 0.4rem;
}

.progress-bar-track {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  height: 8px;
  overflow: hidden;
  margin: 0.5rem 0;
}

.progress-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent), var(--scoreboard));
  /* Rank progress MOVES. Every bar in the app is painted by setting
     style.width on this element - the tier bar, the badge ladders, the banner
     ladders, squad rep - so one transition here means a rank that changed
     while you were away slides to its new place when the screen opens, rather
     than being drawn already there. 600ms is slow enough to be seen as motion
     and short enough that nothing waits on it. */
  transition: width 600ms var(--ease-out);
}

/* A player's stats: a row of value/label pairs that WRAP BETWEEN PAIRS AND
   NEVER INSIDE ONE.

   This used to be one run of text joined with "·", and at phone width it
   broke wherever the line happened to run out - "25.7 pts · 5.8 reb · 3.9
   ast · 1.3" then "stl · 1.1 blk", so a number ended one line and its unit
   began the next, on every card in the pool. Flex with a non-wrapping pair
   as the flex item is what makes that impossible rather than unlikely. */
.player-stats {
  color: var(--muted);
  font-size: var(--text-xs);
  margin-top: 0.25rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.15rem 0.7rem;
  align-items: baseline;
}

.stat-pair {
  display: inline-flex;
  align-items: baseline;
  gap: 0.2rem;
  white-space: nowrap;
}

/* The number is the thing being compared between two cards, so it carries
   the weight and the brighter ink; the unit is a caption on it. */
.stat-value {
  color: var(--text);
  font-weight: 700;
}

.stat-label {
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: var(--text-micro);
}

/* A pair with no label - football's unit member names. Not a statistic, so
   it takes the whole width above the columns rather than sitting in one. */
.stat-lead {
  flex: 1 0 100%;
  white-space: normal;
  color: var(--text);
}

.stat-lead .stat-value {
  font-weight: 600;
}

.empty-note {
  color: var(--muted);
  font-size: 0.85rem;
  font-style: italic;
}

.pool-elsewhere-note {
  color: var(--accent-bright);
}

.pool-none-note {
  color: var(--buzzer);
}

/* A render failure, not a search result - it has to read as something that
   went wrong on our side rather than as another "no match" note. */
.pool-error-note {
  color: var(--buzzer);
  font-style: normal;
  font-weight: 600;
}

/* ---- Auth screen ---- */
/* The first screen anybody sees, and it was a form floating in the middle of
   a very dark page. The glow is the same idea as the sport wash on the body -
   a light source behind the thing you are meant to look at - and it is the one
   place in the app where the accent can sit directly behind the brand mark,
   because there is no content around it to fight.
   
   position:relative and z-index on the card, so the halo is behind the card
   rather than over the form. */
.auth-card {
  position: relative;
  max-width: 420px;
  margin: 2rem auto 0;
  z-index: 1;
}

.auth-card::before {
  content: "";
  position: absolute;
  /* Anchored to the logo at the top of the card rather than to its middle:
     the card is tall and mostly legal text, and a halo centred on it lights
     the disclaimer. */
  left: 50%;
  top: 0;
  /* Clamped to the viewport, not just to the card. At 26rem flat this is 416px
     wide centred on a 360px screen - 28px hanging off each side, and a
     pointer-events:none decoration still makes the page scroll sideways. */
  width: min(26rem, 92vw);
  height: min(26rem, 92vw);
  transform: translate(-50%, -45%);
  background: radial-gradient(circle, rgba(var(--accent-rgb), 0.16) 0%, transparent 62%);
  pointer-events: none;
  z-index: -1;
}

.auth-card h2 {
  margin-bottom: 0.25rem;
}

.auth-status {
  margin-top: 0.75rem;
  font-size: 0.85rem;
  color: var(--accent-bright);
  text-align: center;
}

.auth-status.auth-error {
  color: var(--buzzer);
}

.auth-switch {
  text-align: center;
  margin-top: 1rem;
  margin-bottom: 0;
}

.link-btn {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent-bright);
  font-size: inherit;
  font-weight: 700;
  text-decoration: underline;
  cursor: pointer;
}

/* The strip above the identity card: presence on the left, the wardrobe
 * button on the right.
 *
 * It was .home-top-left - a left-aligned column holding only the online
 * ticker, with nothing on the right-hand side of the screen at all. A row with
 * space-between rather than a second absolutely-positioned element, so the
 * button cannot overlap the ticker when the count is long, and so it simply
 * wraps under it on a narrow phone instead of colliding.
 *
 * The negative left margin cancels the page gutter for the ticker only: it is
 * applied to the row, and the right-hand button is pulled back by the same
 * amount so it still lines up with the content edge on that side. */
.home-top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0 0 1rem calc(var(--page-gutter) * -1);
}

/* flex:0 0 auto overrides the phone rule further down that stretches
   .sport-card-action to fill its row. That rule is for the two pills on a
   sport card, which share a row and need the width to stay tappable; this
   button sits alone opposite the online ticker, and stretched to full width it
   reads as a banner across the top of the screen rather than as a button. */
.home-top > .sport-card-action {
  flex: 0 0 auto;
  margin-left: auto;
}

/* This used to hardcode white, because --accent-contrast's dark navy landed
   under 4.5:1 on the old blue. With the accent now varying per sport neither
   choice is safe to hardcode: white reads at 3.3:1 on basketball orange and
   dark reads at 4.1:1 on football blue, so each sport carries whichever one
   clears the floor against its own accent (see the theme block in
   js/sports/<id>/index.js). */
.how-to-play-btn {
  background: var(--accent);
  color: var(--accent-contrast);
  border: none;
  border-radius: var(--radius-pill);
  padding: 0.5rem 1.15rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  cursor: pointer;
}

.how-to-play-btn:hover {
  filter: brightness(1.08);
}

.signed-in-as {
  color: var(--accent-bright);
  font-weight: 700;
}

/* ---- Player banner (home screen identity card) ----
   The equipped team's banner IS the card's background - a vertical field of
   its two colors with the abbreviation ghosted huge in the corner, the same
   treatment as a standalone banner tile (see .banner-art) but filling the
   whole card instead of sitting in it. --banner-c1/--banner-c2 are set
   inline in ui.js's renderPlayerBannerCard; the class/vars stay off entirely when
   nothing is equipped, so the layout never depends on having earned
   something. */
.player-banner {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  position: relative;
  overflow: hidden;
  /* So the artwork rules below can ask how wide THIS CARD is. The viewport is
     the wrong question: what blurs the art is the card's width divided by the
     image's, and the card is not the viewport. */
  container-type: inline-size;
}

/* ---- The equipped banner's artwork ----
   The art is wallpaper, not content, so it is painted rather than marked up as
   an <img> - this card is a layout container with the player's name, badges
   and stats on top of it.

   It is painted on a LAYER (.pb-banner-wash) rather than on the card itself.
   That was originally so a container query could switch treatments by the
   card's width, and an element cannot match a container query on itself - only
   its descendants can. The query is gone now that the artwork fills the card at
   every width, but the layer stays: the card's own background is its panel
   fallback, and painting over it would remove the thing a missing file falls
   back to.

   HISTORY, because the obvious "simplification" here is to go back to what was
   tried first. The artwork used to be ~265x90, which on a ~1060px card is a
   3.9x upscale that shipped looking like a rendering fault. Two workarounds
   came and went - a blurred wash (still read as "blurry": deliberate softness
   and accidental softness are indistinguishable to whoever is looking) and a
   colour field plus a small sharp copy of the art. Both are deleted. Tiling was
   measured and rejected too: the textures do not wrap, with edge seams 2-20x
   their own neighbouring-pixel variation.

   What actually fixed it was 2120x720 source files. If undersized art ever
   appears again, applyArtResolution() in js/ui.js is what notices - see
   docs/banner-artwork.md. */
.pb-banner-wash {
  /* Off unless the banner actually has artwork. Off is the important half: the
     card is a flex column, so an unstyled div would not merely be invisible -
     it would take the card's 0.9rem gap and push the contents apart. */
  display: none;
}

.player-banner.has-banner-image > .pb-banner-wash {
  display: block;
  position: absolute;
  inset: 0;
  z-index: 0;
  background-image: var(--banner-image);
  background-size: cover;
  background-position: center;
  pointer-events: none;
}

.player-banner.has-banner {
  border-color: rgba(255, 255, 255, 0.25);
}

/* Only banners with no artwork of their own fall back to the flat two-color
   fade. A patterned one (camo, crew tier, Founder) keeps the banner-art-*
   class it was given in renderPlayerBannerCard - written as :not() rather than
   letting the two rules fight, since .player-banner.has-banner outranks a
   single .banner-art-* class and would silently win.

   A real PNG banner needs the same exemption for the same reason, and it is
   worth spelling out because this rule beat it twice over: it outranks
   .player-banner.has-banner-image on specificity (0,3,0 against 0,2,0), AND
   `background` is the shorthand, which resets background-image even on a tie.
   Equipping Volcanic showed a plain orange fade on the home card while the
   same banner rendered correctly as a tile. */
.player-banner.has-banner:not([data-banner-art]):not(.has-banner-image) {
  background: linear-gradient(180deg, var(--banner-c1) 0%, var(--banner-c2) 100%);
}

.player-banner.has-banner::before {
  content: attr(data-banner-abbr);
  position: absolute;
  right: -0.04em;
  bottom: -0.32em;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 9rem;
  line-height: 1;
  letter-spacing: -0.02em;
  color: rgba(255, 255, 255, 0.14);
  white-space: nowrap;
  pointer-events: none;
  z-index: 0;
}

.player-banner.has-banner::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(115deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0) 30%),
    linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.42) 100%);
  pointer-events: none;
  z-index: 0;
}

/* A patterned banner is far busier behind text than the flat fade this card
   used to have - tiger camo puts hard black-on-gold edges directly under the
   username. This scrim is weighted to the left, where the name, join date and
   stats all live, and thins out toward the right so the artwork still reads
   and the emblem keeps its glow. */
/* Image banners need the same treatment, and for a reason the patterned ones
   do not have: their field is built from the banner's own two colours, and
   some of those are pale. Arctic Stripe put near-white behind a white username.
   The default scrim above even adds a white sheen at the top left, which is
   exactly where the name sits, so on a light banner it was making the contrast
   worse rather than better. */
.player-banner.has-banner.has-banner-image::after,
.player-banner.has-banner[data-banner-art]::after {
  background:
    linear-gradient(
      90deg,
      rgba(0, 0, 0, 0.74) 0%,
      rgba(0, 0, 0, 0.6) 34%,
      rgba(0, 0, 0, 0.3) 62%,
      rgba(0, 0, 0, 0.24) 100%
    ),
    linear-gradient(180deg, rgba(0, 0, 0, 0.12) 0%, rgba(0, 0, 0, 0.4) 100%);
}

.player-banner.has-banner > * {
  position: relative;
  z-index: 1;
}

/* The home card is a much bigger canvas than a tile, so these are sized in
   rem rather than inheriting the tile's em scale - an emblem at 2.1em of the
   card's font size would be a speck on it. Both sit above the card's ::after
   wash so the star stays gold and the words stay legible. */
/* Selectors deliberately carry `.player-banner.has-banner >` rather than the
   bare class: the rule above sets `position: relative` on every direct child
   at (0,2,0), which outranks a lone class and would drop these back into the
   flex flow - the star then stretches to the full card width and shoves the
   layout down instead of floating over it. `width: max-content` is belt and
   braces for the same reason. */
.player-banner.has-banner > .pb-banner-emblem {
  position: absolute;
  width: max-content;
  right: 1.6rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 5rem;
  line-height: 1;
  color: var(--banner-c1, #f2c14e);
  text-shadow: 0 0 0.28em rgba(242, 193, 78, 0.75), 0 3px 8px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  z-index: 2;
}

.player-banner.has-banner > .pb-banner-label {
  position: absolute;
  width: max-content;
  right: 1.2rem;
  bottom: 0.9rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.95rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #fff;
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95), 0 0 0.8em rgba(0, 0, 0, 0.75);
  /* ITS OWN BACKDROP, because the card's is a team's colours and those are not
     ours to change. White on the Nets' second colour - which is white - came
     out at 3.03:1 even under the card's 42% scrim, and Milwaukee's cream at
     3.77:1. Deepening the card scrim until those cleared would have darkened
     every banner in the game to fix four, and would still not cover a banner
     whose background is a PNG rather than a gradient.
     A plate under the label is independent of both: it puts a known colour
     behind the text whatever is behind IT, so the label is readable over a
     team's colours, over artwork, and over anything added later.
     See scripts/verify-contrast.mjs, which measures the composite. */
  background: rgba(0, 0, 0, 0.55);
  padding: 0.12rem 0.45rem;
  border-radius: 6px;
  white-space: nowrap;
  pointer-events: none;
  z-index: 2;
}

/* On a phone the card is narrow enough that a 5rem star would crowd the
   player's name, so it steps down and tucks into the top corner. */
@media (max-width: 560px) {
  .player-banner.has-banner > .pb-banner-emblem {
    font-size: 3.2rem;
    right: 1rem;
    top: 1.5rem;
    transform: none;
  }

  .player-banner.has-banner > .pb-banner-label {
    font-size: var(--text-xs);
    right: 0.8rem;
    bottom: 0.7rem;
  }

  /* The star sits over the top-right corner, which on a narrow card is where
     a long username would otherwise run straight under it. */
  .player-banner.has-emblem .pb-top {
    padding-right: 3.6rem;
  }
}

@media (max-width: 560px) {
  .player-banner.has-banner::before {
    font-size: 6rem;
  }
}

.pb-top {
  display: flex;
  align-items: center;
  gap: 0.9rem;
}

.pb-identity {
  flex: 1;
  min-width: 0;
}

.pb-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.3rem;
}

.pb-joined {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  padding: 0.15rem 0.55rem;
  border-radius: var(--radius-pill);
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--line);
  color: var(--muted);
  white-space: nowrap;
}


/* Badges get their own full-width row rather than sharing pb-top with the
   avatar/name - at 2x size (see .pb-badge) they're meant to be the second
   thing you notice on the card, not a corner accent. */
.pb-badges {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}

.pb-badge {
  width: 5.2rem;
  height: 5.2rem;
  border-radius: 14px;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  font-size: 2.5rem;
}

.pb-badge.empty {
  border-style: dashed;
  color: var(--muted);
  font-size: 2rem;
}

.pb-badge-tier {
  position: absolute;
  bottom: -4px;
  right: -4px;
  font-size: 1.3rem;
}

/* THE STATS SIT ON SOMETHING. Three loose value-over-label pairs laid onto
   busy artwork is what "floating" looks like, and their contrast depended on
   the card-wide scrim happening to be dark there. As one plate they read as
   the card's stat line and carry their own contrast. width:max-content, so it
   is as wide as the numbers rather than stretching across artwork. */
.pb-stats {
  display: flex;
  gap: 1.4rem;
  width: max-content;
  max-width: 100%;
  flex-wrap: wrap;
  padding: 0.5rem 0.9rem;
  border-radius: var(--radius-inner);
  background: rgba(0, 0, 0, 0.42);
  border: 1px solid rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(2px);
}

.pb-stat {
  display: flex;
  flex-direction: column;
}

.pb-stat-value {
  font-family: var(--font-mono);
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--scoreboard);
}

.pb-stat-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}

@media (max-width: 560px) {
  .pb-top { flex-wrap: wrap; }
  .pb-badge { width: 4rem; height: 4rem; font-size: 1.9rem; }
  .pb-badge.empty { font-size: 1.5rem; }
}

.player-avatar {
  font-size: 2rem;
  width: 3rem;
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--bg);
  border: 1px solid var(--accent);
  box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.3);
}

/* An emblem fills its circle edge to edge and now carries its own rim in the
   team's second colour (see emblemSvg), so the avatar's usual accent border
   would sit outside it as a second, differently-coloured ring. No border, no
   background: the drawing IS the avatar. Emoji icons keep the ring, which is
   what makes them read as sitting in a slot rather than being the slot. */
.player-avatar.has-emblem-icon {
  background: none;
  border: none;
  box-shadow: none;
  padding: 0;
  overflow: hidden;
}

.player-avatar .emblem {
  width: 100%;
  height: 100%;
  display: block;
}

/* Quieter than a hint - it is not instruction, it is a statement of record -
   but not hidden either, which would defeat the point of making it. */
.legal-note {
  margin-top: 1.1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--line);
  font-size: var(--text-micro);
  line-height: 1.5;
  color: var(--muted);
  text-align: center;
}

/* Privacy and Terms, directly under the disclaimer they belong with. No rule
   of its own above them - two stacked hairlines read as a broken border - and
   a real tap target on a phone, where 0.68rem text with default link padding
   is under every minimum. */
.legal-links {
  margin-top: 0.35rem;
  padding-top: 0;
  border-top: none;
}

.legal-links a {
  color: var(--muted);
  text-decoration: underline;
  display: inline-block;
  padding: 0.35rem 0.25rem;
}

.legal-links a:hover,
.legal-links a:focus-visible {
  color: var(--accent-bright);
}

.player-name {
  font-family: var(--font-display);
  font-weight: 900;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 1.35rem;
}


/* ---- Badges tab ---- */
.badge-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.75rem;
}

@media (max-width: 860px) {
  .badge-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* --- Rarity ---
   Every earned badge printed its tier in the same accent orange, so Bronze and
   Hall of Fame differed only by the word. --tier-ink comes from data-tier
   (written in renderBadgeCollection from BADGE_TIERS) and drives the chip, the
   icon rim and the progress fill together; a fifth tier needs a colour here
   and nothing else. Violet tops the scale rather than a brighter gold, because
   "more gold" is not a reading anyone gets. */
.badge-tile {
  --tier-ink: var(--muted);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  padding: 0.9rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  box-shadow: var(--shadow-card);
  transition: border-color var(--motion-fast) var(--ease-out),
              box-shadow var(--motion-base) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
}

.badge-tile[data-tier="Bronze"] { --tier-ink: #cd8b52; }
.badge-tile[data-tier="Silver"] { --tier-ink: #cdd6e0; }
.badge-tile[data-tier="Gold"] { --tier-ink: #f2c14e; }
.badge-tile[data-tier="Hall of Fame"] { --tier-ink: #b39aff; }

@media (hover: hover) {
  .badge-tile:not(.locked):hover {
    border-color: var(--tier-ink);
    box-shadow: var(--shadow-lift);
    transform: translateY(-2px);
  }
}

/* Unearned badges stay legible rather than blanked out - you should be able
   to read what a badge tracks before you've earned it, otherwise the tab
   can't tell you what to chase. FLAT as well as dim: an earned tile is lit
   from above and an unearned one is a hole in the page, which is a reading
   that survives being glanced at where six percent of opacity does not. */
.badge-tile.locked {
  opacity: 0.62;
  background: var(--surface-1);
  box-shadow: none;
}

/* MAXED OUT. Nothing left to chase on this badge, and it should not look like
   one that is halfway. A rim in its own tier colour, a lit floor, and the tier
   chip goes solid - three quiet signals rather than one loud one, and none of
   them changes the tile's height, which is what keeps the grid a grid. */
.badge-tile.maxed {
  border-color: var(--tier-ink);
  box-shadow: 0 0 0 1px var(--tier-ink), 0 0 22px -6px var(--tier-ink), var(--shadow-card);
  background:
    radial-gradient(120% 90% at 50% 130%, color-mix(in srgb, var(--tier-ink) 16%, transparent), transparent 72%),
    var(--surface-2);
}

/* One of the three on your banner. A dashed accent ring, so at a glance you
   can see which badges you are already wearing without reading every button -
   dashed rather than solid so it cannot be confused with the maxed rim above,
   which is a property of the badge rather than of your choice. */
.badge-tile.featured {
  outline: 2px dashed rgba(var(--accent-rgb), 0.75);
  outline-offset: -4px;
}

.badge-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.badge-icon {
  font-size: 1.6rem;
  width: 2.4rem;
  height: 2.4rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  background: var(--bg);
  border: 1px solid var(--line);
  flex-shrink: 0;
}

.badge-tile:not(.locked) .badge-icon {
  border-color: var(--tier-ink);
  box-shadow: 0 0 12px -2px var(--tier-ink);
  background: color-mix(in srgb, var(--tier-ink) 10%, var(--bg));
}

.badge-name {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: 0.9rem;
}

.badge-tier {
  display: inline-block;
  font-size: var(--text-micro);
  color: var(--tier-ink);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.1rem 0.45rem;
  margin-top: 0.1rem;
  border-radius: var(--radius-pill);
  border: 1px solid color-mix(in srgb, var(--tier-ink) 45%, transparent);
  background: color-mix(in srgb, var(--tier-ink) 12%, transparent);
}

/* Solid at the top tier: the chip stops being a label on the badge and becomes
   the badge's rank. */
.badge-tile.maxed .badge-tier {
  background: var(--tier-ink);
  border-color: var(--tier-ink);
  /* The four tier inks are all light, so dark ink is the readable one on every
     one of them; checked at #cd8b52, the darkest, which comes out at 6.4:1. */
  color: #16181d;
}

/* Not a chip when there is nothing to rank - "Not earned yet" is a state, not
   a tier, and a pill around it would say it had been awarded one. */
.badge-tile.locked .badge-tier {
  color: var(--muted);
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  padding: 0;
  border: none;
  background: none;
  font-size: var(--text-xs);
}

.badge-blurb {
  font-size: var(--text-xs);
  color: var(--muted);
}

.badge-progress {
  font-size: var(--text-micro);
  color: var(--muted);
  font-family: var(--font-mono);
}

/* The one line on a maxed tile that says why it looks different. */
.badge-tile.maxed .badge-progress {
  color: var(--tier-ink);
  font-weight: 700;
}

/* The bar takes the tier's colour too, so a full Bronze bar and a full Gold
   bar are not the same picture. The track is stepped down onto the tile's own
   surface rather than sitting on --panel, which is now the same colour as the
   tile behind it and made the empty half of every bar invisible. */
.badge-tile .progress-bar-track {
  background: var(--surface-1);
  border-color: var(--line);
}

.badge-tile[data-tier] .progress-bar-fill {
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--tier-ink) 65%, #000) 0%,
    var(--tier-ink) 100%
  );
}

/* Three across on a phone, the same as the icon grid.
   
   Badges went 3 -> 2 -> ONE COLUMN, so the rewards screen became a single
   stack of wide cards and reading your badges meant scrolling past every one
   of them. Icons were already 3 across at this width and read fine, which is
   the shape being matched here rather than invented. The tile shrinks to pay
   for it - see .badge-tile below. */
@media (max-width: 560px) {
  .badge-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.5rem;
  }

  .badge-tile {
    padding: 0.55rem 0.4rem;
    gap: 0.3rem;
    align-items: center;
    text-align: center;
  }

  /* Stacked rather than side by side: at a third of a 390px screen there is
     no room for an icon and a name on one line. */
  .badge-head {
    flex-direction: column;
    gap: 0.25rem;
  }

  /* A third of the width means everything that was one line is now three.
     The tile keeps what identifies the badge and what you are chasing - name,
     tier, progress - and gives the blurb two lines instead of five. It is
     the sentence you read once to learn what a badge tracks, not the thing
     you come back to check. */
  .badge-icon {
    width: 2rem;
    height: 2rem;
    font-size: 1.25rem;
  }

  .badge-name {
    font-size: var(--text-xs);
    /* The 0.03em tracking is what pushed the longest single-word names -
       ENCYCLOPEDIA - past a third of a 390px screen. Without it the name
       fits; break-word is the backstop for a longer one arriving, and breaks
       only when a word genuinely cannot fit rather than wherever it lands. */
    letter-spacing: 0;
    overflow-wrap: break-word;
  }

  /* The progress bar is the tile's one full-width element, and
     `align-items: center` above shrank it to its content - which is nothing,
     because it is an empty div sized by CSS. It rendered as a stray dot
     between the blurb and the caption. */
  .badge-tile .progress-bar-track {
    align-self: stretch;
  }

  .badge-blurb {
    font-size: var(--text-micro);
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
  }

  .badge-tier,
  .badge-progress {
    font-size: var(--text-micro);
  }
}


/* ---- Squads placeholder ---- */
.coming-soon-card {
  text-align: center;
  padding: 2.5rem 1.25rem;
}

.coming-soon-icon {
  font-size: 2.6rem;
  display: block;
  margin-bottom: 0.5rem;
}

.coming-soon-memo {
  color: var(--accent-bright);
  font-weight: 600;
  margin: 0;
}

/* Practice-mode cards carry a stat line under the name, so they need to stack
   rather than sit on the single baseline the strict-mode cards use. */
.player-card.with-stats {
  align-items: flex-start;
}

.player-card.with-stats .player-stats {
  margin-top: 0.2rem;
}

/* Tabular figures on the NUMBERS only. The whole line used to be monospace,
   which is right for digits lining up and wrong for the words beside them -
   "sk allowed" in a mono face at 12px is noticeably harder to read than the
   same words in the body face. */
.player-card.with-stats .stat-value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* ---- Team banners ----
   A vertical field of the franchise's two colours with the abbreviation
   ghosted huge in the corner, like a number on a retired jersey banner -
   built entirely from the franchise entry's colors/abbr, so every team gets
   a distinct banner with no image assets to ship and no likeness to
   license. Renders identically in a WebView, so it carries over to the app
   build unchanged. Wide (5:2) rather than square, so it reads as a banner
   rather than a badge/icon. */
.banner-art {
  position: relative;
  width: 100%;
  aspect-ratio: 5 / 2;
  border-radius: 6px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  overflow: hidden;
  font-size: 1rem;
}

/* ---- Camo banner patterns ----
   Layered radial-gradient blobs at irregular offsets read as disruptive
   camo without an image asset, the same reasoning as the court backdrop.
   Both colors come from the banner entry via --art-c1/--art-c2 (set inline
   in bannerArt), so one rule per pattern covers any palette. */
.banner-art-camo-woodland,
.banner-art-camo-desert,
.banner-art-camo-tiger,
.banner-art-camo-gold,
.banner-art-camo-diamond {
  background-color: var(--art-c2);
  background-image:
    radial-gradient(ellipse 38% 46% at 18% 26%, var(--art-c1) 0%, transparent 60%),
    radial-gradient(ellipse 30% 40% at 62% 70%, var(--art-c1) 0%, transparent 62%),
    radial-gradient(ellipse 26% 34% at 88% 22%, var(--art-c1) 0%, transparent 58%),
    radial-gradient(ellipse 22% 30% at 40% 88%, var(--art-c1) 0%, transparent 60%);
}

/* Tiger stripes are directional bars, not blobs. */
.banner-art-camo-tiger {
  background-image:
    repeating-linear-gradient(72deg, var(--art-c2) 0 8%, transparent 8% 14%, var(--art-c2) 14% 19%, transparent 19% 30%),
    linear-gradient(180deg, var(--art-c1) 0%, var(--art-c1) 100%);
}

/* Gold gets a metallic sheen band rather than more blobs. */
.banner-art-camo-gold {
  background-image:
    linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.55) 46%, transparent 58%),
    radial-gradient(ellipse 40% 50% at 22% 30%, var(--art-c1) 0%, transparent 62%),
    radial-gradient(ellipse 34% 42% at 74% 72%, var(--art-c1) 0%, transparent 60%),
    linear-gradient(180deg, var(--art-c2) 0%, var(--art-c1) 100%);
}

/* Diamond: the rarest one, so it gets the most going on - a faceted
   cross-hatch plus a bright sweep, and it's the only banner that animates. */
.banner-art-camo-diamond {
  background-image:
    repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.22) 0 6px, transparent 6px 12px),
    repeating-linear-gradient(-45deg, rgba(255, 255, 255, 0.18) 0 6px, transparent 6px 12px),
    linear-gradient(160deg, var(--art-c1) 0%, var(--art-c2) 55%, var(--art-c1) 100%);
  box-shadow: inset 0 0 20px rgba(159, 232, 255, 0.55);
}

.banner-art-camo-diamond::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%);
  animation: diamond-sheen 3.5s ease-in-out infinite;
  pointer-events: none;
}

@keyframes diamond-sheen {
  0%, 100% { transform: translateX(-60%); opacity: 0.35; }
  50% { transform: translateX(60%); opacity: 0.8; }
}

/* Founder: a gold burst behind a star that stays inside the frame.
   --burst-x follows the star, which sits on the left of a tile but on the
   right of the wide profile card. Without it the burst lands under the
   heaviest part of that card's readability scrim and the gold disappears. */
.banner-art-founder {
  --burst-x: 26%;
  background-color: var(--art-c2);
  background-image:
    conic-gradient(
      from 200deg at var(--burst-x) 50%,
      rgba(242, 193, 78, 0.34) 0deg, transparent 55deg,
      rgba(242, 193, 78, 0.24) 130deg, transparent 200deg,
      rgba(242, 193, 78, 0.3) 285deg, transparent 360deg
    ),
    radial-gradient(ellipse 55% 95% at var(--burst-x) 50%, rgba(242, 193, 78, 0.45) 0%, transparent 68%),
    linear-gradient(180deg, var(--art-c2) 0%, #0a0c11 100%);
}

.player-banner.banner-art-founder {
  --burst-x: 78%;
}

/* Founder is the one banner whose artwork IS gold, so the standard scrim -
   tuned for busy camo - would mute the thing that makes it special. Lighter
   on the right, where the star and its glow live and no text sits. */
.player-banner.has-banner.banner-art-founder::after {
  background:
    linear-gradient(
      90deg,
      rgba(0, 0, 0, 0.72) 0%,
      rgba(0, 0, 0, 0.5) 34%,
      rgba(0, 0, 0, 0.12) 66%,
      rgba(0, 0, 0, 0.06) 100%
    ),
    linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.34) 100%);
}

/* A mark that sits fully inside the banner, at full opacity - the opposite of
   the abbreviation slot below, which is bled off the corner on purpose. Sized
   and positioned in em so it holds its proportions everywhere a banner is
   drawn (tile, matchup intro, profile card). */
.banner-emblem {
  position: absolute;
  left: 0.55em;
  top: 50%;
  transform: translateY(-50%);
  font-size: 2.1em;
  line-height: 1;
  color: var(--art-c1);
  text-shadow: 0 0 0.3em rgba(242, 193, 78, 0.9), 0 2px 4px rgba(0, 0, 0, 0.65);
  pointer-events: none;
  z-index: 2;
}

/* Readable corner text, not a watermark: solid white over a shadow so it
   holds up against whatever the artwork is doing underneath. */
.banner-label {
  position: absolute;
  right: 0.6em;
  bottom: 0.45em;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.8em;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #fff;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 0.6em rgba(0, 0, 0, 0.7);
  white-space: nowrap;
  pointer-events: none;
  z-index: 2;
}

.banner-art::before {
  content: attr(data-abbr);
  position: absolute;
  right: -0.05em;
  bottom: -0.22em;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 3.4em;
  line-height: 1;
  letter-spacing: -0.02em;
  color: rgba(255, 255, 255, 0.2);
  white-space: nowrap;
  pointer-events: none;
}

.banner-art::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0) 32%);
  pointer-events: none;
}

/* Placeholder sport marker until franchise banners get real art (city
   skylines, etc. per-sport) - just a small corner emoji so a banner reads
   as "this is the NFL one" at a glance once more than one sport has
   franchises here. */
.banner-sport-badge {
  position: absolute;
  top: 0.35em;
  left: 0.4em;
  font-size: 1.05em;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

/* The banner carries a hint of your kit rather than being repainted by it - the
   banner art is the reward you earned and should still look like itself. A trim
   edge and a tinted ground are enough to say "this is my team" without
   overwriting anything. */
.equipped-banner {
  display: inline-flex;
  align-items: center;
  /* inline-flex, not flex: inside .pb-identity (which is flex:1) a block-level
     title plate stretched the whole width of the card, so a two-word title sat
     at the left end of a 700px bar of tinted background. */
  align-self: flex-start;
  max-width: 100%;
  gap: 0.45rem;
  /* No top margin: this used to be a block of its own under the profile
     header, and is now the left-hand half of the hero's footer bar, where a
     margin would push it out of line with the buttons opposite it. */
  padding: 0.3rem 0.5rem;
  border-radius: 6px;
  border-left: 3px solid var(--my-kit-ink, var(--accent));
  background: color-mix(in srgb, var(--my-kit-ink, var(--accent)) 10%, transparent);
}

.equipped-banner .banner-flying {
  color: var(--my-kit-ink, var(--accent-bright));
}

.banner-flying {
  font-size: var(--text-xs);
  font-weight: 700;
  color: var(--accent-bright);
}

/* minmax(0, 1fr), not 1fr. A bare `1fr` is minmax(auto, 1fr), and that `auto`
   floor is the tile's min-content width - so a tile whose caption is a full
   sentence ("6 / 75 - Win 75 online ranked games.") pushed its column to
   295px and the whole five-column row overflowed its container by 439px,
   cutting the last banner off the right edge. The franchise tiles never hit
   it because their captions are four words. The 0 floor lets every column
   shrink to its share and the text wrap instead. */
.banner-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.7rem;
}

/* Four across in the wardrobe's wider dialog rather than five, so a preview is
   ~190px instead of 76 - and the name stops wrapping onto two lines, which is
   what was making every tile taller than its own artwork. */
.modal-wide .banner-grid {
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 0.85rem;
}

@media (max-width: 720px) {
  .modal-wide .banner-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.modal-wide .banner-tile {
  padding: 0.55rem;
  gap: 0.3rem;
}

/* An empty shelf's note is a sentence, not a tile: as a grid item it was
   getting one column and wrapping into a 90px ribbon of italics. */
.banner-grid > .empty-note,
.badge-grid > .empty-note,
.icon-grid > .empty-note {
  grid-column: 1 / -1;
}

@media (max-width: 900px) {
  .banner-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* Three across, matching the badges and icons. Was two here and one below
   400px, which made the same single-stack scroll the badges had. */
@media (max-width: 560px) {
  .banner-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.45rem;
  }
}

.banner-tile {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* Tighter at a third of the width. This first went in ABOVE the rule it is
   overriding, where identical specificity meant source order won and the
   phone kept the desktop padding - the third time in this pass that an
   override was written before the thing it overrides. */
@media (max-width: 560px) {
  .banner-tile {
    padding: 0.35rem;
    gap: 0.25rem;
  }
}

/* Locked banners stay readable: you should be able to see which team you're
   working toward, and how close you are. */
.banner-tile.locked .banner-art {
  filter: grayscale(1);
  opacity: 0.45;
}

/* ...but a banner with real artwork gets a much gentler lock than a franchise
   gradient does. Full greyscale flattens a two-colour fade into a slightly
   duller two-colour fade - no information lost - while it turns Volcanic,
   Skulls and Pink Blossom into the same grey smear, so the entire ladder
   reads as one texture and there is nothing left to want. Dimmed and slightly
   desaturated still says "locked" while leaving the art recognisable, which
   is the whole point of showing an unearned reward at all. */
.banner-tile.locked .banner-art.has-image {
  filter: grayscale(0.3) brightness(0.72);
  opacity: 0.9;
}

.banner-tile.equipped {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent), 0 0 16px rgba(var(--accent-rgb), 0.3);
}

/* --- The wardrobe's picker tiles ------------------------------------------
   The same tile, as a control. In the wardrobe there is no per-tile "Fly
   this" - the tile IS the button, and the one primary action lives in the bar
   under the grid - so the third of the tile that pill used to take goes back
   to the artwork. */
.banner-tile-pick {
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transition: border-color var(--motion-fast) var(--ease-out),
              box-shadow var(--motion-base) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
}

@media (hover: hover) {
  .banner-tile-pick:hover {
    border-color: rgba(255, 255, 255, 0.35);
    transform: translateY(-2px);
  }
}

.banner-tile-pick:active {
  transform: translateY(0);
}

/* SELECTED IS NOT EQUIPPED. The equipped tile keeps its accent ring (it says
   what you are wearing); selection is a heavier, brighter outline plus a lift,
   because it is the thing the bar below is currently talking about. A tile can
   be both, and then reads as both. */
.banner-tile-pick.selected {
  border-color: var(--accent-bright);
  outline: 2px solid var(--accent-bright);
  outline-offset: 1px;
  box-shadow: 0 0 22px -4px rgba(var(--accent-rgb), 0.75), var(--shadow-lift);
}

.banner-tile-pick:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
}

/* --- The wardrobe's action bar ---
   Replaces one pill per tile - twenty-one identical "Fly this" buttons in a
   grid - with one button that says what it will do to what you just clicked.
   Sticky, so browsing a long shelf never scrolls the commit off screen. */
.locker-bar {
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin-top: 1rem;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  background: var(--surface-1);
  box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
}

.locker-bar-art {
  width: 5.5rem;
  flex: 0 0 auto;
}

.locker-bar-text {
  min-width: 0;
  flex: 1;
}

.locker-bar-name {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: 0.95rem;
  overflow-wrap: anywhere;
}

.locker-bar-state {
  font-size: var(--text-micro);
  color: var(--muted);
}

.locker-bar-action {
  flex: 0 0 auto;
  white-space: nowrap;
}

@media (max-width: 480px) {
  /* The button drops to its own full-width row rather than being squeezed to
     a two-word ellipsis beside a preview and a name. */
  .locker-bar {
    flex-wrap: wrap;
  }

  .locker-bar-action {
    flex: 1 0 100%;
  }
}

/* Founder: the one banner nobody else can earn - a permanent gold glow
   marks it as different in kind, not just another unlocked tile. */
.founder-tile {
  border-color: #f2c14e;
  box-shadow: 0 0 0 1px #f2c14e, 0 0 16px rgba(242, 193, 78, 0.35);
}

.founder-tile.equipped {
  box-shadow: 0 0 0 1px #f2c14e, 0 0 22px rgba(242, 193, 78, 0.55);
}

/* 1st Player: the other banner nobody else can earn - a platinum glow keeps
   it visually distinct from Founder's gold rather than reading as a dupe. */
.first-player-tile {
  border-color: #e8e6e1;
  box-shadow: 0 0 0 1px #e8e6e1, 0 0 16px rgba(232, 230, 225, 0.35);
}

.first-player-tile.equipped {
  box-shadow: 0 0 0 1px #e8e6e1, 0 0 22px rgba(232, 230, 225, 0.55);
}

/* ---- Icon shelf -------------------------------------------------------- */

/* Tighter than the banner grid: an icon is a small round mark, so a tile the
   width of a banner would be mostly empty space. */
.icon-grid {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 0.7rem;
}

@media (max-width: 900px) {
  .icon-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

@media (max-width: 560px) {
  .icon-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.icon-tile {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 0.6rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  text-align: center;
}

.icon-art {
  width: 3.4rem;
  height: 3.4rem;
  display: grid;
  place-items: center;
}

.icon-art .emblem {
  width: 100%;
  height: 100%;
  display: block;
}

/* Emoji icons get the same ring the identity card gives them, so a preview
   looks like what it will actually become. Emblems need none - they draw their
   own rim. */
.icon-art.is-emoji {
  font-size: 1.9rem;
  border-radius: 50%;
  background: var(--bg);
  border: 1px solid var(--accent);
}

/* Locked icons stay legible - which team, and how far off you are - the same
   way locked banners do. */
.icon-tile.locked .icon-art {
  filter: grayscale(1);
  opacity: 0.45;
}

.icon-tile.equipped {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent), 0 0 16px rgba(var(--accent-rgb), 0.3);
}

.icon-name {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  font-size: var(--text-micro);
  letter-spacing: 0.02em;
  /* Team names are long and the tile is narrow; wrapping is correct here,
     clipping is not. */
  overflow-wrap: anywhere;
}

.icon-progress {
  font-size: var(--text-micro);
  color: var(--muted);
}

.icon-tile .progress-bar-track {
  width: 100%;
}

.icon-equip {
  width: 100%;
  font-size: var(--text-micro);
  padding: 0.3rem 0.4rem;
}

.banner-name {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  font-size: var(--text-micro);
  letter-spacing: 0.02em;
}

.banner-progress {
  font-size: var(--text-micro);
  color: var(--muted);
  font-family: var(--font-mono);
}

/* Full-width tap target - the app build is touch-first, so this can't rely on
   hover to be discoverable. */
.banner-equip {
  width: 100%;
  padding: 0.4rem 0.5rem;
  font-size: var(--text-micro);
  min-height: 2.2rem;
}

/* ---- Post-game recap ---- */
.game-recap {
  text-align: center;
  margin-bottom: 0.9rem;
  padding: 0.75rem 1rem;
  border-radius: 10px;
  background: var(--panel);
  border: 1px solid var(--line);
  animation: recap-pop 0.45s ease;
}

@keyframes recap-pop {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

.recap-headline {
  font-family: var(--font-display);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 1rem;
  color: var(--accent-bright);
}

.recap-detail {
  font-size: 0.82rem;
  color: var(--muted);
  margin-top: 0.25rem;
}

.badge-feature {
  width: 100%;
  padding: 0.35rem 0.5rem;
  font-size: var(--text-micro);
  min-height: 2rem;
}

.badge-feature.is-featured {
  border-color: var(--accent);
  color: var(--accent-bright);
}


/* ---- Tactic picker ---- */
.tactic-heading {
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: var(--text-xs);
  color: var(--muted);
  margin: 1rem 0 0.5rem;
}

.tactic-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 0.5rem;
  margin-bottom: 1rem;
}

@media (max-width: 860px) {
  .tactic-grid { grid-template-columns: repeat(2, 1fr); }
}

.tactic-card {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 0.6rem 0.55rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  text-align: left;
  cursor: pointer;
  color: var(--text);
  min-height: 5rem;
}

.tactic-card.active {
  border-color: var(--accent);
  background: linear-gradient(160deg, rgba(var(--accent-rgb), 0.22), transparent);
  box-shadow: 0 0 0 1px var(--accent);
}

.tactic-head {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.tactic-blurb {
  font-size: var(--text-micro);
  color: var(--muted);
  line-height: 1.3;
}

/* WHAT THE PLAN BUYS AND WHAT IT COSTS.
   These chips had no rules at all, so they rendered as inline text with
   nothing between them and a card read "+2 Rushing+2 Ball Control-2 Explosive
   Plays-1 Comeback Ability" as one unbroken run. Pills, wrapped, with the
   gains and the costs coloured apart - the whole point is that a player can
   see the trade at a glance rather than parse it. */
.tactic-trade {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  margin-top: auto;
  padding-top: 0.45rem;
}

.tactic-trade-item {
  font-family: var(--font-display);
  font-size: var(--text-micro);
  font-weight: 700;
  line-height: 1.25;
  padding: 0.14rem 0.4rem;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  white-space: nowrap;
}

.tactic-trade-item.up {
  color: var(--win);
  background: rgba(63, 207, 142, 0.13);
  border-color: rgba(63, 207, 142, 0.32);
}

.tactic-trade-item.down {
  color: var(--buzzer);
  background: rgba(232, 67, 61, 0.11);
  border-color: rgba(232, 67, 61, 0.3);
}

/* ---- Rotation phase ----
   One row per player against a single 240-minute budget: minutes belong to
   players, not positions, so the list reads as one set of trade-offs. */

.rotation-row.rotation-starter .rotation-role {
  color: var(--accent-bright);
}

.rotation-row.rotation-bench .rotation-role {
  color: var(--muted);
}

.rotation-role {
  font-family: var(--font-display);
  font-size: var(--text-micro);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  margin-right: 0.15rem;
}

.rotation-pos {
  font-size: var(--text-micro);
  color: var(--muted);
}

/* A blocked confirm needs to look blocked, or it reads as a broken button. */
.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* A disabled PRIMARY button needs more than transparency. Faded to 45%, the
   accent fill still reads as the bright call-to-action it normally is, and the
   dark label on top of it drops to a contrast nobody can read - so the one
   button that is explaining why it can't be pressed became the least legible
   thing on the screen. Flattened to a neutral panel with muted text instead:
   clearly inactive, and still readable. */
.btn.btn-primary:disabled {
  opacity: 1;
  background: var(--panel);
  border: 1px dashed var(--line);
  color: var(--muted);
  box-shadow: none;
}

/* ---- Court backdrop ----
   Drawn with borders and radial gradients rather than an image, so it stays
   crisp at any size and ships nothing extra to the app build. */
.game-stage {
  position: relative;
  border-radius: 14px;
  overflow: hidden;
  margin-bottom: 1rem;
  border: 1px solid var(--line);
  background: radial-gradient(ellipse at 50% 40%, #1b2740 0%, var(--bg) 70%);
}

/* A slow ambient sweep behind the whole stage so the screen feels alive even
   between period reveals, not just when something is actively animating. */
.game-stage::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse at 50% 30%, rgba(var(--accent-rgb), 0.18) 0%, transparent 65%);
  opacity: 0.35;
  animation: stage-glow-pulse 4s ease-in-out infinite;
  pointer-events: none;
}

@keyframes stage-glow-pulse {
  0%, 100% { opacity: 0.3; }
  50% { opacity: 0.7; }
}

/* The final-buzzer payoff, fired once from finish() - a bigger, warmer flash
   than any in-game period-flash, so the end of the game reads as the
   biggest moment on screen. */
.game-stage.final-flash {
  animation: final-stage-flash 1s ease;
}

@keyframes final-stage-flash {
  0% { box-shadow: 0 0 0 rgba(255, 209, 102, 0); }
  25% { box-shadow: 0 0 40px rgba(255, 209, 102, 0.6); }
  100% { box-shadow: 0 0 0 rgba(255, 209, 102, 0); }
}

/* The court, its markings and everything drawn on it - the shot chart, its
   callouts, its zone summary, its moment banners - were one thing and are all
   gone. The scoreboard is the whole stage.

   The ledger that fed them outlived the floor (js/sports/nba/playback.js): it
   still drives the play-by-play, the sounds and the run/lead lines. Only the
   drawing went away. */

/* The board keeps its own surface here. This used to strip it flat, which was
   right when the COURT was the stage and the scoreboard was a caption beneath
   it. The board is the stage now, so flattening it removed the only thing
   making it look like a board. */
.game-stage .scoreboard {
  position: relative;
  margin-bottom: 0;
}

/* ---- Play-by-play feed ---- */
.play-feed {
  position: relative;
  padding: 0 1rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  min-height: 3.2rem;
}

/* Football's box score is three tables; each needs to say which one it is. */
/* Compact roster rows (football). The name leads, the season sits under it. */
.slot-name { font-weight: 600; }
.slot-season { font-size: var(--text-xs); color: var(--muted); }

/* The names inside a drafted unit - "Sherman, Maxwell, Lane, Thurmond".
 *
 * Football-only, and it shipped with no rule at all: an inline span holding up
 * to four comma-joined full names cannot wrap on its own, so a secondary drawn
 * from long surnames measured 460px inside a 360px phone and took the whole
 * page into horizontal scroll with it. Nothing caught it because the layout
 * audit only ever ran on the post-game screen, by which point the roster panel
 * that draws these is gone.
 *
 * Wrapping is the fix; the width cap keeps a long roll from pushing the column
 * wide on desktop before wrapping gets the chance. */
.slot-members {
  max-width: 100%;
  font-size: var(--text-micro);
  color: var(--muted);
  overflow-wrap: anywhere;
}

.box-group-label {
  margin: 0.7rem 0 0.25rem;
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.play-card {
  background: rgba(0, 0, 0, 0.45);
  border-left: 3px solid var(--accent);
  border-radius: 6px;
  padding: 0.4rem 0.65rem;
  font-size: var(--text-xs);
  animation: play-in 0.35s ease;
}

/* A drive's summary is bookkeeping, not a highlight - it should be legible
   without competing with a touchdown for attention. */
.play-card.drive-summary {
  opacity: 0.82;
  font-size: 0.82rem;
}


.play-card.hot {
  border-left-color: var(--buzzer);
  color: var(--text);
}

/* Only the newest hot card glows - once it's pushed down the feed by a later
   headline it settles back to the plain .hot look, so the glow reads as
   "happening right now" rather than every hot line staying lit forever. */
.play-card.hot:first-child {
  animation: play-in 0.35s ease, hot-glow-pulse 1.4s ease-in-out infinite;
}

@keyframes hot-glow-pulse {
  0%, 100% { box-shadow: 0 0 10px rgba(232, 67, 61, 0.25); }
  50% { box-shadow: 0 0 20px rgba(232, 67, 61, 0.55); }
}

.play-card.lead-change {
  border-left-color: #ffd166;
  background: rgba(255, 209, 102, 0.12);
  font-weight: 700;
}

.play-card.final {
  border-left-color: var(--accent-bright);
  color: var(--accent-bright);
  font-weight: 600;
}

@keyframes play-in {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: translateY(0); }
}


/* Final-round game-plan phase, shown in place of the draft pool. */
.tactic-phase {
  background: var(--panel);
  border: 1px solid var(--accent);
  border-radius: 10px;
  padding: 1rem;
  box-shadow: 0 0 0 1px var(--accent), 0 0 22px rgba(var(--accent-rgb), 0.22);
}

.tactic-phase .tactic-grid {
  grid-template-columns: 1fr;
  gap: 0.4rem;
  margin-bottom: 0.9rem;
}

/* TWO GAMEPLANS, SIDE BY SIDE.
   Football asks how you attack and how you defend at once, so the two
   decisions sit next to each other on anything wide enough to read them that
   way - seeing both at the same time is most of what makes them feel like one
   plan rather than two unrelated questions. Below that they stack, because two
   columns of cards on a phone is two unreadable columns. */
.tactic-phase .strategy-groups {
  display: grid;
  /* AS MANY COLUMNS AS THERE ARE GROUPS, not always two.
     This was a hard `repeat(2, 1fr)`, written when offence and defence were
     shown together. They are separate ROUNDS now, so one group renders at a
     time - and it sat in the left half of a two-column grid with the right
     half empty, which is what made the cards look squeezed and off-centre
     against a panel twice their width. auto-fit collapses the empty track, so
     one group fills the panel and two still sit side by side. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: 1rem;
  align-items: start;
}

.strategy-group-title {
  margin: 0 0 0.15rem;
  font-size: 0.95rem;
  color: var(--text);
}

.strategy-group-hint {
  margin: 0 0 0.5rem;
  font-size: var(--text-xs);
  color: var(--muted);
}

/* EVERY CARD IN A ROUND IS THE SAME HEIGHT. They are one set of options, and
   a column of three different-sized boxes reads as three unrelated things -
   the shortest blurb should not produce the smallest button. `1fr` rows size
   them all to the tallest, and `margin-top: auto` on the trade chips pins the
   tradeoff to the bottom of each, so the pills line up across the round. */
.tactic-phase .strategy-group-grid {
  grid-template-columns: 1fr;
  grid-auto-rows: 1fr;
  gap: 0.4rem;
  margin-bottom: 0;
  align-items: stretch;
}

/* The gameplan round is the only thing being decided, so it gets more of the
   width while it is up - the flanking rosters are reference, not the task. At
   the default 1.4fr the cards were narrow enough to wrap "BALANCED OFFENSE"
   onto two lines. Progressive: a browser without :has() keeps the old split. */
@media (min-width: 861px) {
  .draft-body:has(.tactic-phase:not(.hidden)) {
    grid-template-columns: 1fr 1.85fr 1fr;
  }
}

@media (max-width: 760px) {
  .tactic-phase .strategy-groups {
    grid-template-columns: 1fr;
    gap: 1.1rem;
  }
}

.tactic-phase .tactic-card {
  min-height: 0;
  flex-direction: column;
  align-items: stretch;
  gap: 0.45rem;
  padding: 0.75rem 0.8rem;
}

.tactic-phase .tactic-head {
  flex: none;
  width: 100%;
  font-size: 0.82rem;
  line-height: 1.2;
}

.tactic-phase .tactic-blurb {
  width: 100%;
  font-size: var(--text-micro);
  line-height: 1.35;
}

/* ---- Rotation phase ---- */
.rotation-grid {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin-bottom: 0.6rem;
}

.rotation-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 0.45rem 0.6rem;
}

.rotation-label {
  font-size: var(--text-xs);
  color: var(--text);
}


.rotation-value {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--scoreboard);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.rotation-slider {
  width: 100%;
  margin: 0.15rem 0;
  accent-color: var(--accent);
  cursor: pointer;
}

.rotation-total {
  font-size: var(--text-micro);
  color: var(--muted);
  margin: 0 0 0.9rem;
}

.rotation-total.rotation-warning {
  color: var(--warn, #e0a030);
}

/* ---- Modal ----
   One shell reused by the draft's position picker and the How to Play tab. */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(4, 8, 14, 0.78);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.25rem;
  z-index: 50;
}

.modal {
  background: var(--panel);
  border: 1px solid var(--accent);
  border-radius: 12px;
  box-shadow: 0 0 0 1px var(--accent), 0 18px 50px rgba(0, 0, 0, 0.55);
  width: min(560px, 100%);
  /* No transition on width: the variant is set before the dialog is shown. */
  /* 85vh measured against the address-bar-hidden viewport is taller than the
     screen while the address bar is showing, which put the confirm button of
     a tall modal - the season picker, the slot picker - below the fold with
     nothing to scroll. */
  max-height: 85vh;
  max-height: 85dvh;
  display: flex;
  flex-direction: column;
  animation: modal-in 0.16s ease;
}

@keyframes modal-in {
  from { opacity: 0; transform: translateY(-8px) scale(0.99); }
  to { opacity: 1; transform: none; }
}

.modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.9rem 1.1rem;
  border-bottom: 1px solid var(--line);
}

.modal-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 0.95rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* THE ONE WAY OUT OF A DIALOG, and it measured 20x24 on a phone - well under
   the 44px floor, and the only control in the app still under it at 360px
   after this pass. Escape and a backdrop tap both close a modal too, but
   neither is discoverable, and on a touchscreen there is no Escape at all.
   Sized as a square target rather than by padding, so the glyph stays where it
   was and only the box around it grows. */
.modal-close {
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  margin: -0.5rem -0.5rem -0.5rem 0;
  background: none;
  border: none;
  border-radius: var(--radius-inner);
  color: var(--muted);
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}

.modal-close:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: -2px;
}

.modal-close:hover { color: var(--text); }

/* Scrolls inside itself so long content never scrolls the page behind it. */
/* A dialog whose CONTENT wants a wider frame - today only the wardrobe. Its
   grid is banner ARTWORK, and five columns inside 560px gives each banner 76
   pixels to be recognised in, which is not enough to tell Desert Wind from
   Forest Pixel. Telling them apart is the entire task. */
.modal.modal-wide {
  width: min(920px, 100%);
}

.modal-body {
  padding: 1rem 1.1rem 1.2rem;
  overflow-y: auto;
  /* A flick that reaches the end must not start scrolling the screen behind
     the backdrop, which is meant to be inert. On .modal this did nothing -
     .modal has no overflow of its own, so it is not a scroll container and
     had no scroll to keep from chaining. This is the element that scrolls. */
  overscroll-behavior: contain;
}

/* Position picker inside the modal */
.modal-player {
  font-size: 0.9rem;
  margin-bottom: 0.15rem;
}

.modal-player-meta {
  color: var(--muted);
  font-size: var(--text-xs);
  margin-bottom: 0.9rem;
}

.modal-slot-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: 0.5rem;
}

.modal-slot {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 9px;
  color: var(--text);
  padding: 0.7rem 0.6rem;
  font-weight: 700;
  font-size: 0.85rem;
  cursor: pointer;
}

.modal-slot:hover {
  border-color: var(--accent);
  background: linear-gradient(160deg, rgba(var(--accent-rgb), 0.18), transparent);
}

/* How to Play copy */
.howto-section { margin-bottom: 1rem; }
.howto-section:last-child { margin-bottom: 0; }

.howto-section h4 {
  margin: 0 0 0.3rem;
  font-family: var(--font-display);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--accent-bright);
}

.howto-section p {
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--muted);
}

/* ---- Defensive matchups ---- */
.matchup-grid {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin-bottom: 0.9rem;
}

.matchup-row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 0.5rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 0.45rem 0.6rem;
}

.matchup-mine {
  font-size: var(--text-xs);
  color: var(--text);
}

.matchup-arrow {
  color: var(--muted);
  font-size: var(--text-micro);
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

.matchup-pick {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--text);
  font-family: inherit;
  font-size: var(--text-xs);
  padding: 0.3rem 0.4rem;
  width: 100%;
}

.matchup-pick:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.matchup-slot {
  font-family: var(--font-display);
  font-size: var(--text-micro);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--accent-bright);
  margin-right: 0.2rem;
}

/* --- Era bracket picker --------------------------------------------------
   Chips rather than another radio list: the era is a mood choice made at a
   glance, and stacking a second radio group under the mode one made both
   harder to read. */
.era-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.75rem 0 0;
}

.era-chip {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.45rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.85rem;
  cursor: pointer;
  transition: border-color var(--motion-fast) var(--ease-out),
              color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out),
              transform var(--motion-fast) var(--ease-out);
}

@media (hover: hover) {
  .era-chip:hover:not(.active) {
    border-color: rgba(var(--accent-rgb), 0.55);
    color: var(--text);
    background: var(--surface-3);
  }
}

.era-chip:active {
  transform: translateY(1px);
}

/* FILLED, not tinted. The selected bracket used to be a 16%-opacity wash
   behind the same text colour as its neighbours, which at a glance is
   indistinguishable from the hover state one chip over. */
.era-chip.active {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-contrast);
  font-weight: 700;
  box-shadow: 0 2px 10px rgba(var(--accent-rgb), 0.3);
}

/* --- The launch summary ---------------------------------------------------
   One line above Start Draft saying exactly what it will start. Deliberately
   not a hint: it is the statement of a decision, so it takes the display face
   and the accent rather than the muted body voice everything else on this
   screen speaks in. */
.launch-summary {
  margin: 1.2rem 0 0.55rem;
  padding: 0.6rem 0.9rem;
  border: 1px solid rgba(var(--accent-rgb), 0.4);
  border-radius: var(--radius-inner);
  background: rgba(var(--accent-rgb), 0.08);
  color: var(--accent-bright);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.92rem;
  letter-spacing: 0.04em;
  text-align: center;
  text-transform: uppercase;
}

.era-chip-emoji {
  font-size: 1rem;
  line-height: 1;
}

/* --- Online ticker -------------------------------------------------------
   Sits between the brand and the nav tabs. Hidden until the first successful
   count so a network failure shows nothing rather than a misleading "0". */
/* --muted is tuned for secondary text sitting on a panel. This sits on the
   dark header at 0.8rem, where it read as switched-off rather than quiet -
   it's a liveness indicator, so it has to be legible at a glance. Full text
   colour, a little more weight, and a faint chip behind it to separate it
   from whatever the header gradient is doing underneath. */
.online-ticker {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  padding: 0.28rem 0.6rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.07);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

.online-dot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--win);
  box-shadow: 0 0 0 0 rgba(63, 207, 142, 0.6);
  animation: online-pulse 2.4s ease-out infinite;
}

@keyframes online-pulse {
  0% { box-shadow: 0 0 0 0 rgba(63, 207, 142, 0.5); }
  70% { box-shadow: 0 0 0 0.4rem rgba(63, 207, 142, 0); }
  100% { box-shadow: 0 0 0 0 rgba(63, 207, 142, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .online-dot { animation: none; }
}

/* --- Squads ---------------------------------------------------------------
   Two states sharing one screen: browse/create (no squad yet) and detail
   (in a squad - header, roster, chat). See js/squads.js for the data model. */

.squads-browse-actions {
  display: flex;
  gap: 0.6rem;
  margin-top: 0.75rem;
  flex-wrap: wrap;
}

.squads-list {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-top: 1rem;
}

.squad-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 0.9rem 1rem;
}

.squad-card-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-weight: 700;
}

.squad-card-emoji {
  font-size: 1.6rem;
}

.squad-card-tag {
  color: var(--muted);
  font-weight: 600;
  font-size: 0.85rem;
}

.squad-card-motto {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 0.35rem 0;
}

.squad-card-meta {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--muted);
  margin-bottom: 0.6rem;
}

/* --- The squad identity panel ---------------------------------------------
   A crest, a name and a motto on a tinted plate. It was a 2.6rem emoji, a
   1.2rem heading and a grey line reading "Public · 7 / 20 members" - an
   administrative summary of a database row rather than a team you belong to.
   The card it lives in gets the accent as a top edge, the same language the
   home screen's sport cards speak. */
.squad-header-card {
  border-top: 3px solid var(--accent);
  background:
    radial-gradient(120% 80% at 20% -10%, rgba(var(--accent-rgb), 0.14), transparent 62%),
    var(--surface-2);
  box-shadow: var(--shadow-card);
}

.squad-identity {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.squad-crest {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 4rem;
  height: 4rem;
  font-size: 2.4rem;
  line-height: 1;
  border-radius: var(--radius-inner);
  background: rgba(var(--accent-rgb), 0.14);
  border: 1px solid rgba(var(--accent-rgb), 0.45);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.squad-identity-text {
  min-width: 0;
}

.squad-header-name {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  flex-wrap: wrap;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.6rem;
  letter-spacing: 0.01em;
  line-height: 1.15;
  overflow-wrap: anywhere;
}

.squad-header-tag {
  color: var(--accent-bright);
  font-weight: 700;
  font-size: 0.9rem;
  letter-spacing: 0.06em;
}

.squad-header-motto {
  color: var(--muted);
  font-style: italic;
  margin: 0.3rem 0 0;
  font-size: 0.9rem;
}

/* The four facts, in even cells: privacy, size, tier and - new here - who runs
   it, which on a squad you have just joined is the single thing you most want
   to know and the one the header never said. */
.squad-facts {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  margin-top: 1rem;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  overflow: hidden;
}

.squad-fact {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  padding: 0.55rem 0.7rem;
  background: var(--surface-3);
  min-width: 0;
}

.squad-fact-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
}

.squad-fact-value {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.95rem;
  overflow-wrap: anywhere;
}

@media (max-width: 600px) {
  .squad-facts {
    grid-template-columns: 1fr 1fr;
  }

  /* The leader's cell takes the full width. It is the only one holding a
     player-supplied string, and in half a 360px screen a twenty-character
     username breaks mid-word. */
  .squad-fact:last-child {
    grid-column: 1 / -1;
  }

  .squad-crest {
    width: 3.2rem;
    height: 3.2rem;
    font-size: 1.9rem;
  }

  .squad-header-name {
    font-size: 1.25rem;
  }
}

.squad-rank-wrap {
  margin-top: 0.9rem;
}

.squad-rank-caption {
  color: var(--muted);
  font-size: var(--text-xs);
  margin-top: 0.3rem;
}

.squad-manage {
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.squad-invite-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.squad-invite-label {
  color: var(--muted);
  font-size: var(--text-xs);
}

/* Quieter than it was. It is a string you copy once, and it used to be the
   loudest thing under the squad's name - full-strength ink in a bordered chip,
   in its own row above the buttons that edit and disband the squad. */
.squad-invite-code {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  color: var(--muted);
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 0.2rem 0.55rem;
  /* Selectable, because copying it is the only thing anyone does with it. */
  user-select: all;
}

.squad-manage-buttons {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.btn-small {
  padding: 0.35rem 0.7rem;
  font-size: var(--text-xs);
}

.btn-danger-small {
  color: var(--buzzer);
  border-color: var(--buzzer);
}

.btn-danger-small:hover {
  background: var(--buzzer);
  color: var(--text);
}

.squad-edit-form {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 0.75rem;
}

.squad-emoji-palette {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

.squad-emoji-choice {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  font-size: 1.2rem;
  padding: 0.3rem 0.45rem;
  cursor: pointer;
  line-height: 1;
}

.squad-emoji-choice.active {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

.squad-edit-form textarea,
.squad-edit-form select {
  width: 100%;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--text);
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 0.5rem 0.6rem;
  resize: vertical;
}

.squad-roster {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* --- Member cards ---------------------------------------------------------
   Mark, name, role, record - and the management actions behind a toggle, so a
   leader's roster reads as the people they play with rather than as four
   red-and-grey buttons per row. */
.squad-member {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 0.75rem;
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-inner);
  padding: 0.6rem 0.75rem;
  transition: border-color var(--motion-fast) var(--ease-out);
}

/* Your own row, marked by its edge rather than by a colour on the name - the
   name is a player's, and recolouring it would say something about them. */
.squad-member.is-me {
  border-left: 3px solid var(--accent);
}

.squad-member.managing {
  border-color: rgba(var(--accent-rgb), 0.55);
}

.squad-member-avatar {
  width: 2.4rem;
  height: 2.4rem;
  font-size: 1.2rem;
}

.squad-member-info {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

.squad-member-name {
  font-weight: 700;
  overflow-wrap: anywhere;
}

.squad-member-meta {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* Role as a chip, and the two that carry authority say so in colour. "Member"
   is the ordinary case and stays quiet - a squad where every row is a
   highlighted badge has no hierarchy to read. */
.squad-member-role {
  font-size: var(--text-micro);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.1rem 0.45rem;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  color: var(--muted);
}

.squad-member-role.role-leader {
  color: var(--scoreboard);
  border-color: rgba(255, 209, 102, 0.5);
  background: rgba(255, 209, 102, 0.12);
}

.squad-member-role.role-co-leader {
  color: var(--accent-bright);
  border-color: rgba(var(--accent-rgb), 0.5);
  background: rgba(var(--accent-rgb), 0.12);
}

.squad-member-record {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

/* Subtle until needed - which is the whole point. 44px so it is still a real
   tap target at its quiet size. */
.squad-member-manage {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: var(--radius-inner);
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  transition: color var(--motion-fast) var(--ease-out),
              border-color var(--motion-fast) var(--ease-out),
              background var(--motion-fast) var(--ease-out);
}

.squad-member-manage:hover,
.squad-member-manage[aria-expanded="true"] {
  color: var(--text);
  border-color: var(--line);
  background: rgba(255, 255, 255, 0.05);
}

.squad-member-manage:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 1px;
}

/* The opened panel spans the row under everything else, so the buttons get
   full width rather than being squeezed beside a name. */
.squad-member-actions {
  grid-column: 1 / -1;
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  padding-top: 0.6rem;
  margin-top: 0.1rem;
  border-top: 1px solid var(--line-soft);
}

.squad-chat-card {
  display: flex;
  flex-direction: column;
}

.squad-chat-messages {
  height: 16rem;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0.65rem;
  margin-bottom: 0.7rem;
}

.squad-chat-message {
  max-width: 85%;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0.4rem 0.6rem;
}

.squad-chat-message.mine {
  align-self: flex-end;
  background: rgba(var(--accent-rgb), 0.18);
  border-color: var(--accent);
}

.squad-chat-author {
  font-weight: 700;
  font-size: var(--text-xs);
  margin-right: 0.5rem;
}

.squad-chat-time {
  color: var(--muted);
  font-size: var(--text-micro);
}

.squad-chat-body {
  font-size: 0.88rem;
  margin-top: 0.15rem;
  word-break: break-word;
}

.squad-chat-input-row {
  display: flex;
  gap: 0.5rem;
}

.squad-chat-input-row input {
  flex: 1;
}

.squad-leave-row {
  display: flex;
  justify-content: flex-end;
  margin-top: 1rem;
}

/* --- Friends -------------------------------------------------------------- */

.friend-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0.55rem 0.75rem;
  flex-wrap: wrap;
}

.friend-row + .friend-row {
  margin-top: 0.5rem;
}

.friend-row-actions {
  display: flex;
  gap: 0.4rem;
  flex-wrap: wrap;
  flex-shrink: 0;
}

.friend-rank {
  font-family: var(--font-display);
  font-weight: 800;
  color: var(--muted);
  min-width: 2rem;
  flex-shrink: 0;
  text-align: center;
}

/* Leaderboard rows get a dedicated rank | name+record | actions layout -
   the plain .friend-row above (add/incoming/outgoing lists) has no rank
   column and stays a simple two-part flex row. */
.friend-leaderboard-row {
  display: grid;
  /* rank | banner | identity | actions */
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  gap: 0.75rem;
}

/* Each friend's equipped banner, at thumbnail scale. Fixed width rather than
   the tile's usual 100%, so a long username can't squeeze it to nothing.

   THE font-size HERE IS A SCALE FACTOR, NOT TEXT. Nothing inside this element
   is read: .banner-emblem is 2.1em and .banner-art::before is 3.4em, so this
   value is how the artwork is sized down to a thumbnail. The type floor swept
   it from 0.5rem to 0.75rem and made the emblem 25px inside a 23px-tall box
   (3.6rem wide at aspect-ratio 5/2), clipping every banner on the friends
   leaderboard. Exempt on purpose, and named so the next sweep leaves it
   alone. */
.friend-banner {
  width: 3.6rem;
  flex: none;
  --banner-thumb-scale: 0.5rem;
  font-size: var(--banner-thumb-scale);
}

.friend-identity {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

.friend-name {
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.friend-record {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--muted);
}

.friend-leaderboard-row.mine {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

.friend-leaderboard-row.mine .friend-rank {
  color: var(--accent-bright);
}

@media (max-width: 480px) {
  .friend-leaderboard-row {
    grid-template-columns: auto auto 1fr;
  }
  .friend-leaderboard-row .friend-row-actions {
    grid-column: 1 / -1;
  }
  .friend-banner { width: 2.8rem; }
}

/* ---- Phone layout pass ----------------------------------------------------
   One block rather than tweaks scattered through the file, so the small-screen
   story is readable in one place. Everything here is additive: the desktop
   layout above is untouched.

   The recurring problems this fixes are (a) fixed-width/multi-column things
   forcing the page itself to scroll sideways, (b) tap targets under the ~44px
   users can reliably hit, and (c) tables that simply don't fit a 360px screen
   and need to scroll inside their own box instead of stretching the page. */

/* Nothing may make the PAGE scroll horizontally - wide content scrolls inside
   its own container instead. This is the single most common way a layout
   "breaks" on a phone. */
html, body {
  max-width: 100%;
  overflow-x: hidden;
}

/* Stops a flick at the top of a screen from becoming pull-to-refresh. On a
   draft with a running pick clock that reload is not a refresh, it is a lost
   pick.
   
   ON html, NOT body. `overscroll-behavior` only reaches the viewport from the
   element that defines it, and the rule above gives `html` a non-visible
   overflow - which makes `html` that element and stops anything on `body`
   from propagating. Set on body it computed correctly, inspected correctly,
   and did nothing. */
html {
  overscroll-behavior-y: contain;
}

/* A box score is genuinely wider than a phone. Let it scroll inside its own
   frame rather than squeezing columns to nothing. Recent Games used to need
   this too and no longer does - it is a list of match rows now, not a table. */
#full-box-score {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

/* The frames a box score is rendered into, named so the rules further down
   can ask how wide THEY are rather than how wide the window is. The modal is
   the case that makes this necessary: it is 560px on any screen. */
#full-box-score,
.modal-body {
  container-type: inline-size;
  container-name: boxscore;
}

@media (max-width: 700px) {
  :root {
    --page-gutter: 0.85rem;
  }

  #app-root {
    padding: 1rem var(--page-gutter) 2.5rem;
  }

  /* No outdent on a phone. Cancelling the gutter here would put these hard
     against the screen edge, which reads as broken next to the card and the
     section headings below - so they line up with the content instead. */
  .home-top {
    margin-left: 0;
  }

  /* Comfortable thumb targets. 2.75rem ~= 44px, the usual floor. */
  .btn,
  .mode-btn,
  .sport-tile,
  .era-chip {
    min-height: 2.75rem;
  }

  .btn-small {
    min-height: 2.25rem;
  }

  /* Mode/era pickers stack instead of cramming several per row. */
  .mode-toggle {
    flex-direction: column;
  }

  .mode-btn {
    width: 100%;
  }

  /* A taller pool: on a phone the draft board is the whole screen, and a
     380px window showed barely three players at a time. */
  .pool-list {
    max-height: 55vh;
    max-height: 55dvh;
  }

  /* PRACTICE MODE SHOWS FIVE, NOT SEVEN.
     
     A card carrying stats is 76px tall, and a 60vh cap on an 844px phone
     fitted 6.7 of them - which put the top of your own roster at y=828, four
     pixels below the fold. So every time you wanted to check which slot you
     still needed, you scrolled 282px down and back up again, on every one of
     ten rounds.
     
     24rem is five cards and a sliver of the sixth - measured, not guessed:
     27rem gave 5.7 cards and left 90px for the roster, 22rem gave only 4.6
     cards. Five is enough to compare candidates, few enough to halve the
     scroll (282px -> 160px), and the part-cut card is what says the list
     keeps going. :has() rather than a class on
     the list because the stats live on the CARDS - the pool does not
     currently know which ruleset it is rendering under, and giving it a
     second source of truth for that is how the two get to disagree. Where
     :has() is unsupported the 55vh above still applies, which is the
     behaviour this replaces rather than a broken one. */
  .pool-list:has(.player-card.with-stats) {
    max-height: 24rem;
  }

  /* ---- Draft chrome above the search box ----
     The squad banner now carries round, era, squad, turn and timer as one
     block (see .squad-banner-status), and the phone sizing for all of it
     lives with those rules at the end of this file rather than here - two
     places tuning the same five elements is how they drifted apart the first
     time. What's left here is the one thing that isn't part of that banner. */
  /* The ruleset hint and the pool's own prompt both explain the 3-letter
     minimum, and in uppercase the hint alone ran to three lines - together
     they pushed the rosters most of a screen down. Dropping the caps (which
     cost about 15% width for no extra meaning) and easing both sizes gets it
     to two short lines with nothing removed: the "keep typing" feedback is
     still real information once someone has started typing. */
  .knowledge-hint {
    letter-spacing: 0;
    font-size: var(--text-micro);
    margin-bottom: 0.45rem;
  }

  #pool-list .empty-note {
    font-size: var(--text-xs);
    padding: 0.35rem 0;
  }

  /* Deliberately NOT shrunk below 16px: iOS Safari zooms the whole page when
     a focused input's text is smaller than that, which would throw the board
     off-screen the moment someone taps to type. */
  #pool-search {
    font-size: 1rem;
  }

  /* Scoreboard digits at desktop size overflow their row on a narrow phone. */
  .scoreboard {
    padding: 1rem 0.6rem;
  }

  .scoreboard-score {
    font-size: 2rem;
    min-width: 2.5ch;
  }

  .scoreboard-team-name {
    font-size: var(--text-xs);
  }

  .scoreboard-grid {
    font-size: var(--text-xs);
  }

  .scoreboard-grid th,
  .scoreboard-grid td {
    padding: 0.3rem 0.25rem;
  }

  /* End-of-game actions side by side are too narrow to read; stack them. */
  .game-actions {
    flex-direction: column;
  }

  /* Squad/friend rows: let the action buttons wrap under the name instead of
     squeezing the name to an ellipsis. */
  /* Friend rows still wrap their actions under the name. Squad member rows no
     longer need this: their actions live in a toggled panel that already
     spans the full row. */
  .friend-row {
    flex-wrap: wrap;
  }

  .friend-row-actions {
    width: 100%;
    justify-content: flex-start;
  }

  /* No .matchup-side width here any more. The two sides used to sit next to
     each other around a centered VS and had to be narrowed at this width or
     they collided; they are stacked now, and each one is a full player card
     that needs every pixel of the column - narrowing it to 42% clipped the
     player's own name off their card. */
  .matchup-vs {
    font-size: 2rem;
  }

}

/* The bottom bar applies to PHONES, not to narrow windows.
   
   It first shipped inside the max-width:700px block, and the mobile baseline
   caught what that missed: at 844x390 - a phone turned sideways, which is
   both an accident and a deliberate choice for someone propping it against a
   TV - the tap-target count did not move at all (13 -> 13 on the hub, 28 ->
   28 on profile) while every other size roughly halved. A landscape phone
   was getting the desktop nav.
   
   Width alone cannot tell a sideways phone from a small window. `pointer:
   coarse` says a finger is the input device, and a short viewport says it is
   not a tablet held upright. The rest of the phone block - the stacked mode
   toggle, the narrowed gutter - stays behind on width, because those ARE
   about narrowness and stacking controls on a short landscape screen would
   spend the vertical room it has least of. */
@media (max-width: 700px), ((max-height: 520px) and (pointer: coarse)) {
  /* THE BOTTOM BAR.
     
     The four destinations used to be 11.2px text in 40px pills at the TOP of
     the screen - the furthest point from a thumb holding the phone, and the
     single largest contributor to the mobile baseline's 661 undersized tap
     targets, because those same six controls are on all nine screens.

     Fixed to the bottom, four equal columns, glyph over word, 56px tall
     before the safe-area inset. The header keeps the brand and the two
     utilities, so it stops needing to wrap into a second row at all. */
  .nav-tabs {
    /* The nav element itself stops being a box: its two children go to
       opposite ends of the screen. */
    display: contents;
  }

  /* `display: contents` is an AUTHOR rule and `[hidden] { display: none }` is
     a USER-AGENT one, so the author rule wins and navTabs.hidden stops
     hiding anything. Without this the bottom bar is on screen behind the
     sign-in form, before there is a session to navigate with. */
  .nav-tabs[hidden] {
    display: none;
  }

  .nav-primary {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    gap: 0;
    /* The bar's own height, plus whatever the home indicator needs. env()
       reports 0 on hardware without one, so this is not a phone-specific
       branch - it is the same rule everywhere. */
    /* All three insets, not just the bottom. viewport-fit=cover is what makes
       these report anything at all, and a notched phone IN LANDSCAPE - the
       case this breakpoint exists for - puts a ~47px inset on the left or
       right depending on which way it was turned. With only the bottom one
       set, the outermost tab sat under the notch. */
    padding-bottom: env(safe-area-inset-bottom, 0px);
    padding-left: env(safe-area-inset-left, 0px);
    padding-right: env(safe-area-inset-right, 0px);
    background: var(--panel);
    background: color-mix(in srgb, var(--panel) 92%, transparent);
    backdrop-filter: blur(12px);
    border-top: 1px solid var(--line);
  }

  .nav-primary .nav-tab {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.15rem;
    /* 3.5rem = 56px, comfortably over the 44px floor rather than exactly on
       it: the floor is where hit rates start to fall off, not where they are
       good. */
    min-height: 3.5rem;
    padding: 0.4rem 0.2rem;
    border-radius: 0;
    border: none;
    font-size: var(--text-micro);
    letter-spacing: 0.02em;
  }

  .nav-tab-glyph {
    display: block;
    font-size: 1.125rem;
    line-height: 1;
  }

  /* A filled pill is right for a header row and wrong for a bottom bar: at
     this size it becomes a solid block of accent under the thumb. The active
     tab is marked with colour and a top rule instead - the convention every
     phone user already reads without being taught. */
  .nav-primary .nav-tab.active {
    background: none;
    color: var(--accent-bright);
    box-shadow: inset 0 2px 0 0 var(--accent);
  }

  .nav-utility {
    gap: 0.3rem;
  }

  .nav-utility .nav-tab {
    min-height: 2.75rem;
    min-width: 2.75rem;
    padding: 0.5rem 0.7rem;
    font-size: var(--text-xs);
  }

  /* Room for the bar, so the last row of a screen is not sitting underneath
     it. Without this every screen's final control is unreachable. */
  #app-root {
    padding-bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1.5rem);
  }

}

/* Very narrow phones (~360px and under). */
@media (max-width: 400px) {
  .player-name {
    font-size: 1.1rem;
  }

  .pb-stats {
    gap: 1rem;
  }

}

/* Respect a reduced-motion preference across everything added for feel this
   round - the ambient stage glow, the live score pulse, the diamond sheen and
   the hot-card glow all loop forever, which is exactly what this setting is
   meant to stop. */
@media (prefers-reduced-motion: reduce) {
  .game-stage::before,
  .scoreboard-score.pulse,
  .play-card.hot:first-child,
  .banner-art-camo-diamond::after,
  .matchup-countdown.go {
    animation: none;
  }
}

/* ===========================================================================
   Draft grade, post-game analysis, rank ladder, celebration
   ---------------------------------------------------------------------------
   All four are new surfaces rather than tweaks to existing ones, so they sit
   together at the end rather than being scattered through the file.
   =========================================================================== */

/* ---- Draft grade ----------------------------------------------------------
   Handed out the instant both rosters are set. The letter is the point, so it
   is the largest thing on the card and the only thing that carries colour. */
.draft-grade {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-left: 4px solid var(--accent);
  border-radius: 12px;
  padding: 1rem;
  margin-bottom: 1rem;
}

.draft-grade-badge {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 4.5rem;
  height: 4.5rem;
  border-radius: 14px;
  background: rgba(var(--accent-rgb), 0.14);
  border: 2px solid var(--accent);
}

.draft-grade-badge span {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: 2.1rem;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--accent-bright);
}

/* Grade bands. An A shouldn't arrive in the same grey as a D - the letter is
   meant to be readable, and reacted to, from across the room. */
.draft-grade.grade-a { border-left-color: #06d6a0; }
.draft-grade.grade-a .draft-grade-badge { border-color: #06d6a0; background: rgba(6, 214, 160, 0.14); }
.draft-grade.grade-a .draft-grade-badge span { color: #06d6a0; }
.draft-grade.grade-b { border-left-color: #8ecae6; }
.draft-grade.grade-b .draft-grade-badge { border-color: #8ecae6; background: rgba(142, 202, 230, 0.14); }
.draft-grade.grade-b .draft-grade-badge span { color: #8ecae6; }
.draft-grade.grade-c { border-left-color: #ffd166; }
.draft-grade.grade-c .draft-grade-badge { border-color: #ffd166; background: rgba(255, 209, 102, 0.14); }
.draft-grade.grade-c .draft-grade-badge span { color: #ffd166; }
.draft-grade.grade-d,
.draft-grade.grade-f { border-left-color: var(--buzzer); }
.draft-grade.grade-d .draft-grade-badge,
.draft-grade.grade-f .draft-grade-badge { border-color: var(--buzzer); background: rgba(239, 71, 111, 0.14); }
.draft-grade.grade-d .draft-grade-badge span,
.draft-grade.grade-f .draft-grade-badge span { color: var(--buzzer); }

.draft-grade-body {
  min-width: 0;
}

.draft-grade-title {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}

.draft-grade-headline {
  font-weight: 700;
  font-size: 1rem;
  margin: 0.2rem 0 0.5rem;
  color: var(--text);
}

/* THE NOTES STOPPED BEING SENTENCES. They were seven prose bullets of 29-117
   characters against a body about 190px wide on a 360px phone - roughly 27
   characters - so each wrapped to three or four lines and the verdict arrived
   as a paragraph. A fact is a row now and only advice carries words; see
   js/gradenotes.js for the measurements and js/main.js gradeNoteRow for the
   markup. No bullet glyphs: a row with its value flush right does not need
   one, and the padding it cost is width the numbers now use. */
.draft-grade-reasons {
  margin: 0;
  padding-left: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  font-size: 0.85rem;
  color: var(--muted);
}

/* Label left, value right, and NEITHER MAY WRAP - that is the whole promise of
   the row. A label long enough to collide with its value truncates, which is a
   failure a designer can see, rather than reflowing the card silently. */
.grade-note.grade-stat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
  white-space: nowrap;
  /* The dotted rule that carries the eye across the gap - the same device a
     scorecard uses, and it makes a two-column row readable without a border
     per line. */
  border-bottom: 1px dotted rgba(255, 255, 255, 0.08);
  padding-bottom: 0.15rem;
}

.grade-note-label {
  overflow: hidden;
  text-overflow: ellipsis;
}

.grade-note-value {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--text);
}

/* Only the value takes the verdict colour. Colouring the whole row turned the
   card into a traffic light and made the labels harder to read than the
   numbers, which is the wrong way round. */
.grade-note.grade-good .grade-note-value { color: #06d6a0; }
.grade-note.grade-bad .grade-note-value { color: var(--buzzer); }

/* The one note allowed words, and therefore the one allowed to wrap: a clause
   truncated in the middle loses the half that says what to do. Marked with a
   leading arrow rather than a bullet because it is an instruction. */
.grade-note.grade-advice {
  position: relative;
  padding-left: 1rem;
  margin-top: 0.35rem;
  line-height: 1.35;
  color: var(--text);
}

.grade-note.grade-advice::before {
  content: "\2192";
  position: absolute;
  left: 0;
  color: var(--accent-bright);
}

/* The grade lands rather than appears - it's a verdict, and a verdict that
   fades in politely doesn't read as one. */
.draft-grade.grade-stamp .draft-grade-badge {
  animation: grade-stamp 0.55s cubic-bezier(0.2, 1.4, 0.4, 1);
}

@keyframes grade-stamp {
  0% { transform: scale(2.4) rotate(-12deg); opacity: 0; }
  60% { transform: scale(0.92) rotate(2deg); opacity: 1; }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}

/* ---- Post-game analysis ---------------------------------------------------
   The coach's read: the handful of numbers that decided it, then what the
   rotation, matchups and gamestyle actually did. */
.why-breakdown {
  background: var(--panel);
  border: 1px solid var(--line);
  border-left: 4px solid var(--line);
  border-radius: 12px;
  padding: 1rem 1.1rem;
  margin: 1rem 0;
}

.why-breakdown.why-won { border-left-color: #06d6a0; }
.why-breakdown.why-lost { border-left-color: var(--buzzer); }

.why-title {
  margin: 0 0 0.6rem;
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  font-size: 1.05rem;
}

.why-breakdown.why-won .why-title { color: #06d6a0; }
.why-breakdown.why-lost .why-title { color: var(--buzzer); }

.why-reasons {
  margin: 0;
  padding-left: 1.15rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: 0.92rem;
}

.why-coaching {
  margin-top: 0.9rem;
  padding-top: 0.8rem;
  border-top: 1px solid var(--line);
}

.why-coaching-title {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
  margin-bottom: 0.45rem;
}

.why-coaching-list {
  margin: 0;
  padding-left: 1.15rem;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.86rem;
  color: var(--muted);
}

/* ---- Rank ladder ----------------------------------------------------------
   Every tier at once. Ranked is a percentile ladder, which is fair but
   invisible: without this a player only ever sees the rung they're on. */
.rank-ladder {
  list-style: none;
  margin: 0.9rem 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.rank-ladder-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.45rem 0.7rem;
  border-radius: 6px;
  background: var(--bg);
  border: 1px solid transparent;
  font-size: 0.88rem;
}

.rank-ladder-name {
  flex: 1;
  font-weight: 700;
}

.rank-ladder-band {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  color: var(--muted);
  white-space: nowrap;
}

.rank-ladder-row.current {
  border-color: var(--accent);
  background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.25), transparent);
}

.rank-ladder-row.current .rank-ladder-name {
  color: var(--accent-bright);
}

.rank-ladder-row.next {
  border-color: rgba(var(--accent-rgb), 0.45);
  border-style: dashed;
}

.rank-ladder-you {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--bg);
  background: var(--accent-bright);
  border-radius: var(--radius-pill);
  padding: 0.1rem 0.45rem;
}

/* ---- Celebration ----------------------------------------------------------
   Confetti is DOM + CSS rather than a canvas loop: a phone composites
   transformed spans on the GPU with no layout work, and nothing is mounted
   while nothing is celebrating (js/celebrate.js empties the layer). */
.celebration-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 9999;
}

.confetti-piece {
  position: absolute;
  top: -5vh;
  width: 9px;
  height: 14px;
  opacity: 0;
  will-change: transform, opacity;
  animation-name: confetti-fall;
  animation-timing-function: cubic-bezier(0.3, 0.6, 0.6, 1);
  animation-fill-mode: forwards;
}

@keyframes confetti-fall {
  0% { transform: translate3d(0, 0, 0) rotate(0); opacity: 1; }
  85% { opacity: 1; }
  100% { transform: translate3d(var(--drift, 0), 105vh, 0) rotate(var(--spin, 540deg)); opacity: 0; }
}

/* The final banner flaring on a win. Deliberately bigger than the calm
   period-end flash the scoreboard already uses - this is the moment the game
   has been building to, and it previously landed with less fanfare than an
   ordinary quarter ending. */
.final-banner.win-flare {
  animation: win-flare 1.1s ease-out;
}

@keyframes win-flare {
  0% { transform: scale(0.7); opacity: 0; text-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
  40% { transform: scale(1.12); opacity: 1; text-shadow: 0 0 26px rgba(var(--accent-rgb), 0.9); }
  100% { transform: scale(1); opacity: 1; text-shadow: 0 0 0 rgba(var(--accent-rgb), 0); }
}

/* ---- Matchmaking intro ----------------------------------------------------
   Two extra seconds spent on presentation rather than on waiting: the stage
   lights up, the banners arrive one after the other, VS lands with a
   shockwave, and only then does the countdown start. */
.matchup-intro.intro-lit {
  animation: intro-lights 0.8s ease-out;
}

@keyframes intro-lights {
  0% { filter: brightness(0.35); }
  100% { filter: brightness(1); }
}

/* WHO THEY ARE, A BEAT AFTER THE CARD ITSELF.
   The card arriving complete reads as a layout appearing rather than a player
   being introduced, so the badges and the rep/rank/rating row settle in behind
   it. Scoped to .matchup-card, so the home card - which stages no arrival - is
   untouched. Transitioned, never animated, so reduced-motion only has to zero
   the duration; opacity, never display, so the gate measures one layout. */
.matchup-card .pb-badges,
.matchup-card .pb-stats {
  transition: opacity 0.4s ease var(--intro-detail-delay, 0s),
              transform 0.4s var(--ease-out) var(--intro-detail-delay, 0s);
}

.matchup-side:not(.settle) .matchup-card .pb-badges,
.matchup-side:not(.settle) .matchup-card .pb-stats {
  opacity: 0;
  transform: translateY(8px);
}

.matchup-side.settle .matchup-card .pb-stats {
  /* The record reads a fraction after the badges, so the eye is walked down
     the card rather than having two rows appear together. */
  --intro-detail-delay: 0.12s;
}

/* THE LANDING. One punch as the card arrives, before the slow glow below takes
   over: the fly-in's back-out curve already overshoots, and this is the weight
   behind the overshoot rather than a second movement after it. Scale only, and
   under 300ms - a card that keeps moving after it has landed reads as not
   having landed. */
/* On the SLOT rather than on the side itself. .matchup-side.settle already
   owns that element's `animation`, and `animation` is one property rather than
   a list that accumulates - whichever of the two rules came later in this file
   would silently cancel the other, which for the second card is a 50ms window
   between landing and settling. The slot animates nothing else. */
.matchup-side.impact .matchup-card-slot {
  animation: matchup-side-impact 0.28s var(--ease-out);
}

@keyframes matchup-side-impact {
  0% { transform: scale(1.05); }
  55% { transform: scale(0.985); }
  100% { transform: scale(1); }
}

/* A settled banner keeps a slow glow, so the screen stays alive during the
   beat where you're actually reading the opponent's name and rank. */
.matchup-side.settle {
  animation: banner-breathe 2.4s ease-in-out infinite;
}

@keyframes banner-breathe {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.25)); }
  50% { filter: drop-shadow(0 0 18px rgba(var(--accent-rgb), 0.6)); }
}

/* VS lands with the two banners rather than sitting there through the whole
   fly-in - it's the collision, not the label. */
.matchup-vs.vs-land {
  animation: vs-land 0.5s cubic-bezier(0.2, 1.5, 0.4, 1);
}

/* NO translate(-50%, -50%) here, unlike the countdown keyframes below.
   .matchup-countdown is absolutely positioned and that translate is what
   centres it; .matchup-vs is an ordinary flex item, already centred by the
   flex row, so the same translate offset it by half its own size for the
   length of the animation and dropped it back the instant the animation
   ended - a 24x20px jolt landing on exactly the impact beat it is meant to
   sell. Scale and rotation only; the flex row does the positioning. */
@keyframes vs-land {
  0% { transform: scale(3.2) rotate(-8deg); opacity: 0; }
  55% { transform: scale(0.88) rotate(2deg); opacity: 1; }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}

/* ---- Lists that arrive ----
   Switching the profile's sport tab replaces every row at once; with no motion
   the swap is indistinguishable from a glitch - same shapes, different words,
   one frame. Pure CSS, keyed to nothing: the renderers rebuild these elements
   on every paint, so it runs exactly when the rows are new. Capped at six rows,
   past which the last one reads as waiting. `both` prevents a flicker: without
   it row six sits opaque through its delay, then fades in from nothing. */
.match-row,
.record-group {
  animation: row-enter 260ms var(--ease-out) both;
}

.match-row:nth-child(2), .record-group:nth-child(2) { animation-delay: 30ms; }
.match-row:nth-child(3), .record-group:nth-child(3) { animation-delay: 60ms; }
.match-row:nth-child(4), .record-group:nth-child(4) { animation-delay: 90ms; }
.match-row:nth-child(5), .record-group:nth-child(5) { animation-delay: 120ms; }
.match-row:nth-child(n + 6), .record-group:nth-child(n + 6) { animation-delay: 150ms; }

@keyframes row-enter {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

/* A badge going onto your banner should be seen to go. The button already
   changes its label; this is the tile itself acknowledging it, on the one
   interaction in the Rewards screen that changes something. */
.badge-tile.featured {
  animation: badge-featured 320ms var(--ease-out);
}

@keyframes badge-featured {
  0% { transform: scale(1); }
  45% { transform: scale(1.035); }
  100% { transform: scale(1); }
}

/* Nothing above may move for anyone who asked for less motion. The
   information is all still there - only the animation stops. */
@media (prefers-reduced-motion: reduce) {
  .confetti-piece,
  .draft-grade.grade-stamp .draft-grade-badge,
  .final-banner.win-flare,
  .matchup-intro.intro-lit,
  .matchup-intro.intro-lit::after,
  .matchup-side.settle,
  .matchup-side.impact .matchup-card-slot,
  .matchup-vs.vs-land,
  .match-row,
  .record-group,
  .badge-tile.featured {
    animation: none;
  }

  /* A bar that slides to its new value is still motion. The value it lands on
     is identical; it just arrives there in one frame. */
  .progress-bar-fill {
    transition: none;
  }

  /* A transition, not an animation, so `animation: none` above does not reach
     it - and a card whose badges and record fade in is still a card that
     moves. Zeroed rather than removed, so the settled state is identical. */
  .matchup-card .pb-badges,
  .matchup-card .pb-stats {
    transition-duration: 0s;
    transition-delay: 0s;
  }
}

/* ---- Account screen extras ------------------------------------------------ */
.profile-email-actions {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin: 0.35rem 0 0.75rem;
}

.profile-email-actions .hint-text {
  margin: 0;
}

.field-row[hidden] {
  display: none;
}

/* ---- Phone pass for everything above -------------------------------------- */
@media (max-width: 700px) {
  /* The draft board is the whole screen on a phone, so the run-up to a pick
     has to be as short as it can be while still saying everything. */
  .squad-banner {
    padding: 0.5rem 0.6rem;
    margin-bottom: 0.5rem;
  }

  .squad-banner-team {
    font-size: 1.1rem;
  }

  .squad-banner-decade {
    font-size: 0.9rem;
  }

  .squad-banner-era {
    font-size: var(--text-micro);
    padding: 0.1rem 0.45rem;
  }

  .round-label {
    font-size: var(--text-micro);
  }

  .turn-banner {
    font-size: var(--text-micro);
    padding: 0.15rem 0.45rem;
  }

  .pick-timer {
    font-size: 1rem;
  }

  /* PLAY AGAIN CAME AFTER TWO FULL BOX SCORES.
     ------------------------------------------------------------------
     Measured at 360px, the post-game screen is 2050px tall and the two
     buttons a player wants next - Play Again, Return to Home - sat at the
     very bottom of it, past both teams' tables. Every game ended with a
     scroll past 1,100px of numbers to reach the only two controls on the
     screen.

     The tables are not in the way of the story, they ARE the story for
     anyone who wants them - so they move after the actions rather than
     shrinking. Reordered rather than restacked in the markup, so the desktop
     layout and everything that targets these by id are untouched, the same
     technique the draft board uses one screen earlier.

     This is a phone rule because it is a phone problem: at a desktop height
     the tables and the buttons are on screen together and the order says
     nothing. */
  #screen-game {
    display: flex;
    flex-direction: column;
  }

  #screen-game > .game-actions {
    order: 1;
  }

  #screen-game > #btn-game-home {
    order: 2;
  }

  #screen-game > #full-box-score {
    order: 3;
    /* It is the tail of the screen now, not the middle, so it gets a little
       air above it to read as a separate act rather than as more of the same. */
    margin-top: 1.25rem;
  }

  /* The result is stated twice within 100px: the scoreboard above already
     carries 122, 64 and FINAL, and then the banner says 122-64 again. The
     banner earns its place with the WORD - won or lost, and who - so on a
     phone it keeps the verdict and drops the repetition to a single tight
     block against the recap under it, instead of two bordered cards saying
     the same thing with a gap between them. */
  #final-banner {
    margin-bottom: 0.35rem;
    /* Smaller type, because the scoreboard 100px above it is already showing
       these digits at full size. The banner is kept for the VERDICT - the word
       won or lost, and whose name is on it - which the scoreboard never says;
       it just no longer competes with the thing it is repeating. */
    font-size: 1.05rem;
    padding: 0.7rem 0.85rem;
  }

  .game-recap {
    margin-top: 0;
  }

  /* The pool card's desktop padding is a 16px frame on all four sides of the
     one element that wants every pixel. Trimmed, it gives 22px back above the
     list and widens the names by the same on each side - a card that is
     mostly its own border is a card sized for a screen this is not. */
  .draft-pool-panel {
    padding: 0.55rem;
  }

  /* The mode note is the same sentence for a whole draft and it was costing
     30px plus its margins directly above the pool. Compacted rather than
     removed: it is the only thing that says whether a clock is running. */
  .knowledge-hint {
    font-size: var(--text-micro);
    margin: 0.15rem 0 0.4rem;
    line-height: 1.25;
  }

  .draft-grade {
    flex-direction: row;
    gap: 0.75rem;
    padding: 0.75rem;
  }

  .draft-grade-badge {
    width: 3.4rem;
    height: 3.4rem;
  }

  .draft-grade-badge span {
    font-size: 1.5rem;
  }

  .draft-grade-headline {
    font-size: 0.9rem;
  }

  .draft-grade-reasons {
    font-size: var(--text-xs);
  }

  .why-breakdown {
    padding: 0.8rem 0.85rem;
  }

  .why-reasons {
    font-size: 0.85rem;
  }

  .rank-ladder-row {
    font-size: var(--text-xs);
    padding: 0.4rem 0.5rem;
  }

  .rank-ladder-band {
    font-size: var(--text-micro);
  }
}

/* ---- Draft board readability (phones) -------------------------------------
   The two roster panels sit side by side at this width, which is right - both
   teams should be on screen at once - but the type had been squeezed to fit
   rather than the padding, and 0.66rem position tags on a 360px screen are
   not readable at arm's length. Compacting the banner above freed the room to
   put it back. */
@media (max-width: 860px) {
  .draft-body .roster-panel {
    padding: 0.6rem 0.55rem;
  }

  .draft-body .roster-panel h3 {
    font-size: 0.82rem;
    margin-bottom: 0.35rem;
  }

  .draft-body .roster-slot {
    font-size: 0.86rem;
    padding: 0.42rem 0.1rem;
  }

  .draft-body .slot-tag {
    font-size: var(--text-micro);
    min-width: 2.2rem;
  }

  /* A drafted name is the thing you actually read on this screen, so it gets
     the weight - the position tag beside it is a label. */
  .draft-body .slot-filled {
    font-weight: 600;
  }
}

@media (max-width: 700px) {
  /* Player cards in the pool: the name is what you're scanning for. */
  .player-card {
    padding: 0.6rem 0.7rem;
  }

  .knowledge-hint {
    font-size: var(--text-xs);
  }

  /* Now that the chrome above is one banner instead of five rows, the pool
     can afford to be taller - which is the difference between seeing three
     candidates and seeing six. */
  .pool-list {
    max-height: 60vh;
    max-height: 60dvh;
  }

  /* Same five-card cap as the breakpoint above - this rule is later in the
     file and would otherwise put 60vh back. */
  .pool-list:has(.player-card.with-stats) {
    max-height: 24rem;
  }
}

/* ---- Reward toast ---------------------------------------------------------
   What this game changed about YOU. It appears under the analysis panel
   rather than over the screen: a modal here would land on top of the box
   score somebody is reading, and the reward is worth a beat, not an
   interruption. */
.reward-toast {
  display: flex;
  align-items: flex-start;
  gap: 0.9rem;
  background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2), rgba(var(--accent-rgb), 0.04));
  border: 1px solid var(--accent);
  border-radius: 12px;
  padding: 0.9rem 1rem;
  margin: 1rem 0;
}

.reward-toast-icon {
  font-size: 1.9rem;
  line-height: 1;
}

.reward-toast-title {
  font-family: var(--font-display);
  font-weight: 900;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 1.05rem;
  color: var(--accent-bright);
}

.reward-toast-detail {
  font-size: 0.88rem;
  color: var(--muted);
  margin-top: 0.15rem;
}

.reward-toast-more {
  margin: 0.5rem 0 0;
  padding-left: 1.1rem;
  font-size: 0.82rem;
  color: var(--muted);
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

.reward-toast.reward-pop {
  animation: reward-pop 0.6s cubic-bezier(0.2, 1.4, 0.4, 1);
}

.reward-toast.reward-pop .reward-toast-icon {
  animation: reward-icon 0.9s ease-out 0.15s;
}

@keyframes reward-pop {
  0% { transform: scale(0.9) translateY(12px); opacity: 0; }
  100% { transform: scale(1) translateY(0); opacity: 1; }
}

@keyframes reward-icon {
  0% { transform: scale(0.4) rotate(-25deg); }
  55% { transform: scale(1.35) rotate(8deg); }
  100% { transform: scale(1) rotate(0); }
}

@media (prefers-reduced-motion: reduce) {
  .reward-toast.reward-pop,
  .reward-toast.reward-pop .reward-toast-icon {
    animation: none;
  }
}

/* "3 seasons" on a player card whose name resolves to more than one year.
   Muted and small: it is a fact about the card, not a call to action - the
   whole card is already the button. */
.player-card-seasons {
  margin-left: auto;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  white-space: nowrap;
}

/* The year picker. A vertical list rather than a grid, because the rows are
   read down a career and each carries a stat line long enough that two
   columns would wrap it on a phone. */
.season-picker {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin-top: 0.75rem;
}

.season-option {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  width: 100%;
  padding: 0.6rem 0.8rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--text);
  text-align: left;
  font: inherit;
  cursor: pointer;
}

.season-option:hover {
  border-color: var(--accent);
}

.season-year {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.05rem;
  color: var(--accent-bright);
  /* Fixed width so the stat lines start on the same column down the list,
     which is what makes two seasons comparable at a glance. */
  min-width: 3.2rem;
}

.season-line {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

/* ---- Football field -----------------------------------------------------
   Football's stage, the counterpart to basketball's scoreboard. Shown only when the
   active sport declares presentation.stage === "field" (see showStage() in
   js/main.js). Every moving part is positioned as a percentage of the turf so
   the field scales to any width without recomputing anything in JS. */
.football-field {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.5rem 0;
}

.ff-status {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: center;
  gap: 0.35rem 0.9rem;
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  color: var(--muted);
}

/* .ff-quarter, .ff-clock and .ff-score used to live here. All three reprinted
   what the scoreboard directly above already showed, and the clock - the one
   number that changes every play - was buried at the left end of the row. The
   clock now sits in the board's centre cell; see liveStatusLabel() in
   js/sports/nfl/field.js. */

.ff-downdist {
  font-weight: 600;
}

/* POSSESSION, CARRIED BY THE FIELD ITSELF.
   Knowing whether you are attacking or defending is the most important thing
   a viewer needs while watching, and it was only ever inferable from the
   wording of the play description. Colour and direction say it instead:
   the attacking end zone lights up, and the field takes a coloured edge on
   the side with the ball. Never colour ALONE - the chip carries an arrow and
   the team name too, so this reads the same without colour vision. */
.ff-possession {
  margin-left: auto;
  font-weight: 600;
  font-size: var(--text-xs);
  letter-spacing: 0.01em;
  padding: 0.08rem 0.45rem;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  white-space: nowrap;
}

/* THE TEAM'S OWN COLOUR, not the sport accent.
   The end zones below have always been painted in the two players' kits; this
   chip was the one part of the field still speaking in the sport's accent, so
   at a glance "who has the ball" was the only thing on the stage NOT saying it
   in the colour of the player who has it. --team-a-ink is always the viewer's
   own kit (A is "me" in the render frame - see dressStage in js/main.js) and
   --team-b-ink the opponent's, with wornColours guaranteeing the two are far
   apart.

   TINTED rather than filled solid. A curated kit is guaranteed 3:1 against the
   stage (MIN_SURFACE_CONTRAST in js/kits.js), which makes it safe as TEXT on
   this dark ground - it guarantees nothing about white text sitting ON it, and
   the palette includes colours as pale as Bone. So the kit colours the text and
   the border, and only tints the fill. That keeps the loud/quiet distinction
   between having the ball and not, without betting legibility on which kit the
   player happens to have chosen. Same treatment as .ff-endzone, one rule up. */
.ff-user-offense .ff-possession {
  color: var(--team-a-ink, var(--accent-bright));
  background: color-mix(in srgb, var(--team-a-ink, var(--accent)) 28%, transparent);
  border-color: var(--team-a-ink, var(--accent));
}

.ff-user-defense .ff-possession {
  color: var(--team-b-ink, var(--text));
  background: transparent;
  border-color: color-mix(in srgb, var(--team-b-ink, var(--line)) 55%, transparent);
}

/* The edge of the field the ball is heading toward - in the colour of whoever
   is driving toward it, for the same reason. */
.ff-user-offense .ff-field {
  box-shadow: inset 4px 0 0 -1px var(--team-a-ink, var(--accent));
}

.ff-user-defense .ff-field {
  box-shadow: inset -4px 0 0 -1px var(--team-b-ink, var(--muted));
}

/* The end zone being attacked. */
.ff-user-offense .ff-endzone.right,
.ff-user-defense .ff-endzone.left {
  filter: brightness(1.25) saturate(1.2);
}

@media (prefers-reduced-motion: reduce) {
  .ff-field { transition: none; }
}

.ff-field {
  display: flex;
  align-items: stretch;
  width: 100%;
  height: 92px;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid rgba(var(--accent-rgb), 0.25);
}

/* The two end zones are the two drafters' ends of the field, named so you can
   see whose goal line the ball is approaching without reading a label. */
/* Each team's endzone in that team's colour - football's version of "their side
   of the board". Falls back to the sport accent, so a field drawn before the
   kits are known is tinted rather than blank. */
.ff-endzone {
  flex: 0 0 11%;
  display: grid;
  place-items: center;
  background: rgba(var(--accent-rgb), 0.22);
  overflow: hidden;
}

.ff-endzone.left {
  background: color-mix(in srgb, var(--team-a-ink, rgba(var(--accent-rgb), 1)) 30%, transparent);
}

.ff-endzone.right {
  background: color-mix(in srgb, var(--team-b-ink, rgba(var(--accent-rgb), 1)) 30%, transparent);
}

.ff-endzone span {
  font-size: var(--text-micro);
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: center;
  line-height: 1.1;
  padding: 0 0.15rem;
  color: var(--accent-bright);
  /* Sideways, the way an end zone is actually painted, and it keeps a long
     username from pushing the field out of shape. */
  writing-mode: vertical-rl;
  transform: rotate(180deg);
}

.ff-endzone.right span {
  transform: none;
}

.ff-turf {
  position: relative;
  flex: 1 1 auto;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(0, 0, 0, 0.12));
}

.ff-yardline {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: rgba(255, 255, 255, 0.12);
}

.ff-yardline.major {
  background: rgba(255, 255, 255, 0.22);
}

.ff-yardline.midfield {
  background: rgba(255, 255, 255, 0.38);
  width: 2px;
}

.ff-yardnum {
  position: absolute;
  bottom: 2px;
  transform: translateX(-50%);
  font-size: var(--text-micro);
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.3);
  pointer-events: none;
}

/* The latest play, drawn as a band from where the ball was to where it ended.
   Coloured by side so possession is readable without the label. */
.ff-drive {
  position: absolute;
  top: 32%;
  height: 36%;
  border-radius: 3px;
  background: rgba(var(--accent-rgb), 0.3);
  transition: left 340ms ease, width 340ms ease;
}

.ff-drive.b {
  background: rgba(255, 255, 255, 0.18);
}

.ff-scrimmage {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--accent-bright);
  transition: left 340ms ease;
}

/* The chains. Yellow because that is what a first-down line is, everywhere. */
.ff-firstdown {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: #ffd166;
  transition: left 340ms ease;
}

.ff-ball {
  position: absolute;
  top: 50%;
  width: 10px;
  height: 7px;
  margin: -3.5px 0 0 -5px;
  border-radius: 50%;
  background: #d9a066;
  box-shadow: 0 0 6px rgba(217, 160, 102, 0.8);
  transition: left 340ms ease;
  z-index: 2;
}

/* Which way this possession is going. Sits just above the ball and flips with
   the drive, which is the whole point of mirroring B's yardage. */
.ff-arrow {
  position: absolute;
  top: 14%;
  width: 0;
  height: 0;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: left 340ms ease;
}

.ff-arrow.right {
  border-left: 7px solid var(--accent-bright);
  margin-left: 2px;
}

.ff-arrow.left {
  border-right: 7px solid #ffffff;
  margin-left: -9px;
}

.ff-call {
  min-height: 1.2rem;
  text-align: center;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text);
}

.ff-call.score {
  color: var(--accent-bright);
}

.ff-call.takeaway {
  color: var(--buzzer);
}

.ff-call.first {
  color: #ffd166;
}

@media (max-width: 520px) {
  .ff-field {
    height: 72px;
  }
  .ff-status {
    font-size: var(--text-micro);
    gap: 0.25rem 0.6rem;
  }
}

/* ---- Team is the loud channel, and now it is the PLAYER'S colour ----
   Falls back to the old sport-accent-vs-grey pair, which matters more than it
   looks: a client that reaches this stylesheet before dressStage has run, or at
   all if the profile read failed, still gets two distinguishable teams rather
   than two invisible ones. */

/* Made: solid. Missed: hollow with a slash, so the difference survives
   greyscale and colourblindness - it is shape first, colour second. */

/* A three is bigger than a two, so a mark says what it was worth with no
   legend to consult. */

/* Rim finishes. Nothing is called a dunk - the engine has none - but a shot
   from close in is a real fact and earns the loudest mark on the floor. */

/* SETTLED SHOTS ARE NEARLY GHOSTS. This was 0.42, which at a hundred marks is
   an even wash with no focal point - the whole floor competing for attention.
   The accumulated chart is meant to be the QUIET background to whatever just
   happened, and only becomes the subject once the game ends. */

/* At the final buzzer the chart stops being background and becomes the
   artifact: everything lifts to an even, readable weight. Added by finish(). */

/* ---- Callouts -------------------------------------------------------------
   The line a commentator would say, where it happened. Terse on purpose: a
   surname and a verdict is all that can be read in the time it is up, and a
   callout nobody finishes reading is just motion. */

/* ---- Moments --------------------------------------------------------------
   A run, a lead change. Over the centre of the floor rather than on a shot,
   because these are about the GAME, not one attempt. Both are read off the
   ledger's own running score, so they cannot disagree with the scoreboard. */

@media (prefers-reduced-motion: reduce) {
}

/* ---- Final banner: the score leads --------------------------------------
   This was one uppercase sentence with the numbers buried mid-phrase, which
   made the single thing every player looks for the hardest thing to find.
   Three stacked parts now, sized by how much they matter: the outcome, the
   score, then who beat whom.

   Won and lost are spelled out and not left to green-vs-red. That pair is
   exactly the one a red-green colourblind player cannot separate, and this
   banner is the answer to "did I win". */
.final-banner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
}

.fb-outcome {
  font-size: var(--text-xs);
  letter-spacing: 0.14em;
  opacity: 0.85;
}

.fb-score {
  font-size: 2.6rem;
  line-height: 1.05;
  letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
}

.fb-winner {
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  opacity: 0.8;
  text-transform: none;
}

/* Colour as the SECOND cue, after the word. Kept to the border and the
   outcome line so the score itself stays maximum-contrast plain text. */
.final-banner.final-won { border-color: #35c46b; }
.final-banner.final-won .fb-outcome { color: #35c46b; }
.final-banner.final-lost { border-color: #e2574c; }
.final-banner.final-lost .fb-outcome { color: #e2574c; }

@media (max-width: 480px) {
  .fb-score { font-size: 2.1rem; }
}

/* ---- Box score: the MVP row, and a name column that survives scrolling ----
   The box score is wider than a phone by design and scrolls inside
   #full-box-score. That scroll was taking the player's name with it, so three
   columns in you were reading a row of numbers with no idea whose they were.
   The slot and the name stay put now; everything else scrolls under them.

   Sticky rather than a fixed second table: one table means one set of row
   heights, and two tables side by side drift apart the moment a name wraps. */
table.box-table td:nth-child(1),
table.box-table th:nth-child(1),
table.box-table td:nth-child(2),
table.box-table th:nth-child(2) {
  position: sticky;
  background: var(--panel);
  z-index: 1;
}

table.box-table td:nth-child(1),
table.box-table th:nth-child(1) { left: 0; }

/* Offset by the slot column's own width so the two never overlap. */
table.box-table td:nth-child(2),
table.box-table th:nth-child(2) {
  left: 3.6rem;
  /* The edge where the frozen pair ends and the scrolling numbers begin. */
  box-shadow: 1px 0 0 var(--line);
}

/* The MVP's line. A star carries it, a tint reinforces it - the tint alone is
   the first thing lost to a dim screen, a bright room, or a colourblind
   reader, and it would be the only thing saying "this is the best line here". */
.box-mvp td {
  background: rgba(var(--accent-rgb), 0.12);
  font-weight: 600;
}

.box-mvp td:nth-child(1),
.box-mvp td:nth-child(2) {
  /* Sticky cells paint their own background, so the row tint has to be
     restated on them or the frozen pair stays plain while the rest lights up. */
  background: color-mix(in srgb, var(--panel) 88%, rgba(var(--accent-rgb), 1) 12%);
}

.box-mvp-star {
  color: var(--accent-bright);
}

@media (max-width: 480px) {
  table.box-table td:nth-child(2),
  table.box-table th:nth-child(2) { left: 3rem; }
}

/* ---- The name column stops collapsing ----

   The box score already scrolls sideways inside #full-box-score, and the slot
   and player columns are already sticky, so the machinery for reading a wide
   table on a phone was all here. What defeated it was `width: 100%` on the
   table: with no floor under any column, the browser satisfied that by
   crushing the widest text column, and the player name is always the widest
   text column.

   Measured at 390px before this: the name cell was 76px wide and 164px tall
   on the football box score - "New England Patriots Offensive Line" broken
   across seven lines, four characters at a time - and 82px/110px on
   basketball. The table was already 597px wide and scrolling; squeezing the
   name bought nothing, it just made the one column you cannot scroll away
   from unreadable.

   A fixed 9.5rem, not a min-width. This column is STICKY, so every pixel it
   takes is a pixel of stats permanently off-screen; letting it size to the
   longest unit name would park a 265px block over two thirds of the phone.

   A CONTAINER query, not a viewport one. This is a fact about how wide the
   BOX SCORE's frame is, and the two come apart: the stored-game modal is
   `min(560px, 100%)`, so on a 1280px desktop it is a 560px table that a
   viewport query calls wide and leaves crushed. Asking the frame instead
   covers the phone and the modal with one rule, and covers whatever the next
   narrow container turns out to be without anyone remembering to add it. */
@container boxscore (max-width: 700px) {
  /* `width: 100%` is what was doing the crushing. Under auto table layout a
     cell width is only a suggestion, and a table told to fit its container
     will overrule it - so the floor below did nothing until this came off.
     `min-width: 100%` keeps a short table filling the frame; a long one is
     now free to be as wide as it needs and scroll. */
  table.box-table {
    width: auto;
    min-width: 100%;
  }

  /* Names in the body face, figures in the mono one.
     
     The whole table is monospace, which is right for columns of numbers that
     should line up and wrong for a person's name - mono costs roughly a third
     of the characters per line for no benefit a name can use. At 152px
     "Cincinnati Bengals Linebackers" needed four lines in mono and three in
     the body face. The stat columns keep the mono, which is the only place it
     was earning anything.
     
     The longest name in either dataset - "Washington Commanders Defensive
     Line" - takes three lines here, which is exactly the limit
     verify-box-score holds it to. A longer one fails the build deliberately,
     rather than quietly going back to a crushed column. */
  table.box-table td:nth-child(2) {
    font-family: var(--font-body);
  }

  table.box-table td:nth-child(2),
  table.box-table th:nth-child(2) {
    width: 9.5rem;
    min-width: 9.5rem;
    max-width: 9.5rem;
  }

  /* The team and season under the name, held to two lines.
     
     A bound, because the name column is a fixed 9.5rem and "New Orleans
     Saints 2010" already needs 136px in a 133px box - left unbounded, a long
     team name wraps to three or four lines under every unit row and undoes
     the height this block is buying.
     
     Two lines and not one. A single line has to truncate, truncation takes
     the TAIL, and the tail is the season - so the one datum saying WHICH year
     this is would be first to go, on exactly the rows whose names are
     longest, and boxRow()'s own comment calls the season essential to
     checking a line. Measured across both sports at 390px: zero metas clipped
     at two lines. */
  .box-meta {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
  }

  /* Stat columns hold their heading rather than wrapping "TOT YDS" onto two
     lines under a number that needs one. */
  table.box-table th:nth-child(n + 3),
  table.box-table td:nth-child(n + 3) {
    white-space: nowrap;
  }
}

/* Visually hidden, still read aloud. The sound toggle's state lives here so it
   is never carried by the icon alone - a speaker glyph with a line through it
   is small, and at a glance the two states are easy to confuse. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* An icon-only nav button. Narrower than the text tabs beside it, so the row
   still fits a phone with a sport, a profile and a sign-out on it. */
.nav-tab-icon {
  padding-left: 0.6rem;
  padding-right: 0.6rem;
  font-size: 1rem;
  line-height: 1;
}

/* The toggle reads as off without relying on the glyph alone. */
.nav-tab-icon[aria-pressed="false"] {
  opacity: 0.55;
}

/* ---- Profile: identity before administration ------------------------------
   The screen opened on two text inputs, so the first thing you saw on your own
   profile was an account form and your rank sat below it. Identity leads now -
   as the shared player card, see .profile-hero - and the fields that edit the
   account are one fold down behind a closed disclosure. */
.profile-account {
  margin-top: 1rem;
  border-top: 1px solid var(--line);
  padding-top: 0.75rem;
}

.profile-account > summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 0.82rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  /* A summary is focusable already; this makes that visible, which the default
     marker alone does not do well on a dark panel. */
  padding: 0.25rem 0;
}

.profile-account > summary:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
  border-radius: 4px;
}

.profile-account-body {
  padding-top: 0.6rem;
}

/* ===========================================================================
   MOTION
   ===========================================================================

   Two problems, one system.

   The app had thirty keyframe animations and six separate
   `prefers-reduced-motion` blocks between them, each neutralising whatever the
   author of that feature happened to remember. Twenty-six of the thirty were
   not named in any of them - including eight INFINITE pulses, which are the
   worst case: a glow that never stops is the one a vestibular-sensitive player
   cannot look away from, and it was running regardless of what their operating
   system had been told to ask for.

   Protection was opt-in, so every future animation had to remember too. The
   block at the bottom of this section inverts that: reduced motion switches
   animation off APP-WIDE, and anything added later is covered by construction
   rather than by diligence.

   The tokens exist so durations stop being re-invented per component. Three
   speeds is enough for an app of this size, and having exactly three is what
   keeps a press, a card and a screen change feeling like one product. */
:root {
  --motion-fast: 110ms;   /* a press, a tap, a toggle - felt, not watched */
  --motion-base: 220ms;   /* a card selecting, a slot filling */
  --motion-slow: 380ms;   /* a screen arriving */
  --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* ---- Press feedback -------------------------------------------------------
   Every button in the app moves when pressed. On a phone there is no hover to
   confirm a tap landed, and the gap between tapping and the screen changing is
   exactly where a player taps a second time - which is how a double-submit
   starts. */
.btn,
.nav-tab,
.mode-btn,
.sport-card-action,
.link-btn {
  transition: transform var(--motion-fast) var(--ease-out),
              background-color var(--motion-fast) var(--ease-out),
              border-color var(--motion-fast) var(--ease-out),
              opacity var(--motion-fast) var(--ease-out);
}

.btn:active,
.nav-tab:active,
.mode-btn:active,
.sport-card-action:active,
.link-btn:active {
  transform: scale(0.96);
}

/* Hover is gated on devices that actually have a pointer. Without this, a
   touch browser applies :hover on tap and LEAVES IT APPLIED until you tap
   something else - so the last thing touched stays lit and reads as selected.
   That is the single most common way a mobile UI looks broken to its author on
   a desktop. */
@media (hover: hover) and (pointer: fine) {
  .btn:hover:not(:disabled) {
    transform: translateY(-1px);
    filter: brightness(1.08);
  }

  .nav-tab:hover:not(.active) {
    background: rgba(var(--accent-rgb), 0.12);
  }

  .tactic-card:hover:not(.selected) {
    border-color: rgba(var(--accent-rgb), 0.55);
    transform: translateY(-2px);
  }
}

/* ---- Selection ------------------------------------------------------------
   A card that has been chosen should say so in more than colour: it lifts and
   holds, so the choice is visible in a screenshot and to someone who cannot
   separate the selected border from the unselected one. */
.tactic-card {
  transition: transform var(--motion-base) var(--ease-out),
              border-color var(--motion-base) var(--ease-out),
              box-shadow var(--motion-base) var(--ease-out);
}

.tactic-card.selected {
  animation: card-select var(--motion-base) var(--ease-spring);
}

@keyframes card-select {
  from { transform: scale(0.97); }
  60%  { transform: scale(1.02); }
  to   { transform: scale(1); }
}

/* ---- Screen transitions ---------------------------------------------------
   Short and one-directional. A screen change here is navigation, not a
   flourish - anything longer than this and moving around the app starts to
   feel like waiting for it. */
.screen:not(.hidden) {
  animation: screen-in var(--motion-slow) var(--ease-out);
}

@keyframes screen-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* ---- The safety net -------------------------------------------------------
   Everything above, everything already in this file, and everything added
   after it. Durations go to near-zero rather than `none` so animationend and
   transitionend listeners still fire - a handler that waits for one before
   revealing the next thing would otherwise hang forever, which turns an
   accessibility preference into a broken app.

   The search spinner is the one exception, and deliberately: it is not
   decoration, it is the only thing on screen saying a request is still in
   flight. Freezing it would report "finished" for something still running. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  .search-spinner {
    animation-duration: 0.8s !important;
    animation-iteration-count: infinite !important;
  }

  /* Nothing should move on press either - the colour change still confirms it. */
  .btn:active,
  .nav-tab:active,
  .mode-btn:active,
  .sport-card-action:active,
  .link-btn:active {
    transform: none;
  }

  /* REDUCED MOTION IS NOT REDUCED INFORMATION.
   *
   * The blanket rule above is right about movement and wrong about meaning
   * where the animation IS the message. A newly filled roster slot flips and
   * glows, and that glow is the only thing saying "this one is new" - switch it
   * off and a drafted roster is ten identical rows with no indication of which
   * one just changed. The flip is the vestibular part; a background tint is
   * not, and there is no reason to lose both.
   *
   * So the highlight becomes STATIC instead of absent: the slot stays tinted
   * until the next round re-renders the panel, which is a longer, calmer read
   * of the same fact the animation was conveying.
   *
   * The same argument does not apply to the pulses and sweeps this block
   * exists to stop - those decorate, they do not inform. */
  .slot-reveal {
    animation: none !important;
    background: rgba(var(--accent-rgb), 0.35);
    box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.55);
  }
}

/* ---- Box score: the team line ---------------------------------------------
   A table of ten individual lines does not answer "how did we shoot", which is
   the first thing anyone asks about a basketball game. This is that answer,
   in a tfoot so it reads as the table's summary rather than an eleventh
   player. */
table.box-table tfoot .box-totals td {
  border-top: 2px solid rgba(var(--accent-rgb), 0.5);
  border-bottom: none;
  font-weight: 700;
  background: rgba(var(--accent-rgb), 0.07);
}

table.box-table tfoot .box-totals td:nth-child(1),
table.box-table tfoot .box-totals td:nth-child(2) {
  /* The frozen pair paints its own background, so the totals tint has to be
     restated or the sticky cells stay plain while the rest of the row lifts. */
  background: color-mix(in srgb, var(--panel) 92%, rgba(var(--accent-rgb), 1) 8%);
}

/* The percentage under its split. Makes and attempts stay the headline - 2/3
   and 40/60 are both 67% and are not the same claim - so the percentage rides
   underneath at a smaller size rather than replacing them. */
.box-pct {
  display: block;
  font-size: var(--text-micro);
  font-weight: 600;
  color: var(--accent-bright);
  letter-spacing: 0.02em;
}

/* ---- Buzzer overlay: how each team shot, by area ---------------------------
   The settled chart says where the shots came from. These say whether they went
   in, which is the question anybody actually has. Sat over each team's own half
   at the distance the band describes, so the number is on top of the marks it
   summarises instead of in a legend elsewhere. */

/* Makes/attempts leads. 3/7 and 30/70 are both 43% and are not the same game,
   so the volume is the headline and the rate is the follow-up. */

/* Each side's own colour, matching its markers. */

@media (prefers-reduced-motion: reduce) {
}

/* ---- Kit picker -----------------------------------------------------------
   One swatch per kit, each showing BOTH of its colours - the pair is what is
   being chosen, and a single dot would hide the alternate you wear away. */
.profile-kits {
  margin-top: 0.9rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--line);
}

.profile-kits-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.45rem;
}

.profile-kits-title {
  font-size: var(--text-xs);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
}

.profile-kits-note {
  font-size: var(--text-xs);
  font-weight: 700;
  color: var(--my-kit-ink, var(--accent-bright));
}

.kit-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.kit-swatch {
  position: relative;
  width: 2.1rem;
  height: 2.1rem;
  padding: 0;
  border-radius: 50%;
  overflow: hidden;
  cursor: pointer;
  background: none;
  border: 2px solid transparent;
  transition: transform var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out);
}

/* Primary fills, secondary takes the lower corner - the away kit visible on the
   same chip rather than hidden behind a hover. */
.kit-swatch-primary,
.kit-swatch-secondary {
  position: absolute;
  inset: 0;
}

.kit-swatch-primary { background: var(--kit-primary); }

.kit-swatch-secondary {
  background: var(--kit-secondary);
  clip-path: polygon(100% 45%, 100% 100%, 45% 100%);
}

/* Equipped is a ring AND a size change, not colour alone - on a row of
   fourteen colours, colour is the one channel already fully spent. */
.kit-swatch-on {
  border-color: #fff;
  transform: scale(1.12);
}

.kit-swatch:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
}

@media (hover: hover) and (pointer: fine) {
  .kit-swatch:hover { transform: scale(1.08); }
  .kit-swatch-on:hover { transform: scale(1.12); }
}

.profile-kits-hint { margin: 0.5rem 0 0; }

/* ---- The touch floor ----

   One place that says "a finger is doing the pointing, so nothing you can
   press is under 44px". Same condition as the bottom bar above, for the same
   reason: a sideways phone is still a phone, and width alone cannot tell one
   from a small window.

   The mobile baseline is what this is answering. After the nav moved to the
   bottom, the controls left under the floor were a short list and a long
   tail of one instance each - .kit-swatch 82 times at 34x34, #btn-brand 48
   times at 32px tall, then the card actions and the subtabs at 30-31px.
   Giving each of those rules its own min-height would have worked and would
   have put the floor in five places, so the sixth control to arrive would
   miss it. It goes here instead: a new control gets the floor by being
   listed, and the list is the first thing anyone looks at.

   Deliberately the real box, not a pseudo-element expanding the hit area
   under a small visual. An invisible target that works is still a target you
   cannot see, and on a colour swatch - where every neighbour is also live -
   the thing being aimed at should be the thing that is hit. */
@media (max-width: 700px), ((max-height: 520px) and (pointer: coarse)) {
  /* .btn and .mode-btn had a 2.75rem floor already - inside the
     max-width:700px block, so a sideways phone never got it. Start Draft was
     762px wide and 38px tall in landscape. Listing them here rather than
     widening that block's condition, because the rest of it is genuinely
     about narrowness. */
  .btn,
  .mode-btn,
  .sport-tile,
  /* Slot chips, when they are chips you press. Six 50x33 buttons in a row
     under a running pick clock is the worst possible place for a 33px target.
     Scoped to the BUTTON form: renderPositionSelector emits a <span> for a
     slot that is only being reported and a <button> for one that can be
     chosen, so this floor now lands on the controls and leaves the phone's
     one-line roster strip - which is read, never pressed - free to be one
     line. Before that split the floor made every chip 44px tall and the strip
     was 47px of a screen that is 293px short already. */
  button.position-btn,
  /* The draft modal's slot buttons, which are what the note above was always
     describing and never reached: it names "the slot picker in the draft
     modal" and lists `.position-btn`, and the modal is built from
     `.modal-slot` (see openSlotPicker in js/main.js). The floor has been
     landing on the strip behind the modal this whole time and never on the
     six buttons in front of it - 0.7rem of padding on a 0.85rem font is about
     40px, under the floor, chosen under a running pick clock. */
  .modal-slot,
  .sport-card-action,
  .subtab,
  .brand,
  .nav-utility .nav-tab,
  .btn-small,
  .era-chip,
  /* Fields, not just buttons. A 21px-tall email field is as hard to put a
     cursor in as a 21px button is to press, and the profile screen had two
     of them. */
  input[type="text"],
  input[type="email"],
  input[type="password"],
  input[type="search"],
  select,
  textarea {
    min-height: 2.75rem;
  }

  /* A short label makes a narrow button. "C" came out 42px wide against a
     44px-tall box, and a floor on one axis is not a floor. */
  button.position-btn {
    min-width: 2.75rem;
  }

  /* Square, so the floor has to be set on both axes. 2.1rem circles in a
     dense grid were the single most-missed control in the whole audit. */
  .kit-swatch {
    width: 2.75rem;
    height: 2.75rem;
  }

  /* The header stopped needing to stack into two rows once the destinations
     left it, so it is a single row again - but its padding was still the
     desktop 1.25rem while the content below it sits on --page-gutter. The
     wordmark was inset further than everything under it. */
  .top-nav {
    padding: 0.6rem var(--page-gutter);
    /* Same reason as the bar: in landscape the notch eats into the side the
       wordmark sits on. */
    padding-left: calc(var(--page-gutter) + env(safe-area-inset-left, 0px));
    padding-right: calc(var(--page-gutter) + env(safe-area-inset-right, 0px));
  }

  /* The wordmark is a real button spanning most of the header - wide enough
     to hit while reaching for something else, short enough to miss on
     purpose. Given the floor and a hit area that stops at its own text
     rather than running the width of the bar. */
  .brand {
    align-self: flex-start;
    width: fit-content;
  }
}

/* THE PER-SLOT GRID. Every pick on one card, which a run-on line of
   "DL 59 · LB 94 · CB 96 · S 90" could never be at this width - the card body
   is about 190px on a 360px phone. A grid reflows by COLUMN, so a chip is
   never split and adding a slot costs a row rather than a wrap mid-number. */
.grade-note.grade-grid {
  display: block;
  margin-top: 0.35rem;
  /* The grid is a block, and the stat rows after it are single lines with a
     dotted underline. Without this the first of those sits flush against the
     last row of chips and reads as part of the grid. */
  margin-bottom: 0.5rem;
}

/* DIMMED WITH COLOUR, NOT OPACITY, and that distinction is the bug this row
   started with. `opacity` applies to a whole subtree and a child cannot exceed
   its parent's, so dimming this heading to 0.65 and then trying to restore the
   score inside it is impossible - the value would have been stuck at 65% too.
   Setting a muted colour on the caption leaves the number free to use its own. */
.grade-grid-label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 0.3rem;
}

/* The side's overall score, in the heading rather than on a row of its own. */
.grade-grid-value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 0.85rem;
  letter-spacing: 0;
  color: var(--text);
}

.grade-grid-value.grade-good { color: #06d6a0; }
.grade-grid-value.grade-bad { color: var(--buzzer); }

/* auto-fill rather than a fixed count: seven offensive slots and four
   defensive ones share this, and on a wide card both should use the width
   they have instead of one being padded to the other's shape. */
.grade-grid-chips {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(3.1rem, 1fr));
  gap: 0.25rem;
}

.grade-chip {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.05rem;
  padding: 0.2rem 0.1rem;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.04);
  line-height: 1.1;
}

.grade-chip-key {
  font-size: 0.62rem;
  letter-spacing: 0.04em;
  opacity: 0.7;
}

.grade-chip-value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 0.82rem;
  color: var(--text);
}

/* Only the ends take a colour, for the same reason a stat row colours its
   value and not its label: tinting all twelve turns the block into a heat map,
   which reads as decoration rather than as a verdict. */
.grade-chip.grade-good .grade-chip-value { color: #06d6a0; }
.grade-chip.grade-bad .grade-chip-value { color: var(--buzzer); }
