/* --- Bottom tab nav (replaces MenuBarButton row in main.qml) --- */
/* Redesigned 2026-08-14 into a floating glass capsule, then again 2026-08-15 from plain frosted
   glass into an Apple-"Liquid Glass"-style material at the user's request: clearer/more
   translucent through the middle, with the actual optical warping concentrated at the edges
   instead of a uniform blur across the whole pill. That's two distinct layers now, not one:
   .bottom-nav itself is a lighter, more see-through base coat (lower opacity + less blur than the
   old single-layer version), and .bottom-nav__edge (a separate absolutely-positioned element
   mounted alongside it — see js/components/bottomNav.js — not a ::before, so it can sit visually
   behind the row's own buttons without them needing z-index of their own) layers a heavier,
   SVG-warped blur on top, masked with a radial gradient so it's invisible through the pill's own
   middle and only actually visible toward its rounded edges. base.css's #screen-root
   padding-bottom accounts for --nav-float-gap below so scrolled content never hides under the
   floating bar. */
.bottom-nav {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: var(--nav-float-gap);
  width: calc(100% - 24px);
  max-width: calc(var(--app-max-width) - 24px);
  height: var(--nav-height);
  display: flex;
  /* Lighter than the old single-layer 0.72/16px — the "more translucent in the middle" half of
     the Liquid Glass look; .bottom-nav__edge below adds back the heavier frosting, but only
     toward the edges. Turned down further still to 0.16 with the bar's own blur dropped
     entirely, then REVERTED here on 2026-08-27 at the user's request ("a little more opaque,
     not completely transparent... more frosted glass"). Back to the intermediate 0.42/9px:
     the bar carries a light blur of its own again, so the middle reads as glass rather than
     as a hole cut in the bar, while the edge layer still supplies the heavier warped frosting. */
  background: rgba(31, 31, 31, 0.42);
  backdrop-filter: blur(9px);
  -webkit-backdrop-filter: blur(9px);
  /* A true pill/stadium shape (half the bar's own height, so the end caps are real
     semicircles) rather than the softer 20px rounding this used to have — at the user's
     request, so the collapsed state's clip-path below hands off to .nav-bubble's full circle
     looking like the same shape throughout, not two different roundings mid-transition. */
  border-radius: calc(var(--nav-height) / 2);
  /* The two inset shadows are the material's rim highlight/shadow — a bright line where light
     would catch the top edge of a real curved-glass bevel, a faint dark line along the bottom
     where it wouldn't, added alongside .bottom-nav__edge for the same Liquid Glass redesign. */
  box-shadow:
    0 8px 24px rgba(0, 0, 0, 0.45),
    0 0 0 1px rgba(255, 255, 255, 0.05),
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    inset 0 -1px 0 rgba(0, 0, 0, 0.22);
  z-index: 20;
  /* clip-path (not width) is what makes the collapse below read as "liquifying," not just
     shrinking: animating width directly would squeeze every button's flex layout as it went,
     which looks like a squashed row, not a bar draining away. Clipping instead leaves the row's
     real layout untouched underneath — visually, material just disappears off the right edge
     while the already-round left cap stays exactly where it was. */
  transition: clip-path var(--nav-collapse-duration) var(--ease-spring),
    opacity var(--nav-collapse-duration) var(--ease-spring);
  clip-path: inset(0 0 0 0 round calc(var(--nav-height) / 2));
}
.bottom-nav--hidden { display: none; }
.bottom-nav--hidden + .bottom-nav__edge { display: none; }

/* The edge-only warped-glass layer described above. Sits behind .bottom-nav in the DOM (see
   bottomNav.js) and is pointer-events: none — purely decorative, must never intercept a tap meant
   for a nav button. Positioned/sized to exactly track .bottom-nav's own box on every frame
   without any JS: same fixed/left/transform/bottom/width/height/border-radius values, rather than
   measuring and copying .bottom-nav's rect in JS, which would need a ResizeObserver just to stay
   correct across viewport width changes.
   The mask is a wide, short ellipse (proportioned roughly like the pill itself) that's fully
   transparent through the middle ~35% and fully opaque by ~95% out (this was widened to ~42% while
   .bottom-nav itself sat at 0.16 with no blur, to keep the frosting out of a middle that was then
   supposed to read as almost fully clear; narrowed back with that revert, so the two layers meet
   where they used to) — not a geometrically exact ring following the stadium outline (that would need multi-layer mask-composite, which older
   -webkit-mask doesn't handle consistently), but close enough at this element's actual aspect
   ratio to read correctly as "clear in the middle, textured at the edges."
   Each backdrop-filter is declared twice on purpose: the plain blur() first, then the url()+blur()
   version after it. If a browser's CSS parser doesn't accept url() as a valid backdrop-filter
   function, that whole second declaration is invalid and gets discarded entirely — CSS doesn't
   partially apply an invalid value — leaving the first (plain-blur) declaration in effect
   instead of losing backdrop-filter altogether. That's the fallback path for any browser where
   the SVG feDisplacementMap trick (index.html's #liquid-glass-distortion) doesn't render; this
   project has no way to verify real iOS Safari from this environment (see CLAUDE.md's browser-
   verification notes), so this needed a graceful degrade, not just a hope that it works there. */
.bottom-nav__edge {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: var(--nav-float-gap);
  width: calc(100% - 24px);
  max-width: calc(var(--app-max-width) - 24px);
  height: var(--nav-height);
  border-radius: calc(var(--nav-height) / 2);
  pointer-events: none;
  z-index: 19; /* one below .bottom-nav's 20, so its own buttons always paint on top */
  background: rgba(255, 255, 255, 0.04);
  -webkit-mask-image: radial-gradient(ellipse 70% 60% at center, transparent 35%, black 95%);
  mask-image: radial-gradient(ellipse 70% 60% at center, transparent 35%, black 95%);
  backdrop-filter: blur(20px) saturate(140%);
  backdrop-filter: url(#liquid-glass-distortion) blur(20px) saturate(140%);
  -webkit-backdrop-filter: blur(20px) saturate(140%);
  -webkit-backdrop-filter: url(#liquid-glass-distortion) blur(20px) saturate(140%);
  transition: clip-path var(--nav-collapse-duration) var(--ease-spring),
    opacity var(--nav-collapse-duration) var(--ease-spring);
  clip-path: inset(0 0 0 0 round calc(var(--nav-height) / 2));
}
.bottom-nav__edge.bottom-nav--collapsed {
  clip-path: inset(0 100% 0 0 round calc(var(--nav-height) / 2));
  opacity: 0;
}
/* Reddit-app-style scroll collapse (bottomNav.js's own setupScrollCollapse/setCollapsed — moved
   here from screens/pick.js 2026-08-15 so it applies on every screen, not just Pick) — this
   "liquifies" toward .nav-bubble on the left rather than just fading/
   scaling down in place: the visible pill narrows by clipping in from its RIGHT edge only (the
   inset's second value growing 0% -> 100%), while the LEFT edge — already a true half-circle
   cap, per .bottom-nav's border-radius above — never moves. What's left in the instant before it
   fully vanishes is a small rounded sliver sitting almost exactly where .nav-bubble fades in, so
   the two read as one continuous shape handing off rather than two unrelated elements
   cross-fading.
   (A version of the row's own Pick icon separately traveling into the bubble, instead of the
   bubble having its own static icon like below, was tried and reverted the same day — see
   .nav-bubble's own comment.) */
.bottom-nav--collapsed {
  clip-path: inset(0 100% 0 0 round calc(var(--nav-height) / 2));
  opacity: 0;
  pointer-events: none;
}

/* The collapsed state's own small circle (bottom-left, opposite screens/pick.js's paired
   "relaunch the card stack" bubble on the right) — same glass-capsule material as .bottom-nav
   so the two read as the same UI system, just a different shape. Hidden by default (scale +
   opacity, not display) so its own transition below can run in both directions.
   A version where this carried no icon of its own, and the row's real Pick icon visibly
   traveled into this exact spot (measured via getBoundingClientRect + a transform transition)
   instead, was tried and reverted the same day: it worked and verified correctly in this
   project's own testing, but the user reported it landing in the wrong spot on their real
   iPhone, which isn't reproducible here (no real iOS Safari available — see CLAUDE.md's
   browser-verification notes), and two reasoned attempts at the likely cause didn't resolve it
   either. Back to two separate P icons (this bubble's own, below, and the row's) simply
   cross-fading — if the travel effect gets revisited, start from a real device to debug against
   instead of guessing blind again. */
.nav-bubble {
  position: fixed;
  left: var(--nav-float-gap);
  bottom: var(--nav-float-gap);
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: none;
  padding: 0;
  background: rgba(31, 31, 31, 0.82);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.05);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 20;
  opacity: 0;
  transform: scale(0.6);
  pointer-events: none;
  /* Same slowed-down, overshoot-bounce timing as .bottom-nav/.bottom-nav__edge above (not
     --speed-slide) — this bubble and the pill it hands off to need to stay in lockstep, or the
     "one continuous shape" handoff described further down this file falls out of sync. transform
     is the one property here that actually shows the bounce visibly (scale can overshoot past
     1.0 before settling) — clip-path/opacity on the pill itself clamp at their bounds, so the
     curve mostly just reads as a springier deceleration there, but the bubble's pop is where the
     "liquidy bounce" actually shows. */
  transition: opacity var(--nav-collapse-duration) var(--ease-spring),
    transform var(--nav-collapse-duration) var(--ease-spring);
}
.nav-bubble--visible {
  opacity: 1;
  transform: scale(1);
  pointer-events: auto;
}
.nav-bubble img { width: 30px; height: 30px; object-fit: contain; }

.nav-btn {
  flex: 1 1 0;
  background: none;
  border: none;
  border-radius: 20px;
  color: var(--accent);
  font-size: 0.7rem;
  font-weight: 600;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  cursor: pointer;
  opacity: 0.5;
  transform: translateY(0);
  transition: opacity var(--speed-slide) ease, transform var(--speed-slide) ease;
}
.nav-btn:active { opacity: 0.8; }

.nav-btn__icon { width: 22px; height: 22px; }
.nav-btn__icon svg { width: 100%; height: 100%; display: block; }
.nav-btn__icon img { width: 100%; height: 100%; display: block; object-fit: contain; }

.nav-btn[aria-current="true"] {
  opacity: 1;
  transform: translateY(-2px);
}
.nav-btn[aria-current="true"] .nav-btn__icon {
  filter: drop-shadow(0 0 5px var(--accent-dim));
}

/* Pick's icon used to be enlarged/raised here (a bigger icon lifted into its own circular badge,
   "since picking is the app's main function") — undone 2026-08-14 at the user's request, once
   the scroll-collapse bubble (js/components/bottomNav.js's .nav-bubble, further up this file)
   gave Pick a distinct, prominent treatment of its own; keeping both read as redundant. Pick's
   row icon is back to plain .nav-btn__icon sizing, same as every other tab — no
   .nav-btn--primary rule needed any more (bottomNav.js no longer assigns that class either). */

/* --- Toast / snackbar (replaces ErrorBanner.qml) --- */
.toast-host {
  position: fixed;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  max-width: var(--app-max-width);
  display: flex;
  justify-content: center;
  pointer-events: none;
  z-index: 40;
}
.toast {
  margin-top: 10px;
  width: calc(100% - 40px);
  background: var(--bg);
  color: var(--accent);
  border-radius: var(--radius);
  padding: 16px 20px;
  text-align: center;
  /* -100% of the toast's own height clears the box itself; margin-top isn't part of that
     percentage, so it has to be subtracted explicitly (plus a small buffer) or a short/empty
     toast's box ends up a few px into the viewport instead of fully hidden above it — this was
     the fixed-percentage version's actual bug, confirmed by measuring the empty toast's real
     bounding box (bottom edge at y=3.6px instead of <=0) rather than just eyeballing it. */
  transform: translateY(calc(-100% - 26px));
  transition: transform var(--speed-slide) ease;
  box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.toast.toast--visible {
  transform: translateY(0);
}
.toast.toast--error { color: var(--fail); }

/* --- Loading / error status overlay (replaces main.qml's loadingScreen/errorScreen) --- */
.status-overlay {
  /* `fixed` as of 2026-08-18, and this one is a bug the iPad fix would otherwise have CREATED.
     This overlay is mounted on #app. Until now #app was accidentally capped at 100vh above the
     700px breakpoint (see base.css's align-items note), so `absolute; inset: 0` happened to produce
     a viewport-sized overlay and centred correctly. Letting #app grow to its real content height —
     the fix for the background seam — would have made this overlay as tall as the whole page and
     dropped its spinner and retry button a thousand pixels below the fold, exactly the failure the
     week-intro modal already had. Since this is the boot/error surface, that would have turned "the
     backend is down" into "the app is a blank screen with no retry button anywhere". */
  position: fixed;
  inset: 0;
  background: var(--bg-overlay);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  z-index: 30;
  color: #1f1f1f;
  text-align: center;
  padding: 24px;
}
.status-overlay[hidden] { display: none; }
.spinner {
  width: 40px; height: 40px;
  border: 4px solid #1f1f1f33;
  border-top-color: #1f1f1f;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-overlay--error { color: #a01818; }
.status-overlay button {
  margin-top: 8px;
}
/* .spinner's default colors (dark ring on light background) assume --bg-overlay's ghostwhite —
   right for the status overlay above, wrong for anything sitting on the app's own dark --bg.
   This modifier flips it: light track, accent-colored sweep. */
.spinner--light {
  border-color: #f0f0f033;
  border-top-color: var(--accent);
}

/* --- Pull to refresh (components/pullToRefresh.js, added 2026-08-17) ---
   A floating circular badge, deliberately NOT part of layout: position: fixed with no in-flow
   box at all, so it can't push content, can't resize anything, and can't fight the sticky
   headers — the first attempt at this feature grew real height above the scrolled content, and
   that entanglement is a large part of why it got reverted (see the JS file's header comment).
   `top` is set inline per gesture (measured just below whatever chrome is pinned up there);
   transform/opacity are set inline per frame from the pull distance.

   **z-index 0 puts it BEHIND the page content, not in front (changed 0.11.26).** .refresh-slide
   (css/screens.css) is z-index 1 with an opaque background, so the badge lives in the gap the pull
   opens up and the content slides back OVER it when the refresh finishes - the thing that makes
   this read like a native app's refresh rather than a spinner floating on top. It still emerges
   from beneath .week-header (5) and .week-summary-bar (3) exactly as before. */
.ptr {
  position: fixed;
  top: 0;
  left: 50%;
  margin-left: -17px;
  z-index: 0;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Same frosted-glass treatment as .week-summary-bar and the bottom nav, so it reads as part of
     the same chrome rather than a foreign spinner. */
  background: rgba(31, 31, 31, 0.92);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid var(--divider);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
  color: var(--accent-dim);
  opacity: 0;
  transform: translateY(-10px) scale(0.55);
  pointer-events: none; /* purely an indicator — it must never eat the gesture that drives it */
}
/* Only while snapping open/closed. During the drag itself the transform tracks the finger with no
   transition at all, or it would lag behind the pull. */
.ptr--settling { transition: transform 260ms ease, opacity 260ms ease; }
/* "Let go now" — the one state change that has to be readable at a glance mid-gesture. */
.ptr--armed {
  color: var(--accent);
  border-color: rgba(0, 191, 255, 0.45);
  box-shadow: 0 0 10px rgba(0, 191, 255, 0.35), 0 4px 14px rgba(0, 0, 0, 0.45);
}
.ptr__icon { display: flex; }
.ptr__icon svg { width: 19px; height: 19px; display: block; }
/* Reuses @keyframes spin from .spinner above rather than defining a second identical rotation. */
.ptr--loading .ptr__icon { animation: spin 0.8s linear infinite; }

/* --- "Update available" prompt (components/updateModal.js, added 2026-08-15) ---
   Same full-bleed dark-card shape as screens.css's .week-intro-modal, but mounted straight on
   #app (position: fixed, not absolute) since it can fire from app.js regardless of which screen
   or auth state is showing underneath — .week-intro-modal only ever needs to cover the Pick
   screen it's scoped to, so absolute-within-parent is enough there. z-index sits above
   .toast-host (40) so a stray toast can never visually cover it, and well below #splash's 9999
   (which is already gone by the time this could ever fire — checkForUpdate() only runs once
   app.js's main() has mounted). */
.update-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 50;
}
.update-modal__card {
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 28px 24px;
  width: 100%;
  max-width: 340px;
  text-align: center;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
}
.update-modal__title { font-size: 1.4rem; font-weight: 700; color: var(--accent); margin-bottom: 12px; }
.update-modal__line { color: var(--text-on-dark); margin: 0 0 8px; font-size: 0.95rem; }
.update-modal__notes {
  text-align: left;
  color: var(--text-on-dark);
  opacity: 0.85;
  font-size: 0.85rem;
  margin: 12px 0 0;
  padding-left: 20px;
}
.update-modal__notes li { margin-bottom: 6px; }

/* --- "Week recap" popup (components/weekRecapModal.js, added alongside the automated weekly
   rollover) — same full-bleed dark-card shape as .update-modal just above (mounted straight on
   #app via app.js's mountWeekRecapModal, not scoped to a single screen), one step below it in
   z-index: if a deploy update prompt and a week recap ever happened to both be eligible on the
   same boot, the "you're on stale code" prompt is the more important one to see first — the
   week recap re-fires on the very next boot regardless, once weekRecapSeen still isn't set. */
.week-recap-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 45;
  overflow-y: auto;
}
.week-recap-modal__card {
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 28px 24px;
  width: 100%;
  max-width: 340px;
  text-align: center;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  margin: auto;
}
.week-recap-modal__title { font-size: 1.4rem; font-weight: 700; color: var(--accent); margin-bottom: 16px; }
.week-recap-modal__section { margin-bottom: 14px; text-align: left; }
.week-recap-modal__section-title {
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-on-dark);
  opacity: 0.7;
  margin-bottom: 4px;
}
.week-recap-modal__list {
  list-style: decimal;
  padding-left: 20px;
  margin: 0;
  color: var(--text-on-dark);
  font-size: 0.9rem;
}
.week-recap-modal__list li { margin-bottom: 2px; }
.week-recap-modal__list-empty { list-style: none; margin-left: -20px; opacity: 0.6; }
.week-recap-modal__closing { color: var(--text-on-dark); font-weight: 600; margin: 16px 0 4px; }

