/* ============================================================
   GAMEPLAY SCREEN — HUD, pitch, timing meter, HIT, overlays
   ============================================================ */

.game-screen {
  justify-content: space-between;
  /* The HUD is pinned to this top padding edge. In the F1Soft webview the
     native header already clears the status bar, so env(safe-area-inset-top)
     is double-counted and leaves a big gap above the HUD. Cap its contribution
     (min(...)) so the gap stays tight there while a standalone browser on a
     notched device still gets a little clearance. */
  padding: calc(6px + min(var(--safe-t), 8px)) 10px calc(12px + var(--safe-b));
  gap: 8px;
}

/* ------------------------------ HUD ------------------------------ */

.hud {
  display: flex; gap: 6px;
  width: 100%; max-width: 480px;
  flex-shrink: 0;
}
.hud-tile {
  flex: 1; min-width: 0;
  /* No backdrop-filter here: four blurred tiles are a constant GPU cost for
     the whole innings. A more opaque fill reads the same over the backdrop. */
  background: rgba(6,20,38,0.94);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 6px 4px 7px;
  text-align: center;
}
.hud-label {
  display: block;
  font-family: var(--f-ui); font-weight: 500;
  font-size: 0.52rem; letter-spacing: 1.4px;
  color: var(--tx-3);
}
.hud-value {
  display: block;
  font-family: var(--f-mega); font-size: 1.45rem;
  line-height: 1.05; color: #fff;
}
.hud-tile:nth-child(1) .hud-value,
.hud-tile:nth-child(2) .hud-value { color: var(--gold); }

/* ------------------------------ PITCH ------------------------------ */

