/* Every colour the page paints lives here. Nothing below this block should
   hardcode a hex - the dark theme is nothing more than a second copy of these
   values, so a literal colour further down is a rule that silently stays in
   light mode. The two exceptions are #fff text sitting on --red/--navy fills,
   which are dark enough in both themes for white to stay legible. */
:root{
  color-scheme: light;
  --navy: #0f2238;
  --navy-2: #16233a;
  --red: #c8102e;
  --red-dark: #a10d26;
  --blue: #1550a1;
  --bg: #eef1f4;
  --card: #ffffff;
  --border: #e0e4e9;
  --text: #1a1d24;
  --muted: #6b7280;
  --green: #1e7d32;
  --gold: #d9a300;

  --tint: #f3f5f8;              /* neutral fill: badges, hover rows, tag pills */
  --tint-red: #fdeef0;
  --tint-red-border: #f0b8c0;
  --tint-blue: #eef4fd;
  --tint-blue-border: #b9d0f0;
  --border-hover: #c7ccd3;
  --star-off: #c7ccd3;
  --dot-off-fill: #ffffff;      /* empty base diamond + empty outs dot */
  --dot-off-stroke: #c7ccd3;
  --shadow: rgba(0,0,0,0.12);
  --scrim: rgba(15,34,56,0.45);
}

/* Dark theme. --red stays put: it only ever appears as a fill under white
   text or as a filled outs dot, and it reads fine on the dark card. --navy,
   --blue, --green, --red-dark and --gold all lighten, since each is used as
   text or as an accent that has to separate from --card rather than from
   white. */
:root[data-theme="dark"]{
  color-scheme: dark;
  --navy: #3f6699;
  --navy-2: #32527a;
  --red: #c8102e;
  --red-dark: #ff8494;
  --blue: #6ea8f0;
  --bg: #12161c;
  --card: #1a2029;
  --border: #2b323d;
  --text: #e8eaed;
  --muted: #9aa4b2;
  --green: #55c47a;
  --gold: #e0b13c;

  --tint: #232b36;
  --tint-red: #3a1e22;
  --tint-red-border: #5c2c33;
  --tint-blue: #1c2a3d;
  --tint-blue-border: #2c3f57;
  --border-hover: #3a4250;
  --star-off: #4a5361;
  --dot-off-fill: #1a2029;
  --dot-off-stroke: #4a5361;
  --shadow: rgba(0,0,0,0.5);
  --scrim: rgba(0,0,0,0.62);
}
*{box-sizing:border-box;}
body{
  margin:0;
  background:var(--bg);
  font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
  color:var(--text);
}
button{font-family:inherit;cursor:pointer;}
.page{max-width:1180px;margin:0 auto;padding:24px 20px 60px;}

/* ---- Title row ---- */
/* Sticky (Alex's ask): stays reachable while scrolling through a long plays
   list - the session selector and Key Moments/Favorites/Refresh/Settings
   controls are the things you'd actually want mid-scroll, not just the
   title text. An opaque background is required, not decorative - without
   one, scrolled-past content would show straight through once this is
   pinned above it. z-index sits below .modal/.toast (20/30) so those still
   layer correctly on top of a stuck header. */
.title-row{
  display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:10px;
  position:sticky;top:0;z-index:15;background:var(--bg);
  padding:10px 0;margin-top:-10px;border-bottom:1px solid var(--border);
}
.title-row h1{font-size:24px;margin:0;letter-spacing:normal;}
.session-select{
  width:auto;min-width:132px;padding:6px 10px;font-size:14px;font-weight:700;
  color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:6px;
}
/* Session selector + the two icon buttons travel together as one cluster -
   on phone this whole group wraps to its own line below the title (see the
   600px block), which is what keeps all three on one line instead of the
   selector splitting off with the title the way it used to. */
.title-actions{margin-left:auto;display:flex;align-items:center;gap:10px;}

/* Icon-only, borderless, muted buttons - Refresh now matches the theme
   toggle's treatment instead of looking like a separate bordered button, so
   the two read as one family of small header controls. Both icons in
   .theme-toggle ship in the markup; CSS picks whichever matches the theme so
   there is no JS icon swap to keep in sync. */
.icon-btn{
  display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;
  width:28px;height:28px;padding:0;border:none;background:none;border-radius:6px;
  color:var(--muted);line-height:0;
}
.icon-btn svg{width:15px;height:15px;display:block;}
.icon-btn:disabled{opacity:0.45;cursor:default;}
@media (hover: hover){
  .icon-btn:hover:not(:disabled){color:var(--text);background:var(--tint);}
}
.theme-toggle .icon-sun{display:none;}
:root[data-theme="dark"] .theme-toggle .icon-moon{display:none;}
:root[data-theme="dark"] .theme-toggle .icon-sun{display:block;}

/* Phone-only Key Moments / Favorites shortcuts - hidden here, re-shown by
   the phone breakpoint below. Deliberately subtle: a color change only, no
   filled background circle (unlike .chip.active's red fill) - the star also
   fills solid when active, the same outline-vs-filled language stars
   already carry everywhere else. */
.header-toggle-btn{display:none;}
.header-toggle-btn.active{color:var(--navy);}
#header-fav-toggle.active svg{fill:currentColor;}

/* Reserves no space at all when empty (the common case) - only takes a line
   once app.js actually puts a message in it. */
.refresh-status{font-size:12px;color:var(--muted);text-align:right;}
.refresh-status:not(:empty){margin-bottom:10px;}

/* ---- Scoreboard ---- */
/* Sits above #scoreboard so "Updated X min ago" always has somewhere to
   live, even when the scoreboard itself is hidden (e.g. "Full season") - see
   the markup comment in index.html. When the "Scoreboard" label is present
   (app.js sets its text only while games are showing) it sits on the left of
   this same line; built-at's margin-left:auto keeps it right-justified
   either way. */
.section-head{display:flex;align-items:center;gap:10px;margin-bottom:8px;}
.scoreboard-head{font-size:11px;font-weight:800;color:var(--muted);letter-spacing:0.6px;text-transform:uppercase;}
.built-at{font-size:12px;color:var(--muted);margin-left:auto;}
.scoreboard{margin-bottom:22px;}
/* Column count is set inline by applyScoreboardColumns() (app.js) so every
   row ends up as close to equal-count as possible - a plain wrapping flex
   row would rather overfill earlier rows (e.g. 6-then-2 for 8 tiles) than
   balance them. */
.scoreboard-row{display:grid;gap:10px;}
.scoreboard-tile{
  position:relative;
  background:var(--card);border:1px solid var(--border);border-radius:8px;
  padding:10px 12px 12px;text-align:left;font-family:inherit;color:inherit;
  display:flex;flex-direction:column;gap:8px;overflow:hidden;
}
.scoreboard-tile{cursor:pointer;}
.scoreboard-tile:focus-visible{outline:2px solid var(--navy);outline-offset:2px;}
.scoreboard-tile.selected{border-color:var(--navy);box-shadow:0 0 0 2px var(--navy) inset;}

/* Replay control. Lives in .sb-foot's action row next to the leverage pill -
   it used to sit at the tile's top-right, on top of the scorebug. Still a real
   nested <button> inside the role="button" tile; app.js stops the event so it
   never also selects the game. */
.tile-replay-btn{
  position:relative;z-index:2;
  width:20px;height:20px;padding:0;display:flex;align-items:center;justify-content:center;
  border:1px solid var(--border);border-radius:20px;background:var(--tint);
  color:var(--muted);cursor:pointer;flex-shrink:0;
}
.tile-replay-btn svg{width:9px;height:9px;fill:currentColor;margin-left:1px;}
.tile-replay-btn:hover,.tile-replay-btn:focus-visible{
  color:var(--navy);border-color:var(--navy);
}
.tile-replay-btn.loading{opacity:0.4;pointer-events:none;}
@media (hover: hover){
  .scoreboard-tile:hover{border-color:var(--border-hover);}
  .scoreboard-tile.selected:hover{border-color:var(--navy);}
}
/* Team stack on the left, scorebug (inning arrow, diamond, outs) on the
   right - same left/right split as a play card's moment-left/moment-right,
   and matches the reference scoreboard layout. */
.sb-body{display:flex;align-items:center;justify-content:space-between;gap:10px;}
.sb-teams{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;}
.sb-row{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:700;color:var(--muted);}
.sb-row.batting{color:var(--text);}
.sb-logo{width:20px;height:20px;object-fit:contain;flex-shrink:0;}
/* Fixed width so the score sits at the same x-position on both rows (and
   every tile) regardless of abbreviation length ("BBEG" vs "KC") - right
   next to its own team instead of pushed out to the tile's far edge, where
   it read as belonging to the diamond/outs cluster instead. */
.sb-abbr{letter-spacing:0.2px;min-width:47px;}
.sb-score{font-size:14px;font-weight:800;}
.sb-state{display:flex;align-items:center;gap:10px;flex-shrink:0;}
.sb-state .inning-indicator{min-width:20px;}
.sb-state .state-stack{min-width:0;}
.sb-state .state-badge{font-size:9.5px;padding:4px 7px;}

/* Leverage floats centered above the WP bar rather than sitting in its own
   row - it's a label on the bar, not a separate piece of the tile, and this
   way a finished game (no badge) just shows the bar with nothing reserved
   above it. */
/* margin-top reserves real space for the absolutely-positioned badge below,
   rather than letting it float over whatever sb-body happens to end with. */
.sb-foot{position:relative;margin-top:20px;}
.wp-bar{display:flex;height:4px;border-radius:3px;overflow:hidden;background:var(--border);}
.wp-seg{height:100%;}
/* The pill and the replay button centre together as one row. Previously
   .sb-lev centred itself; now the row does, so a finished game (pill omitted)
   still centres correctly on the button alone. */
.sb-actions{
  position:absolute;left:50%;bottom:100%;transform:translateX(-50%);margin-bottom:4px;
  display:flex;align-items:center;gap:5px;
}
.sb-lev{
  font-size:10px;font-weight:800;color:var(--muted);white-space:nowrap;
  background:var(--tint);border:1px solid var(--border);border-radius:20px;padding:2px 8px;letter-spacing:0.2px;
}
.sb-lev.hot{color:var(--red-dark);background:var(--tint-red);border-color:var(--tint-red-border);}

/* ---- Filters ---- */
.filters-card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:18px 20px;margin-bottom:22px;max-width:100%;}
/* Phone-only disclosure. The min-width rule below force-shows .filters-body
   regardless of the .collapsed class, so widening the window can never leave
   the panel stuck shut. */
