/* fun-pack.css — the shared rip experience for every fun pack.
 * ===========================================================================
 * Aaron, 2026-09-04: "all of the best bits of pack dojo in terms of features,
 * UI and UX must be applied when the user is ripping the marvel cards and any
 * other sets we add."
 *
 * ⚠ IT REUSES `body.sim-pulling`, IT DOES NOT INVENT A SECOND CLASS.
 * rip-header.css already fades and disables the header, the tab bar and the
 * sticky open bar under that exact class, and rip/admin/cli/tabbar-check.js
 * already sweeps every `position:fixed; bottom:0` element in the repo against
 * its offset block. A parallel `fp-pulling` would be a second list nobody
 * remembers to extend — the failure this repo has paid for twice in one day
 * (the trades SEND button, then the brand link killing a pull). One class,
 * inherited by everything, is the rule.
 *
 * What this file adds is only what is specific to a fun pack: the overlay that
 * has to opt back INTO pointer events while the page is inert, the pack and
 * card choreography, and the burst video's own layer.
 */

/* ⚠ THE PAGE IS INERT DURING A PULL AND THE REVEAL IS THE ONE EXCEPTION.
   body.sim-pulling{pointer-events:none} is inherited by everything — that is
   the point, and it is why the brand link can no longer kill a pull. The
   overlay opts back in the same way #card-overlay does on the pack page. */
/* ⚠ ONLY THE RESULT PANEL OPTS BACK IN. During the card-only phase the page is
   still inert and the card is a moment to watch, not a control — making it
   tappable there would let a stray touch dismiss the reveal mid-explosion. */
body.sim-pulling .fp-reveal.is-on { pointer-events: auto; }

/* ⚠ THE PAGE GETS OUT OF THE WAY BY ONE INHERITED RULE, NOT BY A LIST OF
   CLASSES. Aaron, 2026-09-04, watching a live pull: "text at top doesn't
   disappear when animation starts." It did not — fading was opt-in via
   .fp-foldable, and the page's heading block had simply never been given it.
   That is the fourth time this repo has shipped a named list that somebody
   forgot to extend (the trades SEND button, the brand link, the bottom-bar
   offsets, this), and the fix is the same one every time: a rule that inherits.

   `.fp-page` is stamped on the page's own wrapper by the engine, so a section
   added next month is covered without anybody remembering anything. The ONLY
   exception is `.fp-stage` — the pack is the thing being watched.

   .fp-foldable stays for anything OUTSIDE that wrapper, and is now belt to this
   brace rather than the mechanism. */
/* ⚠⚠ THE RESET LIVES HERE, ONCE, AND EVERY FUN-PACK PAGE OPTS IN WITH
   `class="fp-host"` ON ITS WRAPPER. A standalone .rip-ui page carries NO global
   reset — packs.htm has `*{box-sizing:border-box}` and these pages do not — so
   any `width:<n>px` + `padding` control computes wider than its box and the
   whole document scrolls sideways. Marvel hit it and fixed it in its own
   <style>; Sonic then shipped with the identical defect, because a fix written
   into one page cannot reach the next one. Measured on sonic.php signed out:
   scrollWidth 396 against a 390 viewport, 386 against 375, 338 against 320 —
   and clean at 412, which is why a casual look never caught it. Signed in it is
   a <button>, which the UA sheet already gives border-box; the one state Aaron
   never occupies was the broken one.
   ⚠ It is a CLASS IN THE MARKUP, not something markPage() stamps at
   DOMContentLoaded: a box-sizing rule that lands after first paint is a visible
   reflow. And it is scoped to the wrapper rather than a bare `*` so it cannot
   leak into a shared component. */
.fp-host, .fp-host *, .fp-host *::before, .fp-host *::after { box-sizing: border-box; }

.fp-page > * { transition: opacity .45s ease; }
/* ⚠⚠ `:not(.fp-through)` IS NOT DEFENSIVE — WITHOUT IT THE MARVEL PACK WAS
   INVISIBLE FOR THE ENTIRE CHARGE. This rule only ever matched DIRECT children
   of the page host, which was true of Sonic (its stage hangs straight off
   <main>) and stopped being true of Marvel the moment the page was split into
   views: the tree is now main.fp-page > div.fp-view > div.fp-stage. The wrapper
   is a direct child, is not .fp-stage, and so took opacity 0 — and opacity is a
   GROUP property, so the perfectly-visible stage inside it painted nothing.
   Nothing errored, and the stage's own computed opacity still read 1, which is
   exactly why the existing check passed on a page where the pack had vanished.
   markPage() stamps .fp-through on every element between the stage and the
   host, so the chain is discovered rather than assumed. Each thing that SHOULD
   fade inside such a wrapper carries .fp-foldable and fades on its own. */
body.sim-pulling .fp-page > *:not(.fp-stage):not(.fp-through) { opacity: 0; }
.fp-foldable { transition: opacity .45s ease; }
body.sim-pulling .fp-foldable { opacity: 0; }

/* ── the floating OPEN bar ─────────────────────────────────────────────────
   ⚠⚠ THE SAME BAR THE POKÉMON PAGE HAS, AND SHARED SO THE NEXT UNIVERSE GETS IT.
   Aaron, 2026-09-08: "make the button the same on the marvel page, floating at
   the bottom constantly, disappears when the pack opens etc." On /rip/packs the
   CTA is #sticky-open-bar — always on screen, whatever you have scrolled to.
   Here it was an ordinary block in the flow, so on a phone the one thing the
   page exists for was below the fold the moment you looked at your cards.

   ⚠ IT IS `.fp-openbar` IN THIS SHEET, NOT A COPY IN marvel.php. That is the
   fourth time in two days the same argument has been settled the same way (the
   pack geometry, the page head, the gloss, the badge shelf): a page's <style>
   loads AFTER this sheet and wins silently, which is exactly how two surfaces
   drift. sonic.php gets the bar by adding the markup, and nothing else.

   ⚠ IT CARRIES `.fp-foldable` IN THE MARKUP, WHICH IS THE WHOLE OF "disappears
   when the pack opens". `body.sim-pulling .fp-foldable{opacity:0}` is two lines
   up and the fun engine already sets that class, so this needs no JS at all —
   and `body.sim-pulling{pointer-events:none}` in rip-header.css means it also
   cannot be tapped through the fade. A bespoke hide here would be a second
   mechanism to keep in step with the one the rest of the page uses.

   ⚠ THE CONTAINER IS pointer-events:none AND THE CHILDREN OPT BACK IN. The bar
   spans the full width over the page's own content — a solid one would eat every
   tap on whatever it floats above, which is #sticky-open-bar's own note and the
   reason it is written this way. */
.fp-openbar{
  position: fixed; bottom: 0; left: 0; right: 0; z-index: 900;
  padding: 12px 16px 20px;
  background: linear-gradient(to top, rgba(3,3,3,.92) 0%, rgba(3,3,3,.45) 45%, transparent 100%);
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  pointer-events: none;
}
.fp-openbar > * { pointer-events: auto; width: 100%; max-width: 380px; margin: 0; }
/* ⚠ AN EMPTY CHILD STILL SPENDS THE GAP. The error line is a live region that is
   empty until something fails, and this page has already paid for that exact
   defect once in .fp-view. display:none is wrong on a live region only when it
   would silence an announcement — here the node is re-shown the moment it has
   text, which is what the announcement rides on. */
.fp-openbar > *:empty { display: none; }

/* ⚠ THE PAGE HAS TO END ABOVE THE BAR. It floats over the content, so without
   this the last thing on the page is unreachable behind it — and on a universe
   page that is the card wall, i.e. the thing people scroll down for.
   `:has()` scopes it to pages that actually carry a bar, so no other /rip/ page
   gains dead space; a browser without :has() simply keeps today's behaviour
   rather than breaking. */
body:has(> .fp-openbar) main { padding-bottom: 104px; }

/* ── the pack back ────────────────────────────────────────────────────────
   ⚠ IT IS THE PACK DOJO BACK, NOT A COLOURED RECTANGLE. Aaron: "you haven't
   applied the pack dojo logo etc to the back of the card." The pack page's
   back is the ensō mark tinted with the pack's own colour, the brand set in
   the site's display face, and a foot credit — and it is the SAME
   /rip/enso-brush.webp asset, not a copy, so it matches the share images this
   product already posts.

   The tint is a MASK, not a filter: mask-image paints --fp-accent through the
   brush's own alpha, so every dry-brush gap and stroke edge survives.
   Recolouring the artwork with a filter flattens exactly those edges. */
.fp-stage { position: relative; }
.fp-pack  {
  position: relative; overflow: hidden;
  transition: transform .35s ease, opacity .3s ease; will-change: transform;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 6px; padding: 18px 14px; text-align: center;
  background: linear-gradient(165deg, #131a24 0%, #080b11 72%);
  border: 2px solid var(--fp-accent, var(--rip-act));
  box-shadow: 0 16px 40px rgba(0,0,0,.5), inset 0 0 44px rgba(0,0,0,.55);
  /* ⚠⚠ THE SIZE IS THE POKEMON PACK'S SIZE, AND IT LIVES HERE SO NO UNIVERSE
     CAN DRIFT FROM IT. Aaron, 2026-09-07: "make the pack size on the marvel
     page the same as the pokemon page." Measured at 390px before this:
     Pokemon 260x462, Marvel 230x307 — a different width AND a different shape
     (0.563 against 0.75), on the one object both pages exist to show you.
     Three pages set their own geometry and all three disagreed (marvel 3/4,
     sonic 3/5, the hub tile 3/4), which is the enumerated-list failure this
     repo keeps paying for. These are packs.htm's own two literals:
     .slab-master-container is 300x530, and 260x462 at <=480px.
     A page must NOT re-declare width or aspect-ratio. The hub's tile is the
     one deliberate exception and overrides both at higher specificity. */
  width: 300px; aspect-ratio: 300 / 530; border-radius: 14px;
}
@media (max-width: 480px) { .fp-pack { width: 260px; aspect-ratio: 260 / 462; } }
/* The crosshatch off the Pokemon pack back (.slab-back::before at .35), which
   is most of the difference between "a surface" and "a dark rectangle". Below
   the ensō and below everything on the face; ::after is already the awaiting
   glow, so this is ::before. */
.fp-pack::before {
  content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: .35;
  background-image:
    repeating-linear-gradient(45deg, transparent, transparent 18px, rgba(255,255,255,.04) 18px, rgba(255,255,255,.04) 19px),
    repeating-linear-gradient(-45deg, transparent, transparent 18px, rgba(255,255,255,.04) 18px, rgba(255,255,255,.04) 19px);
}
/* ⚠ THE GLOSS IS rip-ui.css's `.rs-gloss`, AND IT HAS TO BEAT THE LIFT RULE
   BELOW. `.fp-pack > :not(.fp-enso)` is (0,2,0) and would force it back to
   position:relative — the exact trap packs.htm records for its own ensō, where
   a forced-relative decorative layer took 220px of flow and shoved the foot
   credit off the bottom of the card. `.fp-pack > .rs-gloss` ties at (0,2,0),
   and a tie is decided by source order, which is not a thing to rely on: the
   element selector makes it (0,2,1) and win outright. */
.fp-pack > i.rs-gloss { position: absolute; z-index: 3; }
.fp-enso {
  position: absolute; left: 50%; top: 52%; width: 86%; aspect-ratio: 1/1;
  transform: translate(-50%, -50%); pointer-events: none; z-index: 0; opacity: .20;
  background: var(--fp-accent, var(--rip-act));
  /* ⚠ The .webp is the 512px DPR-2-exact mask; the .png is 356K and exists for
     share-lib.php's GD compositing. A CSS mask reads alpha and nothing else. */
  -webkit-mask: url('/rip/enso-brush.webp') center/contain no-repeat;
          mask: url('/rip/enso-brush.webp') center/contain no-repeat;
}
/* Everything on the face sits above the ring. */
.fp-pack > :not(.fp-enso) { position: relative; z-index: 1; }
.fp-mark  { width: 34px; height: 34px; opacity: .95; }
.fp-brand {
  font-family: var(--rip-display); font-weight: 800; font-size: 15px;
  letter-spacing: .01em; color: var(--rip-fg);
}
.fp-packname {
  font-family: var(--rip-display); font-weight: 800; line-height: 1.08;
  font-size: 19px; color: var(--fp-accent, var(--rip-act)); text-wrap: balance;
}
.fp-foot {
  font-family: var(--rip-data); font-style: normal; font-weight: 600;
  font-size: 9px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--rip-fg-3); margin-top: 2px;
}

