@font-face {
  font-family: "Instrument Sans";
  font-style: normal;
  font-weight: 700;
  src: url("vendor/fonts/InstrumentSans-Bold.ttf") format("truetype");
  font-display: swap;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; user-select: none; }
/* model-viewer shows its own default loading progress bar while a model's
   .glb is still downloading. On the capture screen that bar sits under the
   brightness(0) silhouette filter, which turns it into a visible black bar
   for however long the first (uncached) fetch takes — invisible on repeat
   visits once the browser has it cached. A toddler app doesn't need a
   loading indicator at all, so just zero its height out everywhere. */
model-viewer { --progress-bar-height: 0px; }
html, body {
  margin: 0; padding: 0; height: 100%; overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #2b6cb0;
  touch-action: none;
}
#app { position: fixed; inset: 0; overflow: hidden; }
.screen {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  transition: opacity .25s ease;
}
.screen.hidden { display: none; }
/* Generic show/hide utility - .screen.hidden and .ball-picker-overlay.hidden
   above predate this and are redundant with it now, but left as-is since
   they're harmless and already relied on elsewhere. !important because this
   has to win over any element's own `display` (e.g. .icon-btn's own
   `display: flex`, which sits later in the cascade and would otherwise
   override a plain same-specificity `.hidden` rule). */
.hidden { display: none !important; }

/* ---------- Overworld (home screen) ---------- */
#overworld-screen {
  background: linear-gradient(180deg, #8fd3f4 0%, #a8e6a3 42%, #6fbf6a 100%);
  align-items: center; justify-content: center;
}
#overworld-wrap {
  position: relative;
  overflow: hidden;
  width: min(100vw, 56.25vh);
  height: min(100vh, 177.78vw);
  margin: auto;
  box-shadow: 0 0 0 9999px rgba(0,0,0,0.15);
}
#overworld-decor, #overworld-critters { position: absolute; inset: 0; }
#overworld-decor { pointer-events: none; }
.decor-grass, .decor-rock {
  position: absolute;
  transform: translate(-50%, -50%);
}
.decor-grass {
  width: 40px; height: 20px;
  opacity: 0.8;
}
.decor-grass::before, .decor-grass::after {
  content: ""; position: absolute; bottom: 0;
  width: 18px; height: 20px;
  background: #4f9e4a;
  border-radius: 50% 50% 20% 20%;
}
.decor-grass::before { left: 0; transform: rotate(-10deg); }
.decor-grass::after { right: 0; transform: rotate(10deg); }
.decor-rock {
  width: 30px; height: 20px;
  background: #9a9a92;
  border-radius: 40% 40% 45% 45%;
  box-shadow: inset -4px -3px 0 rgba(0,0,0,0.15);
  opacity: 0.8;
}
.world-critter {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 76px; height: 76px;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
  filter: drop-shadow(0 4px 3px rgba(0,0,0,0.25));
}
.world-critter img {
  width: 100%; height: 100%;
  object-fit: contain;
  /* 2026-08-19 per Wes - overworld critters use the same small pixel-art
     GIFs as the catch screen (see #catch-model below); pixelated keeps
     the blown-up sprite crisp instead of the browser's default blur. */
  image-rendering: pixelated;
  pointer-events: none;
}
.world-critter.uncaught img { filter: brightness(0); opacity: 0.85; }
.world-critter:active { transform: translate(-50%, -50%) scale(0.92); }
#dex-fab {
  position: absolute;
  left: 50%; bottom: max(22px, env(safe-area-inset-bottom));
  transform: translateX(-50%);
  width: 68px; height: 68px;
  border-radius: 50%;
  border: 3px solid #222;
  background: #ee3b3b;
  box-shadow: 0 4px 0 rgba(0,0,0,0.3);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
#dex-fab:active { box-shadow: 0 1px 0 rgba(0,0,0,0.3); transform: translateX(-50%) translateY(3px); }