/* The operator's written note. Sits directly under the title as the recap's lede, so it reads as
   a person talking rather than another data section — hence no uppercase heading, a real reading
   size, and the accent rule to separate it from the numbers below. */
.week-recap-modal__note {
  text-align: left;
  color: var(--text-on-dark);
  font-size: 0.92rem;
  line-height: 1.5;
  border-left: 3px solid var(--accent);
  padding: 2px 0 2px 12px;
  margin: 0 0 18px;
}

/* "Your week" — three ranks side by side. A grid rather than a list because the whole point is
   comparing the player's three placings at a glance. */
.week-recap-modal__you-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}
.week-recap-modal__you-cell {
  background: var(--row-odd, rgba(255, 255, 255, 0.05));
  border-radius: calc(var(--radius) / 2);
  padding: 8px 6px;
  text-align: center;
  /* Anchors the celebration below, which is absolutely positioned inside the cell. Confetti is
     deliberately allowed to fly out past these bounds (no overflow: hidden) — the pieces clearing
     the box is most of what makes it read as a pop. */
  position: relative;
}
.week-recap-modal__you-label {
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-on-dark);
  opacity: 0.65;
}
.week-recap-modal__you-rank {
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--accent);
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
.week-recap-modal__you-sub {
  font-size: 0.66rem;
  color: var(--text-on-dark);
  opacity: 0.75;
  font-variant-numeric: tabular-nums;
}
/* "Overall: 3rd of 8 · 41 pts · up 2" — the season standing, under the three weekly cells. One full
   width line rather than a fourth grid column: it is a different timeframe from the three above it,
   and squeezing it in beside them would read as a fourth metric. */
.week-recap-modal__you-overall {
  margin-top: 8px;
  padding: 7px 10px;
  border-radius: 8px;
  background: var(--row-odd, rgba(255, 255, 255, 0.05));
  font-size: 0.78rem;
  font-weight: 600;
  text-align: center;
  color: var(--text-on-dark);
  font-variant-numeric: tabular-nums;
}
/* "You'd have finished 2nd if Baylor @ Auburn had gone the other way." — js/whatIf.js's one
   counterfactual, sitting under the season line. DELIBERATELY THE QUIETEST THING IN THIS SECTION:
   it is the only line here describing something that did not happen, and giving it the same weight
   as the three real results is how somebody ends up remembering the counterfactual as the outcome.
   No background, so it reads as a footnote to the Your-week block rather than as a fourth panel in
   it. `text-wrap: balance` because this is one sentence of variable length that will wrap to two
   lines on a phone, and a one-word second line under a centred sentence looks like a mistake;
   browsers without it simply wrap as before. */
.week-recap-modal__whatif {
  margin-top: 7px;
  padding: 0 4px;
  font-size: 0.72rem;
  line-height: 1.35;
  text-align: center;
  text-wrap: balance;
  font-style: italic;
  color: var(--text-on-dark);
  opacity: 0.68;
}

/* --- Podium celebration in the three "Your week" cells (weekRecapModal.js's celebratePlace) ------
   One-shot, escalating with the place: bronze pop for 3rd, silver for 2nd, gold-plus-ring-plus-shine
   for 1st. Each cell celebrates on its own, so three third-place finishes give three pops.

   EVERY ANIMATION HERE IS `forwards` AND RUNS ONCE. The modal is built fresh each time it opens and
   is never re-rendered, so nothing needs a replay guard the way screens/pick.js's confetti needs its
   celebratedCards Set — but it does mean the final keyframe is what sits on screen for as long as
   the player reads the modal, which is why each one ends at opacity 0 rather than mid-flight.

   The medal tints are hardcoded hex rather than tokens: tokens.css has no gold/silver/bronze, and
   inventing three would imply a palette-wide meaning they don't have anywhere else in the app. */
.week-recap-modal__you-cell--place1 {
  background: linear-gradient(180deg, rgba(255, 215, 0, 0.16), rgba(255, 215, 0, 0.04));
  box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.38), 0 0 18px rgba(255, 215, 0, 0.16);
  animation: recap-cell-pop-1 620ms var(--ease-spring, ease-out) var(--celebrate-delay, 0ms) both;
}
.week-recap-modal__you-cell--place2 {
  background: linear-gradient(180deg, rgba(219, 226, 236, 0.14), rgba(219, 226, 236, 0.03));
  box-shadow: 0 0 0 1px rgba(219, 226, 236, 0.26);
  animation: recap-cell-pop-2 560ms var(--ease-spring, ease-out) var(--celebrate-delay, 0ms) both;
}
.week-recap-modal__you-cell--place3 {
  background: linear-gradient(180deg, rgba(224, 149, 79, 0.14), rgba(224, 149, 79, 0.03));
  box-shadow: 0 0 0 1px rgba(224, 149, 79, 0.24);
  animation: recap-cell-pop-3 520ms var(--ease-spring, ease-out) var(--celebrate-delay, 0ms) both;
}
.week-recap-modal__you-cell--place1 .week-recap-modal__you-rank {
  color: #ffd700;
  text-shadow: 0 0 14px rgba(255, 215, 0, 0.5);
}
.week-recap-modal__you-cell--place2 .week-recap-modal__you-rank { color: #dfe4ec; }
.week-recap-modal__you-cell--place3 .week-recap-modal__you-rank { color: #e0954f; }

/* The cell's own settle-bounce. Three keyframes rather than one scaled by a variable so the winner
   visibly jumps hardest — and transient, never a resting size change: these three sit in a
   3-column grid, and a permanently larger cell would break the row's alignment. */
@keyframes recap-cell-pop-1 {
  0% { transform: scale(1); }
  38% { transform: scale(1.075); }
  100% { transform: scale(1); }
}
@keyframes recap-cell-pop-2 {
  0% { transform: scale(1); }
  38% { transform: scale(1.045); }
  100% { transform: scale(1); }
}
@keyframes recap-cell-pop-3 {
  0% { transform: scale(1); }
  38% { transform: scale(1.025); }
  100% { transform: scale(1); }
}

.week-recap-modal__pop {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: visible;
}
.week-recap-modal__pop-piece {
  position: absolute;
  top: 42%;
  left: 50%;
  width: 6px;
  height: 6px;
  border-radius: 1px;
  opacity: 0;
  animation: recap-pop-fly 1100ms cubic-bezier(0.15, 0.75, 0.35, 1) forwards;
}
/* Colours cycle in threes within each place, so a burst is variegated without needing a rule per
   particle — which is exactly what the six-particle .confetti-particle in screens.css could get
   away with and this one can't, since the count varies. */
.week-recap-modal__you-cell--place1 .week-recap-modal__pop-piece { background: #ffd700; }
.week-recap-modal__you-cell--place1 .week-recap-modal__pop-piece:nth-child(3n + 2) { background: #fff3b0; }
.week-recap-modal__you-cell--place1 .week-recap-modal__pop-piece:nth-child(3n + 3) { background: var(--accent); }
.week-recap-modal__you-cell--place2 .week-recap-modal__pop-piece { background: #d5dae2; }
.week-recap-modal__you-cell--place2 .week-recap-modal__pop-piece:nth-child(3n + 2) { background: #f4f6fa; }
.week-recap-modal__you-cell--place2 .week-recap-modal__pop-piece:nth-child(3n + 3) { background: var(--accent); }
.week-recap-modal__you-cell--place3 .week-recap-modal__pop-piece { background: #cd7f32; }
.week-recap-modal__you-cell--place3 .week-recap-modal__pop-piece:nth-child(3n + 2) { background: #e8a765; }
.week-recap-modal__you-cell--place3 .week-recap-modal__pop-piece:nth-child(3n + 3) { background: var(--success); }

/* Out along the fan angle, then a little further and 16px down as it fades — the drop is what keeps
   it reading as confetti rather than a starburst. */
@keyframes recap-pop-fly {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(0deg); }
  12% { opacity: 1; }
  62% {
    opacity: 1;
    transform: translate(calc(-50% + var(--dx) * 0.9), calc(-50% + var(--dy))) scale(1)
      rotate(calc(var(--rot) * 0.6));
  }
  100% {
    opacity: 0;
    transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy) + 16px)) scale(0.85)
      rotate(var(--rot));
  }
}

/* First place only: the shockwave ring and the spotlight sweep. */
.week-recap-modal__ring {
  position: absolute;
  top: 42%;
  left: 50%;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  border-radius: 50%;
  border: 2px solid #ffd700;
  opacity: 0;
  pointer-events: none;
  animation: recap-ring 900ms ease-out forwards;
}
@keyframes recap-ring {
  0% { opacity: 0; transform: scale(0.3); }
  18% { opacity: 0.9; }
  100% { opacity: 0; transform: scale(2.7); }
}
.week-recap-modal__shine {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  overflow: hidden; /* clips the bar to the cell — the confetti above deliberately isn't clipped */
  pointer-events: none;
}
.week-recap-modal__shine-bar {
  position: absolute;
  top: -60%;
  bottom: -60%;
  left: -60%;
  width: 45%;
  background: linear-gradient(100deg, transparent, rgba(255, 240, 170, 0.4), transparent);
  transform: skewX(-18deg);
  opacity: 0;
  animation: recap-shine 1150ms ease-in-out forwards;
}
@keyframes recap-shine {
  0% { opacity: 0; left: -60%; }
  20% { opacity: 1; }
  100% { opacity: 0; left: 130%; }
}

/* Reduced motion keeps the result and drops the movement: the medal tint, the ring of colour around
   the cell and the gold/silver/bronze rank number all still say "you placed", with nothing flying,
   sweeping or bouncing. The pieces' resting state is opacity 0, so removing the animation hides them
   rather than freezing a spray of dots mid-cell. */
@media (prefers-reduced-motion: reduce) {
  .week-recap-modal__pop,
  .week-recap-modal__ring,
  .week-recap-modal__shine {
    display: none;
  }
  .week-recap-modal__you-cell--place1,
  .week-recap-modal__you-cell--place2,
  .week-recap-modal__you-cell--place3 {
    animation: none;
  }
}

/* Appended to a weekly section once the picks fetch resolves, so it must not shift what is above
   it — a plain line under the list, visibly secondary to the podium it follows. */
.week-recap-modal__last {
  font-size: 0.78rem;
  color: var(--text-on-dark);
  opacity: 0.6;
  margin-top: 4px;
}

/* Next week's biggest games. Not a ranking, so no numbers. */
.week-recap-modal__next { list-style: none; padding-left: 0; }
.update-modal__actions { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; }

/* --- First-load splash (index.html) --- */
#splash {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  background: var(--bg);
  opacity: 1;
  transition: opacity var(--speed-slide) ease;
}
#splash[hidden] { display: none; }
#splash.splash--hidden {
  opacity: 0;
  pointer-events: none;
}
/* References index.html's #splash-logo-glow SVG filter — see that file's own comment for the
   full history (plain filter: drop-shadow() intermittently clipped to a hard rectangle on iOS
   Safari; a box-shadow fix traded that for a *permanent* rectangle instead, which the user
   didn't like either). The SVG filter blurs the image's actual alpha channel with an explicit,
   generous filter region, so the glow is both P-shaped (not a box) and not relying on the
   browser to infer how much room its blur needs (that inference is what went wrong before). */
.splash__logo {
  width: 88px;
  height: auto;
  filter: url(#splash-logo-glow);
}
.splash__name {
  font-size: 1.15rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  color: var(--text-on-dark);
}

/* Appended to the splash by index.html's classic-script module-failure trap, and by nothing else.
   The splash is otherwise a branded loading screen with no failure state at all, which is exactly
   why a dead app reads as a working one — see that script's comment. Sized generously and
   width-capped because it is the only text on the screen at that point, and the person reading it
   is being asked to do something (reinstall) rather than to wait. */
.splash__failure {
  margin-top: 18px;
  max-width: 280px;
  padding: 0 16px;
  font-size: 0.9rem;
  line-height: 1.45;
  text-align: center;
  color: var(--text-on-dark);
  opacity: 0.85;
}

/* --- Generic buttons (Set Name / retry / install, etc.) --- */
.btn {
  background: var(--accent);
  color: #05262e;
  border: none;
  border-radius: 10px;
  padding: 12px 20px;
  font-weight: 600;
  font-size: 1rem;
  cursor: pointer;
}
.btn:disabled { opacity: 0.4; cursor: default; }
.btn--ghost {
  background: transparent;
  color: var(--accent);
  border: 2px solid var(--accent);
}
/* Added 2026-08-15 for Profile's "Delete Account" (Apple Guideline 5.1.1(v) compliance) —
   reuses --fail, the same token .field--invalid already borrows for "something's wrong here"
   rather than introducing a new color just for this one destructive action. */
.btn--danger {
  background: transparent;
  color: var(--fail);
  border: 2px solid var(--fail);
}

/* --- Form fields (Login/Profile), plain native inputs (no custom keyboard) --- */
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; width: 100%; max-width: 300px; }
.field label { color: var(--accent); font-size: 0.85rem; }
.field input {
  height: 44px;
  border-radius: 8px;
  border: 2px solid transparent;
  padding: 0 12px;
  font-size: 1rem;
}
.field.field--invalid input {
  border-color: var(--fail);
  box-shadow: 0 0 0 2px var(--fail) inset;
}
.field-error {
  color: var(--fail);
  font-size: 0.85rem;
  min-height: 1.2em;
}

/* --- FancyButton equivalent: layered press/selected/locked button used by
   TeamButton and SpreadButton --- */
.fancy-btn {
  /* The team tile — the single most-pressed control in the app, and the one most likely to be held
     a beat too long while deciding. Suppresses the iOS long-press callout and text selection on
     the team name inside it; see .card-face in screens.css for the full reasoning. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  position: relative;
  background: black;
  border: none;
  border-radius: var(--radius);
  padding: 0;
  cursor: pointer;
  aspect-ratio: 1 / 1;
  width: 100%;
}
.fancy-btn__back {
  position: absolute;
  inset: 5px;
  border-radius: var(--radius);
  background: var(--btn-back-unselected);
  transition: background-color var(--speed-fast);
}
.fancy-btn--selected .fancy-btn__back { background: var(--btn-back-selected); }
.fancy-btn--down .fancy-btn__back { background: var(--btn-back-down); }

.fancy-btn__front {
  position: absolute;
  inset: 5px;
  border-radius: var(--radius);
  background: var(--btn-unselected);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  transform: translateY(-9%);
  transition: transform var(--speed-fast), background-color var(--speed-fast);
}
.fancy-btn--selected .fancy-btn__front {
  background: var(--btn-selected);
  transform: translateY(-6%);
  /* Added for the 2026-08 pick-screen redesign: a static glow, not a keyframe/transition — a
     tap always leads straight into a full re-render of this whole card (see
     fancyButton.js's click handler and CLAUDE.md's store.js notes on "a change re-renders the
     whole subscribed screen"), which destroys and recreates this exact node before any
     transition-on-select could ever paint a frame. A static box-shadow re-applies correctly on
     every fresh render instead of trying to animate an entrance that would never be visible.
     The actual "it just happened" feedback lives in fancyButton.js's tap-time flash, which
     deliberately plays out *before* the node gets torn down. */
  box-shadow: 0 0 0 2px var(--accent), 0 0 14px 1px var(--accent-dim);
}
.fancy-btn--down .fancy-btn__front { background: var(--btn-down); transform: translateY(-3%); }
.fancy-btn--locked { cursor: default; opacity: 0.85; }
.fancy-btn--locked .fancy-btn__front { transform: translateY(-8%); }

.fancy-btn__img { width: 70%; height: 70%; object-fit: contain; pointer-events: none; }
.fancy-btn__badge {
  position: absolute;
  bottom: 4px;
  width: 20%;
  height: 20%;
  object-fit: contain;
  pointer-events: none;
}
.fancy-btn__badge--left { left: 4px; }
.fancy-btn__badge--right { right: 4px; }
.fancy-btn__text {
  color: white;
  font-weight: 700;
  text-align: center;
  pointer-events: none;
}

/* --- Profile: the messages bell + modal --------------------------------------------------------
   An archive of what the app has already told you — the once-only week recap and any announced line
   or worth change. The bell is a control at the top of Profile; the messages themselves open in a
   modal, so Profile stays a settings screen rather than a wall of text.

   NOTE the section is hidden via inline `display: none` from JS, NOT the `hidden` attribute:
   PickWeb has no global `[hidden] { display: none }` rule, and an author-origin `display` always
   beats the UA stylesheet's, which is exactly how the first version of this shipped permanently
   expanded. Same trap screens.css documents for .profile-avatar__actions. */
.profile-announcements {
  width: 100%;
  max-width: 300px;
  margin: 0 0 18px;
}

.announcements-bell {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--divider);
  border-radius: var(--radius);
  /* A literal, not var(--surface-raised, …) — that token does not exist in tokens.css, and a var()
     typo silently takes its fallback rather than failing anywhere visible (see the 0.8.8 PickDev
     textarea, which rendered near-white on white for exactly this reason). */
  background: rgba(255, 255, 255, 0.04);
  color: var(--text-on-dark);
  font: inherit;
  cursor: pointer;
}

.announcements-bell__label {
  font-weight: 600;
}

/* Unread count, pushed to the trailing edge. A number rather than a dot because the list now costs
   a tap to reach — the bell has to say whether opening it is worth the trip. Empty and invisible
   once everything has been seen. */
.announcements-bell__count {
  margin-left: auto;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 22px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.announcements-bell__count.is-unread {
  background: var(--accent);
  color: #04212c;
}

/* `position: fixed`, never absolute — the push soft prompt shipped absolute once and centred on its
   container instead of the viewport, so the backdrop dimmed while the card sat off-screen for
   anyone scrolled down. Profile scrolls. */
.announcements-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 45;
  overflow-y: auto;
}

.announcements-modal__card {
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 22px 20px;
  width: 100%;
  max-width: 360px;
  margin: auto;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.announcements-modal__title {
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--accent);
  margin: 0;
}

/* Caps the list rather than the card, so the Close button stays reachable without scrolling the
   whole modal when a season has accumulated a lot of messages. */
.announcements-modal__list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-height: 55vh;
  overflow-y: auto;
}