.filters-toggle{
  display:none;width:100%;align-items:center;justify-content:space-between;gap:10px;
  background:none;border:none;padding:0;font-size:13px;font-weight:800;color:var(--text);
  letter-spacing:0.3px;
}
.filters-toggle .caret{color:var(--muted);font-size:11px;}
.filter-row{display:flex;gap:28px;flex-wrap:wrap;margin-bottom:16px;}
.filter-row:last-of-type{margin-bottom:0;}
.fields-row .filter-group{flex:1;min-width:200px;max-width:320px;}
/* Outs/On-base don't want an equal flex share the way Result/Team/Player do -
   forcing them to the same min-width as a search box or select just stretches
   their box past what a handful of small chips actually need, leaving dead
   space to their right (Alex's catch). Sized to content instead, which also
   frees up room for the row to actually fit on one line on a wide screen. */
.fields-row .filter-group.compact{flex:0 0 auto;min-width:0;max-width:none;}
.player-search-group{position:relative;}
.player-suggest{
  position:absolute;top:100%;left:0;right:0;margin-top:4px;z-index:20;
  background:var(--card);border:1px solid var(--border);border-radius:6px;
  max-height:260px;overflow-y:auto;box-shadow:0 4px 14px var(--shadow);
}
.player-suggest-row{
  display:flex;align-items:center;gap:10px;padding:7px 12px;font-size:13px;cursor:pointer;
}
.player-suggest-row:hover, .player-suggest-row.active{background:var(--tint);}
.player-suggest-row .team{color:var(--muted);font-size:11.5px;margin-left:auto;}
.player-suggest-empty{padding:8px 12px;font-size:12.5px;color:var(--muted);}
.filter-group.wide{flex:1 1 100%;}
.filter-group label.group-label{
  display:block;font-size:11px;font-weight:800;color:var(--muted);
  letter-spacing:0.6px;margin-bottom:8px;text-transform:uppercase;
}
.group-hint{font-weight:600;letter-spacing:0;text-transform:none;opacity:0.75;margin-left:6px;}
.chip-row{display:flex;flex-wrap:nowrap;gap:8px;}
.chip-row.wrap{flex-wrap:wrap;}
.chip{padding:7px 13px;border-radius:6px;font-size:12.5px;font-weight:700;border:1px solid var(--border);background:var(--card);color:var(--text);white-space:nowrap;}
.chip.active{background:var(--red);border-color:var(--red);color:#fff;}
/* Dimming reads as further away from the background in dark mode than it
   does on white, so the disabled Side group needs a little more left. */
.chip:disabled{opacity:0.45;cursor:default;}
:root[data-theme="dark"] .chip:disabled{opacity:0.55;}
.chip:disabled.active{background:var(--card);border-color:var(--border);color:var(--text);}
/* Tag chips OR together rather than excluding each other - the checkmark is
   the visual cue that several can be on at once. */
.chip.multi{border-radius:20px;}
.chip.multi.active{background:var(--navy);border-color:var(--navy);}
.chip.multi.active::before{content:"\2713\00a0";}
/* On-base filter chips reuse the same mini-diamond markup the play cards
   already draw (data.meta.bases_svg, .base-diamond.on/.off) rather than a
   second hand-drawn icon - "aesthetic" here means visually identical to how
   OBC already reads everywhere else in the app, not a new symbol to learn.
   Scaled down slightly (30x30 native) to sit at the same height as the text
   chips beside it. */
.obc-chip-row{flex-wrap:wrap;}
.obc-chip{padding:4px;line-height:0;}
.obc-chip svg{width:22px;height:22px;display:block;}
.obc-chip.active{background:var(--red);border-color:var(--red);}
select, input[type=text]{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;color:var(--text);background:var(--card);}
.filters-footer{display:flex;justify-content:space-between;align-items:center;margin-top:18px;padding-top:14px;border-top:1px solid var(--border);}
.count-text{font-weight:700;font-size:13.5px;}
.footer-actions{display:flex;gap:16px;align-items:center;}
.link-btn{font-size:13px;color:var(--muted);font-weight:600;background:none;border:none;padding:0;}
.link-btn:hover{color:var(--text);}
.play-filtered-btn{margin-right:8px;}

/* ---- Sort bar ---- */
.sort-bar{display:flex;align-items:center;justify-content:flex-end;margin-bottom:16px;padding:0 2px;}
.sort-chip-row{display:flex;gap:8px;flex-wrap:wrap;}
.lbl-short{display:none;}

/* ---- Moment row ---- */
.moment{
  position:relative;
  background:var(--card);border:1px solid var(--border);border-radius:8px;
  margin-bottom:10px;padding:14px 18px 14px 17px;
  display:flex;align-items:stretch;gap:18px;overflow:hidden;
}
.corner-actions{position:absolute;top:8px;right:10px;display:flex;align-items:center;gap:6px;}
.game-link{
  font-size:12px;color:var(--muted);text-decoration:none;line-height:1;
}
.game-link:hover{color:var(--blue);}
/* Jumps into Game Replay at this exact play - same round icon-badge format
   as .tile-replay-btn on the scoreboard cards, so the two read as the same
   affordance. An SVG polygon, not a "▶" text glyph: iOS renders that glyph
   with its own colored emoji presentation, which doesn't match the app's
   monochrome icon set. */
.play-jump-btn{
  position:relative;z-index:2;
  width:20px;height:20px;padding:0;display:flex;align-items:center;justify-content:center;
  border:1px solid var(--border);border-radius:20px;background:var(--tint);
  color:var(--muted);cursor:pointer;flex-shrink:0;
}
.play-jump-btn svg{width:9px;height:9px;fill:currentColor;margin-left:1px;}
.play-jump-btn:hover,.play-jump-btn:focus-visible{color:var(--blue);border-color:var(--blue);}
.play-jump-btn.loading{opacity:0.4;pointer-events:none;}
/* Absolutely positioned, not a flex child - a flex item stretched with
   align-items:stretch only fills the flex line's content-box height, which
   sits inset by .moment's own top/bottom padding, so the bar fell short of
   the card's actual top and bottom edges. top:0/bottom:0 against .moment's
   position:relative spans the full card height at every width instead. */
.lev-bar{position:absolute;left:0;top:0;bottom:0;width:6px;border-radius:4px 0 0 4px;}
.lev-bar.neutral{background:var(--card);border-right:1px solid var(--border);}

/* Featured team's logo, inline to the left of the scorebug at every width. */
.team-logo-inline{width:32px;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
.team-logo-inline-img{max-width:100%;max-height:100%;object-fit:contain;display:block;}

.moment-left{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;gap:6px;}
.timestamp{font-size:11.5px;color:var(--muted);font-weight:600;}
.play-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.player-name{font-size:15px;font-weight:800;color:var(--text);text-decoration:none;}
.player-name:hover{text-decoration:underline;}
.star-btn{background:none;border:none;padding:0 2px;font-size:15px;line-height:1;color:var(--star-off);}
.star-btn.on{color:var(--gold);}

.counterpart{font-size:12.5px;color:var(--muted);}
.counterpart-name{color:var(--muted);text-decoration:none;}
.counterpart-name:hover{text-decoration:underline;}
.counterpart-fav{color:var(--gold);}

.result-pill, .diff-pill{font-size:11px;font-weight:800;padding:3px 10px;border-radius:20px;border:1px solid;letter-spacing:0.3px;white-space:nowrap;}
.result-pill.offense, .diff-pill.zero{color:var(--blue);border-color:var(--tint-blue-border);background:var(--tint-blue);}
.result-pill.defense, .diff-pill.five{color:var(--red-dark);border-color:var(--tint-red-border);background:var(--tint-red);}

.scoring-line{font-size:12.5px;color:var(--muted);font-style:italic;}

.meta-line{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:12.5px;color:var(--muted);}
.wpa-pos{color:var(--green);font-weight:700;}
.wpa-neg{color:var(--red-dark);font-weight:700;}
.why-line{display:flex;gap:6px;flex-wrap:wrap;}
.why-tag{font-size:10.5px;font-weight:700;color:var(--muted);background:var(--tint);border:1px solid var(--border);border-radius:4px;padding:2px 7px;letter-spacing:0.3px;}
/* Phone-only duplicate (10c/6d note) - hidden until the phone breakpoint
   swaps which copy is visible. */
.why-line-bottom{display:none;}

/* ---- Scorebug (right side) ---- */
.moment-right{display:flex;align-items:center;gap:16px;flex-shrink:0;}
.inning-indicator{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:22px;}
.tri{width:0;height:0;}
.tri.up{border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:8px solid var(--text);}
.tri.down{border-left:6px solid transparent;border-right:6px solid transparent;border-top:8px solid var(--text);}
.inning-num{font-size:12px;font-weight:800;color:var(--text);}

.score-block{display:flex;flex-direction:column;gap:2px;min-width:70px;}
.score-block .row{display:flex;justify-content:space-between;font-size:13.5px;font-weight:700;}
.score-block .row .abbr{color:var(--muted);font-weight:800;}
.score-block .row .val{color:var(--muted);font-weight:800;}
.score-block .row.batting .abbr, .score-block .row.batting .val{color:var(--text);}

/* Base diamond stacked over the outs circles, or a single state badge. Fixed
   (not min-) width on the play-card scorebug so a FINAL/END INNING badge -
   wider than the diamond+dots it replaces - can't shift .moment-right's
   total width and throw off the logo/inning/score alignment one row above
   or below it. The scoreboard tile version stays auto-width; those cards
   aren't read as a vertical column, so there's nothing to misalign. */
.state-stack{display:flex;flex-direction:column;align-items:center;gap:4px;}
.moment-right .state-stack{width:90px;}
.base-diamond.on{fill:var(--blue);stroke:var(--blue);}
.base-diamond.off{fill:var(--dot-off-fill);stroke:var(--dot-off-stroke);}
.outs-dots{display:flex;gap:5px;}
.outs-dots .dot{width:7px;height:7px;border-radius:50%;border:1px solid var(--dot-off-stroke);background:var(--dot-off-fill);}
.outs-dots .dot.on{background:var(--red);border-color:var(--red);}
.state-badge{
  font-size:10.5px;font-weight:800;letter-spacing:0.6px;padding:5px 9px;
  border-radius:4px;border:1px solid var(--border);color:var(--muted);background:var(--tint);
  white-space:nowrap;
}
.state-badge.final{color:#fff;background:var(--navy);border-color:var(--navy);}

.empty-state{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:34px;text-align:center;color:var(--muted);font-size:14px;}

/* ---- Favorites modal ---- */
.modal{position:fixed;inset:0;background:var(--scrim);display:flex;align-items:center;justify-content:center;padding:20px;z-index:20;}
.modal[hidden]{display:none;}
.modal-card{background:var(--card);border-radius:10px;width:100%;max-width:520px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;}
/* On white the scrim alone separates the card from the page; on dark the two
   are close enough in value that it needs a real edge. */
:root[data-theme="dark"] .modal-card{border:1px solid var(--border);}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:16px 18px;border-bottom:1px solid var(--border);font-size:15px;}
.modal-body{padding:16px 18px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;}
.modal-note{margin:0;font-size:12px;color:var(--muted);line-height:1.5;}
.name-row{display:flex;gap:8px;}
.name-row input{flex:1;}
.settings-row{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:14px;}
.speed-field{display:flex;align-items:center;gap:6px;}
.speed-field input{width:64px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--tint);color:var(--text);font-size:13px;text-align:right;}
.speed-field .speed-x{color:var(--muted);font-size:13px;}
.fav-list{display:flex;flex-direction:column;border:1px solid var(--border);border-radius:6px;max-height:320px;overflow-y:auto;}
.fav-row{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);font-size:13px;}
.fav-row:last-child{border-bottom:none;}
.fav-row .fav-team{color:var(--muted);font-size:11.5px;margin-left:auto;}
.fav-status{font-size:12px;color:var(--muted);min-height:16px;}