/* ---------- Pokedex grid ---------- */
#pokedex-screen { background: #ffffff; }
.dex-header {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  padding: 14px 10px 8px;
  position: relative;
}
.dex-close-btn { position: absolute; left: 14px; width: 44px; height: 44px; font-size: 18px; }
/* 2026-08-19 per Wes - export/import trigger, opposite side from the (also
   intended-44px) close button above. Unlike that one, this stays visible -
   it's the entry point to #sync-overlay (see style rules further down), not
   hidden behind an "overworld is paused" flag. Compound selector (two
   classes, same trick as .icon-btn.dex-close-btn's display:none above) so
   this reliably beats plain .icon-btn's width:56px regardless of source
   order - a plain single-class .dex-sync-btn rule here would lose that
   fight since .icon-btn itself is defined later in the file (discovered
   testing this: .dex-close-btn's own width:44px has silently lost the same
   fight all along, just never visibly mattered since that button is
   display:none). */
.icon-btn.dex-sync-btn { position: absolute; right: 14px; width: 44px; height: 44px; font-size: 18px; }
/* Hidden while overworld is paused (2026-08-16) — Pokedex is home again and
   there's nothing to "close" back to right now. Delete this rule to bring
   the button (and the overworld it links to) back. Uses the compound
   selector so it reliably beats .icon-btn's display:flex regardless of
   CSS source order. */
.icon-btn.dex-close-btn { display: none; }
.dex-count {
  background: rgba(255,255,255,0.9);
  border-radius: 999px;
  padding: 8px 22px;
  font-size: 20px;
  font-weight: 800;
  color: #d62839;
  box-shadow: 0 3px 0 rgba(0,0,0,0.15);
}
.dex-grid-wrap { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 6px 10px 24px; }
.dex-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
}
.dex-cell {
  aspect-ratio: 1 / 1;
  background: transparent;
  border-radius: 20px;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 3px 0 rgba(0,0,0,0.18);
  position: relative;
  overflow: hidden;
  min-height: 92px;
  min-width: 92px;
  cursor: pointer;
}
.dex-cell:active { transform: scale(0.96); box-shadow: 0 1px 0 rgba(0,0,0,0.18); }
/* 2026-08-17 per Wes - brief highlight on the tile scrollGridToTile() just
   snapped to, so "the pokedex jumped to a tile" reads as "that's the one I
   just caught" instead of a random-feeling jump. Outline (not box-shadow)
   so it isn't clipped by this cell's own overflow:hidden. */
.dex-cell.just-caught { animation: dex-cell-caught-glow 1.8s ease-out; }
@keyframes dex-cell-caught-glow {
  0% { outline: 4px solid rgba(255,210,63,0); transform: scale(1); }
  15% { outline: 4px solid rgba(255,210,63,0.95); transform: scale(1.06); }
  100% { outline: 4px solid rgba(255,210,63,0); transform: scale(1); }
}
/* 2026-08-19 per Wes - brief white left-to-right wipe over the tile she
   just caught, played once as the pokedex settles into view after a first
   catch (see spawnCaughtTileWipe/finishCatch in app.js), alongside the
   glow above. A plain absolutely-positioned overlay animated via the Web
   Animations API (matches spawnCatchFlash/spawnCaughtStars' pattern) -
   .dex-cell's own overflow:hidden + border-radius (above) clip it to the
   tile's rounded corners for free, so it never needs its own radius. */
.caught-tile-wipe {
  position: absolute; inset: 0;
  background: #fff;
  z-index: 3;
  pointer-events: none;
}
/* Glass tile background, 2026-08-17 per Wes - a separate absolutely-
   positioned layer BEHIND the cell's actual content (model + name/type
   label below), instead of setting background/opacity on .dex-cell itself.
   That keeps this 80%-opacity frosted-glass look scoped to the tile only -
   the model and label stay fully opaque regardless - and leaves room for
   simple background art to be dropped in behind the grid later and show
   through the blur. */