.announcements-item {
  padding: 10px 12px;
  border-left: 3px solid var(--accent);
  background: rgba(255, 255, 255, 0.03);
  border-radius: 0 var(--radius) var(--radius) 0;
  text-align: left;
}

.announcements-item__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}

.announcements-item__title {
  font-weight: 600;
  font-size: 0.92rem;
}

.announcements-item__when {
  font-size: 0.78rem;
  opacity: 0.6;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.announcements-item__body {
  margin: 4px 0 0;
  font-size: 0.88rem;
  line-height: 1.5;
  opacity: 0.9;
}

/* --- Operator broadcast modal (components/broadcastModal.js, migrations/017) -------------------
   The one modal in this app whose content a person typed. Shares .week-recap-modal's full-bleed
   dark-overlay-plus-centred-card shape rather than introducing a fourth one, and sits one step
   ABOVE it in z-index (46 vs 45) so the two agree with js/modalQueue.js's ladder even in the
   impossible case where both are somehow in the DOM at once. The queue is what actually sequences
   them; this is belt-and-braces, and it keeps a reader who finds the CSS first from inferring the
   wrong order.

   Left-aligned body text, unlike the recap's centred columns of numbers: this is prose, and centred
   prose is measurably harder to read past a couple of lines. */
.broadcast-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 46;
  overflow-y: auto;
}
.broadcast-modal__card {
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 26px 24px;
  width: 100%;
  max-width: 340px;
  text-align: center;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  margin: auto;
}
/* The standing label above the title. Deliberately quiet — it is there so a terse message does not
   read as a system error, not to compete with the title itself. */
.broadcast-modal__eyebrow {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-on-dark);
  opacity: 0.55;
  margin-bottom: 8px;
}
.broadcast-modal__title {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--accent);
  margin: 0 0 14px;
  line-height: 1.25;
}
.broadcast-modal__body {
  text-align: left;
  color: var(--text-on-dark);
  font-size: 0.95rem;
  line-height: 1.55;
  margin-bottom: 20px;
  /* An operator can paste a long URL or a team name with no break opportunity; without this the
     card would grow a horizontal scrollbar rather than wrap. */
  overflow-wrap: anywhere;
}
.broadcast-modal__body p { margin: 0 0 10px; }
.broadcast-modal__body p:last-child { margin-bottom: 0; }

/* --- PROTOTYPE (2026-08-19): pick reactions — js/reactions.js + js/components/reactionBar.js.
   Lives in components.css rather than screens.css because two unrelated surfaces mount it
   (screens/previous.js's player rows and components/matchupDetailModal.js's bucket lists), which
   is the same split every other shared component here follows. Delete this block, the two JS
   modules, and their four call sites to remove the feature entirely. */
.reaction-bar {
  display: flex;
  align-items: center;
  gap: 5px;
  flex-wrap: wrap;
}
/* Set by renderReactionBar when a row has no reactions yet. Needed explicitly because the rule
   above declares `display: flex`, which outranks the `hidden` attribute's UA `display: none`. */
.reaction-bar[hidden] { display: none; }
.reaction-bar--row { padding: 0 2px; }
/* The drill-in's variant sits at the end of an existing 36px-avatar row, so it pushes right and
   never wraps — a wrap there would grow the row and reflow the whole bucket list. */
.reaction-bar--compact {
  margin-left: auto;
  flex-wrap: nowrap;
}
.reaction-chip {
  /* Pressed directly, and a long press on a button's own label still selects it on iOS — same
     reasoning as .card-face in screens.css. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  min-height: 26px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--divider);
  color: var(--text-on-dark);
  font-size: 0.8rem;
  line-height: 1;
  cursor: pointer;
  transition: transform var(--speed-spring) var(--ease-spring), background var(--speed-fast),
    border-color var(--speed-fast);
}
.reaction-chip:active { transform: scale(0.9); }
.reaction-chip__count {
  font-size: 0.66rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums; /* a 9 -> 10 must not resize the chip under your thumb */
  opacity: 0.8;
}
/* Your own reaction reads as the accent blue rather than green: green already means "your pick was
   right" everywhere in this app (CLAUDE.md's verdict-coloring convention), and these chips sit
   directly beneath two chips using exactly that vocabulary. Blue is this app's existing "yours /
   active / not a verdict" color. */
.reaction-chip--mine {
  background: rgba(0, 191, 255, 0.16);
  border-color: var(--accent);
}
.reaction-chip--mine .reaction-chip__count { color: var(--accent); opacity: 1; }
.reaction-chip--add {
  background: transparent;
  border-style: dashed;
  color: darkgray;
  /* Square, and padded down from the 8px the emoji chips use — the icon is the whole content, so
     the pill-shaped padding a chip needs around an emoji-plus-count just made this one wide and
     empty next to them. */
  padding: 2px 6px;
}
.reaction-chip--add:hover { color: var(--text-on-dark); border-color: var(--accent-dim); }
/* Sized off the chip's own 26px min-height rather than in rem, so it stays in proportion to the
   emoji chips beside it if that height is ever changed. `display: block` because an inline SVG
   sits on the text baseline and leaves a couple of px of descender space under it, which pushed
   the mark visibly high inside the pill. */
.reaction-chip__add-icon {
  display: block;
  width: 15px;
  height: 15px;
}

/* The emoji sheet. `fixed`, not `absolute` — the document is the scroll container in this app, so
   an absolute overlay sizes to the whole page and centres its card at the middle of that, which is
   off-screen on anything tall (see CLAUDE.md's "Verifying UI changes"). Bottom-anchored on a phone
   so it lands under the thumb rather than in the middle of the screen. */
.reaction-picker {
  position: fixed;
  inset: 0;
  /* 55: above the highest overlay in this app's ladder (the update prompt's 46) — the picker can
     be opened from INSIDE components/matchupDetailModal.js (z-index 40), so it has to outrank a
     modal rather than merely the page. */
  z-index: 55;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: 12px;
  animation: reaction-picker-fade 140ms ease-out;
}
@keyframes reaction-picker-fade { from { opacity: 0; } to { opacity: 1; } }
.reaction-picker__sheet {
  width: 100%;
  max-width: var(--app-max-width);
  /* Clears the floating bottom nav so the sheet never sits half-under it. */
  margin-bottom: calc(var(--nav-height) + var(--nav-float-gap));
  background: var(--row-even);
  border: 1px solid var(--divider);
  border-radius: var(--radius);
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
  animation: reaction-picker-rise 180ms var(--ease-spring);
}
@keyframes reaction-picker-rise {
  from { transform: translateY(24px); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}
/* Above the 700px "desktop" breakpoint the sheet centres instead of hugging the bottom — a bar
   pinned to the bottom of a 1180px-tall iPad is a long way from what you just tapped. */
@media (min-width: 700px) {
  .reaction-picker { align-items: center; }
  .reaction-picker__sheet { margin-bottom: 0; }
}
.reaction-picker__title {
  font-size: 0.8rem;
  color: var(--text-on-dark);
  opacity: 0.8;
  text-align: center;
}
.reaction-picker__grid {
  display: grid;
  /* A fixed 4, not `auto-fit minmax()`: auto-fit fitted 5 columns at 390px, which left the last row
     ragged. Twelve emoji divide evenly into three rows of four here, and into two rows of six above
     the breakpoint — the tablet rule was `repeat(8, 1fr)` while the set was eight, which would have
     left a 8+4 split once it grew. Not one row of twelve: inside a 480px sheet that is a 40px
     target, well under the ~44px minimum a fingertip needs. */
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}
@media (min-width: 700px) {
  .reaction-picker__grid { grid-template-columns: repeat(6, 1fr); }
}
.reaction-picker__emoji {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  aspect-ratio: 1;
  border-radius: 12px;
  background: var(--bg);
  border: 1px solid var(--divider);
  font-size: 1.6rem;
  line-height: 1;
  cursor: pointer;
  transition: transform var(--speed-spring) var(--ease-spring), background var(--speed-fast),
    border-color var(--speed-fast);
}
.reaction-picker__emoji:active { transform: scale(0.88); }
.reaction-picker__emoji--mine {
  background: rgba(0, 191, 255, 0.16);
  border-color: var(--accent);
}
/* Who reacted, under the grid. The names have always existed on the chips as a `title`, which is a
   hover tooltip and therefore reachable on a desktop mouse and on nothing else — this is where they
   are actually readable on a phone. Deliberately in the sheet rather than on the row: a count is
   what you read across a whole week of rows, the names are what you want on the one row you
   stopped at, and a names line on the row would cost height on every reacted row in the list. */
.reaction-picker__who {
  display: flex;
  flex-direction: column;
  gap: 5px;
  /* A pick can carry twelve different reactions from every member at once. Bounded so the sheet
     cannot grow past the screen; in practice a real row is two or three lines and never scrolls. */
  max-height: 30vh;
  overflow-y: auto;
}
.reaction-picker__who[hidden] { display: none; }
/* The rule only when there IS something above it to be separated from. Inside an archived season
   the sheet opens read-only with no grid and no any-emoji field at all, and a hairline directly
   beneath the title would read as a heading underline rather than as a divider between two halves.
   BOTH SELECTORS ARE REQUIRED, and this is an adjacent-sibling rule so it is exactly as literal as
   it looks: 0.12.48 inserted .reaction-picker__custom between the grid and this block, which broke
   the single-selector version silently — the hairline simply stopped rendering, on a sheet that
   still looked plausible without it. */
.reaction-picker__grid + .reaction-picker__who,
.reaction-picker__custom + .reaction-picker__who {
  border-top: 1px solid var(--divider);
  padding-top: 12px;
}

/* THE ANY-EMOJI FIELD (0.12.48). Sits between the twelve-button grid and the who-list; see
   components/reactionBar.js's buildCustom for why it is an input rather than another button (no
   web API opens the platform emoji keyboard — a focused text field is the only thing that does). */
.reaction-picker__custom {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.reaction-picker__custom-input {
  /* These values are copied from .week-header select rather than reached for as tokens. CLAUDE.md
     records the same trap twice: --surface does not exist here and --bg-overlay is `ghostwhite`,
     the login BACKDROP, so a control styled with either renders near-white-on-near-white while
     looking perfectly reasonable in the source. If this needs changing, copy from another control
     that is known to render correctly. */
  background: #2a2a2a;
  color: var(--text-on-dark);
  border: 1px solid var(--divider);
  border-radius: 10px;
  padding: 10px 12px;
  width: 100%;
  /* 16px exactly. Mobile Safari ZOOMS the whole page when a focused input's text is smaller than
     16px, and it does not zoom back out on blur — so a 0.85rem field here would leave the sheet,
     the row behind it and the bottom nav all magnified after one tap, with no way back but a
     pinch. The one place in this app where a font size is a layout decision. */
  font-size: 16px;
  text-align: center;
  /* An emoji is drawn by the platform's own colour font; naming the emoji families first stops a
     text-presentation fallback rendering ❤ or ☺ as a flat outline glyph. */
  font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', inherit;
}
.reaction-picker__custom-input::placeholder {
  color: var(--text-on-dark);
  opacity: 0.4;
  font-family: inherit;
}
.reaction-picker__custom-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.reaction-picker__custom-hint {
  font-size: 0.66rem;
  text-align: center;
  color: var(--text-on-dark);
  opacity: 0.5;
}
.reaction-picker__custom-hint[hidden] { display: none; }
.reaction-picker__who-label {
  font-size: 0.66rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-on-dark);
  opacity: 0.45;
}
.reaction-picker__who-row {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-size: 0.82rem;
}
/* Fixed width so the names start on one column however wide the glyph renders — emoji metrics vary
   between platforms and a ragged left edge is what makes a short list look like scattered text. */
.reaction-picker__who-emoji {
  flex: 0 0 1.5em;
  font-size: 0.95rem;
  line-height: 1.35;
}
.reaction-picker__who-names {
  flex: 1;
  min-width: 0;
  color: var(--text-on-dark);
  opacity: 0.85;
  line-height: 1.35;
}
.reaction-picker__done {
  align-self: center;
  padding: 8px 26px;
  border-radius: 999px;
  background: var(--btn-back-unselected);
  border: 1px solid var(--divider);
  color: var(--text-on-dark);
  font-size: 0.85rem;
  cursor: pointer;
}

/* Collapsing drawer — js/components/collapsible.js (2026-08-20). Profile is the first caller; the
   widths come from that screen's 300px settings column, and the drawer is deliberately transparent
   rather than a card so a collapsed section reads as a row in a list, not as a box that has been
   emptied. */
.collapsible {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 300px;
}
/* The whole header is the button, so the tap target is the full row — the same choice
   .profile-notifications__row--switch makes, and for the same reason: on a phone the row matters
   far more than the 18px glyph at the end of it. */
.collapsible__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  padding: 10px 0;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--divider);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.collapsible__text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}
/* Matches .profile-notifications__heading exactly, so a drawer's title and a still-open section's
   heading read as the same rank of thing rather than as two levels of hierarchy. */