/* ---- Catch Me Up ---- */
.catchup-banner{
  display:block;width:100%;margin:0 0 10px;padding:10px 14px;border-radius:8px;
  border:1px solid var(--navy);background:var(--navy);color:#fff;
  font-size:13.5px;font-weight:700;letter-spacing:0.2px;text-align:left;cursor:pointer;
}
.catchup-banner[hidden]{display:none;}
.catchup-play-icon{width:9px;height:9px;fill:currentColor;vertical-align:-1px;margin-right:1px;}
.catchup-banner:hover{background:var(--navy-2);border-color:var(--navy-2);}
/* The name prompt is an invitation, not news - it recedes to match the other
   muted furniture on the page rather than shouting like the real thing. */
.catchup-banner.quiet{
  background:var(--tint);border-color:var(--border);color:var(--muted);font-weight:600;
}
.catchup-banner.quiet:hover{background:var(--tint);border-color:var(--border-hover);color:var(--text);}

.catchup-modal{padding:12px;}
/* A fixed height, not auto: title and play slides are different sizes, and a
   card that resized under each one would jump on every advance. Sized to the
   content it actually holds (one .moment card) rather than filling the
   viewport, which just framed a small card in a sea of white. */
.catchup-card{
  background:var(--card);border-radius:12px;width:100%;max-width:660px;
  height:min(86vh,570px);display:flex;flex-direction:column;overflow:hidden;position:relative;
  box-shadow:0 10px 40px var(--shadow);
}
:root[data-theme="dark"] .catchup-card{border:1px solid var(--border);}
.catchup-card.paused{outline:2px solid var(--navy);outline-offset:-2px;}

.catchup-close{
  position:absolute;top:8px;right:10px;z-index:2;
  border:none;background:transparent;color:var(--muted);
  font-size:26px;line-height:1;padding:4px 8px;cursor:pointer;border-radius:6px;
}
.catchup-close:hover{color:var(--text);background:var(--tint);}

.catchup-fullscreen{
  position:absolute;top:8px;right:46px;z-index:2;
  border:none;background:transparent;color:var(--muted);
  width:30px;height:30px;padding:5px;cursor:pointer;border-radius:6px;
}
.catchup-fullscreen:hover{color:var(--text);background:var(--tint);}
.catchup-fullscreen svg{width:100%;height:100%;}
.catchup-fullscreen .icon-compress{display:none;}
.catchup-fullscreen.is-fullscreen .icon-expand{display:none;}
.catchup-fullscreen.is-fullscreen .icon-compress{display:block;}

/* Progress doubles as the slide counter and the bar: the ::after strip is
   driven by --catchup-pct, which showCatchUpSlide() sets per slide. */
/* Sits clear of the close button rather than running the bar underneath it -
   the X occupies the top ~36px of the card. */
.catchup-progress{
  position:relative;flex:0 0 auto;padding:30px 44px 10px 18px;
  font-size:11.5px;font-weight:700;letter-spacing:0.4px;color:var(--muted);
  /* border-box min-height must cover the 40px of padding plus a text line, or
     an empty counter (the "done" slide has no "Play x of y") collapses to just
     the padding and the bar underneath sits ~15px too high. */
  min-height:56px;
}
.catchup-progress::before{
  content:"";position:absolute;left:18px;right:18px;bottom:4px;height:3px;
  background:var(--tint);border-radius:2px;
}
/* --catchup-pct is a unitless number, not a percentage: calc() cannot divide
   by a percentage, and doing so makes the whole width declaration invalid, so
   the bar silently never renders. */
.catchup-progress::after{
  content:"";position:absolute;left:18px;bottom:4px;height:3px;border-radius:2px;
  width:calc((100% - 36px) * var(--catchup-pct, 0) / 100);
  background:var(--navy);transition:width 240ms linear;
}

/* Step controls, carousel style. Pinned to the card's edges and vertically
   centred; the slide reserves matching horizontal padding so scene content
   never runs underneath them. */
.catchup-nav{
  position:absolute;top:50%;transform:translateY(-50%);z-index:3;
  width:34px;height:34px;padding:0;display:flex;align-items:center;justify-content:center;
  border:1px solid var(--border);border-radius:50%;background:var(--card);
  color:var(--muted);cursor:pointer;opacity:0.75;
}
.catchup-nav svg{width:16px;height:16px;display:block;}
.catchup-nav.prev{left:6px;}
.catchup-nav.next{right:6px;}
.catchup-nav:hover:not(:disabled){opacity:1;color:var(--navy);border-color:var(--navy);}
.catchup-nav:disabled{opacity:0.22;cursor:default;}

/* pan-y hands vertical scrolling to the browser and keeps horizontal drags for
   the swipe handler - without it a sideways drag can trigger the platform's own
   back-navigation gesture instead of stepping a play. */
.catchup-slide{
  touch-action:pan-y;
  flex:1 1 auto;overflow-y:auto;padding:6px 46px 8px;
  display:flex;flex-direction:column;justify-content:center;
  opacity:0;transform:translateY(6px);cursor:pointer;
}
.catchup-slide.in{opacity:1;transform:none;transition:opacity 260ms ease,transform 260ms ease;}
/* The card inside a slide is the same .moment the feed renders - it just gets
   room to breathe and drops the hover affordances that mean nothing here. */
.catchup-slide .moment{margin:0;}

.catchup-title{text-align:center;padding:20px 10px;}
.catchup-title-teams{
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
  font-size:20px;font-weight:800;letter-spacing:0.3px;line-height:1.3;
}
.catchup-title-team{display:flex;align-items:center;gap:10px;}
.catchup-title-logo{width:34px;height:34px;object-fit:contain;}
.catchup-at{color:var(--muted);font-weight:600;}
.catchup-title-sub{margin-top:10px;font-size:12.5px;font-weight:600;color:var(--muted);letter-spacing:0.3px;}

/* ---- Play Scene (slideshow play slide) ---- */
/* The scene fills the slide rather than sitting as a small block centred in
   it - the card is a stage, and on phone it is the whole viewport. */
.play-scene{position:relative;display:flex;flex-direction:column;gap:6px;width:100%;}
.catchup-slide > .play-scene{flex:1 1 auto;justify-content:space-evenly;}
/* Pulled in tighter than the general 8px gap above and below the diamond
   specifically (Alex's report: on a short viewport the slide had to
   scroll to show everything, chopping the scorebug off the top on
   widescreen) - the diamond is the one element big enough that shaving a
   few px off its own clearance is barely noticeable, unlike doing the
   same to the wheels/ribbon spacing. */
.scene-scorebug{margin-bottom:-1px;}
/* The result pill, seated right under the leverage meter and above the
   defense text line (Alex's spec) - everything else that used to share its
   row (diff pill, LA/EV readout) stayed behind in .scene-play-line, below
   the field. Same centered-flex layout as that row for a consistent look. */
.scene-result-line{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:7px;}
/* The defense text line (Decision 7) - one short muted line between the
   scorebug and the field, sized like .scene-recap-top's secondary text. A
   fixed min-height keeps the container's own presence from shifting the
   layout between plays that have a line and plays that don't (Decision 4's
   graceful-empty case) - it's still an empty box, just never a jump. */
.scene-defense{
  min-height:13px;text-align:center;font-size:10.5px;font-weight:600;
  color:var(--muted);
}
.scene-top{display:flex;align-items:center;justify-content:center;gap:22px;margin-bottom:-4px;}

/* position:relative is load-bearing: the watermark below is absolutely
   positioned and would otherwise resolve against the whole modal card and
   cover the entire slide. */
.scene-diamond-wrap{position:relative;flex:0 0 auto;width:min(78%,340px);}
.scene-diamond{width:100%;height:auto;display:block;overflow:visible;}
/* Real field colors, sampled directly from Alex's reference photo (grass
   rgb(54,123,68), dirt/warning-track rgb(199,165,128), fence rgb(38,85,48))
   - fixed, not theme-adaptive: a real broadcast graphic never re-themes the
   field itself for light/dark mode. */