.dex-cell::before {
  content: "";
  position: absolute; inset: 0;
  background: #ffffff;
  opacity: 0.8; /* the tile's own 80%-opacity/20%-transparency glass look */
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  border: 1px solid rgba(255,255,255,0.5);
  transition: background-color .2s ease;
  z-index: 0;
}
/* 2026-08-19 round 4 per Wes - was ".dex-cell img", a descendant selector
   that also matched the small type-badge <img> nested inside
   .dex-cell-label (see .dex-cell-type-icon below) - same specificity
   either way (one class + one tag), but this rule's width:100%/height:100%
   happened to win, blowing the type icon up to fill almost the whole
   tile instead of staying a small corner badge. Scoped to the direct-
   child artwork image only (">" instead of a space) so it can no longer
   match the nested type icon at all, regardless of specificity. */
.dex-cell > img {
  width: 100%; height: 100%;
  /* Official artwork PNGs, not pixel art - smooth default scaling, unlike
     the catch screen's pixelated GIF sprites (see #catch-model below). */
  object-fit: contain;
  pointer-events: none;
  position: relative; z-index: 1;
}
.dex-cell.uncaught > img { filter: brightness(0); opacity: 0.82; }
.dex-cell.loading::before { background: rgba(255,255,255,0.5); }
/* Caught cells pick up a background tinted by the Pokemon's primary type
   (--dex-type-color, set per-cell in app.js) so the grid reads as a
   colorful "collection" once she's caught something, matching the
   reference mockup. Uncaught cells stay neutral white behind the
   silhouette — no spoilers before she's actually caught it. Alpha comes
   from the shared ::before opacity below, not baked into the color itself. */