.collapsible__title {
  font-size: 0.9rem;
  font-weight: 600;
  opacity: 0.8;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
/* The state line. Same size and opacity as .profile-notifications__hint — it is the same kind of
   text, and matching it keeps a collapsed drawer from looking louder than the section it replaced. */
.collapsible__summary {
  font-size: 0.75rem;
  opacity: 0.55;
  line-height: 1.35;
}
.collapsible__summary[hidden] { display: none; }
.collapsible__chevron {
  display: flex;
  flex: 0 0 auto;
  opacity: 0.55;
  transition: transform var(--speed-slide) var(--ease-spring);
}
.collapsible__header[aria-expanded='true'] .collapsible__chevron { transform: rotate(180deg); }
.collapsible__header[aria-expanded='true'] { border-bottom-color: transparent; }
.collapsible__body {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 14px 0 4px;
  animation: collapsible-reveal var(--speed-slide) ease-out;
}
.collapsible__body[hidden] { display: none; }
/* Fades and lifts rather than growing to height: a max-height or grid-rows transition needs
   overflow: hidden on the open state too, which clips the focus rings on the controls inside. */
@keyframes collapsible-reveal {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .collapsible__body { animation: none; }
  .collapsible__chevron { transition: none; }
}

/* The badge rows are a second group inside the Notifications & alerts drawer, and this hairline is
   the only thing that says so. Without it the order reads "…push prefs, Turn off on this device,
   Show unpicked count" — a full-width action button with one more switch below it, which invites
   exactly the wrong reading that turning notifications off on this device also kills the badge. It
   does not: the badge is device-local, needs no subscription, and stays available on a host where
   push isn't configured at all. Grouping them is right (they are both "how the app nudges you");
   letting them run together as one flat list is not. */
.collapsible__body > .profile-notifications__body + .profile-notifications__body {
  border-top: 1px solid var(--divider);
  padding-top: 14px;
}


/* ==================================================================================================
   MOVED HERE FROM screens.css ON 2026-08-20.

   These twelve blocks style js/components/*.js and had been sitting in screens.css purely because
   that is where each was first written. screens.css was 3,438 lines and roughly 1,390 of them were
   component CSS, so neither file matched its own name. Nothing below was rewritten — the blocks were
   moved verbatim, in their original relative order, with their comments.

   CASCADE NOTE, since this move puts them EARLIER in the cascade (index.html links components.css
   before screens.css): checked before moving. Exactly one selector, `.card-face--mini`, appears both
   here and in what stayed behind (screens/previousBinder.js's grid), and the two rules set different
   properties — aspect-ratio here, transition there — so order cannot decide between them. No
   @keyframes name is defined twice across the three stylesheets.
   ================================================================================================== */

/* --- Base .toggle-group (components/toggleGroup.js) ---------------------------------------------
   MOVED HERE 2026-08-20 TOGETHER WITH THE BLOCKS BELOW, AND IT HAD TO BE. This base is what the
   matchupDetailModal block further down overrides, and both selectors are a single class, so the
   cascade is decided purely by source order. Moving only the override left the BASE later in the
   file and winning: .matchup-detail-modal__toggle's `gap: 6px 10px` lost to `gap: 8px`, the filter
   chips repacked 4-to-a-line instead of 3, and the modal card grew 2px. Caught by comparing the
   computed style of every element before and after — a selector-string comparison does NOT catch
   this, because the two selectors are different strings matching the same element.

   So: an override and the base it overrides move together, or neither moves.

   `.week-header .toggle-group` and `.toggle-group--notify` deliberately STAY in screens.css. The
   first is higher specificity so order cannot affect it; the second is a single class that was
   already later in the cascade than this base and still is. */
/* Base .toggle-group look — unscoped (moved out of .week-header 2026-08-16, alongside
   components/toggleGroup.js's extraction) so any consumer of that shared factory gets a sensible
   default appearance regardless of where it's mounted, not just inside a .week-header bar.
   .week-header .toggle-group below now only adds the layout tweak specific to sitting in that
   bar (margin-left: auto, pushing it to the row's trailing edge) — every podium.js call site
   still gets that for free since none of them have migrated off hand-rolled markup yet (see
   Documents/roadmap.md's Phase 5, deferred). */
.toggle-group { display: flex; gap: 8px; align-items: center; }
.toggle-group button {
  background: none; border: none; cursor: pointer; font-weight: 600; color: var(--accent-dim);
}
.toggle-group button[aria-pressed="true"] { color: var(--accent); }

/* --- "Continue with Google" / "Continue with Apple" (components/oauthButtons.js) ---
   Styled as a matched light/dark pair, both legible sitting directly on the auth screen's dark
   background: Google as a plain white pill (its icon needs a light background to read correctly,
   so it isn't worth a dark variant here), Apple as a black pill with a light border for
   definition against --bg (#1f1f1f is dark but not quite black — a borderless black pill would
   nearly vanish into it), per Apple's own guidance to use a bordered/white style specifically on
   non-white backgrounds rather than their default all-black button.
   Google's real (non-mock) button is now Google's own rendered widget (theme: 'outline', see
   oauthButtons.js's initGoogleButton comment for why — a mobile One Tap reliability bug, not a
   style choice) rather than this .oauth-btn--google markup, which mock mode still uses. .oauth-
   btn-slot just centers/contains that rendered iframe the same width as everything else here. */
.oauth-buttons { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 300px; }
.oauth-btn-slot { display: flex; justify-content: center; }
.oauth-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  height: 44px;
  border-radius: 10px;
  font-weight: 600;
  font-size: 0.95rem;
  cursor: pointer;
}
.oauth-btn--google {
  background: white;
  color: #3c4043;
  border: 1px solid #dadce0;
}
.oauth-btn--apple {
  background: #000;
  color: white;
  border: 1px solid rgba(255, 255, 255, 0.35);
}

/* Shown in place of Google's rendered widget when its SDK can't be loaded — a real, tappable
   button plus a persistent explanation, replacing the empty slot and fading toast that used to
   be all a blocked player got (see oauthButtons.js's renderGoogleFallback). Reuses .oauth-btn--
   google's markup verbatim, so the failure state looks like a normal button rather than an error
   screen; only the hint beneath it says otherwise. The hint follows .auth-divider's muted idiom
   (--text-on-dark at reduced opacity) rather than --fail — the player has done nothing wrong and
   a red error would overstate a network hiccup they can probably fix in one tap. */
.oauth-fallback { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.oauth-fallback .oauth-btn { width: 100%; }
.oauth-fallback-hint {
  margin: 0;
  color: var(--text-on-dark);
  opacity: 0.55;
  font-size: 0.78rem;
  line-height: 1.4;
  text-align: center;
}

/* --- Pick chips (components/pickChips.js) — replaced the single combined worth chip that used
   to sit unconditionally in .matchup-row__center. Same tier-color/verdict language as
   worth-chip (css further below), just two smaller chips instead of one, each able to leave the
   tray and pin itself to the corner of the team button or spread-toggle segment it's actually
   placed on. Both .fancy-btn and .spread-toggle__option are already `position: relative` (the
   FancyButton rules; the toggle's own rule above), so no extra wrapper is needed for the
   absolute placement below. */
.pick-chip {
  position: relative;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  line-height: 1;
  font-weight: 800;
  color: white;
  border: 2px dashed rgba(255, 255, 255, 0.4);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
  flex: 0 0 auto;
}
.pick-chip__value { font-size: 0.6rem; }
.pick-chip__label { font-size: 0.4rem; opacity: 0.8; text-transform: uppercase; }
.pick-chip--tier-1 { background: #4a4a4a; }
.pick-chip--tier-2 { background: var(--accent-dim); }
.pick-chip--tier-3 { background: linear-gradient(155deg, #ffd700, #b8860b); color: #402d00; }
.pick-chip--earned { box-shadow: 0 0 0 2px var(--success), 0 2px 6px rgba(0, 0, 0, 0.4); }
.pick-chip--missed { box-shadow: 0 0 0 2px var(--fail), 0 2px 6px rgba(0, 0, 0, 0.4); opacity: 0.7; }
.pick-chip--push { box-shadow: 0 0 0 2px var(--accent), 0 2px 6px rgba(0, 0, 0, 0.4); }
.pick-chip--live { animation: worth-chip-live-pulse 1.6s ease-in-out infinite; }

/* Spread chip reads as the "secondary, optional" bet — smaller, no pt/pts label (not enough
   room once it's pinned onto the already-small spread pill), and pinned to the opposite corner
   from the outright chip's own placement below so a game with both placed doesn't stack them. */
.pick-chip--spread {
  width: 20px;
  height: 20px;
  border-width: 1.5px;
}
.pick-chip--spread .pick-chip__label { display: none; }
.pick-chip--spread .pick-chip__value { font-size: 0.52rem; }

.fancy-btn > .pick-chip--outright {
  position: absolute;
  top: 4px;
  right: 4px;
  z-index: 5;
}
.spread-toggle__option > .pick-chip--spread {
  position: absolute;
  top: -10px;
  right: 2px;
  z-index: 5;
}

.chip-help-btn {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-dark);
  opacity: 0.6;
  font-size: 0.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex: 0 0 auto;
}
.chip-help-btn:hover, .chip-help-btn:focus-visible { opacity: 1; }

/* --- Worth chip (components/worthChip.js), replacing the old plain "Npts" label --- a
   poker-chip badge whose fill color is a "denomination" tier off the raw stake (1 = plain gray,
   2 = accent blue, 3+ = gold); once locked a colored ring layers the actual outcome on top
   (green = earned something so far, red = earned nothing), and the value itself switches from
   the stake ("3") to a live/final payout ("+3.0"). */
.worth-chip {
  position: relative;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  line-height: 1;
  font-weight: 800;
  color: white;
  border: 2px dashed rgba(255, 255, 255, 0.4);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
  flex: 0 0 auto;
}
.worth-chip__value { font-size: 0.68rem; }
.worth-chip__label { font-size: 0.45rem; opacity: 0.8; text-transform: uppercase; }
.worth-chip--tier-1 { background: #4a4a4a; }
.worth-chip--tier-2 { background: var(--accent-dim); }
.worth-chip--tier-3 { background: linear-gradient(155deg, #ffd700, #b8860b); color: #402d00; }
.worth-chip--earned { box-shadow: 0 0 0 2px var(--success), 0 2px 6px rgba(0, 0, 0, 0.4); }
.worth-chip--missed { box-shadow: 0 0 0 2px var(--fail), 0 2px 6px rgba(0, 0, 0, 0.4); opacity: 0.7; }
.worth-chip--live { animation: worth-chip-live-pulse 1.6s ease-in-out infinite; }
@keyframes worth-chip-live-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.08); }
}

/* --- Verdict ring on the picked team's own button (components/fancyButton.js), replacing the
   old lock/checkered-flag overlay stamped over the whole card. Puts the win/loss signal directly
   on the thing you tapped instead of a separate icon+caption disconnected from the pick itself.
   Also applied to the spread chip (components/spreadButton.js) for a spread-only pick. */
.fancy-btn--verdict-correct {
  box-shadow: 0 0 0 3px var(--success), 0 0 16px 2px rgba(51, 204, 51, 0.5);
}
.fancy-btn--verdict-incorrect {
  box-shadow: 0 0 0 3px var(--fail), 0 0 16px 2px rgba(204, 51, 51, 0.5);
}

.matchup-score {
  text-align: center;
  font-weight: 800;
  font-size: 1.05rem;
  color: var(--text-on-dark);
  margin-top: 8px;
}
.matchup-card--live .matchup-score { color: var(--fail); }

/* --- "How this scored" breakdown modal (components/breakdownModal.js), added 2026-08-16 ---
   Same dark-overlay-plus-centered-card look as .week-intro-modal below, but position: fixed
   (mounted straight to document.body, not a screen-level container — see that file's own header
   comment) and a higher z-index so it always wins over the pick-stack's full-screen takeover
   (z-index: 30) if it's somehow ever triggered while that's open. */
.breakdown-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 40;
}
.breakdown-modal__card {
  position: relative;
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 26px 22px 22px;
  width: 100%;
  max-width: 360px;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
}
.breakdown-modal__close {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-dark);
  cursor: pointer;
  font-size: 0.9rem;
}
.breakdown-modal__title {
  font-size: 1.15rem;
  font-weight: 700;
  color: var(--accent);
  margin-bottom: 14px;
  padding-right: 24px;
}
.breakdown-modal__line {
  color: var(--text-on-dark);
  font-size: 0.9rem;
  line-height: 1.5;
  margin: 0 0 10px;
}
.breakdown-modal__line:last-child { margin-bottom: 0; }
.breakdown-modal__line strong { color: var(--accent); }
/* A breakdown-modal card whose content is a LIST rather than a few paragraphs
   (components/pointsOnTheLineModal.js, 2026-08-19). The shared card is itself the scroll box and
   __close is absolutely positioned inside it, so on a 25-game week the close button rode away with
   the content and the only way out of the modal was to scroll back up or tap the backdrop. Here the
   card stops scrolling and becomes a column: a pinned head carrying the title and the close button,
   and a body that scrolls under its own overflow. Nothing passes beneath the head — the two are
   separate boxes — so the head needs no background of its own. */
.breakdown-modal__card--split {
  display: flex;
  flex-direction: column;
  padding: 0;
  overflow: hidden;
}
/* Positioned, so __close's absolute top/right resolve against the head rather than the card —
   which is what makes it stay put while the body scrolls. */
.breakdown-modal__head {
  position: relative;
  flex: 0 0 auto;
  padding: 26px 22px 0;
}
/* min-height: 0 is load-bearing: a flex item's default min-height is `auto`, which refuses to
   shrink below its content, so without it the body would size to the whole list and the CARD would
   overflow instead of the body scrolling. */
.breakdown-modal__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0 22px 22px;
}