/* One beat. --hb-dur is set per pack by the engine so a bigger pack thumps
   slower, exactly as tierLevel() does on the pack page. */
@keyframes fpBeat {
  0%   { transform: scale(1)    rotate(0deg); }
  18%  { transform: scale(1.13) rotate(-1.5deg); }
  38%  { transform: scale(.97)  rotate(1deg); }
  62%  { transform: scale(1.07) rotate(-.5deg); }
  100% { transform: scale(1)    rotate(0deg); }
}
.fp-pack.is-beat { animation: fpBeat var(--hb-dur, 700ms) cubic-bezier(.36,.07,.19,.97); }

/* ⚠ THE CHARGE AND THE DISAPPEARANCE ARE TWO ANIMATIONS, NOT ONE — AND THAT IS
   MOST OF WHY THIS DID NOT FEEL LIKE THE POKÉMON REVEAL. The old fpCharge did
   the whole thing in 900ms: swell, brighten, shrink to nothing. The pack page
   splits it, because they are different beats. `pack-charge` runs for the WHOLE
   charge (1.8s on Starter, 4s on Neutronium) and barely moves — scale 1 → 1.02
   — while brightness goes to 2.1 and the border blooms; then `slab-vacuum-suck`
   takes 450ms to pull the pack into nothing right before the clip's explosion.
   Slow build, sudden loss. Compressing both into 900ms is the difference
   between a pack detonating and a pack blinking out.

   The numbers are `pack-charge`'s own (packs.htm:761) rather than re-derived. */
@keyframes fpCharge {
  0%   { transform: scale(1);     filter: brightness(1);
         box-shadow: 0 16px 40px rgba(0,0,0,.5), inset 0 0 44px rgba(0,0,0,.55); }
  25%  { transform: scale(1.006); filter: brightness(1.2); }
  55%  { transform: scale(1.003); filter: brightness(1.45); }
  80%  { transform: scale(1.013); filter: brightness(1.72); }
  100% { transform: scale(1.02);  filter: brightness(2.1);
         box-shadow: inset 0 0 100px var(--fp-accent, var(--rip-act)),
                     0 0 140px var(--fp-accent, var(--rip-act)); }
}
.fp-pack.is-charge { animation: fpCharge var(--fp-charge, 1800ms) ease-in forwards; }

/* ⚠ THE PACK IS SUCKED INTO THE EXPLOSION, IT DOES NOT FADE. 450ms, scale to 0
   with a 12deg twist — and it is timed to land exactly as the clip's explosion
   frame arrives, which is what makes the clip look like it is consuming the
   pack rather than playing over it. */
@keyframes fpSuck {
  0%   { transform: scale(1) rotate(0deg); opacity: 1; }
  100% { transform: scale(0) rotate(12deg); opacity: 0; }
}
.fp-pack.is-suck { animation: fpSuck .45s cubic-bezier(.55,.055,.675,.19) forwards; }

/* ⚠ THE CHARGE VIGNETTE — THE CHEAPEST HALF OF THE TENSION, AND IT WAS MISSING.
   The pack page darkens the whole viewport from the edges inward over the full
   charge, anchored on the pack's own centre, so by the explosion there is
   nothing on screen but the thing about to burst. One fixed div, one opacity
   transition, no per-frame work. */
.fp-vig {
  position: fixed; inset: 0; pointer-events: none; z-index: 48; opacity: 0;
  background: radial-gradient(ellipse at var(--vx, 50%) var(--vy, 50%),
              transparent 20%, rgba(0,0,0,.9) 80%);
}
/* ⚠ THE BEAT'S DARK FLASH IS A SHALLOWER, HIGHER GRADIENT AND SITS FAR ABOVE
   THE CHARGE ONE (9990 vs 48) — packs.htm's own two values. They are different
   moments: the charge vignette closes the room down slowly and must sit UNDER
   the pack's own glow, while the beat flash is a single dark pulse over
   everything. Sharing one gradient makes the thump read as a flicker. */
.fp-vig--beat {
  z-index: 9990;
  background: radial-gradient(ellipse at var(--vx, 50%) var(--vy, 50%),
              transparent 24%, rgba(0,0,0,.68) 74%);
}

/* ⚠ THE PACK BREATHES WHILE IT WAITS. packs.htm pulses the slab's glow from the
   tap until the charge takes over, and the wait is a real network round trip —
   unlit, a slow answer looks like a tap that did nothing.

   ⚠⚠ IT IS A SEPARATE LAYER FADING, NOT AN ANIMATION ON `.fp-pack` — AND THE
   FIRST CUT OF THIS DEADLOCKED THE WHOLE PULL. `.fp-pack.is-awaiting` and
   `.fp-pack.is-beat` are both (0,2,0) and both set the `animation` shorthand,
   so source order decided: the awaiting rule won, `fpBeat` never ran, its
   `animationend` never fired, and beat() waited for a callback that could not
   arrive. The pull hung before the charge, every time, with no error anywhere.
   packs.htm reached the same shape for a different reason (animating box-shadow
   re-rasterises the whole slab) — one layer, and only `opacity` animates. */
@keyframes fpAwaitFade { 0%, 100% { opacity: 0; } 50% { opacity: 1; } }
.fp-pack::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; z-index: 0; opacity: 0;
  box-shadow: inset 0 0 30px color-mix(in srgb, var(--fp-accent, var(--rip-act)) 55%, transparent);
}
.fp-pack.is-awaiting::after { animation: fpAwaitFade 1.4s ease-in-out infinite; }

/* ── the reveal ───────────────────────────────────────────────────────────
   ⚠ IT IS AN OVERLAY, NOT A BLOCK IN THE PAGE, AND THE FIRST VERSION GOT THIS
   WRONG. Aaron: "animation loads but card doesn't reveal properly, overlay
   doesn't show." He was exactly right — the reveal was an inline element in
   normal flow, so it appeared 237px down a scrolled page, behind a full-screen
   clip, and then JUMPED to 521px when the pack came back underneath it.
   Measured on the real page before the fix: revealTop 237 -> 521.

   The pack page's reveal has always been a centred modal (#card-overlay) and
   that is the thing being copied. Fixed, full-screen, above the clip at 9998,
   with its own scrim so the card reads against the page behind it. */
/* ⚠ TWO PHASES, AND THE FIRST ONE HAS NO SCRIM. Aaron: "the card is supposed
   to appear from the explosion and then the overlay appears. it's not doing
   that." It was not: the full overlay opened AT the explosion, and a 97.5%
   scrim over the clip means you never see the card emerge from anything.

   `.is-card` — the card alone, transparent background, in front of the clip at
   the explosion frame. This is the pack page's own behaviour: the slab reveals
   ON the bursts, in front of the video.
   `.is-on`   — the result panel: the scrim fades in and the details arrive,
   after the clip has finished. */
/* ⚠ THE PANEL IS `art | identity`, WITH THE ACTIONS FULL WIDTH BENEATH BOTH —
   AT EVERY WIDTH, THE PHONE INCLUDED. Aaron, 2026-09-04: "doesn't look anywhere
   near as good as the Pokémon reveal." Measured side by side at 390px, that was
   not taste: the Pokémon overlay is a two-column grid with a hero line, a
   bordered collection box and a primary/secondary button pair, and this was a
   centred stack — 78vw of card art with a 19px name, a tiny chip and two grey
   chips under it. The card filled the screen and nothing else had any weight.

   The proportions here are #card-overlay's own, and every one of them is a
   measurement that page already paid for, so they are copied rather than
   re-derived: 46% to the art on a phone (40% "finished 135px short of the
   window with the card at 156x220 — small art and empty space"), and
   min(280px,25vh) above 560 because a card is 5:7 and a 280px column is 392px
   tall, which overflowed an 808x787 window.

   ⚠ GRID, NOT FLEX-WRAP. That page fixed the same bug three times: flex clamps
   an item by max-width BEFORE deciding where the line breaks, so the actions
   row sat BESIDE the card at 1920 and under it at 1280. Two columns and an
   explicit full-width row behave identically at every width.

   ⚠ `safe center`. Plain centring pushes a panel taller than the window ABOVE
   the scroll origin, i.e. unreachable even with overflow-y:auto — measured on
   that page at -110px on a landscape phone. */