.dex-cell:not(.uncaught)::before { background: var(--dex-type-color, #e8e8e8); }
.dex-cell-label {
  position: absolute; top: 10px; left: 12px;
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
  z-index: 1;
  pointer-events: none;
}
/* .dex-cell-name removed 2026-08-19 per Wes - pokedex grid tiles show only
   the type icon now, no name text. */
.dex-cell-type-icon { width: clamp(20px, 6vw, 34px); height: clamp(20px, 6vw, 34px); display: block; }
/* Uncaught cells keep the type icon hidden — the silhouette + catch flow
   is the whole point, showing the name first would spoil it. */
.dex-cell.uncaught .dex-cell-label { display: none; }

/* ---------- Generic overlay screens ---------- */
.overlay-screen {
  background: radial-gradient(circle at 50% 30%, #1a2a4a 0%, #0b1530 100%);
  color: white;
}
/* Used to hide the top-bar buttons during a throw (see setElsHidden in
   app.js) - visibility instead of display so the button keeps its layout
   space and nothing else on the screen shifts/reflows while it's gone. */
.throw-hide { visibility: hidden !important; }
.top-bar {
  flex: 0 0 auto;
  display: flex; align-items: center; padding: 14px;
}
.icon-btn {
  width: 56px; height: 56px;
  border-radius: 50%;
  background: rgba(255,255,255,0.15);
  border: none;
  display: flex; align-items: center; justify-content: center;
  color: white;
  font-size: 26px;
  cursor: pointer;
}
.icon-btn:active { background: rgba(255,255,255,0.3); transform: scale(0.92); }
/* Ball-picker button sits opposite the back button in the same top bar. */
.ball-picker-btn { margin-left: auto; }
.ball-picker-btn img { border-radius: 50%; }
/* 2026-08-19 per Wes - back/ball-picker buttons on the catch screen need a
   bigger touch target for little fingers - 50% larger than the shared
   .icon-btn base (56px -> 84px, icon/image scaled to match). Scoped to just
   these two buttons, NOT .icon-btn itself, so the pokedex close button and
   the ball-picker's own close button (both also .icon-btn) stay their
   original 56px size. */
#catch-back-btn, #catch-ball-btn {
  width: 84px;
  height: 84px;
  font-size: 39px;
}
#catch-ball-btn-icon {
  width: 48px;
  height: 48px;
}

/* ---------- Catch screen (first catch + re-catch are the same screen) ---------- */
/* 2026-08-19 round 3 per Wes - "Capture" and "Details" (re-catch) used to be
   two separate sections here, back when a caught mon showed static official
   artwork and you could pinch-zoom a real 3D model. Neither is true anymore
   - both showed the same animated catch sprite, the same throw mechanic,
   and the same "always lands back on the pokedex" outcome, so keeping two
   near-identical copies of the same screen was pure duplication. Merged
   into one #catch-* set of rules below (see app.js's "catch flow" section
   for the matching JS merge) - this also means the speak-name button +
   tap-model-for-cry affordance (both used to be Details-only, see
   #catch-speak-btn below) now show up on every catch, first or repeat. */
#catch-stage { flex: 1 1 auto; position: relative; overflow: hidden; }
#catch-model-wrap {
  /* 2026-08-19 round 5 per Wes - rounds 3/4 shrank this box's percentage
     insets, but that alone couldn't fix it: with width:100%/height:100%
     on the image (below), the <img> element's own BOX always fills the
     wrap exactly regardless of insets - object-fit:contain only scales
     the visible pixels inside that box, not the box itself. Because the
     wrap isn't square, a tall/narrow sprite (Kakuna) could use nearly the
     full box height while a wide/short one (Ivysaur) was capped by width
     well before reaching that height - so two mons could render at very
     different visible sizes even though the invisible box was identical.
     Now flex-centers the wrap and lets the image size itself intrinsically
     (see #catch-model's max-width/max-height below) instead of forcing
     it to fill this box. */
  /* 2026-08-19 round 8 per Wes - loosened from top:24%/bottom:34%/left:23%/
     right:23% to give the round-8 size bump below room to breathe. Measured
     the actual gap between the name label and the pokeball across several
     phone sizes (iPhone SE up to 14 Pro Max) before loosening - see
     #catch-model's round 8 comment for the numbers. */
  /* cursor:pointer - the whole wrap doubles as a tap-for-cry target (see
     the pointerdown/pointerup handlers on catchModelWrap in app.js); this
     was Details-only before the round-3 merge above. */
  position: absolute;
  top: 18%; bottom: 28%;
  left: 16%; right: 16%;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
#catch-model {
  /* 2026-08-19 round 6 per Wes - round 5's max-width/max-height (with
     width/height:auto) only shrinks an image that's BIGGER than the cap;
     it doesn't grow one that's smaller, and every sprite's native pixel
     size (~30-220px) is already under this cap - so images stopped
     upscaling at all and rendered tiny at native size. What actually
     works: give the element itself an explicit SQUARE box (same min()
     value for width and height, not auto), then object-fit:contain
     always scales the sprite up or down to fill that square on its
     longer edge - same fix intent as round 5, correct mechanism this
     time. */
  /* 2026-08-19 round 8 per Wes - asked whether mons like Charizard (whose
     idle animation only fills 46-75% of its own GIF canvas at any single
     frame - it sways/spreads its wings through the loop) could safely go
     bigger, or if that risks clipping the motion. It doesn't: the canvas
     is authored to always fully contain the whole animation (confirmed via
     a union-bbox check across all frames = 100% of canvas for every mon
     tested, Charizard included), and object-fit:contain always scales the
     FULL canvas into its box, never just the current frame's silhouette -
     so the only real ceiling is overlapping the name label above or the
     pokeball below, not motion clipping. Measured that gap across several
     phone sizes (iPhone SE's ~338-358px name-to-ball gap was the tightest)
     and confirmed the old 48vw/30vh cap was using well under half of it -
     bumped to 58vw/38vh, which still leaves a comfortable ~60px+ margin on
     both the tightest tested device and the wrap's own widened insets
     above. */
  /* 2026-08-19 round 9 per Wes - first pass at fixing per-mon size
     inconsistency (see Sprite-Size-Tuner.html): most mons were reading a
     bit too large on average, with a handful that should be bigger instead
     coming out too small - a global box shrink first, per-mon corrections
     via SPRITE_SCALE_OVERRIDES later once tuned. Flat -10% on both
     dimensions: 58vw/38vh -> 52.2vw/34.2vh. */
  width: min(52.2vw, 34.2vh);
  height: min(52.2vw, 34.2vh);
  object-fit: contain;
  /* 2026-08-19 per Wes - switched from the 3D model-viewer pipeline to a
     small pixel-art animated GIF (PokeAPI/sprites' "showdown" set, same
     CC0 source Pokemon Showdown itself serves). Native size is tiny
     (~30-220px depending on the mon) so it needs pixelated upscaling to
     stay crisp instead of going soft/blurry - see the scaling mockup this
     replaced. Also sidesteps the whole T-pose/mesh-tearing bug class from
     the old animation-transplant pipeline (Asset-Pipeline-Investigation.md)
     since there's no 3D rig left to mismatch. */
  image-rendering: pixelated;
  /* 2026-08-19 round 4 per Wes - dropped the brightness(0) mystery-
     silhouette treatment: shows full color from the moment the screen
     opens, caught or not - hiding it behind a silhouette didn't actually
     add mystery. Same call already made for the name label above. */
  /* 2026-08-19 round 7 per Wes - dropped the slow up/down "bob" idle
     animation on the not-yet-caught sprite (was `animation: bob 2.4s
     ease-in-out infinite`, see the now-removed @keyframes bob below this
     rule used to reference) - sprite now just sits still until thrown. */
  transition: transform .4s ease-in, opacity .4s ease-in;
}
/* Shrinks away to nothing right as the ball absorbs it (see onAbsorb in
   app.js, fired together with the catch-flash). Removing this class
   (revealCatch()) transitions it back to full size/opacity together with
   the color reveal. */