/* Second-level heading within a breakdown-modal card (added 2026-08-16 for chipHelpPopover.js's
   worth-explainer section) — distinct from __title so it doesn't read as a second, competing
   top headline; smaller, and carries its own top margin to separate it from whatever paragraph
   came before it. */
.breakdown-modal__subtitle {
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--text-on-dark);
  margin: 18px 0 8px;
}

/* --- Profile trading-card popup (components/profileCardModal.js, 2026-08-16) — the "tap someone
   in Podium/Previous" half of Documents/roadmap.md §2's "Profile trading card" entry. Same
   overlay/card/close-button shape as .breakdown-modal above (own copy, not shared — matching
   this app's existing per-modal-file convention), plus card-specific avatar/team/stats content.
   `--team-color` is set inline per-card (profileCardModal.js) from the player's favorite team's
   own color, falling back to --accent here for a player with no favorite team set. */
.profile-card-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 40;
}
.profile-card-modal__card {
  position: relative;
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 30px 22px 22px;
  width: 100%;
  max-width: 340px;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
/* The same inset frame line .swipe-card::before uses on the Pick-stack trading cards — the
   detail that reads as "trading card" rather than a plain rounded rectangle, added here
   (2026-08-16) for visual consistency with those. Same inset/border/radius values, copied
   rather than shared, matching how this file already keeps each card-ish component's CSS
   self-contained (see .profile-card-modal's own header comment). pointer-events: none so it
   never competes with the close button or backdrop-click handling. */
.profile-card-modal__card::before {
  content: '';
  position: absolute;
  inset: 10px;
  border: 1.5px solid rgba(255, 255, 255, 0.16);
  border-radius: calc(var(--radius) - 6px);
  pointer-events: none;
}
.profile-card-modal__close {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-dark);
  cursor: pointer;
  font-size: 0.9rem;
}
/* The colored "ring" behind the avatar circle — a padded gradient disc in the favorite team's
   color (or --accent, unset) sitting a couple px larger than the avatar itself all the way
   around, so it reads as a frame rather than a border fighting the circular crop underneath. */
.profile-card-modal__avatar-ring {
  width: 132px;
  height: 132px;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--team-color, var(--accent)), var(--row-even) 70%);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 14px;
}
.profile-card-modal__avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  overflow: hidden;
  position: relative;
  background: var(--row-even);
  display: flex;
  align-items: center;
  justify-content: center;
}
.profile-card-modal__name {
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--text-on-dark);
}
.profile-card-modal__team {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
  font-size: 0.85rem;
  color: var(--text-on-dark);
  opacity: 0.85;
}
.profile-card-modal__team img { width: 20px; height: 20px; object-fit: contain; }
.profile-card-modal__stats-section { width: 100%; }
/* Scope toggle (2026-08-19 — Season / League / Overall, widening left to right). Reuses the shared
   .toggle-group look; centered and slightly tightened so the three short words read as one
   control between the identity block and the table. */
.profile-card-modal__scopes {
  justify-content: center;
  margin-top: 16px;
}
.profile-card-modal__scopes button { font-size: 0.8rem; }
/* The caption naming what the table below is counting ("College Pick'em · 2026"). Deliberately
   quiet — it is a label for the control above it, not a stat — but it is the only thing that
   distinguishes three tables sharing the same column headings, so it is not optional. */
.profile-card-modal__scope-caption {
  margin: 8px 0 0;
  text-align: center;
  font-size: 0.72rem;
  letter-spacing: 0.03em;
  color: var(--text-on-dark);
  opacity: 0.6;
}
.profile-card-modal__stats {
  width: 100%;
  margin-top: 12px;
}
/* Total/Outright/Spread × Points/Rank/Medals/Streak table (2026-08-16, replacing this card's
   original single-metric 2x2 stat grid, at the user's request — four Total-only numbers turned
   out to be less than they wanted to see here). A CSS grid of plain divs, not a native <table> —
   see profileCardModal.js's own comment for why. Zero grid-gap on purpose: cell borders below are
   what draw the row lines, and a gap would break them into disconnected dashes instead of
   continuous rules across the row. */
.profile-card-modal__table {
  display: grid;
  grid-template-columns: auto repeat(3, 1fr);
  width: 100%;
  border-radius: 10px;
  overflow: hidden;
  background: var(--divider-2);
}
.profile-card-modal__cell {
  min-width: 0;
  padding: 7px 6px;
  font-size: 0.72rem;
  color: var(--text-on-dark);
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  border-bottom: 1px solid var(--divider);
}
/* The last row (whichever one it is — Points alone, or Streak once Rank/Medals/Streak are also
   shown) shouldn't get a trailing rule under it. Sibling position, not class, on purpose: every
   row is always a complete run of 4 cells (this component never emits a partial row), so "last 4
   children" reliably means "the last row" regardless of how many rows came before it. */
.profile-card-modal__table .profile-card-modal__cell:nth-last-child(-n+4) {
  border-bottom: none;
}
.profile-card-modal__cell--head {
  font-weight: 700;
  color: var(--accent);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  font-size: 0.62rem;
}
.profile-card-modal__cell--label {
  justify-content: flex-start;
  text-align: left;
  opacity: 0.8;
  font-weight: 600;
}
.profile-card-modal__cell--value {
  font-weight: 700;
  color: var(--accent);
}
/* Medals is the one row where a cell holds three short pieces (gold/silver/bronze counts) rather
   than one — side by side they don't fit this table's narrow columns without wrapping mid-number
   (found in real testing, not guessed up front), so each cell stacks its three counts as their
   own line instead of fighting for horizontal room. */
.profile-card-modal__cell--medals {
  flex-direction: column;
  gap: 1px;
  font-size: 0.64rem;
  line-height: 1.2;
  padding-top: 5px;
  padding-bottom: 5px;
}
/* Accuracy row (2026-08-16, profileCardModal.js's loadAccuracyRow/addAccuracyCell) — same
   two-line-stack idea as .profile-card-modal__cell--medals above (a percentage plus its N/M
   fraction don't both fit on one line in these narrow columns), just two lines instead of three. */
.profile-card-modal__cell--accuracy {
  flex-direction: column;
  gap: 1px;
  padding-top: 5px;
  padding-bottom: 5px;
}
.profile-card-modal__cell-sub {
  font-size: 0.6rem;
  font-weight: 400;
  color: var(--text-on-dark);
  opacity: 0.6;
}
/* The `…` placeholder cells loadAccuracyRow appends immediately, before its fetch resolves —
   dimmed and not bold, so a table that's otherwise instant doesn't read as if this row already
   has a real (if boring) answer. */
.profile-card-modal__cell--loading {
  color: var(--text-on-dark);
  opacity: 0.45;
  font-weight: 400;
}
.profile-card-modal__stats-empty {
  color: var(--text-on-dark);
  opacity: 0.7;
  font-size: 0.9rem;
  margin-top: 20px;
}

/* --- Week intro/recap modal (components/weekIntroModal.js) --- */
.week-intro-modal {
  /* `fixed`, NOT `absolute` — fixed 2026-08-18 after a real-iPad report ("notification modals made
     her scroll down to see it"), reproduced in WebKit: absolute + inset:0 sizes this overlay to the
     nearest positioned ancestor, which is a page as tall as the whole game list. Measured at
     820x1180 the overlay was 2009px tall and `align-items: center` put the card 905px down — below
     the fold on iPad landscape, and at the very bottom edge (836 of 844) even on an iPhone. The
     backdrop still dimmed the visible screen, so it read as "the app is stuck behind a black
     screen" rather than as a scroll position.
     This is the SAME bug CLAUDE.md already records being fixed once for the soft prompts, which
     override `position` to fixed further down this file — the fix was applied to the two children
     and never to the parent they inherit everything else from. With this corrected, those overrides
     are redundant but harmless, and are deliberately left in place so neither prompt silently
     depends on this rule staying right. */
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 30;
}
.week-intro-modal__card {
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 28px 24px;
  width: 100%;
  max-width: 340px;
  text-align: center;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
}
.week-intro-modal__title { font-size: 1.4rem; font-weight: 700; color: var(--accent); margin-bottom: 12px; }
.week-intro-modal__line { color: var(--text-on-dark); margin-bottom: 8px; font-size: 0.95rem; }
/* The "N still to pick" line — the one line in this card that changes between openings, so it gets
   the accent rather than the muted body treatment the other lines use. */
.week-intro-modal__line--strong {
  color: var(--accent);
  font-weight: 600;
  opacity: 1;
}
.week-intro-modal__actions { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; }
/* The notifications soft prompt (components/pushPromptModal.js) reuses the week-intro overlay and
   card wholesale — it's the same "one card, two actions, centered over the pick screen" shape — and
   needs its own body-copy rule, since its single paragraph runs longer than the intro's short stat
   lines and wants tighter, less shouty text.
   POSITION: FIXED, NOT ABSOLUTE — and this is a correctness fix, not a preference. .week-intro-modal
   is `position: absolute; inset: 0`, which works for the week intro because that one is appended at
   mount, when the pick container is scrolled to the top and roughly viewport-height anyway. The push
   prompt is appended to the SAME container but can be shown while the player is scrolled well down a
   long list of games, and `inset: 0` on an absolutely-positioned overlay spans the whole CONTAINER —
   so the dim covers everything (which looks fine) while the card centers itself at container-height
   / 2, i.e. off-screen. Caught in a headless run where the overlay was visibly present but the card
   was nowhere in the viewport. Fixed centres on the viewport regardless of scroll.
   z-index sits above the bottom nav (45) so the card is never overlapped by it. */
.push-prompt-modal {
  position: fixed;
  z-index: 50;
}
.push-prompt-modal__line {
  color: var(--text-on-dark);
  font-size: 0.9rem;
  line-height: 1.45;
  opacity: 0.85;
  margin: 0;
}

/* --- Install soft prompt (components/installPromptModal.js) ---
   Shares .push-prompt-modal's fixed/z-index treatment above (both are appended into the Pick
   screen's container, which is scrollable — see that rule's comment for the off-screen-card bug
   that fixed positioning exists to prevent). Only the iOS instruction list is new. */
.install-prompt-modal {
  position: fixed;
  z-index: 50;
}
.install-prompt-modal__steps {
  color: var(--text-on-dark);
  font-size: 0.9rem;
  line-height: 1.6;
  margin: 0;
  padding-left: 1.2rem;
  text-align: left;
  opacity: 0.9;
}
.install-prompt-modal__steps li + li {
  margin-top: 0.35rem;
}
/* The Share glyph sits inline mid-sentence ("Tap [icon] in the browser bar"), so it has to sit on
   the text baseline rather than the line box — hence the explicit vertical-align nudge instead of
   letting an inline SVG default to baseline-with-descender-gap. Sized in em so it tracks the
   surrounding text if the step list's font-size ever changes. */
.install-prompt-modal__glyph {
  width: 1.15em;
  height: 1.15em;
  vertical-align: -0.25em;
  margin: 0 0.1em;
  color: var(--accent);
}

/* --- Swipe card (components/swipeCard.js) --- */
/* touch-action: none on every descendant, not just .swipe-card itself, is load-bearing: the
   property isn't inherited (base.css's own header comment on the universal `*` rule notes this
   for the pinch-zoom case, but it bites here too), and that universal `*` rule gives every child
   of .swipe-card — the team logos, nameplate text, diagonal background — its own explicit
   touch-action: pan-x pan-y, which *outranks* .swipe-card's touch-action: none on that child
   (not a cascade/inheritance situation, a separate direct match). A drag that starts with a
   finger on one of those children — not the card's own bare background — was letting the browser
   begin its native vertical pan on #screen-root underneath before swipeCard.js's Pointer Events
   handlers ever got a chance to react, which is what showed up as a visible scrollbar and the
   background page shifting while swiping a card up/down. Pointer capture (onPointerDown's
   setPointerCapture) doesn't prevent this — touch-action is decided at touchstart, before pointer
   event dispatch/capture comes into play at all. */
.swipe-card, .swipe-card * {
  touch-action: none;
}
.swipe-card {
  position: absolute;
  inset: 0;
  background: var(--row-even);
  border-radius: var(--radius);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
  user-select: none;
  cursor: grab;
  display: flex;
  align-items: stretch;
  /* Drives the "riffle into place" motion in pickStack.js: every card in the visible window is
     a real, fully-wired swipe card from creation (see pickStack.js's buildCardNode) — a peek
     getting promoted to the front card, or just moving one slot closer, is the SAME element
     getting a new --peek-depth, not a new node snapping to its resting spot. The spring easing
     gives that move an overshoot on settle, which reads as a satisfied riffle rather than a
     mechanical slide — --ease-spring-riffle specifically (see tokens.css), not the plainer
     --ease-spring shared by unrelated animations elsewhere, since this one was tuned bouncier on
     its own, at the user's request. Explicitly turned off while actually being dragged below, so
     a live drag still follows the pointer 1:1 instead of lagging behind it through this same
     transition. */
  transition: transform var(--speed-spring) var(--ease-spring-riffle), opacity var(--speed-spring) var(--ease-spring-riffle);
}
/* An inset frame line, short of the card's own edge — the detail that reads as "trading card"
   rather than just a rounded rectangle. ::before so it paints behind the real content (team
   names/images, the spread hint) instead of over it; pointer-events: none so it never competes
   with the card's own drag handling. Kept on peek cards too (::before isn't scoped to the
   interactive card only) so the stack behind the current card reads as a stack of the same kind
   of card, not a plain card in front of blank rectangles. */