.fp-reveal {
  position: fixed; inset: 0; z-index: 9999;
  display: none;
  grid-template-columns: minmax(0, 46%) minmax(0, 1fr);
  gap: 13px 15px;
  justify-content: safe center; align-content: safe center; align-items: start;
  text-align: left; padding: 18px 14px;
  transition: background .38s ease;
  /* ⚠ NEARLY OPAQUE, NOT MERELY DARK. At .88 the page behind read straight
     through it — the red OPEN button and the header sat visibly across the
     card in a 390px screenshot. A reveal is a modal moment; the page behind it
     is not part of the composition, and backdrop-filter is not supported
     everywhere so the scrim cannot rely on the blur to do this job. */
  background: transparent;
  overflow-y: auto; overscroll-behavior: contain;
}
.fp-reveal.is-on { display: grid; }
.fp-reveal.is-on {
  background: rgba(4, 6, 10, .975);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
/* ⚠ PHASE 1 IS A CENTRED FLEX BOX, NOT THE GRID WITH ITS TEXT HIDDEN. The card
   comes out of the explosion ALONE, so it has to be centred in the viewport —
   and `opacity:0` siblings still occupy their tracks, which is what used to
   push it off centre before the panel had even opened. They are display:none
   here and the layout mode itself changes at .is-on. */
/* ⚠ `:not(.is-on)` ON EVERY PHASE-1 RULE, INCLUDING `display`. Both classes are
   on the element at once — revealCard() adds .is-card and show() adds .is-on to
   the same node — so a bare `.fp-reveal.is-card` is an equal-specificity tie
   that source order decides, and it silently kept the panel in the phase-1 flex
   box: measured, the grid never applied and the identity column overlapped the
   card. The phase is "card, and not yet the panel", so say that. */
.fp-reveal.is-card:not(.is-on) { display: flex; align-items: center; justify-content: center; }
/* ⚠ THE CARD IS `.fp-cardwrap` NOW, NOT `.fp-art` — this selector names the
   REVEAL'S DIRECT CHILD, and wrapping the image moved that. Left as
   `:not(.fp-art)` it hid the card in the one phase the card is the only thing
   on screen: measured, the art came back 0×0 at top 0. */
.fp-reveal.is-card:not(.is-on) > *:not(.fp-cardwrap) { display: none; }

/* ⚠ `align-self` IS SCOPED TO THE PANEL, BECAUSE AN ITEM'S OWN ALIGNMENT BEATS
   ITS CONTAINER'S. This is the second half of "card loads at top", and the one
   that was actually on screen: the grid rule pinning the art to the top of its
   column applied in PHASE 1 as well, where the container is a centred flex box
   — and `align-self:start` on the item overrides `align-items:center` on the
   parent every time. Measured before the fix: the card sat 202px above centre,
   i.e. hard against the top padding, with the page heading showing behind it.
   The container's rule was right and the item silently opted out of it. */
.fp-cardwrap        { grid-column: 1; position: relative; display: block; width: 100%; }
.fp-reveal.is-on .fp-cardwrap { align-self: start; }
/* ⚠ `overflow-wrap` HERE, BECAUSE IT INHERITS. Clamping the BOXES is only half
   the job: a single word longer than the column still paints straight out of a
   correctly-sized box, and `getBoundingClientRect()` on that element reports no
   overflow at all — only the container's scrollWidth sees it. That is what
   "Counterintelligence" did after the pill was fixed. One declaration on the
   column covers the name, the meta line and anything added here later. */
.fp-info   { grid-column: 2; align-self: center; min-width: 0;
             display: flex; flex-direction: column; align-items: flex-start; gap: 7px;
             overflow-wrap: break-word; }
/* ⚠ `.fp-tray`, NOT `.fp-foot` — that name is already the pack back's foot
   credit, twenty lines up. Reusing it would have given a one-line <em> inside
   the pack a full-width grid-column flex column. Grep before naming. */
.fp-tray   { grid-column: 1 / -1; width: 100%; max-width: 380px;
             justify-self: center; display: flex; flex-direction: column; gap: 9px; }

/* ⚠ THE CARD IS THE HERO AND IT GETS A REAL EDGE. A bare rounded rectangle with
   a drop shadow is what "not anywhere near as good" looked like: the pack page
   frames its card in a slab that glows the tier's own colour. This is the same
   idea at a fun pack's weight — a hairline, the pack's accent bloomed behind
   the art, and the depth shadow the overlay already had. */
.fp-art {
  display: block; width: 100%; height: auto; border-radius: 11px;
  background: var(--rip-panel-2);
  box-shadow: 0 16px 42px rgba(0,0,0,.62),
              0 0 0 1px rgba(255,255,255,.10),
              0 0 38px -8px var(--fp-accent, var(--rip-act));
}
/* Phase 1: big and centred, before the panel takes a column off it. */
.fp-reveal.is-card:not(.is-on) .fp-cardwrap { width: min(320px, 74vw); }

/* ⚠ THE WHITE FLASH IS THE ONE REVEAL EFFECT THAT FIRES ON EVERY PATH ON THE
   PACK PAGE (`.reveal-flash`, packs.htm:742) and it was missing here entirely.
   It is what sells the card as having been struck out of the burst rather than
   faded in — half a second, over the art, gone. */
@keyframes fpCardFlash {
  0%   { opacity: 0; }
  30%  { opacity: 1; }
  100% { opacity: 0; }
}
.fp-cardflash {
  position: absolute; inset: 0; border-radius: 11px; background: #fff;
  opacity: 0; pointer-events: none; z-index: 2;
}
.fp-reveal.is-pop .fp-cardflash { animation: fpCardFlash .5s ease-out forwards; }

/* ⚠ THE STATIC SHEEN — packs.htm's `.holo-sheen`, always on, never animated.
   A flat scan on a black scrim reads as a picture of a card; one diagonal
   highlight reads as a card under a light. `mix-blend-mode: overlay` so it
   brightens the art rather than greying it, and it must not eat the tap that
   dismisses the panel. */
.fp-sheen {
  position: absolute; inset: 0; border-radius: 11px; pointer-events: none;
  z-index: 1; mix-blend-mode: overlay;
  background: linear-gradient(135deg,
              transparent 28%, rgba(255,255,255,.30) 44%,
              rgba(255,255,255,.06) 54%, transparent 66%);
}

/* ── the identity column ───────────────────────────────────────────────── */
/* ⚠⚠ A FLEX ITEM'S `min-width` IS `auto`, WHICH MEANS MIN-CONTENT — AND THAT IS
   WHY THE PANEL SCROLLED SIDEWAYS (Aaron, 2026-09-07: "fix overflow", with a
   screenshot of the reveal scrolled right and the card half off the left edge).
   The pill is `white-space:nowrap`, so `.fp-idrow`'s min-content width is the
   WHOLE set name; as a flex item of `.fp-info` it was free to grow to it.
   Measured at 390px with the real font: .fp-info 180.5 (correct) but .fp-idrow
   and .fp-pill both 244.6 — the overlay's scrollWidth 440 against a 390 client,
   i.e. 50px of horizontal scroll.

   ⚠ AND IT MADE THE PILL'S OWN `max-width:100%` MEANINGLESS — the 100% was of a
   parent that had already grown to fit it, so the ellipsis it asks for could
   never fire. A clamp measured against a box the clamped thing is sizing is not
   a clamp. Whenever `max-width:100%` "does nothing", check what the 100% is OF.

   ⚠ `> *`, NOT A LIST OF THE ROWS THAT EXIST TODAY. A block added to this
   column next month inherits the clamp instead of being remembered. */
.fp-info > *      { max-width: 100%; min-width: 0; }
/* ⚠ DESCENDANTS, NOT `> *`. `.fp-flag` is `display:contents`, so the chip that
   actually becomes a flex item of this row is its CHILD — a `> *` rule lands on
   the box that was taken out of the layout and reaches nothing. */
.fp-idrow *       { min-width: 0; }
.fp-idrow { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; min-width: 0; max-width: 100%; }
.fp-pill {
  font-family: 'Press Start 2P', var(--rip-display), sans-serif; font-size: 9px;
  letter-spacing: .04em; padding: 6px 10px; border-radius: 999px;
  /* ⚠ THE ACCENT IS A FILL COLOUR, NOT A TEXT COLOUR, ON A NEAR-BLACK SCRIM.
     Marvel red straight onto rgba(4,6,10,.975) measured 3.1:1 — the pill read
     as dark-on-dark in the 390px screenshot. Every accent this product will
     ever take is a saturated brand colour, so the label is mixed toward white
     rather than each page being asked to supply a second, lighter one. */
  color: #fff; border: 0;
  background: var(--fp-accent, var(--rip-act));
  box-shadow: 0 0 20px -4px var(--fp-accent, var(--rip-act));
  /* ⚠ IT WRAPS, IT DOES NOT ELLIPSIS — THE SET NAME IS DATA, NOT COPY.
     "The Galaxy's Most Wanted" is 245px of Press Start 2P at 9px against a
     180px column on a 390px phone, and no font size that fits it on one line is
     readable (it would need ~6.6px). Truncating is the wrong trade here because
     nothing else on the reveal says which set the card came from, and the next
     universe's names are not ours to shorten either. Two short lines show the
     whole name; `break-word` only splits a word that cannot fit on its own, so
     it breaks at the spaces. */
  max-width: 100%; min-width: 0;
  white-space: normal; overflow-wrap: break-word; line-height: 1.5;
}
/* ⚠ THE CARD'S NAME IS THE HERO LINE, BECAUSE A FUN PACK HAS NO PRICE TO PRINT.
   The Pokémon panel's weight comes from one huge gradient-clipped number; strip
   the value out of that layout and the hole is exactly what this page looked
   like. A fun pack's payoff is WHICH card it is, so the name takes that slot.
   ⚠ padding + box-decoration-break:clone are NOT optional on a
   background-clip:text run (Aaron, 2026-07-12: italic glyph overhang was being
   cut off on his phone). Padding alone does not fix a wrapped span — clone is
   what gives every line fragment its own padding. */
.fp-hero {
  font-family: var(--rip-display); font-weight: 800; margin: 0;
  font-size: clamp(23px, 6.4vw, 30px); line-height: 1.1; letter-spacing: -.02em;
  /* ⚠ HYPHENATE THE HERO LINE, DON'T JUST SNAP IT. Card names are data and some
     are one long word — "Counterintelligence" is 19 characters against a 180px
     column on a 390px phone, so it cannot fit at any size this clamp reaches.
     `break-word` alone gives "Counterintellige / nce"; with hyphens the break
     lands at a syllable and reads as typography rather than damage. It is only
     ever a hint — a browser with no dictionary for the page's lang falls back
     to the inherited break-word, which is why both are set and neither is
     load-bearing on its own. NOT on .fp-sub or .fp-pill: a hyphen in a metadata
     run or a pixel-font eyebrow reads as a glyph, not a break. */
  -webkit-hyphens: auto; hyphens: auto;
  padding: 0 .14em; margin-left: -.14em;
  -webkit-box-decoration-break: clone; box-decoration-break: clone;
  /* ⚠ THE TAIL OF THE GRADIENT IS A LIGHTENED ACCENT, NOT THE ACCENT. Ending on
     the raw brand colour put the last word of the name at Marvel red on a
     near-black scrim — the exact "cramped and dark" reading this rebuild is
     fixing. Mixed 55% toward white it still says which pack this is and stays
     legible on any accent the product takes later. */
  background-image: linear-gradient(135deg, #ffffff 0%, #ffffff 42%,
                    color-mix(in srgb, var(--fp-accent, var(--rip-act)) 55%, #ffffff) 100%);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
  text-wrap: balance;
}
.fp-sub {
  font-family: var(--rip-data); font-weight: 600; font-size: 12.5px;
  color: var(--rip-fg-3); margin: 0; line-height: 1.4;
  font-variant-numeric: tabular-nums;
}

@media (min-width: 560px) {
  /* 280 + 15 + 380 = 675 — one readable column pair however wide the screen
     gets, capped on the TRACK because the overlay itself must stay full-screen. */
  .fp-reveal { grid-template-columns: min(280px, 25vh) minmax(0, 380px); gap: 15px 16px; }
  .fp-hero   { font-size: 34px; }
  .fp-info   { gap: 9px; }
}

.fp-hint { font-size: 11.5px; color: var(--rip-fg-3); opacity: .75; margin: 2px 0 0;
           text-align: center; }

/* ⚠ THE CARD EXPLODES OUT OF NOTHING, AND IT IS THE **ART** THAT DOES IT, NOT
   THE OVERLAY. `card-explode-out` (packs.htm:1706) starts at scale 0 at five
   times brightness and overshoots to 1.18 before settling — 900ms of it. The
   old fpPop started at scale .55 and took 520ms, which reads as a panel opening
   rather than a card being thrown out of an explosion, and animating the
   OVERLAY meant the scrim and the buttons scaled with it. */
@keyframes fpExplode {
  0%   { transform: scale(0)    rotate(-8deg); opacity: 0; filter: brightness(5); }
  55%  { transform: scale(1.18) rotate(2deg);  opacity: 1; filter: brightness(1.8); }
  78%  { transform: scale(.95)  rotate(-1deg);             filter: brightness(1.1); }
  100% { transform: scale(1)    rotate(0deg);              filter: brightness(1); }
}
/* ⚠ THE WRAPPER, SO THE FLASH AND THE SHEEN TRAVEL WITH THE CARD. Animating
   the <img> alone left both layers sitting still at full size while the art
   scaled up through them. */
.fp-reveal.is-pop .fp-cardwrap { animation: fpExplode .9s cubic-bezier(.175,.885,.32,1.275) forwards; }
@media (prefers-reduced-motion: reduce) {
  .fp-pack.is-beat, .fp-pack.is-charge, .fp-pack.is-suck, .fp-pack.is-awaiting::after,
  .fp-reveal.is-pop .fp-cardwrap, .fp-reveal.is-pop .fp-cardflash { animation: none; }
  .fp-pack.is-suck { opacity: 0; }
  .fp-vig, .fp-cardflash { display: none; }
  /* `both` fill means removing the animation must not leave these at opacity 0. */
  .fp-reveal.is-on .fp-info, .fp-reveal.is-on .fp-tray { animation: none; }
}

/* ── the burst clip ───────────────────────────────────────────────────── */
/* ⚠ position:fixed and z-index 9998 mirror #burst-video on the pack page, and
   pointer-events:none is not optional — a full-screen element that eats taps
   during a pull is the cookie-banner defect all over again. */
.fp-burst {
  opacity: 0; position: fixed; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 100%; height: auto; z-index: 9998; pointer-events: none;
  /* The engine writes .transition at both ends of the fade, as packs.htm does
     (0.7s in, 0.8s out); this is the resting value, not the live one. */
  transition: opacity .7s ease;
}
.fp-burst.is-on { opacity: 1; }

/* ── the post-pull strip ──────────────────────────────────────────────── */
/* ⚠ NEW SITS BESIDE THE PACK PILL, NOT ON A LINE OF ITS OWN. That is the pack
   page's `.ov-idrow` decision, and the reason is width: on a ~200px identity
   column a chip with a whole row to itself buys nothing and costs ~26px of the
   panel's height. */
.fp-flag { margin: 0; display: contents; }
.fp-new {
  font-family: 'Press Start 2P', var(--rip-display), sans-serif; font-size: 9px;
  color: #06240f; background: var(--rip-up); border-radius: 999px;
  padding: 6px 10px; display: inline-block; letter-spacing: .04em;
  box-shadow: 0 0 18px -4px var(--rip-up);
}
.fp-dupe {
  font-family: var(--rip-data); font-weight: 700; font-size: 10px;
  letter-spacing: .04em; text-transform: uppercase; padding: 6px 10px;
  border-radius: 999px; display: inline-block;
  color: var(--rip-fg-3); border: 1px solid var(--rip-line);
}

/* ⚠ THE COLLECTION FIGURE IS A BORDERED BOX, NOT A LOOSE LINE. On the pack
   page it is a panel with the count, the bar and the way to go and look at it
   — one control. Loose on a scrim it read as a caption nobody had placed. */
.fp-box {
  display: flex; align-items: center; gap: 11px; width: 100%; box-sizing: border-box;
  background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.12);
  border-radius: 12px; padding: 9px 11px 9px 13px;
}
.fp-colbar { flex: 1 1 auto; min-width: 0; font-family: var(--rip-data);
  font-weight: 800; font-size: 12.5px; color: var(--rip-fg);
  font-variant-numeric: tabular-nums; }
/* ⚠ The count must not wrap. "1 / 1779" broke across two lines beside the bar
   because the <b> was allowed to shrink — a four-digit total is the normal
   case here, not the edge one. */
.fp-colbar b { white-space: nowrap; }
.fp-colbar u { display: block; text-decoration: none; color: var(--rip-fg-3);
  font-weight: 700; font-size: 11px; }
/* ⚠ rgba on white, NOT --rip-panel-2. The track was panel-2 on a .975 black
   scrim, i.e. invisible — measured in a 390px screenshot, the bar read as a gap
   between two words. A progress bar nobody can see is not a progress bar. */
.fp-colbar span { display: block; margin-top: 6px; height: 8px; border-radius: 999px;
  background: rgba(255,255,255,.09); overflow: hidden; }
.fp-colbar i { display: block; height: 100%; border-radius: 999px;
  background: linear-gradient(90deg,
              color-mix(in srgb, var(--fp-accent, var(--rip-act)) 55%, #ffffff),
              var(--fp-accent, var(--rip-act)));
  transition: width .6s ease; }

/* ⚠ ONE PRIMARY, ONE SECONDARY — NOT TWO GREY CHIPS. The pack page pairs a
   filled OPEN ANOTHER at flex:1.6 with an outlined SHARE at flex:1, because a
   straight 50/50 split demotes the primary action to "one of two buttons".
   ⚠ (0,2,0)+ on every anchor: rip-ui.css repaints anchors at (0,2,1), which is
   how a CTA ends up cyan on its own accent fill. */
.fp-acts { display: flex; gap: 8px; }
.fp-acts button, .fp-acts a {
  flex: 1 1 0; min-width: 0; min-height: 46px;
  font-family: var(--rip-display); font-weight: 800; font-size: 14px;
  border-radius: 11px; padding: 12px 10px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; gap: 7px;
  border: 1px solid rgba(255,255,255,.28); background: rgba(255,255,255,.10);
  color: var(--rip-fg); text-decoration: none;
}
.fp-acts button.is-primary, .fp-reveal a.fp-primary:not(.rip-btn) {
  flex: 1.6 1 0; border: none; color: #fff;
  background: linear-gradient(135deg,
              color-mix(in srgb, var(--fp-accent, var(--rip-act)) 78%, #ffffff),
              var(--fp-accent, var(--rip-act)) 62%,
              color-mix(in srgb, var(--fp-accent, var(--rip-act)) 72%, #000000));
  box-shadow: 0 6px 18px -4px var(--fp-accent, var(--rip-act)),
              inset 0 1px 0 rgba(255,255,255,.18);
}
.fp-acts button[disabled] { opacity: .5; cursor: default; }

/* ⚠ THE PANEL ARRIVES, IT DOES NOT APPEAR. Two beats, 90ms apart — identity
   first, then the controls — so the eye lands on the card and the name before
   the buttons. A single opacity flip is what made this read as a modal rather
   than a reveal. */
@keyframes fpIn {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
.fp-reveal.is-on .fp-info { animation: fpIn .34s ease-out both; }
.fp-reveal.is-on .fp-tray { animation: fpIn .34s ease-out .09s both; }


/* ── burst effects ─────────────────────────────────────────────────────────
   Ported from packs.htm's screenShake() and spawnBurstSparks(). Particle
   COUNTS are the reduced ones that page settled on: it once fired ~436 DOM
   elements at peak and phones could not keep up. A fun pack has one clip and
   no chase layers, so 40 is plenty. */
@keyframes fpShake {
  0%,100% { transform: translate(0,0); }
  20% { transform: translate(-5px,-2px); }
  40% { transform: translate(7px,3px); }
  60% { transform: translate(-5px,2px); }
  80% { transform: translate(5px,-2px); }
}
.fp-shake { animation: fpShake .45s ease-in-out; }

/* ⚠ pointer-events:none on every particle and on the flash. They are painted
   over the whole viewport during a pull; anything here that can take a tap is
   the cookie-banner defect again. */
.fp-particle {
  position: fixed; border-radius: 50%; pointer-events: none; z-index: 9997;
  animation: fpParticle .7s ease-out forwards;
}
@keyframes fpParticle {
  0%   { opacity: 1; transform: translate(-50%,-50%) scale(1); }
  100% { opacity: 0; transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(.25); }
}
.fp-flash {
  position: fixed; inset: 0; pointer-events: none; z-index: 9996;
  background: radial-gradient(circle at var(--bx,50%) var(--by,50%),
              var(--fp-accent, #fff) 0%, transparent 55%);
  animation: fpFlash .5s ease-out forwards;
}
@keyframes fpFlash { 0% { opacity: .55; } 100% { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .fp-shake { animation: none; }
  .fp-particle, .fp-flash { display: none; }
}

/* ── the binder ────────────────────────────────────────────────────────────
   Aaron, 2026-09-05: "and what about the massive growing list of cards?"
   It was a flat auto-fill grid holding one <img> per owned card — unbounded,
   and slower with every pull. This is Pack Dojo's own binder shape: nine
   pockets to a sheet, rings down the spine, empty pockets padding the last
   sheet so it keeps its shape, one pager and one filter. */
.fp-bd-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.fp-bd-pack {
  flex: 0 1 190px; min-width: 0; font-family: var(--rip-data); font-weight: 700;
  font-size: 13px; color: var(--rip-fg); background: var(--rip-panel-2);
  border: 2px solid var(--rip-line); border-radius: 10px; padding: 10px 11px;
}
/* ⚠ A SEARCH FIELD MUST NOT SHARE ITS FILL WITH THE BLOCK ABOVE IT — Aaron,
   2026-08-19, on the trades picker: "I genuinely didn't even see that filter
   box... it looks like it's just a title." Raised fill, a 2px border (1.5px
   computes to 1px at DPR 1), and 16px text because iOS zooms the page in on
   focus below that. */
.fp-find { flex: 1 1 auto; min-width: 0; display: flex; align-items: center;
  background: var(--rip-panel-2); border: 2px solid var(--rip-line-2, var(--rip-line));
  border-radius: 10px; padding: 0 10px; }
.fp-find:focus-within { border-color: var(--fp-accent, var(--rip-act)); }
.fp-find-in {
  flex: 1 1 auto; min-width: 0; border: 0; background: transparent;
  color: var(--rip-fg); font-family: var(--rip-data); font-weight: 600;
  font-size: 16px; padding: 10px 0; outline: none;
}
.fp-bd-count { font-family: var(--rip-data); font-weight: 700; font-size: 12px;
  color: var(--rip-fg-3); margin: 0 0 10px; font-variant-numeric: tabular-nums; }

.fp-sheet {
  position: relative; padding: 12px 12px 12px 26px; border-radius: 14px;
  background: var(--rip-panel-2); border: 1px solid var(--rip-line);
}
/* The rings down the spine — the one detail that says "binder" rather than
   "grid", and it costs a repeating-gradient rather than nine elements. */
.fp-rings {
  position: absolute; left: 9px; top: 14px; bottom: 14px; width: 8px;
  border-radius: 99px; pointer-events: none;
  background: repeating-linear-gradient(to bottom,
              var(--rip-line-2, var(--rip-line)) 0 10px, transparent 10px 22px);
}
.fp-pockets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }

/* ── the same pockets, on a page that is NOT a universe page ───────────────
   `/rip/u/<name>` shows a collector's universe cards beside their Pokémon
   binder, and its column is far wider than .fp-wrap's 980. Three fixed columns
   there would paint 360px pockets, so this is the same pocket at the same size
   with the COUNT left to the width — 96px is the pocket the rest of the site
   draws, and it is the width the server asks card-image.php for.

   ⚠ IT LIVES HERE, NOT IN u.php's OWN <style>. A universe pocket has one
   definition and a page-level copy loads after this sheet and wins silently —
   which is exactly how the pack geometry, the page head, the gloss and the
   badge shelf each drifted across two surfaces in one day.

   ⚠⚠ IT IS A MODIFIER OF .fp-pockets, AND IT WAS `.fp-shelf` FOR ONE COMMIT.
   `.fp-shelf` is ALREADY TAKEN, ~500 lines below, by the pack shelf — the grid
   of .fp-tile pack faces. Two rules of the same name in one sheet: nothing
   errored, the later one simply won, and because both are auto-fill grids at
   104 vs 96px the page looked completely correct. It was found only by the
   guard's negative test refusing to go red — renaming MY rule left the OTHER
   one satisfying the assertion. **Grep the sheet for a class name before
   defining one**, and when a guard will not fail, suspect the test. */
.fp-pockets--fill { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }

/* ⚠ ART + CAPTION, NEVER ONE BOX. rip/CLAUDE.md: the caption used to be
   absolutely positioned over the art behind a scrim, and that is what rendered
   "$10,660" as "$10,6(" on the Pokémon binder. Only things that cannot grow may
   sit on the art; a card's NAME can. */
/* ⚠⚠ THIS POCKET IS THE POKÉMON BINDER'S POCKET, DECLARATION FOR DECLARATION
   (2026-09-09). Aaron: "the pokemon binder looks completely different from the
   marvel binder, for example. I don't know why you've done that, when it
   requires extra work." Fair, and the answer is that a second binder ENGINE was
   written — which the data genuinely justifies, since a universe has no tiers,
   no grades and no values — and the LOOK was rewritten along with it, which
   nothing justified. Renaming `.pk-art` to `.fp-pk-art` meant nothing forced the
   two to agree, and they drifted:

     aspect-ratio  5/7 (a real card)   vs  3/4        ← the shape was simply wrong
     art           gradient + inset    vs  flat fill
     gloss         a diagonal ::after  vs  none
     sleeve        .card, inset 3px    vs  <img> in the box   ← see fun-pack.js
     caption       min-height reserved vs  nothing     ← ragged rows

   Every value below is copied from `.pocket` / `.pk-art` / `.pk-meta` /
   `.pk-name` in rip/account.css. `fun-pack-rip-check.js` diffs the two
   stylesheets on exactly these properties, so the next edit to either one has
   to be made to both or it goes red — the same "change one, change the other"
   arrangement packs.htm's gloss already lives under, but enforced rather than
   remembered.

   ⚠ WHY NOT JUST LOAD account.css HERE. It aliases its whole token set onto
   rip-ui.css and carries the binder, collection and stats surfaces with it —
   ~2,000 lines to import for one component, on four pages that use none of the
   rest. The one-definition move is to lift the pocket into rip-ui.css, which
   both already load; that is a change to the POKÉMON binder as well and is not
   what was asked for here. */
.fp-pocket { position: relative; display: flex; flex-direction: column; min-width: 0;
  --pk-cap: 9px; }
.fp-pk-art {
  position: relative; aspect-ratio: 5/7; border-radius: 7px; overflow: hidden;
  background: linear-gradient(155deg, rgba(255,255,255,.055), rgba(255,255,255,.012) 45%, rgba(0,0,0,.28));
  box-shadow: inset 0 0 0 1px rgba(255,255,255,.10), inset 0 2px 6px rgba(0,0,0,.5);
}
/* The sheen across the sleeve — what makes a pocket read as laminated rather
   than as a picture in a box. */
.fp-pk-art::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 4;
  border-radius: 7px;
  background: linear-gradient(118deg, rgba(255,255,255,.20) 0%, rgba(255,255,255,.05) 16%,
    transparent 34%, transparent 72%, rgba(255,255,255,.07) 100%);
  mix-blend-mode: screen; opacity: .75;
}
/* ⚠ THE SLEEVE. `--tc` is the Pokémon binder's tier colour; here it is the
   pack's own hue, supplied per row as `--pk-c`, and it falls back to the same
   #333 when a pack has no such idea rather than inventing one. */
.fp-pk-card {
  position: absolute; inset: 3px; border-radius: 5px; overflow: hidden;
  border: 1.5px solid var(--pk-c, #333);
  background: var(--rip-bg-2, #0b0f16);
}
.fp-pk-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fp-pocket:hover .fp-pk-card { filter: brightness(1.12); }
/* ⚠ `min-height` IS WHAT KEEPS THE ROWS LEVEL. A grid row sizes to its tallest
   pocket, so an unreserved caption makes a sheet ragged the moment one name
   wraps — the same budget rule the set shelf's two-line name already follows. */
.fp-pk-meta { display: flex; align-items: baseline; gap: 5px; margin-top: 4px;
  min-width: 0; min-height: calc(var(--pk-cap) * 1.25); }
.fp-pk-name {
  flex: 1; min-width: 0; font-size: var(--pk-cap); font-weight: 700;
  color: var(--rip-fg-2); line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fp-pk-set {
  flex: 0 0 auto; font-family: var(--rip-data); font-size: var(--pk-cap);
  font-weight: 800; color: var(--rip-fg-3); white-space: nowrap;
}
/* An empty pocket is the sheet keeping its shape, not a missing card — and it
   keeps the SAME shape, or a part-full sheet steps mid-row. */
.fp-pocket.is-empty::before {
  content: ''; display: block; aspect-ratio: 5/7; border-radius: 7px;
  border: 1px dashed var(--rip-line); opacity: .5;
}

.fp-pager { display: flex; align-items: center; justify-content: center; gap: 10px;
  margin-top: 12px; font-family: var(--rip-data); font-weight: 700; font-size: 12px;
  color: var(--rip-fg-3); font-variant-numeric: tabular-nums; }
.fp-pager button {
  font-family: var(--rip-data); font-weight: 800; font-size: 12px; cursor: pointer;
  color: var(--rip-fg-2); background: var(--rip-panel-2);
  border: 1px solid var(--rip-line); border-radius: 9px; padding: 9px 13px;
  min-height: 38px;
}
.fp-pager button[disabled] { opacity: .4; cursor: default; }

/* ⚠ ONLY THINGS THAT CANNOT GROW SIT ON THE ART — rip/CLAUDE.md's pocket rule.
   A star and a "x3" are two characters; a card NAME is not, and putting one
   there is what rendered "$10,660" as "$10,6(" on the Pokémon binder. */
.fp-pk-star, .fp-pk-dupe {
  position: absolute; font-style: normal; font-family: var(--rip-data);
  font-weight: 800; font-size: 9px; line-height: 1; padding: 3px 4px;
  border-radius: 4px; pointer-events: none;
}
.fp-pk-star { top: 3px; left: 3px;  color: #ffd45e; background: rgba(0,0,0,.55); }
.fp-pk-dupe { top: 3px; right: 3px; color: var(--rip-fg); background: rgba(0,0,0,.62); }
/* The card's own aspect, as a hairline down the pocket. Painted only when the
   pack HAS such an idea — `--pk-c` is unset otherwise and nothing shows, rather
   than a default colour inventing a fact about the card. */
/* ⚠ SUPERSEDED BY THE SLEEVE. `--pk-c` is now the sleeve's border, exactly as
   `--tc` is on the Pokémon pocket, so a second stripe down the art box would be
   the pack's colour drawn twice in one pocket. */

/* ⚠ THE UNIQUE STAR SITS WITH THE NEW BADGE, NOT NEAR THE NAME — and it is
   gold-on-dark rather than a fill, so it reads as a mark printed on the card
   rather than as a grade or a score. Marvel Champions has no rarity; this is
   the ★ the card itself carries. */
.fp-uniq {
  font-family: var(--rip-data); font-weight: 800; font-size: 9px;
  letter-spacing: .04em; padding: 6px 9px; border-radius: 999px;
  color: #ffd45e; border: 1px solid rgba(255,212,94,.45);
  background: rgba(255,212,94,.10); white-space: nowrap;
}

/* ⚠⚠ THE RARITY CHIP. Added 2026-09-09 — Aaron: "on the power rangers and
   disney page, the user has no idea if they're pulling a common card,
   uncommon, rare etc." It sits in .fp-idrow beside the set pill, which already
   wraps, so it costs no horizontal budget: that row is `flex-wrap:wrap` with
   `min-width:0` on its descendants precisely because the pill overflowed once.

   ⚠ THE LADDER IS WEIGHT AND BRIGHTNESS, NOT A NEW HUE, AND THAT IS FORCED.
   rip/CLAUDE.md's colour language already spends every signal colour this site
   has: cyan means "do this", amber means money, gold means prize, and the pack
   accent means "which universe". A rarity scale in gold would read as a prize
   on the one screen where a real prize can appear. So a common card is quiet
   grey, and rarer grades get border, glow and letter-spacing — the same
   information, carried by emphasis rather than by a colour that already means
   something else here. */
/* ⚠⚠ THE RARITY LADDER IS EIGHT TREATMENTS, AND EACH ONE SWITCHES ON A
   MECHANISM THE STEP BELOW DOES NOT HAVE (2026-09-09, third attempt).

   Attempt one was three classes: on Lorcana's eight grades four pills were
   byte-identical. Attempt two interpolated every property linearly off the
   rank — every rung then had its own numbers, and Aaron said, correctly,
   "there's not much difference between one and the next." One eighth of one
   range per step is a change nobody sees.

   A ladder is read by things APPEARING, not by a value creeping. So:

     t0  flat grey — no colour anywhere
     t1  + the pip takes the pack accent          (colour enters)
     t2  + the border takes it                    (the outline joins in)
     t3  + the WORD goes white and the border full accent
     t4  + an accent wash behind it               (the pill itself is tinted)
     t5  + an outer glow                          (it lights what is around it)
     t6  + a metallic two-tone sweep and a 2px edge
     t7  + the foil sheen travels across it       (motion, and only here)

   ⚠ EIGHT IS THE VOCABULARY'S, NOT ANY PACK'S. rarityChip() quantises a pack's
   rank onto it, so 6 rungs land on 0,1,3,4,6,7 and 5 on 0,2,4,5,7 — the ends
   are always the first and last treatment. A ninth grade in some future pack
   changes nothing here, and no pack has a class of its own.

   ⚠ THE BLOCKS ARE COMPLETE, NOT CUMULATIVE. Each level restates what it
   paints. It is more lines than chaining `.fp-t5, .fp-t6, .fp-t7`, and it is
   the version somebody can audit: what a rung looks like is one block, not a
   set of selector lists to intersect in your head. */
.fp-rar {
  --fp-r: 0;                        /* the slope, still used by the two things
                                       that genuinely read better as one */
  --fp-rc: var(--fp-accent, var(--rip-act));
  /* ⚠ inline-flex + a gap, because the pip is a real box in the line box rather
     than a decoration painted over the text. `isolation` is what keeps the foil
     sweep's `z-index:-1` INSIDE the chip: without a stacking context a negative
     child paints behind the chip's own background and disappears. */
  display: inline-flex; align-items: center; gap: 6px;
  position: relative; isolation: isolate; overflow: hidden;
  font-family: var(--rip-data); font-weight: 800; font-size: 9.5px;
  /* Tracking opens as the grade climbs — the rarest word is the most spaced,
     which is how a foil stamp is set on the real thing. */
  letter-spacing: calc(.05em + var(--fp-r) * .05em);
  text-transform: uppercase;
  padding: 6px 10px 6px 8px; border-radius: 999px;
  white-space: nowrap;
  color: #93a3b8;
  border: 1px solid rgba(255,255,255,.14);
  background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.015));
}
.fp-rar::before {
  content: ''; flex: 0 0 auto;
  width:  calc(4px + var(--fp-r) * 2px);
  height: calc(4px + var(--fp-r) * 2px);
  border-radius: 50%;
  background: #7c8a9c;
}

/* t0 — flat. The commonest grade must look like nothing has happened, or every
   step above it has less room to mean something. */

/* t1 — colour enters, and only in the pip. */
.fp-rar.fp-t1::before { background: var(--fp-rc); }

/* t2 — the outline joins in. */
.fp-rar.fp-t2::before { background: var(--fp-rc); }
.fp-rar.fp-t2 {
  border-color: color-mix(in srgb, var(--fp-rc) 55%, rgba(255,255,255,.14));
}

/* t3 — the word itself takes the light, and the edge goes full accent. */
.fp-rar.fp-t3::before { background: var(--fp-rc); box-shadow: 0 0 6px var(--fp-rc); }
.fp-rar.fp-t3 {
  color: #eef4fb;
  border-color: var(--fp-rc);
}

/* t4 — the pill is tinted, not just outlined. */
.fp-rar.fp-t4::before { background: var(--fp-rc); box-shadow: 0 0 6px var(--fp-rc); }
.fp-rar.fp-t4 {
  color: #fff;
  border-color: var(--fp-rc);
  background:
    linear-gradient(180deg, rgba(255,255,255,.10), rgba(255,255,255,.02)),
    color-mix(in srgb, var(--fp-rc) 22%, transparent);
}

/* t5 — it starts lighting what is around it. */
.fp-rar.fp-t5::before { background: #fff; box-shadow: 0 0 8px var(--fp-rc); }
.fp-rar.fp-t5 {
  color: #fff;
  border-color: var(--fp-rc);
  background:
    linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.03)),
    color-mix(in srgb, var(--fp-rc) 30%, transparent);
  box-shadow: 0 0 16px -4px var(--fp-rc);
  text-shadow: 0 0 10px var(--fp-rc);
}

/* t6 — metallic: a two-tone diagonal through the accent, and a real edge.
   ⚠ The extra border pixel comes out of the padding so the pill keeps height. */
.fp-rar.fp-t6::before { background: #fff; box-shadow: 0 0 10px var(--fp-rc); }
.fp-rar.fp-t6 {
  color: #fff;
  border: 2px solid var(--fp-rc);
  padding: 5px 9px 5px 7px;
  background:
    linear-gradient(115deg,
      color-mix(in srgb, var(--fp-rc) 46%, transparent) 0%,
      rgba(255,255,255,.20) 42%,
      color-mix(in srgb, var(--fp-rc) 46%, transparent) 78%);
  box-shadow: 0 0 22px -4px var(--fp-rc), inset 0 0 14px -8px #fff;
  text-shadow: 0 0 12px var(--fp-rc);
}

/* t7 — the top of any ladder: everything t6 has, brighter, and the only moving
   thing on the panel. Motion is the last mechanism precisely because it cannot
   be out-done by another step. */
.fp-rar.fp-t7::before { background: #fff; box-shadow: 0 0 12px var(--fp-rc), 0 0 3px #fff; }
.fp-rar.fp-t7 {
  color: #fff;
  border: 2px solid var(--fp-rc);
  padding: 5px 9px 5px 7px;
  background:
    linear-gradient(115deg,
      color-mix(in srgb, var(--fp-rc) 62%, transparent) 0%,
      rgba(255,255,255,.30) 42%,
      color-mix(in srgb, var(--fp-rc) 62%, transparent) 78%);
  box-shadow: 0 0 30px -3px var(--fp-rc), inset 0 0 16px -7px #fff;
  text-shadow: 0 0 14px var(--fp-rc), 0 0 4px rgba(255,255,255,.7);
}

/* ⚠⚠ NO STEP IS A SOLID ACCENT FILL, AND THAT RULE HAS NOT MOVED — A
   SCREENSHOT IS THE ONLY THING THAT FOUND IT. An early version filled the top
   rung with the pack accent, which measured perfectly (visible, contrast fine,
   zero overflow at 320/390/1280) and was still wrong: on Power Rangers the
   accent is #00b14f and the NEW badge is `--rip-up`, so a Holographic pull put
   two solid green pills side by side and the rarest card on the page read as a
   second NEW. The set pill is a third solid accent fill in the same row.
   t4-t7 wash and sweep the accent at 22-62% over the panel's dark ground, which
   is a tint rather than a fill: still unmistakable, still not a duplicate of
   either pill beside it, on ANY pack — the collision is structural, not a fact
   about green. */

/* ⚠ THE FOIL SWEEP. A holographic card is recognised by the light MOVING across
   it, so the rarest grade gets exactly that and nothing below it does. */
.fp-rar.fp-t7::after {
  content: ''; position: absolute; inset: 0; z-index: -1;
  border-radius: inherit;
  background: linear-gradient(105deg,
    transparent 34%, rgba(255,255,255,.18) 44%,
    rgba(255,255,255,.7) 50%,
    rgba(255,255,255,.18) 56%, transparent 66%);
  transform: translateX(-130%);
}
@media (prefers-reduced-motion: no-preference) {
  .fp-reveal.is-on .fp-rar.fp-t7 { animation: fpIn .34s ease-out both; }
  /* Starts after the panel has assembled, then rests for most of the cycle —
     a sheen that never stops reads as a loading shimmer, not as foil. */
  .fp-reveal.is-on .fp-rar.fp-t7::after { animation: fpRarFoil 3.2s ease-in-out .5s infinite; }
}
@keyframes fpRarFoil {
  0%        { transform: translateX(-130%); }
  38%, 100% { transform: translateX(130%); }
}

/* ── the badge pop ────────────────────────────────────────────────────────────
   Fired by popBadges() in fun-pack.js when a fun pull earns a badge, at the
   moment the result panel is up.

   ⚠ IT MUST NOT SWALLOW A TAP. It sits over the reveal, and the reveal closes
   on a tap anywhere on its scrim — so a pop that took pointer events would trap
   the reader on the panel until it timed out. `pointer-events:none` on the pop
   AND on every child; there is nothing here to click.

   ⚠ z-index 10000, ONE ABOVE THE REVEAL'S 9999 AND CHOSEN, NOT ARBITRARY. The
   reveal overlay is the top of this page's stack (fun-pack.js moves it to
   <body> precisely so a transformed ancestor cannot capture it); a badge that
   painted underneath it would be invisible on exactly the pull that earned it.

   Bottom-centre, above the safe-area inset — the top of a phone is the card and
   its name, and the bottom is where this site puts every other transient
   message. --fpb-i stacks a second badge above the first rather than on it. */
.fp-badgepop {
  position: fixed; left: 50%; z-index: 10000;
  bottom: calc(18px + env(safe-area-inset-bottom, 0px) + (var(--fpb-i, 0) * 62px));
  transform: translateX(-50%);
  display: flex; align-items: center; gap: 10px;
  max-width: min(340px, calc(100vw - 32px));
  padding: 10px 16px 10px 12px; border-radius: 999px;
  background: rgba(14, 18, 26, .94);
  border: 1px solid var(--fp-accent, var(--rip-act, #35d0d0));
  box-shadow: 0 10px 34px rgba(0,0,0,.55);
  pointer-events: none;
  animation: fp-badgepop-in 5.4s cubic-bezier(.2,.8,.3,1) forwards;
}
.fp-badgepop * { pointer-events: none; }
.fp-badgepop-icon { font-size: 22px; line-height: 1; flex: 0 0 auto; }
.fp-badgepop-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.fp-badgepop-eyebrow {
  font-family: var(--rip-data); font-weight: 800; font-size: 9px;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--fp-accent, var(--rip-act, #35d0d0));
}
/* ⚠ min-width:0 ON THE COLUMN AND overflow ON THE NAME. A flex item will not
   shrink below its content by default, so a long badge name would push the pop
   wider than the phone rather than ellipsing — the same trap the milestone
   share button hit at 360px. */
.fp-badgepop-name {
  font-family: var(--rip-display, inherit); font-weight: 800; font-size: 15px;
  color: var(--rip-fg, #eaf2f6); line-height: 1.2;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
@keyframes fp-badgepop-in {
  0%   { opacity: 0; transform: translateX(-50%) translateY(14px) scale(.94); }
  6%   { opacity: 1; transform: translateX(-50%) translateY(0)    scale(1); }
  88%  { opacity: 1; transform: translateX(-50%) translateY(0)    scale(1); }
  100% { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(.98); }
}
/* A different path, not a slower one — it still has to appear and still has to
   leave, because animationend is what removes the element. */
@media (prefers-reduced-motion: reduce) {
  .fp-badgepop { animation: fp-badgepop-fade 5.4s linear forwards; }
  @keyframes fp-badgepop-fade {
    0%, 100% { opacity: 0 } 4%, 92% { opacity: 1 }
  }
}

/* ── the badge shelf ──────────────────────────────────────────────────────
   ⚠ SHARED, BECAUSE TWO SURFACES NOW SHOW IT. It was `.fh-badge*` inside
   rip/fun.php's own <style>, which is the shape this repo has already paid for
   three times today alone — the page head, the universe switch and the pack
   gloss were each locked inside one page's inline sheet, and every one of them
   is why two pages drifted. The Marvel page's Cards view is the second host
   (Aaron, 2026-09-07, choosing the tab set: "badges = a shelf inside Cards"),
   so it moves here rather than being copied.

   ⚠ A LOCKED BADGE IS DIMMED, NOT HIDDEN. The shape of what is missing is the
   whole point of showing locked ones — the same rule as the binder's empty
   pocket. */
.fp-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.fp-badge  {
  display: flex; align-items: center; gap: 6px;
  padding: 5px 9px; border-radius: 999px; font-size: 12px;
  background: var(--rip-bg-2); border: 1px solid var(--rip-line); color: var(--rip-dim);
}
.fp-badge.is-earned { border-color: var(--fp-accent); color: var(--rip-fg); }
.fp-badge:not(.is-earned) .fp-badge-i { opacity: .38; filter: grayscale(1); }
.fp-badge-i { font-size: 14px; line-height: 1; }
.fp-badge-n { font-weight: 700; }


/* ══════════════════════════════════════════════════════════════════════════════
   THE UNIVERSE PAGE'S OWN LAYOUT — the wall, the roster, the shelf, the binder.
   Moved out of rip/marvel.php's inline <style> on 2026-09-08, when the third
   pack arrived and that file's own tripwire came due:
   "⚠ WHEN A THIRD FUN PACK ARRIVES, EXTRACT THIS."

   ⚠⚠ THE POINT IS THAT TWO UNIVERSE PAGES CANNOT DRIFT. Aaron asked for Power
   Rangers as "a carbon copy of the marvel one — same buttons, same look". A
   second 250-line <style> block is a copy that is identical on the day it ships
   and slowly stops being one; a shared sheet is identical for ever. This is the
   FIFTH time the same argument has been settled the same way on this page — the
   pack geometry, the page head, the gloss and the badge shelf all moved here
   first, each after two surfaces had already disagreed.

   ⚠ THE PREFIX IS `fp-`, NOT `mv-`. These rules were named for Marvel because
   Marvel was the only page that had them. A Power Rangers page emitting
   `class="mv-wall"` is a page whose markup lies about what it is.

   ⚠ ONE DECLARATION IS STILL PER-PAGE AND MUST STAY THERE: `--rip-uni`, the
   pack's accent, which comes from the registry through PHP. Everything else
   here is universe-neutral by construction — every colour is either a token or
   `var(--rip-uni)`, so a pack's identity is one hex in sim_fun_registry() and
   nothing in this file knows which pack it is painting.

   ⚠ DROPPED IN THE MOVE: `.mv-views` / `.mv-viewseg` / `.mv-vtab`, the in-page
   sticky view strip. The bottom tab bar replaced it on 2026-09-07 and the
   markup went with it; the rules had been dead ever since (measured: zero
   occurrences outside the stylesheet). Dead CSS carried into a shared sheet is
   dead CSS two pages then inherit.
   ══════════════════════════════════════════════════════════════════════════ */
/* ⚠ THE box-sizing RESET IS NOT HERE ANY MORE — it is `.fp-host` in
   rip/fun-pack.css, and this page opts in with that class on <main>. It was
   duplicated in-page, which is exactly why sonic.php shipped without it and
   scrolled sideways for weeks. One definition, both pages, every future one. */
.fp-wrap{--fp-accent:var(--rip-uni);max-width:980px;margin:0 auto;padding:22px 16px 60px}

/* ⚠ THE PAGE HEAD IS .rip-pagehead (rip-ui.css) ON BOTH PAGES NOW.
   What used to sit here — text-align:center, a Press Start 2P eyebrow and a
   14px sub — was three separate disagreements with the pack page, and the
   pack page's own version was locked inside its inline <style> where nothing
   else could reach it. Aaron, 2026-09-07: "the marvel page still looks
   completely different to the pokemon one … I keep asking for unity."
   ⚠ .fp-head KEEPS ONLY ITS MARGIN. Give it text-align again and the shared
   component is overridden right back to two designs — the centring was the
   single biggest tell. */
.fp-head{margin-bottom:18px}

/* ── the wall ─────────────────────────────────────────────────────────────
   The counter, three progress tiles and the eight aspect crests. Every figure
   is written by the page script; nothing here is server-rendered, because a
   server-rendered count is stale the moment you pull. */
.fp-wall{margin:0 0 16px}
.fp-wallbox{margin-bottom:10px}
.fp-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.fp-stat{background:var(--rip-panel-2);border:1px solid var(--rip-line);border-radius:11px;
  padding:10px 8px;text-align:center;min-width:0}
.fp-stat b{display:block;font-family:var(--rip-data);font-weight:800;font-size:16px;
  color:var(--rip-fg);font-variant-numeric:tabular-nums}
.fp-stat span{display:block;font-size:10.5px;color:var(--rip-fg-3);margin-top:2px}

/* ⚠ CRESTS ARE DISPLAY-ONLY, NOT BUTTONS. Eight saturated tappable chips would
   compete with the one red CTA, and on this site red IS the action. They are a
   read-out and a legend, nothing more. */
.fp-crests{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:10px 0 0}
.fp-crest{font-family:var(--rip-data);font-weight:800;font-size:10px;letter-spacing:.03em;
  padding:5px 9px;border-radius:999px;border:1px solid var(--rip-line);
  color:var(--rip-fg-3);background:var(--rip-panel-2);opacity:.45}
.fp-crest.is-on{opacity:1;border-color:var(--tc);
  color:var(--tc);
  /* The plain colour first is the fallback where color-mix is unsupported;
     the mix lifts a saturated hue to something readable on a near-black panel. */
  color:color-mix(in srgb, var(--tc) 58%, #fff);
  background:color-mix(in srgb, var(--tc) 16%, transparent)}
.fp-goal{text-align:center;font-size:12.5px;color:var(--rip-fg-2);margin:10px 0 0;
  font-family:var(--rip-data);font-weight:700}

/* ── latest finds ────────────────────────────────────────────────────────── */
.fp-rail-sec{margin:0 0 18px}
.fp-h2{font-family:var(--rip-display);font-weight:800;font-size:15px;margin:0;color:var(--rip-fg-2)}
/* ⚠ THE RAIL SCROLLS ITSELF AND BLEEDS TO THE EDGE. Negative margins put the
   cards under the page padding so the row reads as continuing off-screen —
   without ever letting the PAGE scroll sideways. */
.fp-rail{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;
  scroll-snap-type:x proximity;margin:10px -16px 0;padding:0 16px 4px;
  scrollbar-width:none}
.fp-rail::-webkit-scrollbar{display:none}
.fp-rcard{flex:0 0 auto;width:74px;margin:0;scroll-snap-align:start}
.fp-rcard img{width:74px;height:103px;object-fit:cover;border-radius:6px;display:block;
  background:var(--rip-panel-2);border:1px solid var(--rip-line);
  box-shadow:0 0 0 1px var(--tc, transparent) inset}
.fp-rcard figcaption{font-family:var(--rip-data);font-weight:700;font-size:9.5px;
  color:var(--rip-fg-3);margin-top:4px;line-height:1.2;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.fp-rcard.is-taster img{opacity:.28;filter:grayscale(1)}
.fp-star{color:#ffd45e;font-style:normal}

/* ── sections ────────────────────────────────────────────────────────────── */
.fp-sec{margin-top:26px}
.fp-sechead{display:flex;align-items:baseline;gap:10px;margin:0 0 12px}
.fp-sechint{margin-left:auto;font-family:var(--rip-data);font-weight:700;font-size:11.5px;
  color:var(--rip-fg-3);font-variant-numeric:tabular-nums}
/* Two-way switch. Two, not three: a third option here is the page growing back
   into the thing it replaced. */
.fp-seg{margin-left:auto;display:flex;background:var(--rip-panel-2);border:1px solid var(--rip-line);
  border-radius:9px;overflow:hidden}
.fp-seg button{font-family:var(--rip-data);font-weight:700;font-size:11px;padding:7px 10px;
  border:0;background:transparent;color:var(--rip-fg-3);cursor:pointer}
.fp-seg button.is-on{background:var(--rip-panel-3, var(--rip-line));color:var(--rip-fg)}
.fp-more{display:block;width:100%;margin:12px 0 0;padding:11px;border-radius:10px;
  border:1px solid var(--rip-line);background:var(--rip-panel-2);color:var(--rip-fg-2);
  font-family:var(--rip-data);font-weight:700;font-size:12.5px;cursor:pointer}
.fp-cov{font-size:11.5px;color:var(--rip-fg-3);margin:10px 2px 0;line-height:1.5}

/* ── the pack shelf — what replaced 57 <details> ──────────────────────────
   Measured before: that accordion was 2,905px of a 4,967px page at 390px, and
   28,284px opened. It listed pack NAMES for a product whose appeal is the art. */
/* ⚠ 3-UP AT 390px, NOT 2. The first cut used minmax(148px) — two columns of
   181px, so twelve tiles were 1,947px and the page came out LONGER than the
   accordion it replaced (5,281 vs 4,967 measured). The tile has to be small
   enough that a shelf reads as a shelf. */
.fp-shelf{display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}
.fp-tile{position:relative;display:flex;flex-direction:column;text-align:left;padding:0;
  border:1px solid var(--rip-line);border-radius:12px;overflow:hidden;cursor:pointer;
  background:var(--rip-panel-2);color:inherit;font:inherit}
.fp-tile:hover,.fp-tile:focus-visible{border-color:var(--fp-accent);outline:none}
/* ⚠ contain, NEVER cover. Cropping card art was rejected on the clips already
   ("you've chopped them and made them square"); a pack face is the same. */
.fp-tile__art{position:relative;aspect-ratio:3/4;display:grid;place-items:center;
  background:var(--rip-bg-2, #070a10);overflow:hidden}
.fp-tile__art img{width:100%;height:100%;object-fit:contain;display:block}
.fp-tile__body{padding:7px 7px 9px}
/* Two lines reserved: a grid row sizes to its tallest tile, so a one-line name
   beside "Japanese Terastal Festival" would give a ragged shelf. */
.fp-tile__name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
  overflow:hidden;font-family:var(--rip-display);font-weight:800;font-size:10.5px;
  line-height:1.2;min-height:2.4em;color:var(--rip-fg)}
.fp-tile__prog{display:flex;align-items:center;gap:7px;margin-top:6px}
.fp-tile__num{flex:0 0 auto;font-family:var(--rip-data);font-weight:800;font-size:10px;
  color:var(--rip-fg-3);font-variant-numeric:tabular-nums}
.fp-tile__bar{flex:1 1 auto;min-width:0;height:3px;border-radius:2px;
  background:rgba(255,255,255,.09);overflow:hidden}
.fp-tile__bar i{display:block;height:100%;background:var(--fp-accent);border-radius:2px}
.fp-tile.is-started .fp-tile__num{color:var(--rip-fg-2)}

/* ── the hero roster ─────────────────────────────────────────────────────── */
.fp-roster{display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(92px,1fr))}
.fp-face{background:var(--rip-panel-2);border:1px solid var(--rip-line);border-radius:11px;
  overflow:hidden;padding-bottom:7px}
.fp-face__art{aspect-ratio:3/4;display:grid;place-items:center;background:var(--rip-bg-2,#070a10)}
.fp-face__art img{width:100%;height:100%;object-fit:contain;display:block}
.fp-face:not(.is-both) .fp-face__art img{filter:grayscale(1) brightness(.6)}
.fp-face__n{display:block;font-family:var(--rip-data);font-weight:800;font-size:10.5px;
  color:var(--rip-fg-2);padding:7px 8px 0;line-height:1.2;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fp-face__pips{display:flex;gap:4px;padding:5px 8px 0}
.fp-face__pips span{flex:1 1 0;text-align:center;font-family:var(--rip-data);font-weight:800;
  font-size:8px;letter-spacing:.04em;padding:3px 0;border-radius:4px;
  background:rgba(255,255,255,.05);color:var(--rip-fg-3)}
.fp-face__pips span.is-on{background:color-mix(in srgb, var(--rip-up) 22%, transparent);
  color:var(--rip-up)}

/* ── the fold-away ───────────────────────────────────────────────────────── */
.fp-det{margin-top:26px;border:1px solid var(--rip-line);border-radius:12px;
  background:var(--rip-panel-2);overflow:hidden}
.fp-det summary{cursor:pointer;padding:13px 15px;font-family:var(--rip-data);font-weight:700;
  font-size:12.5px;color:var(--rip-fg-2);min-height:44px;display:flex;align-items:center}
.fp-detlist{margin:0;padding:0 16px 14px 30px;font-size:12px;color:var(--rip-fg-3);line-height:1.6}
.fp-detlist li{margin-bottom:6px}
.fp-detlist b{color:var(--rip-fg-2)}

/* ⚠ GEOMETRY ONLY. The pack BACK — surface, border, ensō, brand type — belongs
   to rip/fun-pack.css so every fun pack looks like the same product. This rule
   used to paint its own red gradient, and because a page's <style> loads AFTER
   the linked sheet it silently won: the back rendered red-on-red with the ensō
   invisible. Anything here that is not a dimension is a rule fighting the
   shared back. */
.fp-stage{display:flex;flex-direction:column;align-items:center;gap:14px;min-height:300px;justify-content:center}
/* ⚠ NO SIZE HERE ANY MORE. .fp-pack carries the Pokémon pack's own 300×530 /
   260×462, so every universe is the same object at the same size; a width or
   an aspect-ratio in a page's <style> loads AFTER the sheet and silently wins,
   which is exactly how this page ended up 230×307 against the pack page's
   260×462. The class stays as the page's handle on the element. */

/* ⚠ THE REVEAL PANEL BELONGS ENTIRELY TO rip/fun-pack.css — there is nothing
   for a page to say about it. It used to carry .fp-card/.fp-art/.fp-name/
   .fp-meta here, and because a page's <style> loads AFTER the linked sheet they
   silently won: a centred stack with 78vw of art and a 19px name. */

.fp-cta{display:block;width:min(340px,100%);margin:0 auto;padding:15px 20px;border:0;
  border-radius:12px;background:var(--rip-uni);color:#fff;font-family:var(--rip-display);
  font-weight:800;font-size:17px;cursor:pointer;text-align:center;text-decoration:none}
.fp-cta[disabled]{opacity:.5;cursor:default}
/* ⚠ (0,3,1) — rip-ui.css repaints every anchor at (0,2,1) and would otherwise
   turn a signed-out <a> CTA cyan on its own red fill. */
/* ⚠⚠ NOT SCOPED TO .fp-wrap ANY MORE, AND THAT IS THE WHOLE POINT. rip-ui.css
   repaints every anchor on a .rip-ui page at (0,2,1) — `.rip-ui a:not(.rip-btn)`
   — so this rule exists purely to outrank it. The moment the CTA moved out of
   <main> and into the floating bar it stopped matching `.fp-wrap …`, and the
   signed-out button rendered its label in CYAN on the Marvel red: measured
   rgb(46,230,214) on rgb(212,32,38). `body a.fp-cta` is (0,2,2) and wins
   wherever the button is put. This is the fourth component on this site to be
   caught by that repaint; anything that sets a link colour on a .rip-ui page
   needs to beat (0,2,1) rather than tie it. */
body a.fp-cta:not(.rip-btn){color:#fff;text-decoration:none;display:block;text-align:center}
.fp-note-sm{text-align:center;font-size:12px;color:var(--rip-fg-3);margin:10px auto 0;max-width:420px}
/* ⚠ NO RESERVED HEIGHT. This was a permanent empty 18px row in the middle of
   the first screen, paying for an error that is almost never there. */
.fp-err{text-align:center;color:var(--rip-danger);font-size:13px;margin:10px 0 0}
.fp-err:empty{margin:0}

/* ⚠ THE VIEW SWITCHER. Four real links, sticky under the shared header — it
   parks at var(--rh-h) rather than 0 for the same reason the admin tab strip
   does: the header is position:fixed at z-index 9000 and cannot be outranked,
   so a top:0 strip is simply covered and swallows every tap on it. Read the
   token, never a number — the header changes height at 900px. */
/* --rip-fg-2, not --rip-fg-3: an inactive tab is still a control somebody has
   to read at arm's length in daylight, and fg-3 is the caption grey. */

/* ⚠ AND THE UNDERLINE. rip-ui.css's `.rip-ui a:not(.rip-btn):hover` is (0,3,1),
   so it beat the chip's own text-decoration and — because a TAP leaves :hover
   stuck on Android — the tab you last pressed kept a permanent underline. Both
   states are pinned here, outside the media query, because the query is exactly
   what a touch device does NOT match. */

/* The hover SKIN, on the other hand, belongs inside it: a sticky hover fill on
   a phone reads as a second selected tab. */

/* ⚠⚠ ONE GAP ON THE COLUMN, AND NO BLOCK INSIDE IT SETS A VERTICAL MARGIN.
   Aaron, 2026-09-07: "you've also done it again, and not put the right spacing
   between the OPEN A PACK button and the marvel collection. There's no gap."
   He is right, and it is the third time this exact shape has shipped — the
   trades pick screen got the identical fix and rip/CLAUDE.md already states the
   rule: "a screen made of optional sibling blocks gets a stack with one gap,
   and no block inside it sets a vertical margin." Measured at 390px before
   this, top to bottom: 16 · 10 · 0 · 0 · 16. Per-block margins cannot express
   the space BETWEEN two blocks that do not know about each other — whichever
   one forgets, wins, and here .fp-err:empty and .fp-wall both correctly
   declined to space themselves and the reader got no gap at all.

   ⚠ THE ZEROING IS A RULE, NOT A LIST. `> *` catches a block added next month;
   an enumerated set of class names is the thing that goes stale. A block may
   still carry a margin for a host that is NOT this stack, and be right in both
   places — the same reason .tg-app .tg-stack > * exists. */
.fp-view{display:flex;flex-direction:column;gap:16px;margin-top:20px}
.fp-view > *{margin-top:0;margin-bottom:0}
/* A hidden view must stay hidden: fun-pack.css's .fp-page fade sets display on
   .fp-foldable descendants, [hidden] is only a UA rule at (0,0,1), and the
   display:flex above is a class rule that would otherwise beat it outright. */
.fp-view[hidden]{display:none!important}

/* ── the printed-rarity odds table ───────────────────────────────────────────
   ⚠ IT LIVES HERE, NOT IN THE PAGE'S <style>. A universe page's inline style is
   exactly one declaration (--rip-uni) and fun-pack-rip-check.js asserts that:
   a page rule loads AFTER this sheet and wins silently, which is how three
   surfaces drifted in one day. A second universe that prints rarity gets this
   table by using the markup.

   ⚠ ONLY A PACK THAT PUBLISHES RARITY EVER RENDERS ONE — sim_fun_rarity_odds()
   returns [] for a pack with no weights, so Marvel emits no table at all rather
   than an empty one.

   ⚠⚠ AND IT IS A SECTION OF THE OPEN VIEW, NOT A ROW INSIDE "How this works".
   It shipped inside that <details>, which is collapsed by default, so Aaron
   never saw it: "I don't see the odds table." This repo's rule about hiding
   reference copy behind a toggle is about HELP text — the odds of the thing
   you are deciding whether to open are the decision, not a footnote about it.
   Anything a player weighs BEFORE tapping belongs in the view. */
.fp-odds-sec{
  background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.12);
  border-radius:12px; padding:11px 13px; box-sizing:border-box;
}
/* .fp-h2 is margin:0 (it is used where a parent owns the spacing); the panel
   has to open the gap itself or the heading sits on the caption. */
.fp-odds-sec .fp-h2{margin:0 0 8px}
.fp-odds{
  /* ⚠ NO max-width AND NO MARGIN HERE ANY MORE. Both were sized for a <li>
     inside the details list; in its own panel the padding does the spacing,
     and a 340px cap left most of the panel empty on a desktop column. */
  width:100%; margin:0; border-collapse:collapse;
  font-family:var(--rip-body); font-size:13px;
  /* Digits in a column line up — the mechanism is tabular-nums, never a
     monospaced face (rip/CLAUDE.md: JetBrains is banned site-wide). */
  font-variant-numeric:tabular-nums;
}
.fp-odds caption{
  text-align:left; font-size:11.5px; letter-spacing:.06em; text-transform:uppercase;
  color:var(--rip-fg-3); padding-bottom:6px;
}
.fp-odds th,.fp-odds td{ padding:5px 8px; border-bottom:1px solid var(--rip-line); }
.fp-odds thead th{
  font-size:11px; letter-spacing:.05em; text-transform:uppercase;
  color:var(--rip-fg-3); font-weight:700; border-bottom-color:var(--rip-line-2);
  /* A header sits over its own column: labels are left, figures are right. The
     default centre was invisible in a 340px box and obviously wrong once the
     table took a desktop column's width. */
  text-align:right;
}
.fp-odds thead th:first-child{text-align:left}
.fp-odds tbody th{ text-align:left; font-weight:700; color:var(--rip-fg-2); }
.fp-odds td{ text-align:right; color:var(--rip-fg-2); }
.fp-odds tbody tr:last-child th,.fp-odds tbody tr:last-child td{ border-bottom:0 }
/* ⚠ THE RAREST ROW IS THE **LAST** ONE — the table runs commonest to rarest, the
   way a pack's own odds are conventionally printed. This rule read
   :first-child when it was written, which highlighted Common: the row nobody is
   scanning for, and it looked deliberate. */
.fp-odds tbody tr:last-child th,.fp-odds tbody tr:last-child td{ color:var(--rip-uni,var(--rip-fg)); font-weight:800 }