#catch-model.absorbing { transform: scale(0.05); opacity: 0; }
#catch-throw-zone { position: absolute; inset: 0; pointer-events: none; }
.pokeball {
  position: absolute; left: 50%; bottom: 60px;
  width: 96px; height: 96px;
  transform: translate(-50%, 0);
  touch-action: none;
  cursor: grab;
  pointer-events: auto;
  filter: drop-shadow(0 6px 3px rgba(0,0,0,0.35));
}
/* The ball's actual look is a real 3D model (see #catch-pokeball-model in
   index.html, src swapped by applyBallVisual() in app.js per the chosen
   ball) instead of a flat CSS-drawn circle - pointer-events:none so
   drag/throw gestures always land on the outer .pokeball div, never get
   intercepted by model-viewer's own touch handling (which is fully
   disabled anyway via disable-zoom/no camera-controls, but this keeps it
   bulletproof). */
.pokeball model-viewer {
  width: 100%; height: 100%;
  pointer-events: none;
  --poster-color: transparent;
}
/* Phase 1 of the throw (see FREE_FLIGHT_MS in app.js) - the ball keeps
   moving in a straight line at release velocity for .3s before homing in,
   so a linear transition here (no ease) reads as real carried-over
   momentum rather than a deceleration into the target. */
.pokeball.free-flight { transition: left .3s linear, top .3s linear; }
/* Phase 2 - homes in on the mon over what's left of FLIGHT_MS after the
   free-flight leg above (HOME_MS in app.js: 1000ms - 300ms = 700ms).
   2026-08-17 per Wes round 2: 420ms still read as "the same" as the old
   260ms, so bumped straight to the requested 700ms. Also swapped the
   easing curve away from cubic-bezier(.4,0,.2,1) (Material's "standard"
   curve, front-loaded - ~78% of the travel completes by the 50% mark
   regardless of duration, which was masking the slowdown) to a symmetric
   ease-in-out cubic so the extra duration actually reads as slower
   throughout, not just a longer tail after the same fast snap. */