.swipe-card::before {
  content: '';
  position: absolute;
  inset: 10px;
  border: 1.5px solid rgba(255, 255, 255, 0.16);
  border-radius: calc(var(--radius) - 6px);
  pointer-events: none;
}
/* transition: none while actively dragging — the drag handler (swipeCard.js's
   applyDragTransform) sets card.style.transform on every pointermove, and the spring transition
   above would otherwise fight that, making the card visibly lag behind the finger/cursor. */
.swipe-card--dragging { cursor: grabbing; transition: none; }
.swipe-card--committing { pointer-events: none; }
/* Peek cards (upcoming, non-interactive) sit behind the current one, scaled/offset/faded by
   their depth for a sense of a real stack rather than one flat card — see pickStack.js's
   --peek-depth custom property, which pickStack.js sets up to PEEK_COUNT (4) deep. Tuned so the
   4th-deepest card is still faintly visible rather than fading all the way to nothing — the
   point is a riffled stack players can see the depth of, not a cliff after two cards.
   The translate/scale balance matters more than it looks: scaling a card down shrinks its edges
   inward by half the lost size (transform-origin is centered), which — found by actually
   measuring the resting box, not just eyeballing it — cancels out a same-or-smaller translate
   almost completely, leaving nothing visibly poking out from behind the front card at all. 13px
   down / 8px right of translate per depth, against a gentler 3.5%-per-depth scale, keeps each
   deeper card's edge genuinely visible past the one in front of it (a few px more per depth),
   which is what actually reads as a fanned/riffled stack rather than a flat card with an
   invisible pile behind it. The down-right (rather than straight-down) direction is a deliberate
   choice, confirmed against the user's own reaction to a live screenshot rather than assumed —
   it reads more like a fanned hand of cards. pointer-events: none is what actually makes a peek
   inert despite being, underneath, the exact same kind of fully-wired swipe card as the front one
   (see the .swipe-card transition comment above) — nothing here disables its drag listeners, they
   just never receive a pointer event.
   Opacity is deliberately shifted by one depth from translate/scale above — depth 1 (the very
   next card) is fully opaque, not the ~0.84 the plain per-depth formula would give it. Depth 1's
   own translate/scale offset alone already keeps it from looking identical to the front card
   (it's visibly smaller and shifted), so nothing was gained by fading it too — and fading it
   actively hurt: for an instant while the front card flies off during a commit, its emptied
   opacity briefly lets whatever's behind it (depth 2) show through depth 1's own translucency,
   which read as a glitch rather than a stack. Depths 2+ still fade normally, just renumbered. */
.swipe-card--peek {
  cursor: default;
  pointer-events: none;
  transform: translate(calc(var(--peek-depth, 1) * 8px), calc(var(--peek-depth, 1) * 13px))
    scale(calc(1 - var(--peek-depth, 1) * 0.035));
  opacity: calc(1 - (var(--peek-depth, 1) - 1) * 0.16);
}
/* Registering --peek-depth as a real <number> is what actually makes the riffle animate.
   Verified the hard way, not assumed: sampling the live computed transform/opacity of a peek
   node frame-by-frame (not just eyeballing a screenshot — see CLAUDE.md's note on why that
   matters for anything animated) showed it snapping straight to its new depth with zero
   interpolated frames despite the transition above being in effect. An unregistered custom
   property is just an opaque token stream to the engine — transform/opacity recompute a
   brand-new discrete value from calc() each time it changes instead of tweening between old and
   new, even though transform/opacity themselves are perfectly transitionable properties. @property
   gives the browser an actual numeric type to interpolate, so the calc()s driving off it now
   produce a smooth in-between value every frame instead of jumping. */
@property --peek-depth {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}
/* Trading-card face (added 2026-08-14, at the user's request, to replace the plain centered
   team-column layout above). Everything here is absolutely positioned inside
   .swipe-card__content rather than flexed/centered, since it's laying out an image (the diagonal
   + logos + VS emblem) with a nameplate floated on top of it, not a simple row of text. */
.swipe-card__content { position: relative; width: 100%; height: 100%; }
/* Inset to exactly match .swipe-card::before's own inset/radius above, so this sits neatly
   *within* that border frame like a card face inside its margin, per the user's own framing
   ("within the margins, a diagonal divide") — not edge-to-edge under the border. Background
   (a per-matchup linear-gradient between the two teams' colors) is set inline by
   pickStack.js's buildCardContent, since it's data-dependent. overflow: hidden clips the
   diagonal's own hard color-stop rectangle to these rounded corners. */
.trading-card__diagonal {
  position: absolute;
  inset: 10px;
  border-radius: calc(var(--radius) - 6px);
  overflow: hidden;
}
.trading-card__logo {
  position: absolute;
  width: 78px;
  height: 78px;
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
  pointer-events: none;
}
.trading-card__logo img { width: 100%; height: 100%; object-fit: contain; }
/* Applied only to logos pickStack.js's needsContrastOutline() actually measures as mostly/only
   the team's own primary color (a solid silhouette crest sitting on a same-colored diagonal
   half all but disappears without this) — real per-logo pixel sampling, not a hand-picked list,
   so this doesn't touch a logo that already reads fine, like the multi-tone crests. Confirmed
   against real logos this correctly leaves alone (Florida State, Montana State, New Mexico,
   Delaware State) and correctly catches (SMU, Nevada, Hawaii, Georgia Tech) — the last two read
   as merely "looks good with it too" rather than "needed it" per the user, which is fine; this
   is meant to catch anything genuinely dim, not just the worst offenders.
   References an SVG <filter> (feMorphology dilate + feFlood + feComposite, injected once by
   pickStack.js's ensureContrastOutlineFilter) rather than CSS drop-shadow() — a first pass built
   from ~8 stacked drop-shadow()s at small offsets *looked* like a uniform ring in screenshots,
   but the user caught it as visibly thicker toward two specific corners in real use: chained
   drop-shadow() functions each shadow the cumulative result of every one before them, not the
   original image independently, so whichever offsets happen to be listed last in the chain
   shadow an already-expanded shape and come out thicker — an ordering artifact no amount of
   retuning the offset values would have fixed. feMorphology's dilate expands the source alpha
   mask by a radius in one real mathematical step instead of N sequentially compounding ones,
   which is what actually makes the result symmetric. */