.dm-grass{fill:#367b44;}
/* Tan warning-track band, one continuous loop around the whole field
   boundary - behind home, down both foul territories, around the outfield
   curve (Alex's reference photo) - see warningTrackPathD in app.js. */
.dm-warning-track{fill:#c7a580;stroke:none;}
/* Uniform-depth fence (Alex's addition to the plan: one distance in every
   direction, for simplicity) - sampled as a polyline under perspective, see
   fencePathD in app.js. */
.dm-fence{fill:none;stroke:#265530;stroke-width:3;stroke-linecap:round;}
/* The fence drawn as a short wall (fenceWallPathD, physics-redesign plan
   Part 6.1/OQ-8) - the ground-level arc duplicated at z=8ft and filled
   between, so an over-the-fence home run visibly clears it. Drawn under the
   .dm-fence stroke above so the wall's own top edge doesn't need a second
   outline. */
.dm-fence-wall{fill:#265530;stroke:none;}
/* Modified dirt cutout (Alex's reference photo): a wide dirt arc over the
   mound down to each foul line, with the actual basepath square left as a
   grass island in the middle (fill-rule=evenodd, two subpaths in one
   <path> - see infieldSkinHtml in app.js), rounded bulges at 1B/3B instead
   of plain corners, plus small separate circles for home plate and the
   mound (the mound reads as its own patch surrounded by grass, not merged
   into the big cutout). */
.dm-infield-dirt, .dm-home-dirt, .dm-mound-dirt{fill:#c7a580;stroke:none;}
.dm-rubber{fill:#f2f0e6;stroke:#9aa1ab;stroke-width:1;}
/* Foul lines/bases/plate: white against the grass/dirt, same as a real
   field - these used to be theme-flipped text-colored lines against a pale
   neutral background; the background is real green/tan now, so white reads
   the way an actual painted line does. */
.dm-foul-line{stroke:#f2f0e6;stroke-width:1.5;opacity:0.9;}
.dm-base{fill:#f2f0e6;stroke:#9aa1ab;stroke-width:2;}
/* I7: post-play occupancy is real, but painting it at slide mount lit bases
   up before any runner had visibly reached them. Delayed to --blight (the
   slowest arriving safe/scoring runner, computed in sceneFieldHtml) so the
   gold fill lands after the tokens do. */
.dm-base.on{animation:baseLight 200ms ease var(--blight,0s) both;}
@keyframes baseLight{
  /* Matches .dm-base's own fixed (non-theme-adaptive) colors, not
     --dot-off-fill/--dot-off-stroke (those are for the wheel/outs dots,
     and go near-black in dark mode) - animation-fill-mode:both means this
     "from" state is what actually paints during the --blight delay, so the
     wrong pair here read as the base already being dark/occupied before any
     runner had visibly reached it, in dark mode specifically (Alex's
     report). */
  from{fill:#f2f0e6;stroke:#9aa1ab;}
  to{fill:var(--gold);stroke:var(--gold);}
}
/* B3-a: tag at the bag - a caught-stealing/stolen-base attempt flashes the
   target base at the moment the runner token (or the out-to-base token)
   actually arrives (--sflash, sceneFieldHtml). Declared after .dm-base.on so
   it wins the cascade on a successful steal's destination base, which is
   both .on and .steal-safe - the flash IS that base's road to its eventual
   gold post-play fill, not a separate effect layered on top. No `transform`
   here deliberately: this element's position/rotation come from an SVG
   `transform` ATTRIBUTE, and any CSS `transform` on the same element would
   replace rather than compose with it. */
.dm-base.steal-safe{animation:baseStealSafe 260ms ease var(--sflash,0s) both;}
@keyframes baseStealSafe{
  0%{fill:#f2f0e6;stroke:#9aa1ab;}
  40%{fill:var(--green);stroke:var(--green);}
  100%{fill:var(--gold);stroke:var(--gold);}
}
.dm-base.steal-out{animation:baseStealOut 260ms ease var(--sflash,0s) both;}
@keyframes baseStealOut{
  0%{fill:#f2f0e6;stroke:#9aa1ab;}
  40%{fill:var(--red);stroke:var(--red);}
  100%{fill:#f2f0e6;stroke:#9aa1ab;}
}
.dm-plate{fill:#f2f0e6;stroke:#9aa1ab;stroke-width:2;}

/* Ball flight (ball-flight-plan.md Stage 4e): the ball itself translates
   home->landing exactly like a runner token; the trail is a real line with
   its own pixel length (app.js computes --len) drawn in via
   stroke-dashoffset. Both lead the runners, which start RUNNER_LEAD_MS behind
   them (see --rdelay below). */
.ball{fill:#fff;stroke:var(--navy);stroke-width:1.5;transform:translate(var(--tx),var(--ty));}
.ball-trail{fill:none;stroke:var(--muted);stroke-width:1.5;stroke-dasharray:3 3;opacity:0.6;}
.ball.air, .ball.ground, .ball.land, .ball.clear{
  animation:ballFly var(--dur) ease-out forwards, ballSettle 350ms ease var(--dur) forwards;
}
@keyframes ballFly{from{transform:translate(var(--fx),var(--fy));}to{transform:translate(var(--tx),var(--ty));}}
@keyframes ballSettle{to{opacity:0.85;}}
.ball-trail.air, .ball-trail.ground, .ball-trail.land, .ball-trail.clear{
  stroke-dasharray:var(--len);stroke-dashoffset:var(--len);
  animation:trailDraw var(--dur) ease-out forwards;
}
@keyframes trailDraw{to{stroke-dashoffset:0;}}
/* C1: the ball's own colour tells the story - the stroke (never the white
   fill, which stays visible against the grass in both themes) goes green for
   a hit, red for an out. A play that is both (a sac fly) reads as an out -
   the ball itself was caught. */
.ball.hit, .ball-trail.hit{stroke:var(--green);}
.ball.out, .ball-trail.out{stroke:var(--red);}
/* A ground ball out specifically goes neutral grey instead - the throw
   itself (throw-out, below) now carries the red "who's out" verdict, so the
   grounder's own trail doesn't also claim it. Two classes beats one, so this
   wins the cascade over the plain .out rule above without needing !important. */
.ball.out.grounded-out, .ball-trail.out.grounded-out{stroke:var(--muted);}

/* C2/C3: small landing-point labels - a terse result abbreviation on every
   batted ball, plus feet on home runs only. Both land after the ball does,
   never before, so they never seem to predict the outcome. */
.ball-label, .ball-dist{
  font-size:7px;font-weight:800;fill:var(--text);paint-order:stroke;
  stroke:var(--card);stroke-width:2.5;stroke-linejoin:round;
  opacity:0;animation:labelIn 260ms ease var(--delay) both;
}
.ball-dist{font-size:8.5px;font-weight:700;fill:var(--muted);}
@keyframes labelIn{to{opacity:1;}}

/* Fielder name labels (Decisions 5-6) - same halo/pop-in treatment AND same
   size as .ball-label (Alex's call - these used to run a shade smaller, but
   the fielder order/name and the on-field hit-result text should read as
   one consistent label system, not two different sizes). */
.fielder-name{
  font-size:7px;font-weight:700;fill:var(--text);paint-order:stroke;
  stroke:var(--card);stroke-width:2.5;stroke-linejoin:round;
  text-anchor:middle;
  opacity:0;animation:labelIn 260ms ease var(--delay) both;
}
/* The original fielder's stacked label (result above name, both inside the
   same <text> as tspans - inherits fielder-name's fill/stroke/halo/pop-in).
   Same size as the name line below it (Alex's call - a smaller result line
   read as an afterthought next to the name), just a touch lighter in weight/
   opacity to keep the name as the visual anchor of the two. */
.fielder-result{font-weight:600;opacity:0.85;}

/* C4: a hit that stayed in the park carries a bit further before the fielder
   gets to it - a second, dimmer trail past the landing point, so "where it
   landed" and "where it was fielded" read as two different things. Base
   transform sits at the already-correct final (rolled-out) position, same
   paint-then-move convention as every other token here - only opacity needs
   to change on reduced motion. */
.ball-rollout{fill:#fff;stroke:var(--muted);stroke-width:1.2;opacity:0;
  transform:translate(var(--tx),var(--ty));
  animation:ballRoll var(--rdur) ease-out var(--rdelay) forwards;}
@keyframes ballRoll{
  from{transform:translate(var(--fx),var(--fy));opacity:0.6;}
  to{transform:translate(var(--tx),var(--ty));opacity:0.6;}
}
.ball-rollout-trail{fill:none;stroke:var(--muted);stroke-width:1;opacity:0.35;
  stroke-dasharray:var(--len);stroke-dashoffset:var(--len);
  animation:trailDraw var(--rdur) ease-out var(--rdelay) forwards;}

/* Fielder convergence (Stage 4e) - sits at the landing point by default (the
   reduced-motion/finished end state), animates in from its fixed anchor after
   the ball's travel time. No fielder on a ball that cleared the fence -
   app.js's fielderTokensHtml already omits the element in that case. */
.fielder{transform:translate(var(--tx),var(--ty));opacity:1;}
.fielder circle{fill:var(--muted);stroke:var(--card);stroke-width:1.5;}
.fielder{animation:fielderIn 350ms ease var(--delay) both;}
@keyframes fielderIn{
  from{transform:translate(var(--fx),var(--fy));opacity:0;}
  20%{opacity:1;}
  to{transform:translate(var(--tx),var(--ty));opacity:1;}
}

/* Throws (Stage 4e/A2-A5) - drawn in after the ball is fielded, same
   dashoffset technique as the ball trail. A multi-throw play (a double play)
   renders one .throw-line per out, each relaying from the previous throw's
   target base, staggered by --delay. Duration is per-line (--draw) rather
   than hardcoded, so THROW_DRAW_MS stays the single source of truth. */
/* Dashed now, and coloured by verdict rather than a flat gold (Alex's call):
   red for a throw that puts someone out, green for the rare safe one (a
   tag-up run that scores anyway, or a successful steal) - see throwHtml/
   stealThrowHtml for which is which. A fixed dash pattern (6 5) can't be
   "drawn in" via stroke-dashoffset the way the ball-trail's solid line is -
   dashoffset on a repeating pattern only slides which segments are on/off,
   it can never grow the visible length, so the line used to just shimmer in
   place instead of extending from the thrower to the base (Alex's report).
   <line>'s own x1/y1/x2/y2 aren't CSS-animatable either (they never got
   promoted off SVG's presentation-attribute-only list the way <rect>'s
   x/y/width/height did), so the real reveal happens on a second element -
   see throwLineHtml in app.js: a <rect> in its own <clipPath>, statically
   (not CSS-)rotated to the line's own bearing, whose WIDTH is what actually
   animates 0 -> --len. The dash pattern itself stays put on the <line>
   underneath; the growing clip is what reveals it outward from the thrower.
   The clip's own base rule has width:0 with no fallback, so reduced motion
   (which drops the animation entirely - see below) needs its own explicit
   full-width override, unlike a plain animation:none skip elsewhere. */
.throw-line{
  fill:none;stroke-width:2;stroke-linecap:round;
  stroke-dasharray:6 5;
}
.throw-line.throw-out{stroke:var(--red);}
.throw-line.throw-safe{stroke:var(--green);}
.throw-clip-rect{
  animation:throwClipGrow var(--draw,180ms) ease var(--delay) both;
}
@keyframes throwClipGrow{from{width:0;}to{width:var(--len);}}

/* Position lives on .rn, opacity/scale on .rn-inner - one element cannot run
   two competing transform animations, and a scoring runner needs both a path
   and a flash. Tokens sit at their DESTINATION by default so the end state is
   what shows when the animation is skipped (reduced motion) or has finished. */
.rn{transform:translate(var(--tx),var(--ty));}
.rn circle{fill:var(--rn-fill,var(--navy));stroke:var(--card);stroke-width:2;}
/* Dark mode's card stroke is nearly the same value as the field fill, so the
   token has no visible edge there - a lighter ring, scoped to dark mode only,
   fixes contrast without touching the team-colour fill. */
:root[data-theme="dark"] .rn circle{stroke:rgba(232,234,237,0.88);stroke-width:1.6;}

/* Runners follow the basepaths, touching every base on the way: one keyframe
   stop per base passed. Legs are all the same length, so evenly spaced stops
   plus linear timing means constant speed - easing per stop would read as
   pausing on each bag. Delayed by --rdelay so they lead off behind the ball
   on a batted-ball play (0 on an excluded play - Stage 4e). */
.rn.legs1, .rn.legs2, .rn.legs3, .rn.legs4{
  animation-duration:var(--dur);animation-timing-function:linear;animation-fill-mode:both;
  animation-delay:var(--rdelay,0s);
}
.rn.legs1{animation-name:rnLegs1;}
.rn.legs2{animation-name:rnLegs2;}
.rn.legs3{animation-name:rnLegs3;}
.rn.legs4{animation-name:rnLegs4;}

@keyframes rnLegs1{
  from{transform:translate(var(--fx),var(--fy));}
  to{transform:translate(var(--p1x),var(--p1y));}
}
@keyframes rnLegs2{
  from{transform:translate(var(--fx),var(--fy));}
  50%{transform:translate(var(--p1x),var(--p1y));}
  to{transform:translate(var(--p2x),var(--p2y));}
}
@keyframes rnLegs3{
  from{transform:translate(var(--fx),var(--fy));}
  33.333%{transform:translate(var(--p1x),var(--p1y));}
  66.667%{transform:translate(var(--p2x),var(--p2y));}
  to{transform:translate(var(--p3x),var(--p3y));}
}
@keyframes rnLegs4{
  from{transform:translate(var(--fx),var(--fy));}
  25%{transform:translate(var(--p1x),var(--p1y));}
  50%{transform:translate(var(--p2x),var(--p2y));}
  75%{transform:translate(var(--p3x),var(--p3y));}
  to{transform:translate(var(--p4x),var(--p4y));}
}

.rn-inner{transform-box:fill-box;transform-origin:center;}
.rn.batter .rn-inner{animation:rnBatter 380ms ease var(--rdelay,0s) both;}
.rn.score circle{fill:var(--green);}
/* Flash on arrival, so the delay is however long the run itself took (plus
   the shared lead-off delay the runner started from). */
.rn.score .rn-inner{animation:rnScore 460ms ease calc(var(--rdelay,0s) + var(--dur)) both;}

/* Out choreography (ball-flight-plan.md Stage 6, generalised by the
   refinements round's I8/A2): a put-out token turns red in place, THEN walks
   a straight line to the dugout, fading on arrival - a person leaving, not a
   token quietly fading. Two shapes:
     .out-walk       - runner put out with no partial advance to show (a
                        strikeout and friends, or a backward-relative throw
                        target like a doubled-off runner - see basepathWaypoints'
                        end<=start guard) - one leg, straight to the dugout.
     .out-to-first,
     .out-to-base    - reaches partway toward the base the throw targeted on
                        the normal basepath timing FIRST, then red, then
                        dugout - two legs, so the out reads as "thrown out
                        going somewhere" rather than "vanished in place."
                        .out-to-first is the batter-specific case (always to
                        1B); .out-to-base is the same shape for any other
                        forced/tag runner, waypoint supplied per play.
   Both rules come after .rn.batter above deliberately: an out token that also
   carries the .batter class (the batter-out fallback) needs THIS animation to
   win the cascade, not the plain appear-in-place one. */
.rn.out-walk, .rn.out-to-first, .rn.out-to-base{animation-fill-mode:both;animation-timing-function:linear;}
.rn.out-walk{animation-name:rnOutWalk;animation-duration:650ms;animation-delay:calc(var(--rdelay,0s) + 250ms);}
.rn.out-to-first, .rn.out-to-base{animation-name:rnOutToBase;animation-duration:1700ms;animation-delay:var(--rdelay,0s);}
@keyframes rnOutWalk{
  from{transform:translate(var(--fx),var(--fy));}
  to{transform:translate(var(--tx),var(--ty));}
}
@keyframes rnOutToBase{
  from{transform:translate(var(--fx),var(--fy));}
  47.06%{transform:translate(var(--p1x),var(--p1y));}
  61.76%{transform:translate(var(--p1x),var(--p1y));}
  to{transform:translate(var(--tx),var(--ty));}
}
/* Explicit "retreat" flag (import_BRC.csv, e.g. LODP - doubled off a caught
   line drive): same run-pause-then-leave timing as .out-to-base (identical
   duration/beats, so it drops into the same choreography), but --p1x/--p1y
   here is the HALFWAY point toward aN (sceneFieldHtml), not a real base - the
   runner gets partway there, then the very same keyframe stop doubles as the
   moment they reverse back to --fx/--fy (their own base) before walking off,
   instead of continuing on to a new spot. */
.rn.out-retreat{animation-fill-mode:both;animation-timing-function:linear;animation-name:rnOutRetreat;animation-duration:1700ms;animation-delay:var(--rdelay,0s);}
@keyframes rnOutRetreat{
  from{transform:translate(var(--fx),var(--fy));}
  35.29%{transform:translate(var(--p1x),var(--p1y));}
  47.06%{transform:translate(var(--fx),var(--fy));}
  61.76%{transform:translate(var(--fx),var(--fy));}
  to{transform:translate(var(--tx),var(--ty));}
}
.rn.out-retreat circle{animation:rnTurnRed 250ms ease calc(var(--rdelay,0s) + 800ms) forwards;}
.rn.out-retreat .rn-inner{animation:rnFadeOut 400ms ease calc(var(--rdelay,0s) + 1300ms) forwards;}
/* Team colour until the flash, matching every other token - the base fill
   must NOT be red here, or the animation below has nothing to animate from
   (an explicit `to` with no `from` interpolates from whatever the property
   would otherwise be, which would make it red from t=0). Reduced motion
   overrides this to red directly, with no animation, in the media block
   below. */
.rn.out-walk circle{animation:rnTurnRed 250ms ease var(--rdelay,0s) forwards;}
.rn.out-to-first circle, .rn.out-to-base circle{animation:rnTurnRed 250ms ease calc(var(--rdelay,0s) + 800ms) forwards;}
@keyframes rnTurnRed{from{fill:var(--rn-fill,var(--navy));}to{fill:var(--red);}}
.rn.out-walk .rn-inner{animation:rnFadeOut 400ms ease calc(var(--rdelay,0s) + 500ms) forwards;}
.rn.out-to-first .rn-inner, .rn.out-to-base .rn-inner{animation:rnFadeOut 400ms ease calc(var(--rdelay,0s) + 1300ms) forwards;}
@keyframes rnFadeOut{to{opacity:0;}}

/* A safe runner (reached or held their forced base, never put out) whose
   half-inning ends on this very play: nobody's left on the bases between
   innings, so after reaching/holding they walk off to the dugout same as an
   out would - just never turning red, since they were never retired. Reuses
   rnOutToBase's exact run-pause-then-leave shape (a single forced advance is
   always one leg, identical to the out choreography's own timing), with
   --p1x/--p1y set to the runner's own current position when there's no real
   advance to show (a true hold, not a forced move) - a same-point "run" is a
   harmless no-op on this keyframe. */
.rn.stranded-to-dugout{
  animation-fill-mode:both;animation-timing-function:linear;
  animation-name:rnOutToBase;animation-duration:1700ms;animation-delay:var(--rdelay,0s);
}
.rn.stranded-to-dugout .rn-inner{animation:rnFadeOut 400ms ease calc(var(--rdelay,0s) + 1300ms) forwards;}

@keyframes rnBatter{
  from{opacity:0;transform:scale(0.6);}
  to{opacity:1;transform:scale(1);}
}
@keyframes rnScore{
  0%{transform:scale(1);opacity:1;}
  40%{transform:scale(1.9);opacity:1;}
  to{transform:scale(1.2);opacity:0;}
}

/* Replaces the removed replay closing card - the final score and the game's
   biggest play, pinned to the play the replay rests on. */
.scene-recap{
  align-self:center;display:flex;flex-direction:column;align-items:center;gap:2px;
  padding:5px 12px;border-radius:8px;text-align:center;
  background:var(--navy);color:#fff;
  animation:recapIn 420ms ease 260ms both;
}
.scene-recap.live{background:var(--tint);color:var(--muted);border:1px solid var(--border);}
.scene-recap-head{font-size:12px;font-weight:800;letter-spacing:0.4px;}
.scene-recap-top{font-size:10.5px;font-weight:600;opacity:0.82;}
@keyframes recapIn{from{opacity:0;transform:translateY(-5px);}to{opacity:1;transform:none;}}

/* Horizontal MLB-style scorebug, the slide's own header beneath the modal's
   progress bar - away team left, home team right (mirrored via row-reverse,
   not a second markup order, so DOM/reading order stays logo-abbr-score for
   both sides). Replaces the old vertical away/home stack that lived in a
   side column next to the diamond.
   Grid thirds, not flex space-between (Alex's report): space-between sizes
   each team's box to its own content, so a wider abbreviation (BBEG, 4
   letters) claims more of the row than a narrower one (SMD, 3) and pushes
   the middle leverage meter off true centre toward the narrower side - this
   used to only be fixed at the >=900px widescreen breakpoint, leaving the
   primary (mobile/narrow) layout with the original off-centre bug. Equal
   thirds here now, unconditionally - the leverage meter sits in the true
   middle third and each team gets the same share regardless of name length. */
.scene-scorebug{display:grid;grid-template-columns:1fr 1fr 1fr;align-items:center;gap:10px;width:100%;}
.scene-scorebug-team{display:flex;align-items:center;justify-self:center;gap:7px;font-size:19px;font-weight:800;min-width:0;color:var(--muted);}
.scene-scorebug-team.home{flex-direction:row-reverse;}
.scene-scorebug-team.batting{color:var(--text);}
.scene-scorebug-logo{width:28px;height:28px;object-fit:contain;flex-shrink:0;}
.scene-scorebug-team .val{transition:none;display:inline-block;}
/* Counting score: every value the play passes through is stacked in one grid
   cell, so the column never changes width while it ticks. */
.scene-scorebug-team .val.counter{display:inline-grid;justify-items:end;}
.scene-scorebug-team .tick-step{grid-area:1/1;opacity:0;}
/* Two one-shot animations per step: appear at --at, disappear at --until. Both
   only fill AFTER their delay, so before --at the step is simply invisible and
   between the two it stays put. The last step has no --until and holds. */
.scene-scorebug-team .tick-step{
  animation:scoreStepIn 420ms cubic-bezier(.2,1.6,.4,1) var(--at) both,
            scoreStepOut 1ms linear var(--until,600s) forwards;
}
/* The value the play started from was already on the board - it shows at once
   rather than popping in, and only needs the exit. */
.scene-scorebug-team .tick-step.first{
  opacity:1;animation:scoreStepOut 1ms linear var(--until,600s) forwards;
}
@keyframes scoreStepIn{
  0%{opacity:0;transform:translateY(7px) scale(0.75);}
  55%{opacity:1;transform:translateY(0) scale(1.4);color:var(--green);}
  to{opacity:1;transform:none;}
}
@keyframes scoreStepOut{to{opacity:0;}}

/* Middle column: inning + outs on top, leverage meter underneath - no OBC
   mini-diamond, the animated diamond right below already shows runners. */
.scene-scorebug-mid{display:flex;flex-direction:column;align-items:center;justify-self:center;gap:2px;flex:0 0 auto;}
.scene-scorebug-state{display:flex;align-items:center;gap:8px;}
/* The beat that marks a new half-inning starting. */
.scene-scorebug-state.new-half{animation:newHalf 900ms ease both;}
@keyframes newHalf{
  0%{transform:scale(1);opacity:0.35;}
  35%{transform:scale(1.35);opacity:1;}
  to{transform:scale(1);opacity:1;}
}
.scene-scorebug-state .tri{
  width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;
}
.scene-scorebug-state .tri.up{border-bottom:7px solid var(--text);}
.scene-scorebug-state .tri.down{border-top:7px solid var(--text);}
.scene-scorebug-state .inning-num{font-size:15px;font-weight:800;}

.scene-meter{position:relative;width:100px;text-align:center;}
.scene-meter svg{width:100%;height:auto;display:block;overflow:visible;}
.mt-track{fill:none;stroke:var(--tint);stroke-width:9;stroke-linecap:round;}
/* Only marking left on the gauge (Alex's call - was also a gray dash at the
   LI 1.0 apex and a soft red background band from here onward; both removed
   as clutter). This is the hot threshold (1.5 today). */
.mt-tick{stroke:var(--tint-red-border);stroke-width:2;}
/* Granularity lives in the fill's colour now, not in extra markings: a
   4-stop gradient across the gauge's full width - deep blue (calm) through
   light blue, light red, to a vivid red (hot) - so intensity reads
   continuously as the bar grows rather than flipping once at the threshold. */
.mt-stop-cold{stop-color:var(--navy);}
.mt-stop-cool{stop-color:var(--blue);}
.mt-stop-warm{stop-color:var(--tint-red-border);}
.mt-stop-hot{stop-color:var(--red);}
.mt-fill{
  fill:none;stroke:url(#levGradient);stroke-width:9;stroke-linecap:round;
  stroke-dasharray:var(--len);stroke-dashoffset:var(--off);
  animation:meterFill 700ms cubic-bezier(.2,.7,.3,1) both;
}
@keyframes meterFill{from{stroke-dashoffset:var(--len);}to{stroke-dashoffset:var(--off);}}
.scene-meter.hot .mt-fill{animation:meterFill 700ms cubic-bezier(.2,.7,.3,1) both, meterPulse 700ms ease 700ms 2;}
@keyframes meterPulse{0%,100%{filter:none;}50%{filter:drop-shadow(0 0 7px var(--red));}}
.scene-meter-val{
  position:absolute;left:0;right:0;top:38%;font-size:19px;font-weight:800;letter-spacing:-0.3px;
}
.scene-meter.hot .scene-meter-val{color:var(--red-dark);}
.scene-meter-lbl{font-size:8.5px;font-weight:700;letter-spacing:1px;color:var(--muted);margin-top:-6px;}

/* Centred to match the diamond and meter above it - left-aligned, the block
   stranded a wide empty margin on the right of an otherwise centred scene. */
.scene-detail{
  display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;text-align:center;
  margin-bottom:-3px;
}

/* Fixed batter-vs-pitcher pairing. Both sides keep their slot for the whole
   run so the eye can stay put between slides. */
.scene-matchup{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;}
.mu-side{display:flex;flex-direction:column;align-items:center;gap:1px;min-width:0;}
.mu-role{
  display:flex;align-items:center;justify-content:center;gap:5px;
  font-size:8.5px;font-weight:800;letter-spacing:1px;color:var(--muted);
}
.mu-logo{width:15px;height:15px;object-fit:contain;flex-shrink:0;}
.mu-name{display:flex;align-items:center;gap:5px;font-size:15px;font-weight:800;min-width:0;}
.mu-name > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.mu-vs{font-size:11px;font-weight:700;color:var(--muted);align-self:flex-end;padding-bottom:3px;}

/* min-height reserves the LA/EV readout's own rendered height (measured:
   14px) even when there's nothing to show here - a walk/strikeout/steal has
   no flight (sceneFlightReadoutHtml returns "") and usually no diff pill
   either, and without this the row collapses to 0 and every other element
   below it hops up a notch on exactly those plays. */
.scene-play-line{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:7px;font-size:15px;min-height:14px;}
.scene-play-line .player-name{font-weight:800;}
/* The result is the headline of the slide - it lands with a bounce rather than
   just appearing. Scoped to the scene so the main feed's pills stay static. */
.play-scene .result-pill{
  font-size:13px;padding:3px 11px;animation:resultPop 460ms cubic-bezier(.2,1.5,.4,1) both;
}
@keyframes resultPop{
  0%{opacity:0;transform:scale(0.55);}
  60%{opacity:1;transform:scale(1.12);}
  to{opacity:1;transform:scale(1);}
}
/* Launch angle / exit velo readout (ball-flight-plan.md Stage 5) - muted,
   monospace-ish chips so they read as telemetry rather than another tag. */
.scene-flight-readout{
  display:inline-flex;gap:7px;font-family:ui-monospace,"SF Mono",Consolas,monospace;
  font-size:12px;font-weight:700;color:var(--muted);letter-spacing:0.2px;
}
.itp-pill{
  font-size:10.5px;font-weight:800;letter-spacing:0.3px;padding:3px 9px;border-radius:20px;
  color:var(--gold-dark,var(--muted));border:1px solid var(--tint-red-border);background:var(--tint);
}
/* Pitch/swing wheel (refinements round F1) - a debugging readout. DIFF shows
   on every play with a pitch/swing or steal roll, HZ only on a real batted
   ball.
   DIFF/HZ sit inside .scene-diamond-wrap itself now (Alex's reference
   screenshot: two circles in the empty foul-territory corners of the field
   graphic's own rectangle, above the play-result pill) - absolutely
   positioned against the wrap's bottom-left/bottom-right, not a row
   underneath the diamond. See sceneFieldHtml.
   Pushed further out/down and shrunk from an earlier 33%/bottom:-2%/6%
   (Alex's report: at some pitch/swing digit pairs the value label swings
   round to the side of the ring facing the diamond and lands on the drawn
   grass/dirt, worst right at the ~90 degree/3-o'clock label angle where
   text runs furthest outward - see wheelLabelRadius in app.js, also
   tightened). No amount of shrinking the label's own reach alone clears
   that worst case without the ring's home-plate side sitting closer to the
   corner than this to begin with - verified against real pitch/swing pairs
   landing at every angle, not just the common ones. */
.scene-diamond-wrap .wheel{
  position:absolute;bottom:-16%;width:40%;display:flex;flex-direction:column;
  align-items:center;gap:1px;
}
.scene-diamond-wrap .wheel:first-of-type{left:-4%;}
.scene-diamond-wrap .wheel:last-of-type{right:-4%;}
/* DIFF/HZ, drawn inside the ring itself now rather than an external label
   above it (Alex's call - the ring has the room once exit velo (was the
   center-small text) moved to sceneFlightReadoutHtml only). */
.wheel-title{
  font-size:9px;font-weight:800;letter-spacing:0.6px;fill:var(--muted);text-anchor:middle;
  paint-order:stroke;stroke:var(--card);stroke-width:2px;stroke-linejoin:round;
}
.wheel-svg{width:100%;height:auto;overflow:visible;}
.wheel-ring{fill:none;stroke:var(--border);stroke-width:1.5;}
/* Reference arc for the result's own diff band (DIFF wheel only) - anchored
   at the first dot, same direction the real arc swept, so the actual diff
   and "the range we set up for this result" sit on the same ring and are
   directly comparable at a glance. */
.wheel-band{fill:none;stroke:var(--tint-red-border,var(--border));stroke-width:3;stroke-linecap:round;opacity:0.55;}
/* LA/HZ read as pure mechanics - a neutral, theme-flipped stroke (black on
   light, white on dark) rather than a colour that could be mistaken for a
   verdict. DIFF is the wheel that actually produced the result, so its arc
   takes the same green/red the ball marker uses (C1) - hit or out, at a
   glance, no need to also read the result pill. */
.wheel-arc{
  fill:none;stroke-width:2;stroke-linecap:round;
  stroke-dasharray:var(--alen);stroke-dashoffset:var(--alen);
  animation:wheelArcDraw 500ms ease 150ms forwards;
}
.wheel-arc-neutral{stroke:var(--text);}
.wheel-arc-hit{stroke:var(--green);}
.wheel-arc-out{stroke:var(--red);}
@keyframes wheelArcDraw{to{stroke-dashoffset:0;}}
/* Markers sit ON the ring, both at the same radius (Alex's correction - only
   the value LABELS below are staggered outward). Shape is by role, not by
   which one appears first: defense (pitcher/catcher) is a baseball, offense
   (batter/runner) is a bat - so a batted ball's pitch dot and a steal's
   catcher dot are both a baseball even though one goes first and the other
   second. Both use fixed, real-life colours rather than theme variables -
   a baseball and a bat look the same regardless of light/dark mode; a fixed
   mid-grey/dark-brown outline (rather than var(--border), which is nearly
   invisible against a light card) keeps both readable in both themes. */
.wheel-dot{transform-box:fill-box;transform-origin:center;opacity:0;}
.wheel-dot-def{fill:#ffffff;stroke:#9aa1ab;stroke-width:0.7;}
.wheel-dot-off{fill:#8b5a2b;stroke:#4a2f14;stroke-width:0.35;stroke-linejoin:round;}
/* Baseball seam curves on the defense marker - real stitch red, overriding
   the ball body's stroke so they read as seams, not another outline. */
.wheel-dot-seam{fill:none;stroke:var(--red);stroke-width:0.6;stroke-linecap:round;}
.wheel-dot-1{animation:wheelDotIn 160ms ease 0ms both;}
.wheel-dot-2{animation:wheelDotIn 160ms ease 650ms both;}
@keyframes wheelDotIn{
  from{opacity:0;transform:scale(0.3);}
  to{opacity:1;transform:scale(1);}
}
/* The raw rolled value next to each dot's position, staggered outward (the
   first value's label closer to the ring, the second's further out) so two
   angularly close values don't collide - lands with its dot, not with the
   centre number. var(--text) for real contrast in both themes, with a
   card-coloured halo so it stays legible crossing the ring/arc lines
   underneath it. */
.wheel-val{
  font-size:9.5px;font-weight:800;fill:var(--text);text-anchor:middle;dominant-baseline:middle;
  paint-order:stroke;stroke:var(--card);stroke-width:2.5px;stroke-linejoin:round;
  opacity:0;
}
.wheel-val-1{animation:wheelDotIn 160ms ease 0ms both;}
.wheel-val-2{animation:wheelDotIn 160ms ease 650ms both;}
.wheel-center-big{
  font-size:15px;font-weight:800;fill:var(--text);text-anchor:middle;dominant-baseline:middle;
  paint-order:stroke;stroke:var(--card);stroke-width:2.5px;stroke-linejoin:round;
  opacity:0;animation:wheelFadeIn 200ms ease 700ms forwards;
}
.wheel-center-small{
  font-size:8px;font-weight:700;fill:var(--muted);text-anchor:middle;dominant-baseline:middle;
  opacity:0;animation:wheelFadeIn 200ms ease 700ms forwards;
}
@keyframes wheelFadeIn{to{opacity:1;}}
/* Batting team's mark, painted on the center-field grass (Alex's call - was
   the infield centroid). Opacity raised substantially (was 0.4) - Alex's
   ask was clearly visible/saturated, not faded into the grass, while still
   reading as painted onto the turf rather than floating above it (hence
   still short of fully opaque). Same value in both themes: the grass it
   sits on is a fixed real-field green, not a theme-adaptive tint, so
   there's no light/dark contrast difference left to compensate for. */
.dm-mark{opacity:0.75;}
/* Half-inning break (Alex's ask): the last out's own CF mark fades in place
   to a "Mid Nth"/"End Nth" pill rather than the reel cutting straight to the
   next team's mark on the following slide - see sceneFieldHtml's isHalfEnd.
   CF_BREAK_CROSSFADE_MS (app.js) times this against the same slide's own
   dwell, extended by CF_BREAK_BONUS_MS so the pill gets real reading time
   before auto-advance. Fixed/non-theme-adaptive same as every other on-field
   mark - the grass under it never changes with theme, so neither does this. */
.dm-mark-fading{animation:cfMarkFadeOut 450ms ease var(--break-delay,0s) both;}
@keyframes cfMarkFadeOut{from{opacity:0.75;}to{opacity:0;}}
.dm-break-pill{opacity:0;animation:cfBreakPillIn 450ms ease var(--break-delay,0s) both;}
@keyframes cfBreakPillIn{from{opacity:0;}to{opacity:1;}}
.dm-break-pill rect{fill:#f2f0e6;stroke:#9aa1ab;stroke-width:1.5;}
.dm-break-pill text{
  font-size:13px;font-weight:800;fill:#1a2029;text-anchor:middle;
  dominant-baseline:middle;letter-spacing:0.3px;
}
/* Now a direct .play-scene child, between the defense line and the field
   (Alex's call - moved out of .scene-detail, which used to sit below the
   field) - matched by ancestor rather than direct-child so it still works
   the same both nested (desktop) and flattened via display:contents
   (mobile, see the max-width:600px block below). .scene-detail used to hand
   this centering down via its own align-items:center/text-align:center -
   .play-scene doesn't (it's a plain flex column, full-width children), so
   this now centers itself explicitly, same as .scene-defense right above it
   does for its own row. */
.play-scene .scoring-line{font-size:12.5px;color:var(--green);font-weight:700;text-align:center;}

.scene-ribbon{padding:10px 0 6px;}
/* Exactly the plot box, and the only positioning context the marker sees. */
.rb-plot{position:relative;}
.scene-ribbon svg{width:100%;height:96px;display:block;overflow:visible;}
/* Team-coloured shading between the line and the 50/50 axis. Kept low-opacity
   so the line, dividers and marker all stay readable on top of it. */
.rb-fill{opacity:0.3;stroke:none;}
:root[data-theme="dark"] .rb-fill{opacity:0.38;}

.rb-mid{stroke:var(--border);stroke-width:1;stroke-dasharray:3 4;}

/* Whose 100% is at which end of the y-axis. Chipped so they stay legible
   where the line runs close to the top or bottom of the plot. */
.rb-y{position:absolute;left:0;pointer-events:none;}
.rb-y span{
  display:inline-block;padding:1px 5px;border-radius:3px;
  font-size:8px;font-weight:800;letter-spacing:0.5px;color:var(--muted);
  background:var(--card);opacity:0.85;
}
.rb-y-top{top:0;}
.rb-y-bot{bottom:0;}
/* Half-inning boundaries. vector-effect keeps them hairline-thin despite the
   non-uniform scaling this SVG gets stretched by. */
.rb-div{stroke:var(--border);stroke-width:1;vector-effect:non-scaling-stroke;opacity:0.7;}
.rb-div.future{opacity:0.3;}
.rb-axis{position:relative;height:14px;margin-top:6px;}
.rb-axis-tick{
  position:absolute;top:0;transform:translateX(-50%);
  font-size:8.5px;font-weight:700;color:var(--muted);letter-spacing:0.2px;white-space:nowrap;
}
/* Half-innings the game has not reached yet - present so the remaining
   regulation is visible, dimmed so it does not read as already played. */
.rb-axis-tick.future{opacity:0.4;}
/* The line carries the same colour as the shading beneath it: whoever leads
   that stretch owns it, and the colour changes exactly where the lead does.
   Stroke colour is set per segment inline, since it comes from team data. */
.rb-seg{
  stroke-width:2.5;vector-effect:non-scaling-stroke;
  stroke-linecap:round;stroke-linejoin:round;
}
/* Already-seen stretches recede without losing their team identity. */
.rb-seg.seen{stroke-width:1.5;opacity:0.45;}
.rb-new{
  stroke-width:3;stroke-dasharray:var(--len);animation:ribbonDraw 650ms ease both;
}
@keyframes ribbonDraw{from{stroke-dashoffset:var(--len);}to{stroke-dashoffset:0;}}
/* Marker: the home team's badge riding the newest point, carrying their odds
   and what this play did to them. Overlaid in HTML because the SVG beneath is
   stretched with preserveAspectRatio="none". */
/* A zero-size box pinned exactly on the plotted point, so the badge centres on
   it rather than the whole badge-plus-readout row being centred (which left
   the logo sitting well to the left of the play it marks). The readout hangs
   off to one side without moving the logo. */
.rb-marker{
  position:absolute;width:0;height:0;transform:translate(-50%,-50%);
  animation:ribbonDot 650ms ease both;
}
@keyframes ribbonDot{0%,55%{opacity:0;}to{opacity:1;}}
.rb-marker-logo, .rb-marker-dot{
  position:absolute;left:0;top:0;transform:translate(-50%,-50%);
}
/* The ring colour is set inline to the badge team's own colour. */
.rb-marker-logo{
  width:22px;height:22px;object-fit:contain;
  background:var(--card);border-radius:50%;padding:1.5px;
}
.rb-marker-dot{
  width:11px;height:11px;border-radius:50%;background:var(--navy);
  box-shadow:0 0 0 2px var(--card);
}
.rb-readout{
  position:absolute;left:17px;top:0;transform:translateY(-50%);
  display:flex;align-items:baseline;gap:5px;white-space:nowrap;
}
.rb-marker.flip .rb-readout{left:auto;right:17px;}
.rb-pct{font-size:15px;font-weight:800;letter-spacing:-0.3px;}
.rb-readout .wpa-pos, .rb-readout .wpa-neg{font-size:12px;font-weight:700;}

.scene-flash{
  position:absolute;inset:-12px;border-radius:12px;pointer-events:none;z-index:1;
  opacity:0;animation:sceneFlash 900ms ease both;
}
@keyframes sceneFlash{0%{opacity:0;}25%{opacity:0.22;}to{opacity:0;}}

/* Phone gets the full viewport height, so the diamond goes big and the
   supporting numbers move underneath it instead of competing for width.

   The reading order also changes here: bases, then what happened, then who did
   it, then the game state. .scene-top and .scene-detail become display:contents
   so their children join .play-scene's flex flow directly and can be ordered
   across what are two separate containers on desktop. */
@media (max-width:600px){
  .scene-top, .scene-detail{display:contents;}
  .play-scene{align-items:center;}
  /* Alex's report: result pill / defense-line description / "Player scores"
     read as too spread out here specifically - each is its own short text
     row, so .play-scene's shared 6px gap (fine for the bigger blocks around
     the diamond) shows up three times in a row with nothing bulky between
     them to justify it. Cinches just this trio closer without touching the
     gap everywhere else (diamond, matchup, ribbon). */
  .scene-defense{margin-top:-3px;}
  .play-scene .scoring-line{margin-top:-3px;}
  .scene-diamond-wrap{order:1;width:min(90vw,340px);}
  /* min(90vw,340px) is measured against the full viewport, not the space
     .catchup-slide's own 37px nav gutters leave clear - smaller/narrower on
     this axis so the wheels (pinned to the wrap's own edges) can't reach
     into that gutter and collide with the prev/next arrows. */
  .scene-diamond-wrap .wheel{width:42%;}
  .scene-diamond-wrap .wheel:first-of-type{left:-5%;}
  .scene-diamond-wrap .wheel:last-of-type{right:-5%;}
  .scene-play-line{order:3;font-size:14.5px;}
  /* scoring-line no longer needs an order override here - it now lives
     outside .scene-top/.scene-detail entirely (a direct .play-scene child,
     between the defense line and the diamond), so it keeps the default
     order:0 every other pre-field row already uses and never gets pulled
     into this reflow in the first place. */
  .scene-matchup{order:5;}
  .scene-recap{order:0;}
  .scene-scorebug{order:0;}
  .scene-ribbon{order:8;width:100%;}
  .scene-meter{width:92px;}
  .scene-scorebug-team{font-size:17px;}
  .scene-ribbon svg{height:84px;}
  /* Slightly smaller controls here so the reserved gutters do not squeeze two
     long player names into an awkward wrap. */
  .catchup-nav{width:30px;height:30px;}
  .catchup-nav.prev{left:3px;}
  .catchup-nav.next{right:3px;}
  .catchup-slide{padding-left:37px;padding-right:37px;}
  /* Always stacked here, pitcher over batter, rather than letting it depend on
     how long the two names happen to be. The role labels carry the pairing, so
     the "vs" has nothing left to do. */
  .scene-matchup{flex-direction:column;gap:7px;}
  .mu-vs{display:none;}
  .play-scene .result-pill{font-size:14px;padding:4px 13px;}
}

.catchup-pause-hint{
  flex:0 0 auto;text-align:center;padding:8px 12px 12px;
  font-size:12px;font-weight:700;color:var(--muted);letter-spacing:0.3px;
}
.catchup-pause-hint[hidden]{display:none;}

@media (max-width:600px){
  .catchup-modal{padding:0;}
  .catchup-card{height:100vh;max-width:none;border-radius:0;}
  .catchup-title-teams{font-size:17px;}
  .catchup-title-logo{width:28px;height:28px;}
}

/* Honour a reduced-motion preference: the slideshow still advances, it just
   stops sliding and fading while it does. Every Play Scene component shows its
   END state instantly here - runners on their final base, the meter at its
   value, the ribbon fully drawn - rather than being hidden or frozen mid-way.
   The base (non-animated) rules above are all written to be that end state,
   so switching the animations off is all this needs to do. */
@media (prefers-reduced-motion:reduce){
  .catchup-slide{transform:none;}
  .catchup-slide.in{transition:none;}
  .catchup-progress::after{transition:none;}
  .rn, .rn.legs1, .rn.legs2, .rn.legs3, .rn.legs4, .rn-inner,
  .rn.batter .rn-inner, .rn.score .rn-inner,
  .rn.out-walk, .rn.out-to-first, .rn.out-to-base, .rn.out-retreat, .rn.stranded-to-dugout,
  .rn.out-walk circle, .rn.out-to-first circle, .rn.out-to-base circle, .rn.out-retreat circle,
  .rn.out-walk .rn-inner, .rn.out-to-first .rn-inner, .rn.out-to-base .rn-inner, .rn.out-retreat .rn-inner,
  .rn.stranded-to-dugout .rn-inner,
  .mt-fill, .scene-meter.hot .mt-fill,
  .rb-new, .rb-marker, .scene-flash,
  .scene-recap, .scene-scorebug-state.new-half, .play-scene .result-pill,
  .dm-base.on, .dm-base.steal-safe, .dm-base.steal-out,
  .fielder, .fielder-name, .throw-line, .throw-clip-rect,
  .ball-label, .ball-dist, .ball-rollout, .ball-rollout-trail,
  .wheel-arc, .wheel-dot-1, .wheel-dot-2, .wheel-val-1, .wheel-val-2,
  .wheel-center-big, .wheel-center-small,
  .dm-mark-fading, .dm-break-pill{animation:none;}
  /* !important (physics-redesign plan Part 6.3 item 3): each play generates
     its own .ball.kmArc-<id> rule with the same 2-class specificity as
     .ball.clear here, inserted later in document order (a fresh <style>
     block per slide) - without !important that per-play rule would win and
     defeat this preference. */
  .ball, .ball.clear, .ball-trail{animation:none!important;}
  /* End states: the out is recorded and the score is final, just without the
     pop or the count-up. */
  .dm-base.on{fill:var(--gold);stroke:var(--gold);}
  /* The half-inning break pill: end state is the pill fully in, the mark
     fully faded - same "skip straight to the settled outcome" rule as
     everything else here. */
  .dm-mark-fading{opacity:0;}
  .dm-break-pill{opacity:1;}
  .ball-label, .ball-dist, .fielder-name{opacity:1;}
  .ball-rollout{opacity:0.6;}
  .ball-rollout-trail{stroke-dashoffset:0;}
  .wheel-arc{stroke-dashoffset:0;}
  .wheel-dot-1, .wheel-dot-2, .wheel-val-1, .wheel-val-2,
  .wheel-center-big, .wheel-center-small{opacity:1;transform:scale(1);}
  .scene-scorebug-team .tick-step{animation:none;opacity:0;}
  .scene-scorebug-team .tick-step:last-child{opacity:1;}
  /* A put-out token is simply gone at the dugout anchor - no travel, no
     colour transition (ball-flight-plan.md Stage 6, "Reduced motion" note).
     .rn's base rule already places it at --tx/--ty (the dugout), so only the
     visibility needs to change here. */
  .rn.out-walk .rn-inner, .rn.out-to-first .rn-inner, .rn.out-to-base .rn-inner, .rn.out-retreat .rn-inner,
  .rn.stranded-to-dugout .rn-inner{opacity:0;}
  .rn.out-walk circle, .rn.out-to-first circle, .rn.out-to-base circle, .rn.out-retreat circle{fill:var(--red);}
  .rn.score .rn-inner{opacity:0;}
  .mt-fill{stroke-dashoffset:var(--off);}
  .rb-new{stroke-dasharray:none;}
  .scene-flash{opacity:0;}
  /* Ball at its landing point, fielder there, throw line drawn - never
     hidden, never frozen mid-flight (ball-flight-plan.md Stage 7b). */
  .ball-trail{stroke-dashoffset:0;}
  .throw-clip-rect{width:var(--len);}
  .fielder{opacity:1;}
}

.toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--navy);color:#fff;padding:10px 18px;border-radius:6px;font-size:13px;font-weight:600;z-index:30;}

/* Hover styling is an enhancement only - nothing is discoverable by hover
   alone, since phones have no hover state. */
@media (hover: hover){
  .link-btn:hover{color:var(--text);}
}

/* ---- Tablet / narrow laptop ---- */
@media (max-width: 900px){
  .filter-row{gap:18px;}
  .chip-row{flex-wrap:wrap;}
  /* Card content wraps once it's this narrow - the leverage bar is already
     out of the flex flow (base rule, all widths) so wrapping doesn't strand
     it on the first line. */
  .moment{flex-wrap:wrap;}
}

/* Above the phone breakpoint the filters panel is always open, whatever
   class JS last left on the card. */
@media (min-width: 601px){
  .filters-body{display:block !important;}
}

/* ---- Phone ---- */
@media (max-width: 600px){
  .page{padding:18px 14px 40px;}

  .scoreboard-row{gap:8px;}
  .scoreboard-tile{padding:9px 10px 10px;gap:6px;}
  .sb-score{font-size:13px;}
  .sb-lev{font-size:9px;padding:2px 6px;}

  /* Title + session selector + all four icon buttons (Key Moments,
     Favorites, Refresh, Dark mode) share one line - title-actions keeps the
     base rule's margin-left:auto (no longer overridden to a forced
     full-width second line), which is what pushes the selector/icons to the
     right end of the same row the heading is on. Shrinking the heading, the
     selector, the icon buttons themselves, and the gaps between them is
     what leaves enough room now that there are four icons instead of two. */
  .title-row{gap:6px;flex-wrap:nowrap;}
  .title-row h1{font-size:16px;letter-spacing:normal;white-space:nowrap;}
  .title-actions{gap:4px;}
  .icon-btn{width:24px;height:24px;}
  .icon-btn svg{width:13px;height:13px;}
  .header-toggle-btn{display:inline-flex;}
  .session-select{min-width:0;width:auto;padding:4px 5px;font-size:11px;}

  /* 10c: collapsed filters bar and the sort chips share one line - same
     7px vertical padding as .sort-chip-row .chip below, so the two match
     in height exactly. Only affects the collapsed bar: the more specific
     :not(.collapsed) rule below overrides padding for the expanded panel,
     which stays roomier on purpose. */
  .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;margin-bottom:16px;}
  .filters-card{flex:1 1 auto;margin-bottom:0;padding:7px 13px;}
  .filters-card:not(.collapsed){flex:1 1 100%;padding:14px 14px 12px;}
  .filters-toggle{display:flex;}
  .filters-card.collapsed .filters-body{display:none;}
  .filters-card:not(.collapsed) .filters-body{margin-top:14px;}
  .sort-bar{flex:0 0 auto;margin-bottom:0;padding:0;}
  .sort-chip-row .chip{padding:7px 11px;}
  .lbl-long{display:none;}
  .lbl-short{display:inline;}

  /* 10d: chip groups wrap onto as many lines as they need - never scroll. */
  .filter-row{gap:16px;margin-bottom:14px;}
  .fields-row .filter-group{max-width:none;min-width:0;flex:1 1 100%;}
  .filters-footer{flex-wrap:wrap;gap:10px;}

  /* 10e: the scorebug stays one horizontal strip beneath .moment-left,
     roughly 140px wide, instead of stacking into several tall lines. The
     leverage bar becomes an absolute left edge so it still spans the full
     card height once the contents wrap. */
  .moment{gap:10px;padding:12px 14px 12px 17px;}
  .lev-bar{width:5px;}
  .moment-left{flex:1 1 100%;}
  .moment-right{flex:1 1 100%;justify-content:flex-start;gap:12px;}
  .team-logo-inline{width:26px;height:26px;}

  .player-name{font-size:14px;}
  .result-pill, .diff-pill{font-size:10.5px;padding:3px 8px;}
  .meta-line{gap:10px;font-size:12px;}
  .why-tag{font-size:10px;padding:2px 6px;}

  /* The counterpart ("vs X") drops to its own line so the result/diff
     pills have the line to themselves instead of wrapping mid-pill. */
  .counterpart{order:2;flex-basis:100%;margin-top:2px;}

  /* Tags move to the bottom of the whole card, below the scorebug, instead
     of sitting under meta-line - the two copies in the markup swap which
     one is visible. */
  .moment-left > .why-line{display:none;}
  .why-line-bottom{display:flex;flex-basis:100%;margin-top:10px;}

  .inning-indicator{min-width:18px;}
  .tri.up{border-left-width:5px;border-right-width:5px;border-bottom-width:7px;}
  .tri.down{border-left-width:5px;border-right-width:5px;border-top-width:7px;}
  .inning-num{font-size:11px;}
  .score-block{min-width:56px;}
  .score-block .row{font-size:12px;}
  .state-stack{gap:3px;}
  .state-stack svg{width:24px;height:24px;}
  .outs-dots .dot{width:6px;height:6px;}
  .state-badge{font-size:9.5px;padding:4px 7px;letter-spacing:0.4px;}
}

/* ---- Widescreen slideshow ---- */
/* The card was sized for a phone-width slide even on a desktop window. On a
   genuinely wide viewport it gets real room to grow; everything inside
   (the diamond SVG, the wheels) already scales off percentages/viewBox, so
   this is only the outer caps plus a few font sizes that were fixed in px. */
@media (min-width: 900px){
  .catchup-card{max-width:min(92vw,1080px);height:min(90vh,820px);}
  /* Was capped down to 350 for a stretch (a short/wide window could make
     the whole slide taller than the card, forcing a vertical scroll that
     chopped the scorebug off the top) - back to its full native size
     (460 = FIELD_W) now that the layout fits without that squeeze. */
  .scene-diamond-wrap{width:min(50%,460px);}
  .scene-play-line{font-size:18px;}
  .mu-name{font-size:17px;}
  /* The equal-thirds scorebug grid (Alex's spec) is now the unconditional
     base rule above, not a widescreen-only override - nothing left to add
     here. */
}

/* Real browser fullscreen (the expand button in the card's corner) - more
   room again once the browser chrome itself is gone. Targets the modal
   element, since that's what requestFullscreen() is called on in app.js;
   the card inside grows to fill it. -webkit- prefix kept for Safari, which
   has historically lagged the unprefixed pseudo-class. */
.catchup-modal:fullscreen, .catchup-modal:-webkit-full-screen{
  padding:0;background:var(--card);
}
.catchup-modal:fullscreen .catchup-card,
.catchup-modal:-webkit-full-screen .catchup-card{
  max-width:min(96vw,1500px);height:96vh;border-radius:0;
}