.pokeball.flying { transition: left .7s cubic-bezier(.65,0,.35,1), top .7s cubic-bezier(.65,0,.35,1), transform .7s; }
/* Quicker, "falling" transition for the ground-drop step after the absorb
   flash - deliberately shorter than .flying's so it matches the DROP_MS
   timeout in app.js and the wiggle doesn't start while the ball is still
   visibly mid-drop. */
.pokeball.dropping { transition: left .38s ease-in, top .38s ease-in; }
/* The landing wiggle (and the flight/drag tumble) is no longer a flat 2D
   CSS transform:rotate() - it's driven frame-by-frame in app.js via the
   inner model-viewer's own `orientation` attribute, which actually
   rotates the 3D model in space instead of spinning the whole flat
   element like a coin. See startFlightSpin()/startWiggleSpin() in
   createThrowController(). */
/* Absorb flash + particle burst - fires the instant the thrown ball reaches
   the mon (see spawnCatchFlash() in app.js), before it drops + wiggles.
   Positioned via fixed left/top set inline in JS to the exact viewport
   point, since the ball can land anywhere on screen. */
.catch-flash {
  position: fixed;
  width: 220px; height: 220px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,246,200,0.9) 35%, rgba(255,220,120,0.4) 60%, rgba(255,220,120,0) 75%);
  pointer-events: none;
  z-index: 20;
}
.catch-spark {
  position: fixed;
  width: 10px; height: 10px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 6px 2px rgba(255,255,255,0.8);
  pointer-events: none;
  z-index: 20;
}
.catch-spark.gold { background: #ffd23f; box-shadow: 0 0 6px 2px rgba(255,210,63,0.8); }
/* 2026-08-17 per Wes - the old plain white .burst radial flash is gone,
   replaced by the star-burst SVG built entirely in JS (spawnCaughtStars in
   app.js) - no CSS class needed for it, it's plain SVG attributes + a
   single opacity fade via the Web Animations API at the end. */
.caught-banner {
  /* 2026-08-17 per Wes round 2 - moved again, this time to just below the
     mon's name (was bottom: 200px, up near the ball's resting spot).
     The name row (#catch-speak-btn) sits at top: 5%; 14% clears its own
     height and lands the banner right underneath it. */
  position: absolute; left: 50%; top: 14%; transform: translateX(-50%) scale(0);
  background: #ffd23f; color: #7a4b00; font-weight: 900; font-size: 26px;
  padding: 12px 34px; border-radius: 999px; box-shadow: 0 4px 0 rgba(0,0,0,0.2);
  transition: transform .4s cubic-bezier(.34,1.56,.64,1);
  pointer-events: none;
}
.caught-banner.show { transform: translateX(-50%) scale(1); }
/* 2026-08-19 per Wes - name + speaker used to be two separate elements laid
   out in a row (name, then a small circular speaker button beside it), with
   only the speaker getting the circular background/tap feedback. Now a
   single stacked, centered touch target: name on top, speaker icon below
   it, both wrapped in the one pill background - tapping the name itself
   plays the cry too, not just the small icon next to it. #catch-speak-btn
   IS the button; #catch-name-label is just a plain span inside it.
   2026-08-19 round 3 per Wes - this was Details-only before the
   capture/details merge above; every catch gets it now. */
#catch-speak-btn {
  position: absolute; top: 5%; left: 50%; transform: translateX(-50%);
  z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px;
  padding: 10px 26px 14px;
  border-radius: 28px;
  background: rgba(255,255,255,0.15);
  border: none; color: white; cursor: pointer;
  font-family: inherit;
}
/* Keep the centering transform on press - a plain scale() here (like the
   old circular-only button used) would re-anchor from the button's own
   left edge instead of its center, visibly kicking it sideways. */