.trading-card__logo--needs-outline img {
  filter: url(#trading-card-contrast-outline);
}
/* Anchored into the upper-left / lower-right triangles the diagonal split creates (away always
   upper-left, home always lower-right — the same left-to-right reading order the plain list
   already uses for away/home, just turned diagonal instead of horizontal). Horizontally (left:
   28%/72%) still a true mirror pair, sum to 100%.
   Vertically, no longer a 100%-summing mirror as of 2026-08-16 — both shifted up 9.3
   percentage points (top: 35.5%/64.5% -> 26.2%/55.2%) at the user's own request, once the
   kickoff-time line (added earlier the same day) made .trading-card__info tall enough to clip
   over the home logo's bottom edge. .trading-card__vs deliberately did NOT move with them — it
   has to stay sitting on the diagonal split's actual geometric boundary (the true center of
   .trading-card__diagonal, which itself didn't move), so shifting it too would visibly pull it
   off that seam. The two logos moving while the seam-anchored VS emblem stays put is exactly why
   this pair can't sum to 100% top anymore; the user explicitly accepted that trade-off ("even
   though it won't be exactly centered") in exchange for headroom to enlarge the nameplate below.
   9.3 points (~42px at this card's own size) was chosen empirically, not guessed — measured via
   a throwaway Playwright script against the real rendered card (getBoundingClientRect on the
   front, non-peek card specifically — every .trading-card__* class repeats once per peeked-at
   card in the stack, so a naive querySelector can silently grab a scaled-down peek instead of
   the interactive one) until both the home-logo/info gap and the away-logo/banner gap cleared
   by roughly the same ~12px margin. Re-verify the same way before pushing either number further
   — hand-eyeballing a screenshot missed the original clipping bug in the first place. */
.trading-card__logo--away { top: 26.2%; left: 28%; transform: translate(-50%, -50%); }
.trading-card__logo--home { top: 55.2%; left: 72%; transform: translate(-50%, -50%); }
/* The small conference badge from components/teamButton.js's FancyButton tiles, reused here in
   miniature. Always the logo's bottom-right corner regardless of home/away side, at the user's
   request — the first version pointed it toward whichever corner faced the VS emblem (so it
   mirrored side to side), which read as inconsistent rather than deliberate. A white plate
   behind it, not just the bare logo — conference marks are often thin/light-colored and would
   lose all contrast floating directly over an arbitrary team color otherwise.
   bottom: 0; right: 0 (flush with the logo's own corner, not overhanging past it) is
   deliberate, not just tidiness — the previous -4px overhang looked like a stray offset rather
   than an intentional placement. (Until 2026-08-16, the away/home logos were also exact 180°
   point-mirrors of each other through the card's center, which made this corner doubly
   deliberate — the away logo's bottom-right corner was mathematically identical to the
   point-mirror of the home logo's top-left corner, and vice versa. That symmetry broke when the
   two logos' vertical position shifted independently of .trading-card__vs — see
   .trading-card__logo--away/--home's own comment — but bottom:0;right:0 needs nothing from that
   symmetry to keep working: it's just "this logo's own bottom-right corner," per-logo, either
   way.) */
.trading-card__conf-badge {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  border: 1.5px solid rgba(255, 255, 255, 0.9);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  padding: 3px;
}
.trading-card__conf-badge img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* The center emblem, sitting right on the diagonal boundary — a light, mostly-opaque badge
   rather than plain text directly on the split, since text alone would lose contrast against
   whichever team color it happened to cross. */
.trading-card__vs {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: rgba(240, 240, 240, 0.94);
  border: 2px solid rgba(255, 255, 255, 0.6);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #1f1f1f;
  font-weight: 800;
  font-size: 0.7rem;
  letter-spacing: 0.03em;
}
/* Headline ribbon naming the favorite ("FSU favored by 13") — added 2026-08-14, at the user's
   request, as the card's own title rather than only being discoverable via a gesture hint buried
   in the nameplate. Deliberately the exact same floating-glass treatment as .trading-card__info
   below (same background/blur/border/shadow/radius), just anchored to the top edge instead of
   the bottom, so the two read as a matched pair of panels bookending the card art instead of two
   different UI languages on one face. Sits above the logos (which start at 35.5%/64.5% top, see
   .trading-card__logo--away/--home above) with room to spare. */
.trading-card__banner {
  position: absolute;
  left: 18px;
  right: 18px;
  top: 18px;
  text-align: center;
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: rgba(20, 20, 20, 0.62);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.trading-card__banner-headline {
  color: var(--accent);
  font-weight: 800;
  font-size: 0.78rem;
  letter-spacing: 0.02em;
}
/* Worth chip pinned to the banner's own leading edge, vertically centered against the
   headline/hint text — see buildCardBanner's own comment (js/screens/pickStack.js) for why.
   Taken out of the banner's column-flex flow (absolute, positioned against .trading-card__banner
   itself, which is already `position: absolute` and so already a containing block) so it overlays
   the corner instead of pushing the centered headline/hint text down. Sized down from .worth-chip's
   34px list default — this banner is a tighter box than the list's title bar. */
.trading-card__banner .worth-chip {
  position: absolute;
  left: 8px;
  top: 50%;
  transform: translateY(-50%);
  width: 26px;
  height: 26px;
}
.trading-card__banner .worth-chip .worth-chip__value { font-size: 0.56rem; }
.trading-card__banner .worth-chip .worth-chip__label { font-size: 0.38rem; }
/* The gesture hint — "↑ (Swipe Up)" — added 2026-08-14, at the user's request, replacing the
   separate `swipe-card__spread-hint` line that used to live down in the nameplate (removed).
   Deliberately lighter/smaller than the headline above it: this line is an instruction, not the
   card's own identity, so it reads as a caption under the headline rather than competing with it
   for attention. Omitted entirely at spread 0 — see buildCardBanner's own comment for why. */
.trading-card__banner-hint {
  color: var(--text-on-dark);
  opacity: 0.75;
  font-size: 0.66rem;
  font-weight: 600;
  letter-spacing: 0.02em;
}
.trading-card__banner-arrow {
  display: inline-block;
  font-weight: 800;
  /* Slightly larger than the surrounding text and nudged up 1px so the glyph optically centers
     against the lowercase-height text next to it instead of sitting low, the way "↑" renders by
     default in most fonts. */
  font-size: 0.85em;
  transform: translateY(-1px);
}
/* The "floating box" nameplate — a glass panel laid over the bottom of the diagonal art, same
   floating-glass language as the bottom nav capsule elsewhere in this app, so it reads as UI
   laid on top of the card rather than printed into it. */
/* Enlarged 2026-08-16 (bigger padding/gap, plus the text bump below) — freed up by shifting the
   diagonal art's logos/VS emblem upward (see .trading-card__logo--away/--home and
   .trading-card__vs below), which is what made room for a taller nameplate without it clipping
   over the home logo the way the original size did once the kickoff line above was added. */
.trading-card__info {
  position: absolute;
  left: 18px;
  right: 18px;
  bottom: 18px;
  display: flex;
  flex-direction: column;
  gap: 7px;
  background: rgba(20, 20, 20, 0.62);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
  padding: 11px 9px;
}
.trading-card__info-teams { display: flex; gap: 6px; }
.trading-card__info .team-info { flex: 1 1 0; min-width: 0; }
.trading-card__info .team-school { font-size: 0.86rem; font-weight: 700; color: var(--text-on-dark); line-height: 1.15; }
.trading-card__info .team-mascot { font-size: 0.72rem; color: var(--text-on-dark); opacity: 0.75; line-height: 1.15; }
/* Two of these per team now (overall, then conference — see pickStack.js's
   buildTradingCardTeamInfo) instead of the plain list's one combined "W-L (CW-CL)" line, per the
   user's ask to label them explicitly and name the conference rather than just showing its
   record. Tighter line-height than the school/mascot lines above keeps that extra line from
   growing the nameplate as much as a 4th full-size line otherwise would. */
.trading-card__info .team-record { font-size: 0.7rem; color: var(--text-on-dark); opacity: 0.6; margin-top: 1px; line-height: 1.15; }
/* Kickoff day/time caption — added 2026-08-16. Same treatment as .trading-card__location right
   below (small, muted caption text), but always rendered (every matchup has a date) rather than
   conditional, so it needs its own top padding/divider-free spacing rather than relying on
   .trading-card__location's to also cover it — a "Pick 'em" game with no venue data would
   otherwise have nothing giving this line breathing room from the team records above it. */
.trading-card__kickoff {
  text-align: center;
  color: var(--text-on-dark);
  opacity: 0.65;
  font-size: 0.72rem;
  padding-top: 5px;
}
/* Venue/location caption — added 2026-08-14 alongside .trading-card__banner above. Only ever
   rendered when pickStack.js actually has a venue/location to show (nothing synced before
   migrations/003_add_matchup_location.sql has one). No border-top divider — even though
   .trading-card__kickoff above is now always present as a preceding sibling (added 2026-08-16),
   the two share the same muted caption styling with no visual break between them on purpose, so
   they read as one continuous "game logistics" block rather than two separately-boxed facts. */
.trading-card__location {
  text-align: center;
  color: var(--text-on-dark);
  opacity: 0.65;
  font-size: 0.72rem;
  padding-top: 5px;
}

.swipe-card__stamp {
  position: absolute;
  top: 20px;
  padding: 6px 14px;
  border: 3px solid currentColor;
  border-radius: 8px;
  font-weight: 800;
  font-size: 1.1rem;
  letter-spacing: 0.05em;
  pointer-events: none;
  transform: rotate(-12deg);
}
/* left/right/up's color + border-color below are only the fallback now (added 2026-08-15,
   swipeCard.js's makeStamp sets both properties inline per-team whenever pickStack.js has a
   `stampColors` entry for that side — see makeStamp's own comment for why border-color needs to
   be set explicitly rather than riding along on `border: 3px solid currentColor` above). Still
   worth keeping a real fallback here rather than defaulting to plain white/gray, in case a team
   is missing color data (nullable in the real schema). */
.swipe-card__stamp--left { left: 20px; color: var(--fail); border-color: var(--fail); }
.swipe-card__stamp--right { right: 20px; color: var(--success); border-color: var(--success); transform: rotate(12deg); }
.swipe-card__stamp--up { left: 50%; top: 16px; transform: translateX(-50%) rotate(0deg); color: var(--accent); border-color: var(--accent); }
.swipe-card__stamp--down { left: 50%; bottom: 16px; top: auto; transform: translateX(-50%) rotate(0deg); color: darkgray; border-color: darkgray; }

/* Swipe-direction hint arrows (added 2026-08-15, at the user's request) — see
   swipeCard.js's createSwipeCard `hint` option for the fade-in-then-permanently-dismiss
   behavior this only supplies the two visual states for (base = hidden, `--visible` = faded on).
   Opacity-only, no layout-affecting properties, so toggling the class can't itself jank the
   card's own drag transform. `color` here is only the fallback for when pickStack.js has no team
   color to hand over (see makeHintArrow's own comment) — the common case sets it inline per-team.
   The multi-directional text-shadow is a plain white halo, not a drop-shadow: a *colored* glyph
   sitting on that same team's own diagonal half needs real contrast help (this is the identical
   near-invisible-solid-crest problem .trading-card__logo--needs-outline solves for logos, just
   solved here with a symmetric text-shadow stroke instead of the SVG dilate filter — cheap and
   good enough for a two-character glyph, no per-pixel sampling needed). Stacking several
   text-shadow values like this is safe against the "each one compounds off the last" ordering
   trap chained filter: drop-shadow() calls have (see .trading-card__logo--needs-outline's own
   comment) — text-shadow's list entries are independent copies of the same original glyph, not
   sequentially-shadowed like chained filter functions, so there's no equivalent bug to worry
   about here. */
.swipe-card__hint-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-size: 2.2rem;
  font-weight: 800;
  line-height: 1;
  color: rgba(255, 255, 255, 0.7);
  text-shadow:
    -1px -1px 0 rgba(255, 255, 255, 0.6),
    1px -1px 0 rgba(255, 255, 255, 0.6),
    -1px 1px 0 rgba(255, 255, 255, 0.6),
    1px 1px 0 rgba(255, 255, 255, 0.6),
    0 1px 8px rgba(0, 0, 0, 0.45);
  pointer-events: none;
  opacity: 0;
  transition: opacity 500ms ease;
}
.swipe-card__hint-arrow--visible { opacity: 1; }
.swipe-card__hint-arrow--left { left: 14px; }
.swipe-card__hint-arrow--right { right: 14px; }

/* --- Trend graph (js/components/standingsChart.js) --- */
.standings-chart { padding: 12px 16px 20px; position: relative; }
.standings-chart__svg { width: 100%; height: auto; display: block; touch-action: none; }
.standings-chart__grid line { stroke: var(--divider); stroke-width: 1; }
.standings-chart__grid text { fill: var(--text-on-dark); opacity: 0.45; font-size: 7px; }
.standings-chart__line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.standings-chart__line--other { stroke: var(--text-on-dark); opacity: 0.25; }
.standings-chart__line--you { stroke: var(--accent); opacity: 1; }
.standings-chart__dot { stroke: var(--bg); stroke-width: 2; }
.standings-chart__dot.standings-chart__line--you { fill: var(--accent); }
.standings-chart__dot.standings-chart__line--other { fill: var(--text-on-dark); opacity: 0.35; }
.standings-chart__crosshair {
  stroke: var(--accent);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  pointer-events: none;
  transition: opacity 0.15s ease;
}
.standings-chart__legend {
  display: flex;
  gap: 16px;
  justify-content: center;
  margin-top: 4px;
  font-size: 0.78rem;
  opacity: 0.85;
}
.standings-chart__legend-item { display: flex; align-items: center; gap: 6px; }
.standings-chart__swatch { width: 14px; height: 2px; border-radius: 1px; display: inline-block; }
.standings-chart__swatch--you { background: var(--accent); }
.standings-chart__swatch--other { background: var(--text-on-dark); opacity: 0.4; }
.standings-chart__hint { text-align: center; font-size: 0.72rem; opacity: 0.5; margin: 6px 0 0; }
.standings-chart__tooltip {
  position: absolute;
  top: 8px;
  transform: translateX(-50%);
  background: rgba(20, 20, 20, 0.92);
  border: 1px solid var(--divider);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 0.75rem;
  pointer-events: none;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.standings-chart__tooltip-week { opacity: 0.6; font-size: 0.68rem; margin-bottom: 2px; }
.standings-chart__tooltip-row { display: flex; gap: 6px; align-items: baseline; }
.standings-chart__tooltip-row strong { color: var(--accent); font-size: 0.9rem; }

/* --- js/components/tradingCardFace.js — shared diagonal team-color "card face" (2026-08-16,
   Previous-screen redesign, Documents/roadmap.md). Sizes: --row (screens/previous.js's
   single-player rows), --mini (screens/previousBinder.js's grid), --full
   (components/matchupDetailModal.js's header). See that file's own header comment for why this is
   a brand-new class family, not an extension of .trading-card__* above. ---
   The diagonal split is two real elements (.card-face__half--away/--home), each clipped to its
   own parallelogram via clip-path, rather than a shared CSS gradient background — a caller
   glowing/dimming "the away half" styles that element directly, with nothing to fall out of sync
   with an independently-defined gradient. The two clip-path polygons below share the same 58%/42%
   top/bottom split points, so they tile the box exactly with no gap or overlap. */
.card-face {
  position: relative;
  /* Long-pressing a card must not start a text selection or raise iOS's "Copy / Save Image / Share"
     callout (2026-08-19, reported on a real phone once the card became a tap target for reactions).
     A card is a control, not prose — there is nothing on it anyone would want to select, and the
     callout appears at exactly the moment a press is being held, i.e. over the top of the gesture.

     `-webkit-touch-callout` is the one that suppresses the iOS menu and it is a WebKit-only
     property with no standard equivalent, so it has no unprefixed partner to pair with — nothing
     else in this stylesheet sets it yet. `-webkit-user-select` is kept alongside the unprefixed
     `user-select` because Safari only dropped the prefix requirement recently, and the phones this
     is read on are exactly the ones that still need it.

     Deliberately scoped to the card and its own row chrome rather than set globally on `body`: the
     modals in this app carry real prose (the payout table, the breakdown, week notes, broadcasts)
     that should stay selectable. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  /* Confines every z-index below (glow 2 / logo 3 / end-block + score 4) to THIS element's own
     stacking context. Without it, `position: relative` with `z-index: auto` creates no stacking
     context at all, so those children compete directly with the page's sticky headers
     (.week-summary-bar is z-index 3, .matchup-section-header 2) — which meant the end-block pill
     and the team logos scrolled *over* the pinned summary bar instead of under it. The numbers
     here are all about layering the parts of a card against each other and were never meant to
     have any say relative to the rest of the page; `isolation` says exactly that, without needing
     to renumber anything or inflate the headers' z-indexes to out-run card internals. Note it does
     not clip: the glow SVG still bleeds past this element's edge as designed. */
  isolation: isolate;
  border-radius: var(--radius);
  /* Deliberately NO overflow: hidden here (moved to .card-face__diagonal below, 2026-08-16) — a
     verdict glow needs to bleed past this element's own edge, matching how
     .fancy-btn--verdict-correct/--incorrect's box-shadow ring already bleeds past its own button
     with no clipping ancestor at all. See tradingCardFace.js's own header comment for the full
     story of why the glow ended up as a separate SVG element (.card-face__glow-svg) instead of a
     CSS filter/class applied directly to .card-face__half. */
}
.card-face--row { height: 56px; }
.card-face--mini { aspect-ratio: 1 / 1; }
.card-face--full { aspect-ratio: 4 / 3; }

.card-face__diagonal {
  position: absolute;
  inset: 0;
  overflow: hidden; /* keeps the halves' sharp clip-path corners from poking out past this
    card's own rounded frame — see .card-face's own comment for why this moved here instead of
    living on that outer element. */
  border-radius: inherit;
}
.card-face--mini .card-face__diagonal,
.card-face--full .card-face__diagonal {
  /* Inset from a bordered frame — both of these read as "a card" in their own right (unlike a
     plain list row, which is edge-to-edge with nothing to sit "within"), matching the swipe-card's
     own inset-frame convention (.trading-card__diagonal). */
  inset: 8px;
  border-radius: calc(var(--radius) - 5px);
}
.card-face--mini .card-face__diagonal::after,
.card-face--full .card-face__diagonal::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 1.5px solid rgba(255, 255, 255, 0.14);
  border-radius: inherit;
  pointer-events: none;
}

.card-face__half {
  position: absolute;
  inset: 0;
  transition: filter 200ms ease;
}
.card-face__half--away { clip-path: polygon(0% 0%, 58% 0%, 42% 100%, 0% 100%); }
.card-face__half--home { clip-path: polygon(58% 0%, 100% 0%, 100% 100%, 42% 100%); }

/* The picked team's own win/loss signal (screens/previous.js's row view) — an SVG polygon with
   its own baked-in glow filter (tradingCardFace.js's setHalfGlow/buildGlowSvg), NOT a CSS
   `filter: drop-shadow()` on a `clip-path`'d div — that combination renders nothing at all in
   Chromium, confirmed with an isolated test before abandoning it (see that file's own header
   comment for the full story). This element is a sibling of .card-face__diagonal, outside its
   `overflow: hidden`, and `overflow: visible` here besides, so the glow is genuinely free to
   bleed past the card's own edge — the same "glowing outline" language
   `.fancy-btn--verdict-correct/--incorrect` uses on the Pick screen's own team tiles, per the
   user's request, just outlining the half itself (and past the card boundary, matching how that
   button's own ring bleeds past its unclipped button) rather than a small logo icon. No separate
   "live vs. finished" intensity — matches fancyButton's own precedent of one glow strength
   throughout, sourced from isOutrightCorrect's real-time-or-final verdict either way (scoring.js
   — same function, no distinction needed here). z-index sits below .card-face__logo/--endblock/
   --score (3/4/4 below) so the glow polygon's own opaque fill never covers them, but above the
   base .card-face__half fill (auto/0) so it isn't itself hidden by its unpicked sibling.

   The explicit width/height below are load-bearing, not belt-and-braces: an <svg> is a REPLACED
   element with an intrinsic aspect ratio derived from its viewBox, and for replaced elements CSS
   resolves an auto height from that ratio and the used width — NOT from `top: 0; bottom: 0`. So
   `inset: 0` alone sized this element by its viewBox's ratio instead of by its container, which
   (combined with the `overflow: visible` this needs to bleed past the card edge) left the glow
   hanging up to ~16px below a wide row, straight across the team-abbreviation labels. It went
   unnoticed for several rounds of tuning because the error is proportional to the card's width and
   was only ~0.7px at the one viewport width being tested. See tradingCardFace.js's SIZE_GLOW_PX
   comment for the full write-up and the matching JS-side change. */
.card-face__glow-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
}
.card-face--mini .card-face__glow-svg,
.card-face--full .card-face__glow-svg {
  inset: 8px;
  width: calc(100% - 16px); /* matches the 8px inset on both sides — see above: an explicit size is
    required here, a percentage of the container alone would be 16px too large. */
  height: calc(100% - 16px);
}

.card-face__logo {
  position: absolute;
  /* An <img>, so it needs drag suppression of its own on top of .card-face's selection rules —
     a desktop drag would otherwise lift the logo out as a drag payload. Same pairing
     .avatar-circle__image already uses. `pointer-events: none` below stops taps but not drags. */
  -webkit-user-drag: none;
  width: var(--face-logo-size, 40px);
  height: var(--face-logo-size, 40px);
  border-radius: var(--radius);
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
  pointer-events: none;
  z-index: 3; /* above .card-face__glow-svg (2) so its glow polygon never covers this logo */
  transition: opacity 200ms ease;
}
/* The losing side's logo on a finished game (screens/previous.js's applyRowColoring) — darkened,
   not faded. `filter` is a single property, not an additive list, so the wrapper's own
   drop-shadow above has to be restated here or setting brightness would drop it; that repetition
   is deliberate. brightness() also composes correctly with the contrast outline, which lives on
   the inner <img> (.card-face__logo--needs-outline below): the child's filter runs first, then
   this one darkens the already-outlined result, so a white outline dims along with its crest
   instead of staying bright on a dimmed logo. Opacity was tried first and is the wrong tool —
   fading a logo toward the team color directly behind it can erase a low-contrast crest
   completely, which is what that outline exists to prevent in the first place. */