.pitch {
  position: relative;
  width: 100%; max-width: 480px;
  flex: 1; min-height: 260px;
  border-radius: 16px;
  overflow: hidden;
  border: 1px solid var(--line);
  background: linear-gradient(to bottom, #0a1c30 0%, #123a1c 32%, #1c5a22 100%);
  touch-action: none;
  box-shadow: 0 18px 44px rgba(0,0,0,0.5);
}

/* ---------- PHOTO PITCH ----------
   The supplied stadium art is a batting-view pitch, so in photo mode it
   replaces the drawn turf/strip/crease/stumps/crowd entirely and only the
   moving pieces (ball, bowler, batsman, hitting zone) sit on top.
   `.photo` is applied by app.js once the image loads. */
.pitch.photo { background: #0d2f14; }
/* On the camera layer, not .pitch, so it scales with the big-shot zoom. */
/* ONE BACKGROUND PIXEL, in panel units. The pitch plate is 941x1672 drawn with
   `cover`, so its scale is max(panelW/941, panelH/1672) — width-driven on a
   wide panel, height-driven on a tall one. Anything that must stay welded to
   something PAINTED IN THE PHOTO (a crease, a stump line, where the bowler
   stands) has to be expressed in this unit, not in % of the panel: a
   percentage only lines up at the one aspect ratio it was tuned at.

   Solving `cover` + `center 58%` for image row Y gives
       bottom = 42cqh + (0.58 x 1672 - Y) x one image pixel
   which is the form every crease-pinned rule below uses. */
.pitch.photo .pitch-cam {
  --img-px: max(0.106270cqw, 0.0598086cqh);
  /* Near crease — the white line the photo paints at row 1599. */
  --crease-near: max(0%, calc(42cqh - 629.24 * var(--img-px)));
  /* Far crease — where the plate's own stumps used to stand (their base was
     image row 925, 63px tall) before they were painted out of it. Pinning the
     far wicket to that spot keeps it on the same mark the photo's perspective
     implies, at any panel aspect. */
  --crease-far: calc(42cqh + 44.76 * var(--img-px));
  --stump-far-h: calc(63 * var(--img-px));
  background: var(--pitch-img) center 58%/cover no-repeat;
}
.pitch.photo .pitch-crowd,
.pitch.photo .pitch-turf,
.pitch.photo .pitch-strip,
.pitch.photo .crease,
.pitch.photo .stumps-far,
.pitch.photo .stumps-near,
.pitch.photo .fielder { display: none; }

/* Geometry recalibrated to the photo's perspective: the far wicket sits
   around 58% and the near crease around 84% of the frame. */
/* Bowler stands at the far wicket, which the photo puts around 53%.
   Small, to respect the photo's perspective at that distance. */
/* Sprites are sized as a share of the panel height, not fixed pixels, so
   they keep the same scale on a 568px-tall phone as on a tall viewport. */
/* The far bowler stands BESIDE his stumps, not on them: at left 50% he was
   centred on the wicket and, being 23px wide against 5px of timber, simply
   hid it. His feet are pinned to the same --crease-far the stumps use, so the
   two stay on one line at any panel aspect rather than only at the one this
   was eyeballed at. */
.pitch.photo .bowler {
  top: auto;
  bottom: 45.5%;                 /* fallback: no container units */
  bottom: var(--crease-far);
  left: 56.5%;
  height: 9%;
}
.pitch.photo .ball { top: 54%; }
.pitch.photo .ball.deliver { animation-name: ball-deliver-photo; }
.pitch.photo .ball.launch { animation-name: ball-launch-photo; }
.pitch.photo .ball.boundary { animation-name: ball-boundary-photo; }
.pitch.photo .hitting-zone { bottom: 15%; width: 104px; }
/* Stumps stand ON the near crease line — the white mark the pitch plate paints
   at pixel row 1599 of its 1672. (The photo shows two crease lines: a wide
   one at row 1458 and this nearer one at 1599; the stumps belong on 1599.)

   That row cannot be a fixed `bottom: %`. The backdrop is `cover` at
   `center 58%`, so the image is SCALED by the panel width but POSITIONED
   against its height; the line therefore drifts as the panel aspect ratio
   changes, and any single percentage is only right at one size. Solving
   `cover` + `58%` for an image row Y gives:

       bottom = 42% of height  +  100 x (0.58 x 1672 - Y) / 941  % of width

   For Y = 1599 that constant is -66.8693, hence the calc below. Container
   units express it directly (cqh/cqw = 1% of the .pitch-cam layer), so the
   stumps stay welded to the line at any panel size. Re-derive with the
   formula above if you swap the photo, move the crease, or change `58%`.

   The `max()` is a floor, not a fudge. This line sits low in frame, so on a
   short, wide panel (a 320x568 phone gets ~298x361) `cover` crops it clean
   off the bottom and the raw value goes negative. There the line simply is
   not in shot, so the stumps rest on the bottom edge instead.

   Width follows from the sprite own 0.287 aspect, which matches real stumps
   (9in across, 28in tall), so this reads at true scale.

   z-index 4 puts the stumps IN FRONT of the batsman (3). That matches the
   depth the crease line implies: the stumps base sits lower in frame than
   his feet, so they are the nearer object and must occlude him, not the
   reverse. Still under the ball (5), which passes in front of everything. */
.pitch.photo .pitch-stumps {
  display: block;
  position: absolute;
  left: 50%;
  bottom: 3%; /* fallback for engines without container units */
  bottom: var(--crease-near);
  /* Width no longer comes from an <img>'s intrinsic size — the stumps are
     assembled from parts now — so the old sprite's 317x1101 footprint is
     restated here. Keeping the same box means the crease maths above, and
     every position tuned against it, still hold. */
  height: 15%;
  aspect-ratio: 317 / 1101;
  transform: translateX(-50%);
  transform-origin: 50% 100%;
  z-index: 4;
  filter: drop-shadow(0 4px 8px rgba(0,0,0,0.55));
}

/* Layout inside the box.
   Each part is sized in percent of the CONTAINER WIDTH and keeps its own
   aspect via height:auto, so the whole assembly scales as one.

   The source art is padded: the stick's timber is only the middle ~34% of
   its file, and the bail ~75% of its. The widths below are therefore larger
   than the visible part — 56% of the box renders a stick about 19% wide,
   which is what puts three of them across the box with real gaps. */
.pitch.photo .pitch-stumps .st-stick,
.pitch.photo .pitch-stumps .st-bail {
  position: absolute;
  height: auto;
  transform-origin: 50% 100%;
}
.pitch.photo .pitch-stumps .st-stick { width: 56%; bottom: 0; }
.pitch.photo .pitch-stumps .st-stick-l { left: -16%; }
.pitch.photo .pitch-stumps .st-stick-m { left: 22%; }
.pitch.photo .pitch-stumps .st-stick-r { left: 60%; }

/* Bails bridge adjacent stumps and sit just proud of the timber tops. */
.pitch.photo .pitch-stumps .st-bail { width: 56%; top: -1.5%; }
.pitch.photo .pitch-stumps .st-bail-l { left: 3%; }
.pitch.photo .pitch-stumps .st-bail-r { left: 41%; }

/* Wicket. The parts scatter instead of the old single-sprite topple: bails
   are light and fly first, the sticks lean and fall behind them, each on its
   own delay so the wicket reads as an impact rather than a hinge. */
.pitch.photo .pitch-stumps.broken .st-bail-l {
  animation: bail-fly-l 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}
.pitch.photo .pitch-stumps.broken .st-bail-r {
  animation: bail-fly-r 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}
.pitch.photo .pitch-stumps.broken .st-stick-l {
  animation: stick-fall-l 0.85s cubic-bezier(0.25, 0.8, 0.35, 1) 0.04s forwards;
}
.pitch.photo .pitch-stumps.broken .st-stick-m {
  animation: stick-fall-m 0.85s cubic-bezier(0.25, 0.8, 0.35, 1) forwards;
}
.pitch.photo .pitch-stumps.broken .st-stick-r {
  animation: stick-fall-r 0.85s cubic-bezier(0.25, 0.8, 0.35, 1) 0.07s forwards;
}

@media (prefers-reduced-motion: reduce) {
  .pitch.photo .pitch-stumps.broken .st-bail-l,
  .pitch.photo .pitch-stumps.broken .st-bail-r,
  .pitch.photo .pitch-stumps.broken .st-stick-l,
  .pitch.photo .pitch-stumps.broken .st-stick-m,
  .pitch.photo .pitch-stumps.broken .st-stick-r {
    animation: none;
    opacity: 0.35;
  }
}

/* The far wicket. BOTH modes show it: batting looks down the pitch at the
   bowler's stumps, bowling looks at the batsman's. The plate no longer paints
   either wicket, so every set on screen is this assembly — which is also what
   lets any of them be knocked over.

   Position and size come from the plate's own geometry (--crease-far), not
   from panel percentages, for the same reason the near stumps and the bowler
   do: a percentage only lines up at one aspect ratio. */
.pitch.photo .pitch-stumps.far {
  display: block;
  bottom: 47%;              /* fallback: no container units */
  bottom: var(--crease-far);
  height: 3.5%;
  height: var(--stump-far-h);
  z-index: 3;
}

/* Batter stands beside the stumps and slightly across.
   z-index 3 puts him BEHIND them (4) — see the stumps block above; they are
   the nearer object, so they occlude him. The wicket therefore always reads
   in full whatever `left` is set to, which is what the topple on a wicket
   needs; moving him now only changes how much of HIM the stumps cover. */
/* Sized against the FIGURE, not the canvas: the five swing frames are
   normalised onto a 460x600 sheet with padding around them (see BAT_FRAMES),
   so the sprite box has to be bigger than the old one to put the same amount
   of batsman on screen — measured 133px of figure at the old 35.5%, against
   160px before. `bottom` accounts for the 40px of canvas below his feet. */
.pitch.photo .batsman { bottom: 2.2%; left: 40%; height: 42.5%; z-index: 3; }

/* Camera layer — scales the whole scene for big-shot zoom. */
.pitch-cam {
  position: absolute; inset: 0;
  /* A size container so the stumps can be placed off BOTH panel dimensions.
     Safe here: the layer is inset:0, so its size never depends on contents.
     Size containment implies layout+style, not paint, so nothing new clips. */
  container-type: size;
  /* Which end of the pitch the camera pivots about. 82% is the near crease,
     right for batting; bowl mode moves it up to the far wicket, where all
     the action is (see applyModeChrome in js/game.js). It is only ever
     changed while the scale is 1, because moving the origin under a live
     transform makes the scene jump. */
  transform-origin: 50% var(--cam-oy, 82%);
  transition: transform 0.45s cubic-bezier(0.2, 0.9, 0.3, 1);
  will-change: transform;
}
/* Delivery push-in — a slow creep toward the batsman for the length of the
   ball, released at contact. Unlike the zooms below it fires on EVERY ball,
   good or bad, so the wait for the ball has some build to it instead of
   being a static frame.

   1.04 is a measured ceiling, not a taste call. The layer's origin is at 82%,
   so everything below that edges off the bottom as it scales: at 1.06 the
   stumps lost 6px of their base, at 1.04 it is ~3px. The contact zooms crop
   far harder, but they are brief and celebratory — this one runs under every
   ball, including the one the player is trying to time.

   Declared BEFORE the zooms so a contact zoom overrides it on the same
   specificity. Duration is set per delivery in js/game.js, since it has to
   match that ball's run-up plus flight. */
.pitch-cam.push {
  transform: scale(1.04);
  transition-duration: var(--cam-push-ms, 1400ms);
  transition-timing-function: linear;
}

/* Wicket hold — the biggest beat in the game, and the only one bowling
   scores at all now. Pushes harder than a boundary and is held while the
   stumps scatter, then released by js/game.js rather than by a timer here,
   so the hold length lives next to the rest of the wicket sequence. */
.pitch-cam.zoom-wicket {
  transform: scale(1.22);
  transition-duration: 0.26s;
}
/* Bowling's wicket is a pitch length away — 19px of stumps — so 1.22 showed
   almost nothing. It gets its own, much harder push, pivoted on the far
   wicket itself (--cam-oy is already 52% there).

   2.05 is safe for sharpness as well as framing: `cover` already draws the
   941px-wide plate at ~0.41 of native on a phone panel, so even at this zoom
   it is still sampling under 1:1 and does not go soft. */
.pitch.bowl-mode .pitch-cam.zoom-wicket { transform: scale(2.05); }

/* SIX — the shot is the only one that travels, so the camera travels with it:
   a hard snap on contact, then a steady recede for the length of the ball's
   1.05s flight, which is what sells the distance. It ends at 1.0 rather than
   below it: the backdrop only fills the panel, so any scale under 1 would
   show the page behind at the edges. The recede is therefore relative — from
   1.26 back to rest — which reads the same without exposing the seams. */
/* The camera FOLLOWS the ball on a boundary. `--cam-pan-x/y` are set per shot
   in js/game.js from the same exit line the ball is given, so the pan matches
   where this particular six actually went rather than being a canned move.
   The signs are inverted there: to look where the ball is going, the scene
   has to move the other way.

   Both keyframes return the pan to 0 before the scale does. Panning is only
   safe inside the overscan a zoom buys — at scale S there is (S-1)/2 of slack
   per side — so the pan peaks while the camera is still well zoomed and is
   back at centre by the time it rests. game.js caps the pan at 6% of the
   panel for the same reason. */
@keyframes cam-six {
  0%   { transform: scale(1.04) translate(0, 0); }
  9%   { transform: scale(1.26) translate(calc(var(--cam-pan-x, 0px) * 0.12),
                                          calc(var(--cam-pan-y, 0px) * 0.12)); }
  35%  { transform: scale(1.20) translate(var(--cam-pan-x, 0px), var(--cam-pan-y, 0px)); }
  70%  { transform: scale(1.10) translate(calc(var(--cam-pan-x, 0px) * 0.45),
                                          calc(var(--cam-pan-y, 0px) * 0.45)); }
  100% { transform: scale(1) translate(0, 0); }
}
.pitch-cam.zoom-six { animation: cam-six 1500ms cubic-bezier(0.22, 0.75, 0.3, 1); }

/* FOUR — same idea, shorter and flatter: the ball races along the ground for
   700ms, so the camera swings across rather than up. */
@keyframes cam-four {
  0%   { transform: scale(1.04) translate(0, 0); }
  12%  { transform: scale(1.18) translate(calc(var(--cam-pan-x, 0px) * 0.18),
                                          calc(var(--cam-pan-y, 0px) * 0.18)); }
  50%  { transform: scale(1.14) translate(var(--cam-pan-x, 0px), var(--cam-pan-y, 0px)); }
  100% { transform: scale(1) translate(0, 0); }
}
.pitch-cam.zoom-four { animation: cam-four 1000ms cubic-bezier(0.25, 0.8, 0.3, 1); }

/* Impact punch — a snap in and out ON CONTACT, sized by how well the ball was
   timed rather than by what it scored. A perfectly timed single should feel
   sharper than a scrambled one, and nothing else on screen carries that: the
   banner names the timing, but only the camera makes it land.

   An animation, not a transition, because it has to return to where it
   started on its own. It therefore beats the transition-driven zooms in the
   cascade for as long as it runs — which is why js/game.js only fires it on
   outcomes that do NOT zoom. On a four or a six the zoom is the punch,
   bigger. */
@keyframes cam-punch {
  0%   { transform: scale(1.04); }
  22%  { transform: scale(1.085); }
  100% { transform: scale(1); }
}
@keyframes cam-punch-soft {
  0%   { transform: scale(1.04); }
  26%  { transform: scale(1.055); }
  100% { transform: scale(1); }
}
.pitch-cam.punch { animation: cam-punch 260ms cubic-bezier(0.18, 0.92, 0.28, 1); }
.pitch-cam.punch-soft { animation: cam-punch-soft 240ms cubic-bezier(0.2, 0.9, 0.3, 1); }

@media (prefers-reduced-motion: reduce) {
  .pitch-cam.push { transform: none; }
  .pitch-cam.punch,
  .pitch-cam.punch-soft { animation: none; }
  .pitch-cam.zoom-wicket { transform: none; }
  .pitch-cam.zoom-six,
  .pitch-cam.zoom-four { animation: none; }
}

.pitch-crowd {
  position: absolute; top: 0; left: 0; right: 0; height: 13%;
  background:
    repeating-linear-gradient(90deg,
      rgba(217,35,46,0.32) 0 1.4%, rgba(29,78,216,0.3) 1.4% 2.8%,
      rgba(247,197,21,0.22) 2.8% 4.2%, rgba(255,255,255,0.16) 4.2% 5.8%),
    linear-gradient(to bottom, rgba(3,10,20,0.72), rgba(3,10,20,0.92));
  filter: blur(3px);
}

/* Outfield with mown stripes receding into the distance */
.pitch-turf {
  position: absolute; top: 11%; left: -18%; right: -18%; bottom: -14%;
  border-radius: 50% 50% 0 0 / 22% 22% 0 0;
  background:
    repeating-linear-gradient(to bottom,
      rgba(255,255,255,0.045) 0 4%, transparent 4% 9%),
    radial-gradient(ellipse at 50% 96%, #3a8a40 0%, #23682a 46%, #12401a 100%);
}

/* Batting strip in perspective */
.pitch-strip {
  position: absolute; left: 50%; bottom: 0;
  transform: translateX(-50%);
  width: 46%; height: 78%;
  background: linear-gradient(to top, #d9c489 0%, #c4ad6c 48%, #9d8a4d 100%);
  clip-path: polygon(16% 100%, 84% 100%, 61% 0%, 39% 0%);
  opacity: 0.92;
  box-shadow: 0 0 40px rgba(0,0,0,0.25);
}

.crease {
  position: absolute; left: 50%; bottom: 14%;
  transform: translateX(-50%);
  width: 34%; height: 2px;
  background: rgba(255,255,255,0.7);
  box-shadow: 0 -46px 0 -0.5px rgba(255,255,255,0.35);
}

/* Real stumps sprite. Hidden on the drawn pitch, which has its own .stumps. */
.pitch-stumps { display: none; }

.stumps { position: absolute; display: flex; gap: 3px; z-index: 3; }
.stumps span { width: 2.5px; background: #f2e8cf; border-radius: 1px; box-shadow: 0 1px 3px rgba(0,0,0,0.5); }
.stumps-far { top: 26%; left: 50%; transform: translateX(-50%); }
.stumps-far span { height: 20px; }
.stumps-near { bottom: 11%; left: 50%; transform: translateX(-50%); }
.stumps-near span { height: 32px; }

/* The photo pitch's own .pitch-stumps sprite is fixed at the near crease
   (see the big comment on it above), which is right for batting but not
   for bowling — there, the wicket that falls is the opposing batsman's,
   at the FAR end. Rather than build a second photo sprite, reuse this
   drawn-mode .stumps-far div (normally hidden by the blanket
   `.pitch.photo .stumps-far { display:none }` rule above) as a small
   marker planted right beside the far-batsman sprite it already sits
   next to positionally. */
/* Superseded by .pitch-stumps.far, which is the real assembly rather than
   three coloured divs. Kept hidden rather than deleted because the drawn
   (non-photo) pitch still uses .stumps-far. */
.pitch.photo.bowl-mode .stumps-far { display: none; }

.fielder {
  position: absolute; top: 22%; right: 22%;
  font-size: 1.1rem; opacity: 0.75;
  filter: drop-shadow(0 3px 5px rgba(0,0,0,0.5));
}

/* Bowler and batsman are real sprites, anchored by their feet so the
   figure stays planted while frames of differing height swap in. */
.bowler {
  position: absolute; top: 24%; left: 50%;
  transform: translateX(-50%);
  z-index: 3; height: 9.5%;
}
/* A canvas now, not an <img>: the opposing bowler is recoloured into the
   other side's kit through the same tint the player's own figure uses. */
.bowler canvas {
  display: block; height: 100%; width: auto;
  filter: drop-shadow(0 4px 8px rgba(0,0,0,0.55));
}
/* Duration tracks BAT_RUNUP_MS (560ms) plus the follow-through beat, so the
   run-in finishes as the ball leaves rather than part-way through it. */
.bowler.bowling { animation: bowler-runup 0.78s cubic-bezier(0.3, 0.6, 0.4, 1) forwards; }
.bowler canvas { transform-origin: 50% 100%; }

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

.batsman {
  position: absolute; bottom: 12%; left: 50%;
  transform: translateX(-50%);
  z-index: 4; height: 16%;
  display: flex; align-items: flex-end;
}
.batsman canvas {
  display: block; height: 100%; width: auto;
  filter: drop-shadow(0 6px 10px rgba(0,0,0,0.6));
}

/* Sized against the stumps, which are the only true-scale reference on the
   pitch. A real ball is ~0.10 of stump height; at 13px it read 0.16 and made
   the wicket look like a toy, and 11px was still visibly oversized at 0.12.
   9.5px is ~0.105 - essentially true scale. It survives hard difficulty only
   because the bowl-mode keyframes now start it near the camera and shrink it
   away, giving the eye a size cue the flat 11px dot never had. */
.ball {
  position: absolute; top: 28%; left: 50%;
  width: 9.5px; height: 9.5px; margin-left: -4.75px;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, #ff7b7b, var(--np-red) 55%, var(--np-red-dk) 100%);
  box-shadow: 0 2px 8px rgba(0,0,0,0.6), 0 0 12px rgba(217,35,46,0.5);
  opacity: 0; z-index: 5;
  will-change: transform;
}
.ball.seam::after {
  content: ''; position: absolute; inset: 1px;
  border-radius: 50%; border: 1px dashed rgba(255,255,255,0.55);
}

/* Bat-ball contact flash, BAT mode only. A frame swap alone (swingBat())
   changes the sprite but the bat never visibly REACHES the ball — this is
   the missing "they touched" beat, a burst at the exact point game.js
   re-anchors the ball to before it flies off. Built from CSS only (radial
   glow + 5 spark rays via ::before/::after and box-shadow, not an image)
   so it stays crisp at any size and needs no asset. Sized/positioned like
   `.ball` (9.5px, centred via negative margin) so it sits exactly on it. */
.bat-impact {
  position: absolute; top: 28%; left: 50%;
  width: 9.5px; height: 9.5px; margin-left: -4.75px; margin-top: -4.75px;
  z-index: 6;                /* above the ball/bat, below the outcome banner */
  opacity: 0;
  pointer-events: none;
}
.bat-impact.burst {
  animation: bat-impact-flash 0.32s ease-out;
}
.bat-impact.burst::before {
  content: '';
  position: absolute; inset: -14px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255,255,255,0.95), rgba(247,197,21,0.55) 45%, transparent 72%);
}
/* 5 spark rays, evenly fanned, drawn once via box-shadow rather than 5
   separate elements — cheaper and there's nothing per-ray to animate
   differently. */
.bat-impact.burst::after {
  content: '';
  position: absolute; top: 50%; left: 50%;
  width: 3px; height: 3px; margin: -1.5px 0 0 -1.5px;
  border-radius: 50%;
  background: #fff8dd;
  box-shadow:
    0 -16px 0 -1px #fff8dd,
    15px -8px 0 -1px #fff8dd,
    15px 8px 0 -1px #fff8dd,
    -15px 8px 0 -1px #fff8dd,
    -15px -8px 0 -1px #fff8dd;
  animation: bat-impact-rays 0.32s ease-out;
}
@keyframes bat-impact-flash {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes bat-impact-rays {
  0%   { transform: scale(0.2); opacity: 1; }
  100% { transform: scale(1.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .bat-impact.burst { animation: none; }
  .bat-impact.burst::before, .bat-impact.burst::after { animation: none; opacity: 0; }
}

.hitting-zone {
  position: absolute; bottom: 24%; left: 50%;
  transform: translateX(-50%);
  width: 76px; height: 40px;
  border: 2px dashed rgba(247,197,21,0.4);
  border-radius: 12px;
  opacity: 0; transition: opacity 0.18s;
  z-index: 3;
}
.hitting-zone.active { opacity: 1; box-shadow: 0 0 22px rgba(247,197,21,0.22) inset; }

/* Hidden from players: the difficulty of each ball is something to feel, not
   to read off a label, and announcing "HARD" before the delivery tells them
   the ball is unfair before they have missed it.
   The element and the code that fills it are intact — game.js still writes
   the level to it every ball — so deleting the `display: none` below brings
   it straight back for QA. */
.difficulty-badge {
  display: none;
  position: absolute; top: 8px; right: 8px; z-index: 6;
  font-family: var(--f-ui); font-weight: 600;
  font-size: 0.55rem; letter-spacing: 1.6px;
  color: var(--gold);
  background: rgba(2,8,18,0.75);
  border: 1px solid var(--line-gold);
  padding: 3px 9px; border-radius: 999px;
}

/* ------------------------ IN-PITCH OVERLAYS ------------------------ */

.timing-flash {
  position: absolute; inset: 0; z-index: 7;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--f-mega); font-size: 1.9rem;
  letter-spacing: 2px; color: #fff;
  text-shadow: 0 0 22px currentColor, 0 3px 0 rgba(0,0,0,0.5);
  opacity: 0; pointer-events: none;
}
.timing-flash.show { animation: flash-pop 0.62s ease forwards; }

/* Cinematic SIX / FOUR / OUT display */
.outcome {
  position: absolute; inset: 0; z-index: 9;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 2px; opacity: 0; pointer-events: none;
  /* Deliberately no backdrop-filter: this overlay covers the whole pitch and
     is on screen for over a second while up to 54 confetti pieces animate.
     Blurring that area every frame was the single worst gameplay cost; a
     heavier scrim gives the same separation for free. */
  background: radial-gradient(ellipse at 50% 50%, rgba(2,8,18,0.62), rgba(2,8,18,0.90));
}
.outcome.show { animation: outcome-in 1.25s ease forwards; }
.outcome.wicket { background: radial-gradient(ellipse at 50% 50%, rgba(142,18,25,0.55), rgba(2,8,18,0.9)); }

.outcome-kicker {
  font-family: var(--f-mega); font-size: 1.5rem;
  letter-spacing: 2px; color: #6ee87a;
  text-shadow: 0 0 20px rgba(110,232,122,0.6), 0 3px 0 rgba(0,0,0,0.5);
}
.outcome-main {
  font-family: var(--f-mega);
  font-size: clamp(3.4rem, 23cqw, 6.2rem);
  line-height: 0.9; letter-spacing: 2px;
  background-image: linear-gradient(180deg, #fff8d4 0%, var(--gold) 42%, #d19b00 72%, #7d5c00 100%);
  -webkit-background-clip: text; background-clip: text; color: transparent;
  filter: drop-shadow(0 5px 0 rgba(2,8,18,0.6)) drop-shadow(0 10px 20px rgba(0,0,0,0.6));
}
.outcome.wicket .outcome-main {
  background-image: linear-gradient(180deg, #ffb3b3 0%, var(--np-red) 45%, #7a0f16 100%);
}
.outcome.wicket .outcome-kicker { display: none; }
.outcome-sub {
  font-family: var(--f-ui); font-weight: 600;
  font-size: 1rem; letter-spacing: 2px; color: #fff;
  margin-top: 4px;
  text-shadow: 0 2px 8px rgba(0,0,0,0.7);
}

.confetti-layer { position: absolute; inset: 0; z-index: 10; pointer-events: none; overflow: hidden; }
.confetto { position: absolute; top: -14px; width: 7px; height: 12px; opacity: 0.9; animation: confetti-fall linear forwards; }

/* Dust kicked up at the exact bat-contact point (see AnimationService.spawnDust
   / game.js's flashBatContact) — a handful of small earthy motes flung out
   and down, distinct from the bright confetti above. `--dx`/`--dy` are set
   per-particle in JS so the burst isn't identical every time. */
.dust-mote {
  position: absolute; width: 4px; height: 4px; margin: -2px 0 0 -2px;
  border-radius: 50%; opacity: 0.85; z-index: 6; pointer-events: none;
  animation: dust-kick 0.55s ease-out forwards;
}
@keyframes dust-kick {
  0%   { transform: translate(0, 0) scale(1); opacity: 0.9; }
  100% { transform: translate(var(--dx), var(--dy)) scale(0.35); opacity: 0; }
}

/* A handful of fading dots trailing the ball on its way to a four/six (see
   AnimationService.ballTrail) — cheap motion-blur stand-in with no extra
   image asset. Positioned in pixels (JS reads the ball's live rect each
   frame) rather than percentages, unlike the ball itself. */
.ball-trail-dot {
  position: absolute; width: 6px; height: 6px; margin: -3px 0 0 -3px;
  border-radius: 50%; background: rgba(217, 35, 46, 0.55); z-index: 4;
  pointer-events: none; animation: trail-fade 0.26s ease-out forwards;
}
@keyframes trail-fade {
  0%   { opacity: 0.7; transform: scale(1); }
  100% { opacity: 0; transform: scale(0.3); }
}

@media (prefers-reduced-motion: reduce) {
  .dust-mote, .ball-trail-dot { animation: none; opacity: 0; }
}

.pause-overlay {
  position: absolute; inset: 0; z-index: 12;
  display: none; flex-direction: column;
  align-items: center; justify-content: center; gap: 12px;
  background: rgba(2,8,18,0.9);
  backdrop-filter: blur(6px);
}
.pause-overlay.show { display: flex; }
.pause-overlay h3 {
  font-family: var(--f-mega); font-size: 2rem;
  letter-spacing: 3px; margin: 0 0 6px; color: var(--gold);
}
.pause-overlay .btn { min-width: 180px; padding: 12px; }

/* Pre-innings "3, 2, 1" — shown once, before the first ball of a game.
   Text content per tick comes from game.js; the pop animation is restarted
   per digit via AnimationService.restart(). */
.ready-countdown {
  position: absolute; inset: 0; z-index: 13;
  display: none; align-items: center; justify-content: center;
  pointer-events: none;
  font-family: var(--f-mega); font-size: 5rem; color: var(--gold);
  text-shadow: 0 4px 18px rgba(0,0,0,0.6);
}
.ready-countdown.show { display: flex; }
.ready-countdown.pop { animation: ready-pop 0.6s ease-out; }
@keyframes ready-pop {
  0%   { transform: scale(0.4); opacity: 0; }
  35%  { transform: scale(1.08); opacity: 1; }
  70%  { transform: scale(1); opacity: 1; }
  100% { transform: scale(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ready-countdown.pop { animation: none; opacity: 1; }
}

/* ------------------------ TIMING METER ------------------------ */

/* Overlaid on the pitch, hard against its right edge, so it costs no playing
   area — the alternative, sitting beside the panel, squeezes the pitch (and
   with it the batsman and stumps, which are sized off the panel) on exactly
   the narrow phones that can least afford it. */
.timing-meter {
  position: absolute;
  /* A little over a third of the pitch height, vertically centred: any taller
     and it dominates the scene it is overlaid on. The bands keep their
     proportions whatever the height — see layoutTimingMeter() in js/game.js,
     which measures the track and lays them out from ScoringConfig — so
     shortening it changes nothing about the timing. */
  top: 37%; bottom: 37%;
  right: 10px;
  width: 22px;
  z-index: 8;
  pointer-events: none;
}

.tm-track {
  position: relative;
  display: flex; align-items: stretch;
  /* Bottom-to-top: the first band the marker meets is the one nearest the
     player, matching the ball coming towards them. */
  flex-direction: column-reverse;
  width: 100%; height: 100%;
  border-radius: 7px; overflow: hidden;
  border: 1px solid rgba(255,255,255,0.14);
  box-shadow: 0 4px 14px rgba(0,0,0,0.45);
}
/* Widths come from app.js (inline flex-basis), never from here -- they are
   derived from ScoringConfig so the bar cannot drift out of step with the
   scoring again. Only the colours live in CSS. */
.tm-seg {
  display: flex; align-items: center; justify-content: center;
  flex-grow: 0; flex-shrink: 0;
  overflow: hidden; white-space: nowrap;
  font-family: var(--f-ui); font-weight: 700;
  /* Letter-spacing was 1.4px, which on a 29px GOOD band spent a third of the
     width on gaps. The bands are much narrower now the run-in shares the
     track, so the type is tighter and smaller and only appears where it
     actually fits — see layoutTimingMeter() in js/game.js. */
  font-size: 0.5rem; letter-spacing: 0.2px; color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,0.75);
}
/* A band too narrow for its word keeps its colour and drops the text; the
   colour is the signal, a clipped half-word is just noise. */
.tm-seg.no-label { font-size: 0; }
.tm-seg-early,
.tm-seg-late { background: linear-gradient(180deg, #e0524f, #a3242c); }
.tm-seg-early_good,
.tm-seg-late_good { background: linear-gradient(180deg, #6fa8dc, #3b6ea5); }
.tm-seg-perfect { background: linear-gradient(180deg, #4fd463, #24a13a); }
/* Run-in. Not a scoring band — it is the ball's flight, drawn so the marker
   is visible and moving long before it reaches anything that scores. Muted
   on purpose: it must read as "not yet", not as another band to aim for. */
.tm-seg-approach {
  background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04));
  color: rgba(255,255,255,0.35);
}
/* PERFECT is the one the player is actually aiming at, so it keeps a little
   more presence than the bands either side of it. */
.tm-seg-perfect { font-size: 0.56rem; letter-spacing: 0.3px; }

.tm-indicator {
  position: absolute; left: -3px; bottom: 0;
  height: 3px; width: calc(100% + 6px);
  background: #fff; border-radius: 2px;
  box-shadow: 0 0 10px rgba(255,255,255,0.95), 0 0 22px rgba(255,255,255,0.5);
  z-index: 2;
  will-change: transform;
}
/* The sweep itself is driven from JS via the Web Animations API (see
   startSweep in js/game.js) with explicit pixel keyframes, so there is no CSS
   animation here. A CSS @keyframes that computed its distance from var() inside
   calc() rendered only in Chromium and sat still in Firefox/Safari. */

/* ------------------------ HIT BUTTON / FOOTER ------------------------ */

.game-footer {
  display: flex; align-items: center; gap: 12px;
  width: 100%; max-width: 480px;
  flex-shrink: 0;
}

.btn-hit {
  flex: 1;
  padding: 19px;
  font-family: var(--f-mega);
  font-size: 1.85rem; letter-spacing: 5px;
  color: #fff;
  border-radius: 999px;
  background:
    radial-gradient(ellipse at 50% 20%, rgba(255,255,255,0.35), transparent 60%),
    linear-gradient(180deg, #ff4d4d 0%, var(--np-red) 45%, var(--np-red-dk) 100%);
  box-shadow:
    0 12px 32px rgba(217,35,46,0.5),
    0 0 0 4px rgba(217,35,46,0.18),
    inset 0 2px 0 rgba(255,255,255,0.35),
    inset 0 -3px 10px rgba(0,0,0,0.35);
  text-shadow: 0 2px 6px rgba(0,0,0,0.45);
  animation: hit-pulse 1.8s ease-in-out infinite;
}
.btn-hit:disabled { opacity: 0.4; animation: none; }
.btn-hit:active { transform: scale(0.94); }
.btn-hit[hidden] { display: none; }

.tap-hint {
  flex: 1;
  margin: 0;
  text-align: center;
  font-weight: 700;
  font-size: 0.85rem;
  letter-spacing: 2px;
  color: rgba(255, 255, 255, 0.72);
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.tap-hint[hidden] { display: none; }

/* While the over is waiting to be started, the hint is the only thing telling
   the player anything is expected of them — so it breathes rather than sitting
   there as static text. It stops the moment the ball is on its way, because by
   then the instruction has been followed and a pulsing line would compete with
   the delivery for attention. */
@keyframes hint-breathe {
  0%, 100% { opacity: 0.62; transform: scale(1); }
  50%      { opacity: 1;    transform: scale(1.045); }
}
.tap-hint.awaiting {
  color: var(--gold, #f5c518);
  animation: hint-breathe 1500ms ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .tap-hint.awaiting { animation: none; opacity: 1; }
}

@media (min-height: 720px) {
  .pitch { min-height: 320px; }
}
@media (min-width: 768px) {
  .hud, .pitch, .timing-meter, .game-footer { max-width: 560px; }
  .btn-hit { font-size: 2.1rem; padding: 22px; }
}

/* ============================ BOWLING MODE ============================
   Bowling is played FIRST-PERSON: the player is the bowler, so their own
   figure is never drawn. That is deliberate and not a missing sprite --
   it is also why bowling needed no new artwork.

   What changes on the pitch:
     - the near batsman is hidden (you are standing where he stood),
     - the far-end figure becomes the opposing batsman rather than a
       bowler running in,
     - the ball travels AWAY up the pitch (see the -bowl keyframes).
   The near stumps stay: they are the bowler's-end wicket and give the
   delivery something to leave from. */

.pitch.bowl-mode .batsman { display: none; }

/* The far figure is now a real front-on batsman sprite, so the bowler sprite
   no longer has to stand in for one. */
.pitch.bowl-mode .bowler { display: none; }

/* Opposing batsman at the far crease. Hidden outside bowl mode, and hidden
   until a frame is set so a missing asset degrades to an empty crease rather
   than a broken image. */
/* Smaller than it looks it should be on paper: at 11% he read as standing a
   few metres away rather than a pitch length away, which flattened the whole
   perspective. `top` is raised by the same amount the height loses so his feet
   stay on the far crease instead of floating above it. */
.far-batsman {
  position: absolute;
  left: 50%; top: 45.5%;
  height: 8.5%;
  transform: translateX(-50%);
  display: none;
  align-items: flex-end;
  z-index: 3;
}
/* `ready` is set by AnimationService on the first successful draw, so the
   box is never revealed before there is a figure in it (the old guard was
   :has(img[src]), which a canvas has no equivalent of). */
.pitch.bowl-mode .far-batsman:has(canvas.ready) { display: flex; }
/* The batsman stands in front of his stumps and slightly to the off side, so
   he occludes them rather than the other way round — he comes later in the
   DOM than .pitch-stumps.far, which at an equal z-index would have painted
   the wicket over him. The offset keeps the stumps visible past his body,
   which matters when they are the thing being knocked over. */
.pitch.bowl-mode .far-batsman { z-index: 4; left: 54%; }

.far-batsman canvas {
  display: block; height: 100%; width: auto;
  filter: drop-shadow(0 4px 7px rgba(0,0,0,0.5));
}

/* Mode badge, mirroring .difficulty-badge on the opposite corner. */
.mode-badge {
  position: absolute; top: 10px; left: 10px;
  z-index: 6;
  padding: 3px 9px;
  border-radius: 999px;
  background: rgba(6,20,38,0.86);
  border: 1px solid var(--line);
  font-family: var(--f-ui); font-weight: 700;
  font-size: 0.55rem; letter-spacing: 1.2px;
  color: var(--gold);
  pointer-events: none;
}

/* ==================== BOWLING — OVER-THE-SHOULDER VIEW ====================
   Reworked to match the supplied reference: the camera sits BEHIND the
   bowler, not at their eyes.

   This is not a cosmetic preference. First-person bowling means the player
   never sees their own jersey, which silently defeats the personalised-name
   feature. Putting the camera over the shoulder is what makes that name
   visible in bowl mode, so the two features are designed together.

   Requires artwork we do not yet have: a bowler seen FROM BEHIND, arm
   raised, with a clear flat back panel for the name and number.
   `assets/images/baller.png` is front-on and cannot stand in. Until that
   sprite lands, `.bowler-fg` has no image and the view falls back to the
   first-person framing, which still plays correctly.
   ------------------------------------------------------------------------ */

/* The pitch target — the glowing length marker the delivery aims at.
   Drawn as three flattened ellipses rather than an image so its depth can
   move per delivery: a yorker's ring sits deep near the batsman, a bouncer's
   sits short. `--target-depth` is the only thing game.js needs to set. */
.bowl-target {
  --target-depth: 62%;   /* distance down the pitch, set per delivery */
  --target-x: 50%;       /* across the pitch — follows the ball's swing */
  --target-size: 30%;    /* width as a share of the panel */
  position: absolute;
  left: var(--target-x);
  top: var(--target-depth);
  width: var(--target-size);
  aspect-ratio: 1;
  transform: translate(-50%, -50%) scaleY(0.34); /* flatten into perspective */
  transform-origin: 50% 50%;
  display: none;
  /* Any residual difference between the aimed depth and the delivery's own
     measured bounce point eases rather than snaps. */
  transition: top 180ms ease-out;
  z-index: 2;            /* on the turf, under ball, stumps and players */
  pointer-events: none;
}
/* Shown in both modes now: bowling aims with it, batting reads the length
   off it. Only bowling makes it sweep (.aiming below). */
.pitch.photo .bowl-target,
.pitch.bowl-mode .bowl-target { display: block; }

/* AIMING. Before the run-up starts, the mark tracks across the pitch and the
   player stops it by pressing — where it stops is the line they bowl. `left`
   is animated rather than a transform because the transform is already spoken
   for by the perspective flatten, and this runs once per ball at idle where a
   layout-driven animation costs nothing that matters. */
/* Kept on the strip: 30-70% ran the mark out onto the outfield, where no ball
   is ever going to pitch. This is the width of the pitch itself at the mark's
   depth, so every position the player can stop on is a line they could
   actually bowl. */
@keyframes bowl-aim-sweep {
  0%   { left: 45%; }
  50%  { left: 55%; }
  100% { left: 45%; }
}
.bowl-target.aiming {
  animation: bowl-aim-sweep 2400ms ease-in-out infinite;
  opacity: 0.85;
}

@media (prefers-reduced-motion: reduce) {
  .bowl-target.aiming { animation: none; }
}

.bowl-target .bt-ring,
.bowl-target .bt-core {
  position: absolute;
  left: 50%; top: 50%;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
.bowl-target .bt-ring-outer {
  width: 100%; height: 100%;
  border: 2px solid rgba(90, 200, 255, 0.55);
  box-shadow: 0 0 18px rgba(90, 200, 255, 0.45),
              inset 0 0 22px rgba(90, 200, 255, 0.25);
}
.bowl-target .bt-ring-inner {
  width: 58%; height: 58%;
  border: 2px solid rgba(120, 215, 255, 0.75);
  box-shadow: 0 0 14px rgba(120, 215, 255, 0.5);
}
.bowl-target .bt-core {
  width: 20%; height: 20%;
  background: radial-gradient(circle, rgba(190,240,255,0.95), rgba(90,200,255,0.35) 70%, transparent);
  box-shadow: 0 0 16px rgba(150, 225, 255, 0.8);
}

/* Breathes while the ball is live so the eye is drawn to the length. */
.bowl-target.live .bt-ring-outer { animation: bt-pulse 1.15s ease-in-out infinite; }
.bowl-target.live .bt-ring-inner { animation: bt-pulse 1.15s ease-in-out infinite 0.18s; }

/* Struck: the ring flares once when the ball lands on it. */
.bowl-target.hit .bt-ring-outer,
.bowl-target.hit .bt-ring-inner { animation: bt-flare 0.5s ease-out forwards; }

/* ---- Foreground bowler (over-the-shoulder) ----
   Anchored bottom-left like the reference, large and close to camera.
   Hidden automatically while it has no image, so the absence degrades to
   the first-person view rather than to a broken-image box. */
/* Pulled back down the pitch and scaled down to match. Moving "back" in this
   view means smaller AND higher up the frame, since the ground recedes
   upwards — changing only the height would just shrink him in place. He also
   drifts toward the centre line, because the pitch converges with distance. */
/* Standing at his own crease, wide of the stumps — feet at ~79% of the panel,
   just above the near crease line at ~88%, and offset left so the stumps and
   the pitch target stay unobscured.
   He is deliberately NOT mid-pitch: from there he both blocks the far batsman
   and reads as though he has already run past the crease. */
/* The sprite frame AND the personalised name/number are baked together into
   this canvas's pixels by drawBowlerFrame() in js/animations.js — see
   docs/features/stadium-and-art.md for why (replaced a separate img plus
   two live-CSS-transformed text spans). `display` is toggled via `.ready`,
   added once the first frame has actually been drawn — a canvas has no
   `src` attribute to gate visibility on the way `:has(img[src])` did. */
/* The bowler stands ON THE PAINTED CREASE, so all three of his dimensions are
   in background pixels (see --img-px), not panel percentages. As percentages
   he drifted with the aspect ratio — measured 32px above the crease on a wide
   panel and on it on a narrow one — which is what put him outside the crease
   whenever the window changed shape. His feet now sit on the same line as the
   stumps at every size, and he scales with the photo instead of with the
   panel. bowlReleasePoint() measures this sprite, so the ball's release point
   follows him automatically. */
.bowler-fg {
  position: absolute;
  left: 16%; bottom: 6%;      /* fallback: no container units */
  /* Left of the middle stump by enough to clear it: he delivers from BESIDE
     the wicket, not in front of it. He grew with the rescale below (the
     figure is ~18% of the panel wide now), and at the old 226 he covered the
     stumps completely — measured spanning 34-53% against the wicket's
     47-53%. */
  left: calc(50% - 300 * var(--img-px));
  bottom: var(--crease-near);
  /* Sized against the stumps beside him, which are the only true-scale object
     in shot: 80px of timber to 135px of bowler read as 1.7 : 1, where a real
     bowler is about 2.5 : 1 against a 71cm wicket — and he stands NEARER the
     camera than it, so if anything he should exceed that. 610 puts the figure
     at ~205px against the same 80px of stumps. */
  height: 50%;
  height: calc(610 * var(--img-px));
  width: auto;
  display: none;
  z-index: 6;            /* nearest object in the scene */
  pointer-events: none;
  filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
.pitch.bowl-mode .bowler-fg.ready { display: block; }

/* Bowl mode hides the near batsman either way — you are standing where he
   stood. The far figure is the opposing batsman. */
.pitch.bowl-mode .batsman { display: none; }


/* ---------- Boundary view ----------------------------------------------
   The pitch is one photograph, so the camera cannot turn and look at the
   outfield the way a 3D game can — past the overscan a zoom buys there is
   nothing behind the image. A four or six therefore CUTS to a second plate
   of the outfield for the length of the ball's flight, which is how the
   shot gets to be seen arriving at the rope.

   Kept out of .pitch-cam deliberately: it replaces that view rather than
   moving with it. */
.boundary-view {
  position: absolute; inset: 0;
  z-index: 7;                      /* over the scene, under banner/confetti */
  /* `contain`, not `cover`. Cover scaled the plate to the panel's WIDTH and
     then cropped ~28% of its height, and the first thing to go was the sky
     and the mountains — the only part of the frame that says what time of
     day it is, which is the whole point of having three of them. Contained,
     the entire plate is visible, and the rope lands at 79.9% of the panel,
     which is what BOUNDARY_ROPE_PCT already assumes.
     The ground colour fills the narrow bars either side. */
  /* Cover, pushed as far toward the top as the rope allows.
     The panel is proportionally wider than the plate, so cover scales to the
     WIDTH and the crop comes out of the height — 22% of it. At `top` the cut
     lands on the bottom of the frame and takes the rope and its sponsor
     boards with it (the rope sits at 87.5% of every plate, measured); at the
     old 74% it took the sky and the mountains, which are the only thing that
     says what time of day it is. 43% is the highest the frame can sit with
     the rope still inside it, so the mountains show and the ball still lands
     on the rope at BOUNDARY_ROPE_PCT. */
  background: var(--boundary-img, url('../assets/images/stadium-boundary-night-3.png'))
              center 43%/cover no-repeat;
  opacity: 0;
  visibility: hidden;
  transform: scale(1.06);
  transition: opacity 160ms linear, transform 900ms cubic-bezier(0.2, 0.7, 0.3, 1);
  pointer-events: none;
}
.boundary-view::after {            /* keeps the banner legible over the plate */
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(4,10,22,0.42) 0%, rgba(4,10,22,0.08) 38%,
                                      rgba(4,10,22,0.30) 100%);
}
.boundary-view.live {
  opacity: 1;
  visibility: visible;
  transform: scale(1);             /* eases out as the ball travels */
}

/* The ball crossing the outfield.

   The plate looks back at the rope from beyond it, so a shot travels TOWARD
   the camera: the ball starts small out by the stands and grows as it comes,
   clearing the rope at the bottom of frame. `--bv-dx/dy` are pixels handed
   over by js/game.js — dx carries the same exit line the pitch ball was
   given, so both halves of the shot agree on which way it went. */
.bv-ball {
  position: absolute;
  left: 50%; bottom: 46%;
  width: 4.4%; aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #ff8a7a 0%, #c9372c 46%, #6f140d 100%);
  box-shadow: 0 3px 12px rgba(0,0,0,0.5);
  opacity: 0;
  z-index: 1;
}
/* A SIX clears the rope and carries on over the camera — it leaves frame.
   A FOUR does not: it runs along the turf and FINISHES ON THE ROPE, with a
   skid and a settle, because that contact is the whole point of a four. */
.boundary-view.live.six .bv-ball { animation: bv-fly-six var(--bv-ms, 900ms) cubic-bezier(0.22, 0.45, 0.55, 1) forwards; }
.boundary-view.live.four .bv-ball { animation: bv-fly-four var(--bv-ms, 900ms) cubic-bezier(0.22, 0.5, 0.35, 1) forwards; }

/* A SIX is hit through the air, so the path is a parabola, not a line.
   It used to travel steadily down-frame while growing, which reads as a ball
   rolling along the turf towards the rope — the one thing a six is not.
   `--bv-lift` pulls it up against the ground line: hard early, still well
   clear of the turf as it crosses the rope, and it leaves frame in the air. */
@keyframes bv-fly-six {
  0%   { transform: translate(-50%, 0) scale(0.3); opacity: 0; }
  10%  { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.10),
                              calc(var(--bv-dy, 200px) * 0.08 - var(--bv-lift, 0px) * 0.42))
         scale(0.40); opacity: 1; }
  45%  { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.45),
                              calc(var(--bv-dy, 200px) * 0.38 - var(--bv-lift, 0px)))
         scale(0.72); opacity: 1; }
  75%  { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.75),
                              calc(var(--bv-dy, 200px) * 0.66 - var(--bv-lift, 0px) * 0.85))
         scale(1.15); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--bv-dx, 0px)),
                              calc(var(--bv-dy, 200px) - var(--bv-lift, 0px) * 0.45))
         scale(1.85); opacity: 1; }
}

/* A four CROSSES the rope — four runs are only four once the ball is over it.
   `--bv-dy` therefore runs past the rope rather than stopping on it: the ball
   reaches the padding at 72% of the travel (that is where the rope sits along
   this path), skids over it, and finishes beyond it, still in frame so the
   crossing is actually seen. `forwards` holds it there for the rest of the
   scene. */
@keyframes bv-fly-four {
  0%   { transform: translate(-50%, 0) scale(0.3); opacity: 0; }
  8%   { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.08),
                              calc(var(--bv-dy, 200px) * 0.08)) scale(0.36); opacity: 1; }
  52%  { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.55),
                              calc(var(--bv-dy, 200px) * 0.52)) scale(0.8); opacity: 1; }
  /* on the rope */
  72%  { transform: translate(calc(-50% + var(--bv-dx, 0px) * 0.74),
                              calc(var(--bv-dy, 200px) * 0.667)) scale(1); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--bv-dx, 0px)), var(--bv-dy, 200px)) scale(1.34); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .boundary-view { transition: opacity 160ms linear; transform: none; }
  .boundary-view.live { transform: none; }
  .boundary-view.live .bv-ball { animation: none; opacity: 0; }
}