#catch-speak-btn:active { background: rgba(255,255,255,0.35); transform: translateX(-50%) scale(0.96); }
#catch-name-label {
  font-size: clamp(28px, 9vw, 48px); font-weight: 900; letter-spacing: 1px; line-height: 1.05;
}
.speak-icon { font-size: 24px; line-height: 1; }

/* ---------- Ball picker (shared overlay) ---------- */
/* 2026-08-19 per Wes - used to be a flat show/hide (display:none <-> flex)
   on the whole overlay, so the card just popped in/out with no motion.
   Now the overlay stays display:flex at all times and fades its backdrop
   in/out via opacity, while the card itself slides up from off-screen
   (transform: translateY) - a proper bottom-sheet slide instead of a pop.
   `.hidden` still gets toggled by openBallPicker()/closeBallPicker() in
   app.js unchanged; only what it DOES here has changed. Needs its own
   `!important` to beat the global `.hidden { display: none !important; }`
   utility (see top of file) - the two-class selector below outweighs that
   rule's one-class specificity, so this one wins despite both being
   !important. */
.ball-picker-overlay {
  position: fixed; inset: 0; z-index: 30;
  background: rgba(0,0,0,0.6);
  display: flex; align-items: flex-end;
  opacity: 1;
  visibility: visible;
  transition: opacity .28s ease, visibility 0s linear 0s;
}
.ball-picker-overlay.hidden {
  display: flex !important;
  opacity: 0;
  visibility: hidden;
  transition: opacity .28s ease, visibility 0s linear .28s;
}
.ball-picker-card {
  width: 100%; max-height: 78vh;
  background: #14224a;
  border-radius: 24px 24px 0 0;
  display: flex; flex-direction: column;
  box-shadow: 0 -8px 30px rgba(0,0,0,0.4);
  transform: translateY(0);
  transition: transform .32s cubic-bezier(.32,.72,0,1);
}
.ball-picker-overlay.hidden .ball-picker-card { transform: translateY(100%); }
.ball-picker-header {
  flex: 0 0 auto;
  display: flex; align-items: center;
  padding: 14px 20px 6px;
}
.ball-picker-header .icon-btn { width: 44px; height: 44px; font-size: 20px; }
.ball-picker-grid {
  flex: 1 1 auto; overflow-y: auto;
  display: grid; grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  padding: 6px 16px 26px;
}
.ball-picker-cell {
  background: none;
  border: 3px solid transparent;
  border-radius: 16px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px;
  padding: 8px 4px 10px;
  cursor: pointer;
  color: #fff;
}
/* No resting tile behind each ball, and deliberately no "currently
   equipped" highlight either - keeps this screen simple, just a grid of
   balls to tap. Only a brief flash on tap for touch feedback. */
.ball-picker-cell:active { background: rgba(255,255,255,0.12); }
.ball-picker-cell img { width: 64px; height: 64px; }
.ball-picker-cell span {
  font-size: clamp(11px, 2.6vw, 13px); font-weight: 700; text-align: center;
}

/* ---------- Progress export/import (shared overlay) ---------- */
/* 2026-08-19 per Wes - same bottom-sheet card/backdrop as the ball picker
   above (reuses .ball-picker-overlay/.ball-picker-card/.ball-picker-header
   as-is), just different content: two plain action buttons instead of a
   grid, plus a one-line status message under them. */
.sync-body {
  padding: 6px 20px 30px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.sync-action-btn {
  background: rgba(255,255,255,0.12);
  border: none;
  border-radius: 16px;
  padding: 16px;
  color: #fff;
  font-size: 17px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}
.sync-action-btn:active { background: rgba(255,255,255,0.26); transform: scale(0.98); }
.sync-status {
  min-height: 20px;
  text-align: center;
  font-size: 14px;
  color: rgba(255,255,255,0.85);
}