.card-face__logo--loser {
  filter: brightness(0.45) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.card-face__logo img { width: 100%; height: 100%; object-fit: contain; }
/* Same technique/reasoning as .trading-card__logo--needs-outline above — see that rule's own
   comment — now shared via js/logoContrast.js instead of being pickStack.js-specific. */
.card-face__logo--needs-outline img { filter: url(#trading-card-contrast-outline); }

.card-face--row .card-face__logo--away { top: 50%; left: 16%; transform: translate(-50%, -50%); }
.card-face--row .card-face__logo--home { top: 50%; left: 84%; transform: translate(-50%, -50%); }
.card-face--mini .card-face__logo--away { top: 30%; left: 28%; transform: translate(-50%, -50%); }
.card-face--mini .card-face__logo--home { top: 62%; left: 72%; transform: translate(-50%, -50%); }
.card-face--full .card-face__logo--away { top: 30%; left: 28%; transform: translate(-50%, -50%); }
.card-face--full .card-face__logo--home { top: 62%; left: 72%; transform: translate(-50%, -50%); }

/* Always positioned at its own half's OUTER edge (away: left; home: right) — see
   tradingCardFace.js's own comment on why that's what keeps it clear of the diagonal clip-path
   regardless of which half it ends up attached to. */
/* A direct child of .card-face (a sibling of .card-face__diagonal and .card-face__glow-svg), NOT
   nested inside its favored half — see tradingCardFace.js's own comment on why: nested inside a
   half, its z-index (meant to outrank the glow SVG's) silently didn't take effect in testing. */
.card-face__endblock {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.68rem;
  font-weight: 800;
  color: white;
  background: rgba(20, 20, 20, 0.88);
  padding: 3px 7px;
  border-radius: 999px;
  z-index: 4; /* above .card-face__glow-svg's (2) and .card-face__logo's (3) — same reasoning */
  transition: box-shadow 200ms ease;
}
/* The spread pick's own verdict ring — same idea/values as .card-face__logo--verdict-* above,
   just a smaller radius to fit this pill's own shape, sourced from isSpreadCorrect rather than
   isOutrightCorrect (the same two-grades-shown-independently split fancyButton's own header
   comment already documents for the spread chip). Only applied when isSpreadCorrect doesn't
   return null (a push isn't a win or a loss either way — see that function's own comment). */
.card-face__endblock--verdict-correct {
  box-shadow: 0 0 0 2px var(--success), 0 0 10px 1px rgba(51, 204, 51, 0.6);
}
.card-face__endblock--verdict-incorrect {
  box-shadow: 0 0 0 2px var(--fail), 0 0 10px 1px rgba(204, 51, 51, 0.6);
}
/* A live (not yet finished) spread pick — restored at the user's request alongside the matching
   half-glow's own "blue while live" state (screens/previous.js's applyRowColoring). */
.card-face__endblock--verdict-live {
  box-shadow: 0 0 0 2px var(--accent), 0 0 10px 1px rgba(0, 191, 255, 0.6);
}
/* Positioned by its own side modifier now (card-face__endblock--away/--home), not an ancestor
   selector — it's no longer a descendant of .card-face__half--away/--home at all (see above).
   `row` has no inset on .card-face__diagonal (0), so 4px from .card-face's own edge lines up with
   that half's outer edge exactly; `mini`/`full` inset .card-face__diagonal by 8px, so this needs
   the matching extra offset (8px + the same 4px margin) to still land in the same visual spot
   relative to the half beneath it, even though this element itself isn't inset. */
.card-face__endblock--away { left: 4px; }
.card-face__endblock--home { right: 4px; }
.card-face--full .card-face__endblock--away { left: 12px; }
.card-face--full .card-face__endblock--home { right: 12px; }
/* Mini gets a tighter 6px margin than full's 12px, and a smaller pill (with a matching step-down on
   .card-face--mini .card-face__score below) — a binder cell is as narrow as ~146px at a 3-column
   layout, where the centered score and this badge otherwise met in the middle. See that score rule
   for the full reasoning. */
.card-face--mini .card-face__endblock {
  font-size: 0.56rem;
  padding: 2px 5px;
}
.card-face--mini .card-face__endblock--away { left: 6px; }
.card-face--mini .card-face__endblock--home { right: 6px; }

.card-face__score {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 0.8rem;
  font-weight: 800;
  color: white;
  background: rgba(0, 0, 0, 0.55);
  padding: 3px 8px;
  border-radius: 8px;
  z-index: 4; /* same reasoning as .card-face__endblock just above */
  white-space: nowrap;
}
.card-face--full .card-face__score { font-size: 1.1rem; padding: 4px 12px; }
/* Mini cards show BOTH the line and the score (2026-08-16 — see screens/previousBinder.js's
   buildMiniCard comment), and unlike a row (340px wide, with the two badges at opposite ends) a
   binder cell is as narrow as ~146px once the grid fits 3 columns. At the inherited sizes the
   centered score and the favorite-edge end-block met in the middle. Both are stepped down (see the
   .card-face--mini .card-face__endblock rule above for its half) to keep real clearance between
   them at the narrowest cell the grid can produce — verified by measuring both badges' actual
   boxes across widths rather than by eye, since two elements that only touch at *some* widths are
   exactly the kind of thing a single screenshot certifies as fine. */
.card-face--mini .card-face__score { font-size: 0.62rem; padding: 2px 6px; border-radius: 6px; }
.card-face__score:empty { display: none; }

/* A LIVE game's running score at the divide (2026-08-20, at the user's request — see
   tradingCardFace.js's applyFinalState for the reasoning). Same badge as the final score, since it
   is the same fact in the same place, MARKED so it can never be read as a result: a pulsing red dot
   plus a red-tinted ring, the same signal .live-tag and .final-row__live-dot already give a game in
   progress elsewhere in this app. The background stays dark rather than going red — this badge sits
   over two arbitrary team colors, and legibility is the one thing it cannot trade away — so the
   tint is carried by the ring and the dot, which are drawn over that guaranteed-dark ground.
   Deliberately NOT the accent blue this screen's live *pick* verdicts use: that is the pick's state
   and this is the game's, and one live game's card can carry both at once (a blue verdict glow
   traces the picked half directly behind this badge). */
.card-face__score--live {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  background: rgba(46, 8, 8, 0.72);
  box-shadow: 0 0 0 1px rgba(204, 51, 51, 0.9);
}
.card-face__score-dot {
  flex: 0 0 auto;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--fail);
  /* Same keyframes .final-row__live-dot uses (css/screens.css) — one live pulse in this app, not
     a second one that drifts out of step with it. */
  animation: live-dot-pulse 1.6s ease-in-out infinite;
}
.card-face--full .card-face__score-dot { width: 8px; height: 8px; }
/* The mini card drops the dot entirely and keeps only the ring. The score and the favorite-edge
   end-block already only just clear each other in a ~146px 3-column binder cell (see the
   .card-face--mini .card-face__score rule above for the measurements that forced both badges down
   a size), so the ~10px a dot and its gap would add is exactly what closes that gap — and the ring
   still distinguishes live from final, which is the whole job. */
.card-face--mini .card-face__score-dot { display: none; }

/* --- Matchup detail popup (components/matchupDetailModal.js, View 3 of the Previous redesign,
   2026-08-16) — same overlay/card/close-button shape as .profile-card-modal above (own copy, not
   shared — this app's existing per-modal-file convention, see that rule's own header comment). */
.matchup-detail-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 40;
}
.matchup-detail-modal__card {
  position: relative;
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 26px 16px 16px;
  width: 100%;
  max-width: 380px;
  max-height: 86vh;
  overflow-y: auto;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.matchup-detail-modal__close {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-dark);
  cursor: pointer;
  font-size: 0.9rem;
  z-index: 3; /* above the card-face header, which otherwise sits on top of everything below it */
}
.matchup-detail-modal__header { width: 100%; }
.matchup-detail-modal__names {
  text-align: center;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text-on-dark);
}
/* The state line under the school names (0.12.38): "Final · Sat 8/29", or the running clock
   while the game is on. Same vocabulary and the same muted treatment as
   .previous-row__meta on the screen this modal opens from, so one matchup reads the same at
   all three sizes; the live variant borrows .final-row--live's red rather than the blue this
   screen uses for PICK verdicts, because this describes the state of the GAME. */
.matchup-detail-modal__state {
  text-align: center;
  margin-top: 2px;
  font-size: 0.72rem;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--text-on-dark);
  opacity: 0.6;
}
.matchup-detail-modal__state--live {
  color: var(--fail);
  opacity: 0.9;
  font-weight: 600;
}
/* Reuses .toggle-group's base look (css/screens.css, near .week-header) via
   components/toggleGroup.js's extraClassName — centered and wrapped here since this sits inside a
   narrow modal card rather than a wide sticky header bar. */
.matchup-detail-modal__toggle {
  justify-content: center;
  flex-wrap: wrap;
  gap: 6px 10px;
}
/* Explains the deliberate duplication across the two bucket axes — see
   components/matchupDetailModal.js. Sized and faded well below the toggle it annotates: it should
   answer the question if you stop to ask it, not be the first thing read. */
.matchup-detail-modal__bucket-hint {
  text-align: center;
  font-size: 0.68rem;
  opacity: 0.5;
  margin: 2px 0 0;
}
.matchup-detail-modal__players {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-height: 60px;
}
.matchup-detail-modal__players-empty {
  text-align: center;
  opacity: 0.6;
  font-size: 0.85rem;
  margin: 12px 0;
}
.matchup-detail-modal__player-row {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--bg);
  border-radius: 10px;
  padding: 6px 10px;
  font-size: 0.85rem;
  color: var(--text-on-dark);
}
.matchup-detail-modal__player-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  overflow: hidden;
  position: relative;
  flex-shrink: 0;
}

/* --- Head-to-head (0.12.50) ---------------------------------------------------------------
   You against one other player, opened from the "Compare with you" button on another player's
   profile card (js/components/headToHeadModal.js). Its own class names and its own block, the
   same self-contained arrangement every other modal in this file uses.

   position: fixed, not absolute — the document is what scrolls in this app, so an absolute
   overlay sizes to the whole PAGE and align-items: center puts its card at the middle of the
   document rather than the viewport. That was measured at 820x1180 on a different overlay and
   it put the card 905px down, off-screen in landscape. See CLAUDE.md's "Verifying UI changes".

   z-index sits ABOVE .profile-card-modal's 40, because it opens on top of that card and
   deliberately does not replace it: dismissing the comparison returns the reader to the card. */
.h2h-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 5, 5, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 45;
}
.h2h-modal__card {
  position: relative;
  background: var(--row-even);
  border-radius: var(--radius);
  padding: 26px 20px 22px;
  width: 100%;
  max-width: 380px;
  max-height: 84vh;
  overflow-y: auto;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
.h2h-modal__close {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text-on-dark);
  cursor: pointer;
  font-size: 0.9rem;
}
/* The two faces. A three-column grid rather than a flex row so the "vs" stays dead centre
   however different the two names are in length — with flex the wider name pushes it off. */
.h2h-modal__heads {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  gap: 10px;
  width: 100%;
  margin-bottom: 4px;
}
.h2h-modal__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.h2h-modal__avatar {
  width: 64px;
  height: 64px;
  border-radius: 50%;
  overflow: hidden;
  position: relative;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px solid rgba(255, 255, 255, 0.14);
}
/* Long display names are real here (the legacy import created rows like "brantbarnhart16"), and
   a two-column layout has nowhere to put one. Wrapping is preferred to an ellipsis: the name is
   the label for the whole right-hand column of every number below it. */
.h2h-modal__head-name {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text-on-dark);
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.h2h-modal__vs {
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-on-dark);
  opacity: 0.45;
  padding-top: 24px;
}
.h2h-modal__body { width: 100%; }
.h2h-modal__scopes {
  justify-content: center;
  margin-top: 14px;
}
.h2h-modal__scopes button { font-size: 0.8rem; }
.h2h-modal__caption {
  margin: 8px 0 2px;
  text-align: center;
  font-size: 0.72rem;
  letter-spacing: 0.03em;
  color: var(--text-on-dark);
  opacity: 0.6;
}
/* A scoreline: label above, the two numbers side by side, an optional denominator underneath. */
.h2h-modal__scoreline {
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.h2h-modal__scoreline-label {
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-on-dark);
  opacity: 0.55;
}
.h2h-modal__score {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 10px;
  margin-top: 4px;
}
/* Both numbers are the same size; only the colour separates them. Sizing the leader larger was
   tried and read as a typo on a tied scoreline, where neither part is marked. */
.h2h-modal__score-part {
  font-size: 1.55rem;
  font-weight: 700;
  color: var(--text-on-dark);
  opacity: 0.55;
  font-variant-numeric: tabular-nums;
}
.h2h-modal__score-part--leading {
  opacity: 1;
  color: var(--accent);
}
.h2h-modal__score-dash {
  font-size: 1.1rem;
  color: var(--text-on-dark);
  opacity: 0.35;
}
.h2h-modal__scoreline-note {
  margin-top: 2px;
  font-size: 0.7rem;
  color: var(--text-on-dark);
  opacity: 0.5;
}
.h2h-modal__section-title {
  margin-top: 18px;
  padding-top: 12px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-on-dark);
  opacity: 0.55;
}
/* Split / You / Them. A grid of plain divs rather than a table element, matching every other
   grid-shaped UI in this app (there is no table anywhere in this codebase to be consistent
   with instead). The label column is wider than the three number columns, which are equal so
   the digits line up under their headings. */
.h2h-modal__table {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr 1.4fr;
  gap: 2px 4px;
  margin-top: 10px;
  align-items: center;
}
.h2h-modal__cell {
  padding: 6px 2px;
  font-size: 0.85rem;
  color: var(--text-on-dark);
  font-variant-numeric: tabular-nums;
}
.h2h-modal__cell--head,
.h2h-modal__cell--corner {
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  opacity: 0.5;
  padding-bottom: 2px;
  overflow-wrap: anywhere;
}
.h2h-modal__cell--label {
  text-align: left;
  opacity: 0.8;
  font-size: 0.8rem;
}
.h2h-modal__cell--value { font-weight: 600; opacity: 0.75; }
.h2h-modal__cell--leading { color: var(--accent); opacity: 1; }
.h2h-modal__agreement {
  margin: 12px 0 0;
  font-size: 0.82rem;
  line-height: 1.4;
  color: var(--text-on-dark);
  opacity: 0.85;
}
.h2h-modal__quiet {
  margin: 10px 0 0;
  font-size: 0.75rem;
  line-height: 1.4;
  color: var(--text-on-dark);
  opacity: 0.55;
}
.h2h-modal__loading { opacity: 0.4; }

/* The button that opens it, at the foot of another player's profile card. Full width of the
   card's inner column so it reads as an action rather than as one more stat. */
.profile-card-modal__compare {
  margin-top: 16px;
  width: 100%;
  padding: 10px 12px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-on-dark);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}

/* --- Share week card (0.12.50) -------------------------------------------------------------
   Two entry points into js/components/shareWeekCard.js, styled differently on purpose.

   The recap modal's is SECONDARY to the "Let's go" button directly under it: that modal appears
   once per week per browser and dismissing it retires it for good, so the share control must not
   compete with the one action that lets the reader finish reading. Ghost button, no fill.

   The Podium's is a quiet full-width row at the foot of a settled week's list, where it reads as
   "and here is something you can do with what you just looked at". */
/* display: block + auto margins, NOT align-self: stretch — .week-recap-modal__card is a plain
   block with text-align: center, not a flex column, so a flex property here is silently inert and
   the two buttons would sit SIDE BY SIDE on one line whenever both fit inside the 292px inner
   column, which reads as an accident. Block puts it on its own line above "Let's go" while the
   auto margins keep it content-width and centred, matching .btn's own inline-block shape. */
.week-recap-modal__share {
  display: block;
  margin: 14px auto 0;
  padding: 10px 16px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: transparent;
  color: var(--text-on-dark);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}
.podium-share-row {
  display: flex;
  justify-content: center;
  margin: 18px 0 4px;
}
.podium-share-btn {
  padding: 10px 20px;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(255, 255, 255, 0.05);
  color: var(--text-on-dark);
  font-size: 0.82rem;
  font-weight: 600;
  opacity: 0.85;
  cursor: pointer;
}

/* --- The corner share button on a modal (0.12.51) -------------------------------------------
   One rule serving both the profile card and the head-to-head modal, because they are the same
   affordance in the same place and a second copy is how the two drift apart.

   IT SITS OPPOSITE THE CLOSE BUTTON rather than beside it. Both modals put their close ✕ at
   top-right at 10/10 with a 28px box, and two 28px circles side by side in the same corner read
   as a pair of equals - one of which destroys what you are looking at. Top-LEFT keeps the
   destructive control alone where the reader already expects it.

   Deliberately quieter than the close button: no fill at rest, just a hairline, so it reads as an
   offer rather than as a second way out. Sized to the same 28px box so the two corners balance. */
.modal-share-btn {
  position: absolute;
  top: 10px;
  left: 10px;
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: transparent;
  color: var(--text-on-dark);
  opacity: 0.65;
  cursor: pointer;
  padding: 0;
  z-index: 2;
}
.modal-share-btn svg { display: block; }
/* Hover only, and no transform: the profile card is not FLIP-animated, but this rule is shared and
   the next modal to use it might be - a 2px lift on anything the Podium captures rects for hands
   back 0.11.28's shimmer. See css/desktop.css's own note. */
@media (hover: hover) and (pointer: fine) {
  .modal-share-btn:hover {
    opacity: 1;
    border-color: rgba(255, 255, 255, 0.4);
  }
}
