:root {
  /* The ticket sprites are drawn already leaning ~10 degrees, so the art itself is never rotated —
     rotating it only cancelled the lean the artist put in. This is the lettering layer's tilt, which has
     to be applied because VIP_Tag is drawn flat. */
  --pc-tilt: 0deg;
  /* Flat fills throughout — the game uses solid colour blocks with hard edges, never gradients. */
  --bg: #2a1d3f;
  --surface: #3a2a56;
  --surface-2: #472f68;
  --line: #1e1330;
  --text: #ececf2;
  --muted: #a2a2b5;
  --accent: #6f5cff;
  --accent-text: #ffffff;
  --danger: #ff6b6b;
  --ok: #4fd18b;
  --radius: 12px;
  /* On the pass card's purple gradient --muted drops to ~3.9:1, so secondary text there needs its own value. */
  --pass-muted: #cfc9f0;
  /* Lifted from the game's shop so the web store reads as the same product. Face, band and plate come
     off the art direction's own mock - the band is a colour in its own right rather than a wash over
     the face, which is why it is a variable and not an rgba() in the band rule. The two edge colours
     were not restated in that mock and are as they were. */
  --pack-face: #ae64ff;
  --pack-band: #8038cf;
  --pack-edge: #571f8c;
  --buy-face: #c5e53c;
  --buy-edge: #4c7d16;
  --buy-text: #1b3407;
  /* The neutral plaque, same construction as the buy one: light face, dark edge, dark text. Cool
     rather than true grey, or it reads as a disabled control against the purple. */
  --grey-face: #c2bed0;
  --grey-edge: #6c6780;
  --grey-text: #2b2536;
  --gold: #ffce4d;
  --outline: #1a0f2b;
  /* A single-pixel ring, not the two-pixel one this started with: at display sizes that read as a
     slab drawn around the letters rather than an outline on them. */
  --text-outline:
    -1px -1px 0 var(--outline), 0 -1px 0 var(--outline), 1px -1px 0 var(--outline),
    -1px 0 0 var(--outline), 1px 0 0 var(--outline),
    -1px 1px 0 var(--outline), 0 1px 0 var(--outline), 1px 1px 0 var(--outline);
  --text-drop: 0 2px 3px rgba(0, 0, 0, 0.5);
}

* { box-sizing: border-box; }

/* One treatment for every piece of text on the page: a thin ring, then a shadow dropped under it.
   text-shadow rather than -webkit-text-stroke, which eats into the glyph, and paint-order is
   unreliable on HTML text. Both halves are tunable here and nowhere else — raise --text-outline's
   offsets for a heavier ring, or --text-drop's blur and alpha for a deeper drop. Order matters:
   text-shadow paints last-to-first, so the drop has to come after the ring or it paints over it. */
.section-title,
.gem-amount,
.pack-word,
.pass-title,
.day-label,
.daily-promo-title,
.wallet-amount,
.section-sub,
.pass-desc,
.pass-copy-line,
.bonus,
.countdown,
.pass-progress,
.pass-ribbon,
.bonus-ribbon,
.gem-tag,
.reward-timer,
.reward-stock,
.reward-locked,
.day-reward,
.wheel-teaser-title,
.wheel-amount,
.fp-text,
.privilege-title,
.privilege-line,
.privilege-label,
.privilege-days,
.privilege-reward-amount,
.account-name {
  text-shadow: var(--text-outline), var(--text-drop);
}

/* Authored copy on its own dark plate, matching the game banner: the terms carry the colour, the
   plate carries the contrast, so the line stays readable wherever it sits on the artwork. */
.pass-copy {
  align-self: flex-start;
  background: rgba(20, 12, 34, 0.5);
  border: 2px solid var(--outline);
  border-radius: 12px;
  padding: 8px 14px;
}

.pass-copy-line {
  margin: 0;
  color: #ffffff;
  font-weight: 800;
  font-size: 16px;
  line-height: 1.35;
}

.pass-accent-a { color: #ff7ae0; }
.pass-accent-b { color: var(--gold); }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
}

/* One number for the fixed header's height, so the space reserved for it and the header itself cannot
   drift apart. The shop's bar is shorter than the landing page's - see the logo sizes further down. */
:root { --header-h: 124px; }
body[data-page="shop"] { --header-h: 72px; }

/* One number for every control inside a header pill. The pills used to take their height from whatever
   they happened to hold - the nav link on the landing page, the wallet on the shop - so the two pages
   drifted apart and a fix on one could not reach the other. Per page, but one number per page: the
   landing header stands over the video at full size, the shop's is a compact strip like its logo. */
:root { --pill-h: 40px; }
body[data-page="shop"] { --pill-h: 28px; }

body { padding-top: var(--header-h); overflow-x: clip; }

/* The landing page leads with the video instead, and wants the header floating over it. */
body[data-page="landing"] { padding-top: 0; }

a { color: var(--accent); }

:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

.wrap {
  max-width: 960px;
  margin: 0 auto;
  padding: 0 20px;
}

/* The shop is a grid of cards, not prose: at 960px it sat visibly narrower than the 1400px header
   bar above it, and four reward cards could not fit on one row. Matches .header-bar exactly. */
body[data-page="shop"] .wrap { max-width: 1400px; }

/* Read by screen readers, never painted. Not `display: none`, which would hide it from them too. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 12px;
  top: 12px;
  z-index: 10;
  padding: 8px 12px;
  background: var(--surface-2);
  border-radius: 8px;
}

/* Header */

/* Floating bar: the header is a fixed transparent gutter, the visible card is .header-bar.
   pointer-events is off so the empty gutter either side of the card does not eat page clicks. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  padding: 14px 16px;
  background: none;
  pointer-events: none;
}

/* The shop's header floats over the page like the landing page's does - no plate behind it, and the
   base rule's `pointer-events: none` still stands so only the pills themselves take clicks. It carries
   less padding than the landing page's because its logo and badges are smaller (see below). */
body[data-page="shop"] .site-header { padding: 8px 16px; }

/* The bar is only as tall as the tallest thing in it, and on the landing page that is a 92px logo
   standing over the video. In a strip that height is all dead plate, so the shop takes the logo and the
   badges down to the pills' own scale. body's reservation below follows these two numbers. */
body[data-page="shop"] .brand-logo { height: 44px; }
body[data-page="shop"] .store-badge img { height: 40px; }
body[data-page="shop"] .store-badge-play img { height: 60px; margin: -10px; }

/* Bare on the landing page: the logo and badges sit on the video, and only .header-actions is a card. */
.header-bar {
  max-width: 1400px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 20px;
}

.brand,
.header-stores,
.header-actions { pointer-events: auto; }

.header-stores {
  display: flex;
  align-items: center;
  gap: 10px;
}

.store-badge {
  display: inline-flex;
  border-radius: 8px;
}

.store-badge img {
  height: 54px;
  width: auto;
  display: block;
}

/* Measured: Google's 646x250 asset holds only 564x168 of badge, i.e. 41px of transparent padding
   on every side, so the artwork is 67.2% of the box. 54 / 0.672 = 80px to match Apple's height,
   and the negative margin crops that padding back out so the row stays 54px and the gaps stay even. */
.store-badge-play img {
  height: 80px;
  margin: -13px;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 700;
  font-size: 18px;
  color: var(--text);
  text-decoration: none;
  flex-shrink: 0;
}

/* Free-standing on the page, so it is sized for legibility rather than to fit inside anything. */
.brand-logo {
  height: 92px;
  width: auto;
  display: block;
}

.site-nav {
  display: flex;
  gap: 8px;
}

.site-nav a {
  text-decoration: none;
  color: var(--muted);
  text-transform: uppercase;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.06em;
  padding: 10px 12px;
  border-radius: 8px;
}

.site-nav a:hover { color: var(--text); background: var(--surface-2); }

/* The framed clusters in the header, anchored right. Sign out is its own pill so it reads as a
   separate control rather than part of the account identity beside it. */
.header-actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  padding: 8px 10px;
  background: rgba(25, 25, 34, 0.92);
  backdrop-filter: blur(10px);
  border: 1px solid var(--line);
  /* Concentric with the 8px buttons inside: outer radius = inner radius + the padding between them. */
  border-radius: 16px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38);
}

/* The header is tight; the page-body .btn padding would blow the bar out to two rows on mobile. */
.btn-compact {
  padding: 9px 18px;
  font-size: 14px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border-radius: 8px;
}

@media (max-width: 1024px) {
  .header-stores { display: none; }
}

@media (max-width: 560px) {
  :root { --header-h: 92px; --pill-h: 36px; }
  body[data-page="shop"] { --header-h: 64px; --pill-h: 26px; }
  body[data-page="landing"] { padding-top: 0; }
  .site-header { padding: 10px; }
  .header-bar { gap: 10px; }
  .brand-logo { height: 62px; }
  .header-actions { padding: 6px 8px; border-radius: 14px; }
  .site-nav a { padding: 10px 8px; font-size: 13px; }
  /* The name is the only identity left in the pill now the avatar is gone, so it cannot be hidden here.
     max-width + ellipsis on .account-name keeps a long nickname from pushing the bar to two rows. */
  .account-name { max-width: 96px; font-size: 14px; }
  .brand-mark { display: none; }
}

.account {
  display: flex;
  align-items: center;
  gap: 10px;
}

.account-name {
  font-weight: 600;
  max-width: 160px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Buttons */

button {
  font: inherit;
  cursor: pointer;
}

.btn {
  border: 0;
  border-radius: var(--radius);
  padding: 12px 20px;
  background: var(--accent);
  color: var(--accent-text);
  font-weight: 700;
}

/* The price plate, matching the game: green face, dark text. It is the bottom edge of the card rather
   than a plate floating inside it — bled back out through the card's padding and given the card's inner
   corner radius (the 18px frame less its 4px border), so the whole bottom strip is the click target.
   Hence the padding variable: the negative margin has to track it exactly.
   No top edge: the plate used to carry a 3px dark-green rule where it meets the artwork, and the two
   colours already part company plainly enough without a line drawn between them. */
.gem-card .buy,
.reward-card .reward-action {
  margin: auto calc(-1 * var(--card-pad)) calc(-1 * var(--card-pad));
  background: var(--buy-face);
  border: 0;
  border-radius: 0 0 14px 14px;
  color: var(--buy-text);
  font-weight: 800;
  font-size: 18px;
  padding: 10px 14px;
}

.reward-card:not(.day-card) .reward-action { padding: 6px 14px; }
.day-card .reward-action { padding: 7px 14px; }

.gem-card .buy:disabled,
.reward-card .reward-action:disabled {
  background: #6a6178;
  border-color: #3b3346;
  color: #ded8e6;
}

.btn:disabled {
  background: var(--surface-2);
  color: var(--muted);
  cursor: not-allowed;
}

/* Same lift-and-press as .daily-promo-card. The bled buy strips are excluded: they are the card's
   bottom edge, so translating one opens a gap between it and the card. Those brighten instead. */
.btn,
.gem-card .buy,
.reward-card .reward-action {
  transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
}

/* Two exclusions. .btn-link is the sign-out text link, which already answers with colour; a text run
   that jumps under the cursor reads as a layout bug. .buy and .reward-action are the cards' bottom
   edge, so lifting one tears it off the card — they carry .btn too, hence naming them here rather
   than in a later override, which would lose on specificity to this selector's :not() chain. */
.btn:hover:not(:disabled):not(.btn-link):not(.buy):not(.reward-action) { transform: translateY(-2px); }
.btn:active:not(:disabled):not(.btn-link):not(.buy):not(.reward-action) { transform: translateY(2px); }

.pass-buy:hover:not(:disabled) { box-shadow: 0 6px 0 var(--buy-edge); }
.pass-buy:active:not(:disabled) { box-shadow: 0 2px 0 var(--buy-edge); }
.pass-toggle:hover:not(:disabled) { box-shadow: 0 6px 0 var(--outline); }
.pass-toggle:active:not(:disabled) { box-shadow: 0 2px 0 var(--outline); }

.gem-card .buy:hover:not(:disabled),
.reward-card .reward-action:hover:not(:disabled) { filter: brightness(1.1); }
.gem-card .buy:active:not(:disabled),
.reward-card .reward-action:active:not(:disabled) { filter: brightness(0.92); }

.btn-secondary {
  background: var(--surface-2);
  color: var(--text);
  border: 1px solid var(--line);
}

/* .btn also styles anchors (the app deep link), which need a block box for the padding to apply. */
a.btn {
  display: inline-block;
  text-decoration: none;
  text-align: center;
}

/* Full width in the sign-in modal: as an inline pill the label wrapped mid-phrase on a phone. */
a.btn-block {
  display: block;
  width: 100%;
  margin: 16px 0;
}

/* Sits beside the account name and signs the player out with no confirmation, so it
   needs a deliberate tap rather than an easy mis-tap. */
.btn-link {
  background: none;
  border: 0;
  color: var(--muted);
  padding: 10px 12px;
  min-height: 44px;
}

.btn-link:hover { color: var(--text); }

/* Already spaced from the account pill by .header-bar's gap, so it carries no auto margin of its own. */
.header-actions-signout { margin-left: 0; }

/* Two-class selectors so these win wherever a later .header-actions media rule sits in the file.
   Sized off .wallet-entry, which is the tallest thing in the pill beside this one - matching its font
   size and keeping the padding to the same few pixels is what makes the two pills one height. It used
   to carry the page-body link's 10px padding and stood a head taller than the wallet. */
.header-actions.header-actions-signout .btn-link {
  display: block;
  min-height: 0;
  font-size: 15px;
  padding: 0 10px;
  border-radius: 8px;
}

@media (max-width: 560px) {
  .header-actions.header-actions-signout .btn-link { font-size: 14px; padding: 0 8px; }
}

/* Every control in a header pill is exactly --pill-h tall, so a pill's height no longer depends on which
   controls the page happens to render. Scoped to .header-actions so it outranks the later .btn block,
   which is what was already quietly beating .btn-compact's own padding and radius. */
.header-actions .site-nav a,
.header-actions .btn-compact,
.header-actions .account,
.header-actions.header-actions-signout .btn-link {
  height: var(--pill-h);
  min-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  display: inline-flex;
  align-items: center;
  border-radius: 8px;
}

.header-actions .site-nav { align-items: center; }

/* Footer language picker. Native names only, so it stays readable in every locale. */
.lang-picker { margin: 12px 0; }

.lang-picker select {
  font: inherit;
  padding: 8px 12px;
  border-radius: 10px;
  border: 2px solid var(--line);
  background: var(--surface-2);
  color: var(--text);
}

/* Sections and panels */

main { padding: 32px 0 64px; }

.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 24px;
  margin: 24px 0;
}

.panel h2 { margin-top: 0; }

.notice {
  border-left: 3px solid var(--accent);
  padding: 12px 16px;
  background: var(--surface);
  border-radius: 0 var(--radius) var(--radius) 0;
  margin: 16px 0;
}

.muted { color: var(--muted); }

.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  word-break: break-all;
}

/* Sign-in */

.code {
  font-size: 44px;
  font-weight: 700;
  letter-spacing: 0.18em;
  margin: 16px 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

.qr {
  background: #ffffff;
  padding: 12px;
  border-radius: var(--radius);
  display: inline-block;
  line-height: 0;
}

.qr svg { width: 200px; height: 200px; display: block; }

.store-badges {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  margin-top: 20px;
}

/* Landing: the badges sit ON the foot of the trailer, with the daily card straight underneath. They have
   to out-rank it - .daily-promo rides up on z-index 2 and .daily-promo-gift is absolute and bursts a
   further 48px above the card, so at z-index auto the badges paint underneath both and vanish. The card
   gives up its own -56px here because it now follows the badges rather than overlapping the video. */
@media (max-width: 1024px) {
  body[data-page="landing"] .store-badges:not([hidden]) {
    position: relative;
    z-index: 3;
    margin: -80px 0 0;
  }

  /* Keyed off the badges actually being on screen, not off the page: signed in they carry [hidden], the
     sibling match fails, and the card keeps the -56px hero overlap it was designed with. */
  body[data-page="landing"] .store-badges:not([hidden]) ~ .daily-promo { margin-top: 28px; }
}

/* 40px is the ceiling that still keeps both badges on one row at 390px - 44px wraps them. Smaller
   than the header's 54px on purpose: this pair only renders where the header's is hidden. */
.store-badges .store-badge img { height: 40px; }
.store-badges .store-badge-play img { height: 60px; margin: -10px; }

/* The header carries these from 1025px up, so the in-page pair would be a second copy. l.html is
   exempt - it has no header of its own, so its badges have to render at every width. */
@media (min-width: 1025px) {
  body[data-page="landing"] .store-badges,
  body[data-page="shop"] .store-badges { display: none; }
}

/* Shop */

/* A rule, not a plaque: the title sits between two gold lines that run out to the section's width.
   The lines are pseudo-elements of the flex row, so they take whatever width the title leaves and the
   pair always ends level however long the heading is or however it wraps. Sized in em so they track the
   font at every breakpoint instead of needing their own values per media query. */
.section-title {
  position: relative;
  margin: 40px auto 16px;
  padding: 0 16px 8px;
  max-width: 560px;
  /* Kept even though the rule below is a flex row: a long title wraps inside the anonymous flex item,
     and this is what centres the wrapped lines. */
  text-align: center;
  font-size: 24px;
  letter-spacing: 0.02em;
  color: #ffe9c6;
}

/* Not a flat bar: a hairline of gold with a darker edge under it, the way the game draws its dividers,
   and faded at the outer end so the rule stops rather than being cut off. */
.section-title::before,
.section-title::after {
  content: '';
  flex: 1 1 0;
  /* A floor, not a size: on the narrowest card the topaz heading carries an icon, a help button and a
     tag, and at 16px the pair of rules pushed that row past its own width. */
  min-width: 6px;
  height: 4px;
  border-radius: 999px;
  background:
    linear-gradient(to bottom, var(--gold) 0 2px, #8b5a13 2px 4px);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
}

.section-title::before { mask-image: linear-gradient(to right, transparent, #000 42%); }
.section-title::after { mask-image: linear-gradient(to left, transparent, #000 42%); }

.section-sub { text-align: center; color: var(--muted); }

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.card h3 { margin: 0; font-size: 20px; }
.card .desc { margin: 0; color: var(--muted); }

.card .bonus { margin: 0; color: var(--ok); font-weight: 600; }

.card .tag {
  align-self: flex-start;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 2px 10px;
}

.card .countdown { margin: 0; color: var(--muted); font-size: 14px; }

.card .buy { margin-top: auto; }

.card-state {
  margin: 0;
  font-size: 14px;
  color: var(--muted);
}

/* The price and its tax rider stack inside the plate, so the disclosure travels with the button rather
   than floating under the card. A blocked card puts its reason here as a bare text node, which the
   column centres just as it did before. */
.buy,
.pass-buy {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  line-height: 1.15;
}

/* Sized in em so it tracks whatever font-size the button it sits in carries — the packs run 18px, the
   pass 22px, and both drop again on a phone, where a bare 0.6em lands at 8.4px. The floor is what keeps
   it legible there. Quiet on purpose: a disclosure, not a second price. */
.tax-note {
  font-size: max(10px, 0.6em);
  font-weight: 700;
  opacity: 0.72;
}

/* Shop sections — each `kind` from the config gets its own component below. */

/* The plaque's own 40px top margin collapses into this, so the bottom margin alone sets the gap. */
/* 88 here plus the plaque's own 40 above it put 128px between one section's goods and the next one's
   name — against 56 on a phone and 68 on a tablet, so desktop was the odd one out. */
.shop-section { margin: 0 0 44px; }
/* Flex so the topaz "?" coin sits beside the text; the items then centre as flex children. */
/* Wraps, with the trailing rule ordered ahead of the tag: on the narrowest card the topaz heading's
   icon, help button and tag do not fit on one line beside both rules, and letting the tag drop below a
   complete ruled title reads far better than a second line that starts with a stray rule. */
.shop-section .section-title { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 12px; }
.shop-section .section-title::after { order: 1; }

/* Out of the row, not merely ordered last. As a flex child it took its width from the line, so the
   heading and its icon were pushed off the section's centre by however wide the tag happened to be —
   the title no longer lined up with the card grid beneath it. Absolute, it hangs off the trailing rule
   and the heading centres on the section as if the tag were not there. */
.shop-section .section-tag {
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  margin-top: 0;
}

/* Narrower than this there is no room beside the heading, and the tag hanging off the end lands on top
   of it. Back into the row, where it wraps onto its own line under a complete ruled title. */
@media (max-width: 384px) {
  .shop-section .section-tag {
    position: static;
    transform: none;
    order: 2;
    /* A full-width basis so it always takes its own line. Merely allowed to wrap it stayed up beside the
       heading wherever it happened to fit, which is the off-centre push again, just narrower. */
    flex: 0 0 100%;
    margin: 4px auto 0;
  }
}
.shop-section .section-sub { margin: -4px 0 16px; color: var(--muted); font-weight: 700; }

/* Sized off the 24px title rather than a fixed box, so the coin tracks the plaque's text. */
.section-icon {
  flex: 0 0 auto;
  width: 1.6em;
  height: 1.6em;
  background: center / contain no-repeat;
}

.section-icon-topaz { background-image: url('/media/topaz.png?v=2'); }
.section-icon-credits { background-image: url('/media/points-icon.png'); }

/* Section: pass. One item across the full width, art bleeding off the right edge. */

/* Castle art behind the whole card, with a scrim that is opaque under the copy and clears by the
   time it reaches the hero. Without it the white title sits on moonlit water and disappears. */
.pass-card {
  position: relative;
  display: grid;
  grid-template-columns: minmax(280px, 42%) 1fr;
  gap: 24px;
  background-color: #241d4d;
  background-image: url('/media/pass-bg.jpg');
  background-size: cover;
  background-position: center;
  border: 4px solid var(--line);
  border-radius: 18px;
  box-shadow: 0 6px 0 var(--line);
  /* No `overflow: hidden`: the card's own background is clipped by its radius anyway, .pass-art sits
     inside the card at every width it is drawn at and is display:none below 721, and the only thing the
     clip still caught was the corner tag, which has to hang past the edge as it does on a pack. */
}

/* Explicit cells for all three row-1 items. The countdown below is explicitly placed, and a single
   placed item switches the rest to flowing around it — the art would otherwise drop to row 2. */
.pass-body {
  grid-column: 1;
  grid-row: 1;
  padding: 44px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}

/* One line always. nowrap on its own would only trade the wrap for an overflow, since the copy column
   is minmax(280px, 42%) and the title needs about 6px of width per px of font size — 276 at 46px,
   against the 196 the column gives at 721. So the size scales with the viewport until it fits and
   holds at the authored 46 once there is room, from about 1100 up. */
.pass-title {
  position: relative;
  margin: 0;
  font-size: min(46px, 4.2vw);
  white-space: nowrap;
  line-height: 1.05;
  color: var(--gold);
  font-weight: 800;
  letter-spacing: 0.01em;
}

/* A second copy of the letters: clipping a gradient to the real text would take over its color, and the
   shared ring above would go with it. Position rather than transform, unlike the other loops here — a
   text-clipped gradient cannot be composited either way, and transforming this pseudo-element would carry
   the glyphs along with the streak and nothing would sweep.
   The streak itself is the grade badge's: the same radial glow, white core over the warm falloff, rather
   than the flat linear band this used to be. Its 28.4 degree tilt is the one thing that cannot come with
   it — that is a transform on the badge, and a background cannot be rotated under a text clip. Tall
   enough (300%) to cross the letters on the diagonal it does have. */
.pass-title::after {
  content: attr(data-shine);
  position: absolute;
  inset: 0;
  color: transparent;
  text-shadow: none;
  pointer-events: none;
  background: radial-gradient(closest-side,
    rgba(255, 255, 255, 0.9),
    rgba(255, 244, 170, 0.5) 45%,
    rgba(255, 228, 0, 0));
  background-size: 26% 300%;
  background-repeat: no-repeat;
  background-position: -30% 50%;
  -webkit-background-clip: text;
  background-clip: text;
  animation: pass-title-shine 2.033s ease-in-out infinite;
}

@keyframes pass-title-shine {
  0% { background-position: -30% 50%; }
  25.4%, 100% { background-position: 130% 50%; }
}

@media (prefers-reduced-motion: reduce) {
  .pass-title::after { animation: none; }
}

.pass-desc { margin: 0; color: #ffffff; font-size: 17px; font-weight: 600; }
.pass-body .bonus { margin: 0; color: #d8ff8f; font-weight: 700; font-size: 18px; }

/* Holds the CTA on the card floor rather than letting it ride up under the copy when the art column
   makes the card taller than the text. */
.pass-footer {
  margin-top: auto;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 16px;
}

.pass-footer > * { margin: 0; }

/* The game states the remaining time on a dark plate rather than as loose text under the button, and
   every "Time Left" on the page is that same plate — the pass card, the wheel, the points pass and the
   daily row. Only the pass card's corner placement differs, and that is the rule below. */
.pass-card .countdown,
.wheel-timer,
.pass-track-timer,
.day-timer {
  color: #ffe9c6;
  font-size: 15px;
  font-weight: 800;
  background: rgba(20, 12, 34, 0.86);
  border: 2px solid var(--outline);
  border-radius: 999px;
  padding: 6px 14px;
}

/* Shares row 1 with the art so it corners against the card floor. The contents block spans both
   columns and so still falls to row 2, underneath it, rather than being overlapped.
   Flush into the corner as a tab, like .pass-ribbon at the other end: only the inner corner is
   rounded, and the two outer borders go, or they double up on the card's own frame. Must follow the
   plate rule above — same specificity, so order is what decides the radius and the borders. */
.pass-card > .countdown {
  grid-row: 1;
  grid-column: 2;
  align-self: end;
  justify-self: end;
  margin: 0;
  border-radius: 12px 0 0 0;
  border-right: 0;
  border-bottom: 0;
}

/* The ribbon is a corner flag, so it sits outside the padded column. Both outer corners are rounded:
   the card clips to var(--radius), which would otherwise bite a notch out of the ribbon's top-left. */
.pass-ribbon {
  position: absolute;
  top: 0;
  left: 0;
  border-top-left-radius: var(--radius);
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  background: var(--danger);
  color: #2a0d0d;
  padding: 6px 16px;
  border-bottom-right-radius: var(--radius);
  border: 2px solid var(--outline);
  border-top: 0;
  border-left: 0;
}

.pass-buy, .pass-toggle { margin: 10px 0 0; padding: 14px 28px; font-size: 18px; }

/* Replaces the CTA once the pass is owned, so it keeps the CTA's slot and weight without the frame. */
.pass-status {
  margin: 10px 0 0;
  color: var(--gold);
  font-size: 18px;
  font-weight: 800;
}

/* The card's primary action, and the only one on its line, so it outweighs the shared sizing above. */
.pass-buy {
  padding: 18px 40px;
  font-size: 22px;
  background: var(--buy-face);
  border: 3px solid var(--buy-edge);
  border-radius: 12px;
  color: var(--buy-text);
  font-weight: 800;
  box-shadow: 0 4px 0 var(--buy-edge);
  white-space: nowrap;
}

.pass-toggle {
  border: 3px solid var(--outline);
  border-radius: 12px;
  box-shadow: 0 4px 0 var(--outline);
  font-weight: 800;
}

/* The Lobby banner's floating reward. Shares row 1 with the art, sitting at the art column's left so
   it reads as part of the scene rather than a badge stuck on the frame.

   Every measurement in this ornament came from the game divided by the 150px cell the Lobby grid gives
   the tile, so they are all expressed as that fraction of --fs rather than as baked pixels. Resizing
   is then a single override, and the parts cannot drift out of proportion with each other. */
.pass-float {
  --fs: 118px;
  grid-row: 1;
  grid-column: 2;
  align-self: center;
  justify-self: start;
  position: relative;
  width: var(--fs);
  height: var(--fs);
  margin-left: 8px;
  pointer-events: none;
  animation: pass-float-bob 5.083s ease-in-out infinite;
}

/* 0 -> 30 -> 0 in Unity, which moves the holder up the screen; here that is a negative translate.
   The peak is at 2.5s of 5.0833s, i.e. 49.2%, not the 50% a symmetric loop would assume. */
@keyframes pass-float-bob {
  0% { transform: translateY(0); }
  49.2% { transform: translateY(calc(var(--fs) * -0.2)); }
  100% { transform: translateY(0); }
}

/* Unity tints the greyscale glow sprite through ColorMapper and draws it additively. A radial gradient
   gives the same soft rarity halo without needing a tinted copy of the texture. */
.pass-float-glow {
  position: absolute;
  inset: -34%;
  background: radial-gradient(closest-side, rgba(213, 114, 253, 0.55), rgba(137, 60, 216, 0.28) 55%, rgba(137, 60, 216, 0) 72%);
}

/* Equipment_BG_Base's stack, drawn in its order: Shadow, BlackBorder, Fill, InnerBorder. Those sprites
   are plain rounded rectangles that Unity nine-slices, so their corner and stroke stay at a fixed pixel
   size whatever the tile measures — a border-radius box reproduces that, where masking the sprites
   would scale the stroke with the tile. The 30px corner and 15px stroke sit at pixelsPerUnitMultiplier
   2 and the black 6px stroke at 1.5, so against the 150px cell they are 0.1, 0.05 and 0.0267. Colours
   are the Epic variant's — Gradient2 key0/key1 for the fill, Purple_2 for the border. */
.pass-float-tile {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: calc(var(--fs) * 0.05) solid #d572fd;
  border-radius: calc(var(--fs) * 0.1);
  background: linear-gradient(180deg, #6e32bc 0%, #893cd8 100%);
  /* Inset stands in for the Texture layer the game lays over the fill at 10% black. */
  box-shadow: inset 0 calc(var(--fs) * -0.085) calc(var(--fs) * 0.119) rgba(0, 0, 0, 0.16),
              0 0 0 calc(var(--fs) * 0.0267) #000,
              0 calc(var(--fs) * 0.042) calc(var(--fs) * 0.034) rgba(0, 0, 0, 0.45);
}

/* The game's own S_Grade sprite, already coloured, so a plain image. Its artwork fills only the middle
   64% of a 512px square, hence a box far larger than the badge it draws. Unity gives it a 68px box
   centred 11.5 right and 20.2 up from the tile's bottom-left corner — so against the 150px cell the box
   is 0.4533 and its edges overhang by 0.15 and 0.092. */
.pass-float-grade {
  position: absolute;
  left: calc(var(--fs) * -0.15);
  bottom: calc(var(--fs) * -0.092);
  width: calc(var(--fs) * 0.4533);
  height: calc(var(--fs) * 0.4533);
}

.pass-float-grade img {
  width: 100%;
  height: 100%;
  display: block;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.45));
}

/* Grade_Mask — the game masks the sweep with S_GradeMask, the badge's own silhouette, so the streak
   only ever shows across the S itself. */
.pass-float-grade-shine {
  position: absolute;
  inset: 0;
  overflow: hidden;
  -webkit-mask: url('/media/grade-mask.png') center / 100% 100% no-repeat;
  mask: url('/media/grade-mask.png') center / 100% 100% no-repeat;
}

/* The streak is Glow.png, a white radial glow stretched to 27.8x129.3, i.e. 0.185 and 0.862 of the
   cell. Widened to 0.22 because the game's tint of (255, 228, 0) at half alpha reads on a phone but
   vanishes here, gold over gold having almost no contrast; a white core over the same warm falloff
   gives the highlight somewhere to show. That colour and this width are the only departures. */
.pass-float-grade-shine::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--fs) * 0.22);
  height: calc(var(--fs) * 0.862);
  margin: calc(var(--fs) * -0.431) 0 0 calc(var(--fs) * -0.11);
  background: radial-gradient(closest-side, rgba(255, 255, 255, 0.9), rgba(255, 244, 170, 0.5) 45%, rgba(255, 228, 0, 0));
  animation: pass-grade-shine 2.033s ease-in-out infinite;
}

/* Shine_Button_Small.anim: anchoredPosition (-36.1, 3.5) -> (46.6, -4.7) over 0.5167s, then parked
   until the clip loops at 2.0333s — so the sweep is the first 25.4% and the rest is the pause. Over the
   150px cell that is -0.241 to 0.311 across and 0.023 to -0.031 down, with y flipped because Unity's +y
   is up, and the 331.6 z-rotation read as 28.4 clockwise. */
@keyframes pass-grade-shine {
  0% { transform: translate(calc(var(--fs) * -0.241), calc(var(--fs) * -0.023)) rotate(28.4deg); }
  25.4%, 100% { transform: translate(calc(var(--fs) * 0.311), calc(var(--fs) * 0.031)) rotate(28.4deg); }
}

.pass-float-icon {
  width: 76%;
  height: 76%;
  object-fit: contain;
  opacity: 0;
  transition: opacity 300ms linear;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.45));
}

.pass-float-icon.is-shown { opacity: 1; }

/* Spans both card columns, under a rule, the way the reference site lays its contents block out. */
.pass-contents-block {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: minmax(180px, 26%) 1fr;
  gap: 24px;
  align-items: start;
  padding: 24px 32px 32px;
  border-top: 1px solid rgba(255, 255, 255, 0.18);
}

.pass-contents-title {
  margin: 0;
  font-size: 22px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Inline with separators rather than bullets: a pass grants many small things, and a tall
   column of one-line items reads as longer than it is. */
.pass-contents {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  color: var(--pass-muted);
  font-size: 16px;
  font-weight: 700;
}

.pass-contents li:not(:last-child)::after {
  content: '•';
  margin: 0 10px;
  opacity: 0.55;
}

/* Decorative: the hero says nothing the copy does not. The source PNG is cropped to its alpha bounds,
   so `bottom` actually stands him on the card edge instead of floating him above it.
   Fixed height, not min-height: `contain` scales to the box, and a stretched grid cell would grow
   the hero every time the contents list opens. `end` keeps him on the card floor as the card grows. */
/* Sized as a share of the box rather than `contain`, so the card can stay tall without the hero
   growing to fill it. */
.pass-art {
  grid-column: 2;
  grid-row: 1;
  background: url('/media/pass-hero.png') right 8% bottom / auto 84% no-repeat;
  height: 400px;
  align-self: end;
}

/* Below a 1028px viewport the art column is narrower than the hero at 84% of the box, so a fixed share
   of the box overflows it — and anchored left that overflow lands on the tile, which sits at this
   column's start. Width comes from the column instead, so he scales with it and always leaves 130px
   at its left for the tile: 118 of tile plus 12 of clearance. Anchored right, so what does not fit
   comes off his sword side, as it does on desktop. */
@media (max-width: 1300px) and (min-width: 721px) {
  /* Keeps the desktop size — sizing him to the column instead shrank him to half the hero he is at
     1246px. Offset from the column's left edge rather than anchored to either side, so the 130px the
     tile needs stays clear and everything that does not fit runs off the right, costing the sword.
     Runs to 1300 rather than 1024 because that is where `right 8%` puts his left edge at 130 anyway:
     below it that rule slides him back under the tile, and at it the two agree, so nothing jumps. */
  .pass-art { background-position: left 130px bottom; }
}

/* Section: currency packs. One uniform grid; featured only changes the badge. */

.gem-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 16px;
}

.gem-card,
.reward-card {
  --card-pad: 14px;
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: var(--card-pad);
  background: var(--pack-face);
  border: 4px solid var(--pack-edge);
  border-radius: 18px;
  box-shadow: 0 6px 0 var(--pack-edge);
}

/* The amount reads as game art, not body copy: heavy, tight, and outlined so it holds on the purple.
   No text-shadow of its own — it is in the outline selector list at the top of this file, and a local
   one would replace that whole treatment rather than add to it. */
.gem-amount {
  margin: 0;
  /* Sized for a name, not for "x600" - the two swapped places. Names run long ("Awakening Crystals"),
     so this is roughly where the art's caption used to sit rather than where the headline did, and it
     wraps rather than truncating: a clipped reward name is worse than a two-line card. */
  font-size: 18px;
  font-weight: 800;
  line-height: 1.1;
  text-align: center;
  color: #ffffff;
  letter-spacing: 0.01em;
}

/* The headline sits on a darker band across the top of the card, as it does in game. Negative margins
   pull it out to the card's edges, and the radius is 18 minus the 4px border so it hugs the corners.
   Day tiles lead with the day label instead, so the band goes there and their amount stays on the face. */
.gem-card > .gem-amount,
.reward-card:not(.day-card) > .gem-amount,
.day-card > .day-label {
  margin: calc(-1 * var(--card-pad)) calc(-1 * var(--card-pad)) 0;
  /* --band-clear is the width the corner tag takes out of this strip, so the headline centres in what
     is left of it rather than running under the tag. Zero unless the card carries one. */
  padding: 8px calc(var(--card-pad) + var(--band-clear, 0px)) 6px var(--card-pad);
  background: var(--pack-band);
  border-radius: 14px 14px 0 0;
}

.gem-card .bonus,
.reward-card .bonus { margin: 0; color: #eafcc4; font-weight: 700; font-size: 13px; }
.gem-card .buy { margin-top: auto; }

/* Five sprites lifted from the game's shop atlas, smallest pack to largest. */
.pack-art {
  position: relative;
  container-type: inline-size;
  min-height: 132px;
  padding-bottom: 18px;
  background: center top / contain no-repeat;
}

/* Placeholder for a card whose reward has no art yet — keeps the card's height without faking a gem pack. */
.pack-art-empty { background: rgba(255, 255, 255, 0.08); border-radius: 10px; }

/* One set per currency: the credits section and the daily row both pay gems, and dressing those in the
   topaz piles pictured the wrong prize. Anything with no set of its own takes .pack-art-empty. */
.pack-art-topaz-1 { background-image: url('/media/topazpack-1.png'); }
/* ?v= on the redrawn ones only: media is served immutable for a year, so a sprite replaced under its
   own name never reaches a browser that already has it. Bump on every redraw. */
.pack-art-topaz-2 { background-image: url('/media/topazpack-2.png?v=2'); }
.pack-art-topaz-3 { background-image: url('/media/topazpack-3.png'); }
.pack-art-topaz-4 { background-image: url('/media/topazpack-4.png'); }
.pack-art-topaz-5 { background-image: url('/media/topazpack-5.png?v=2'); }

.pack-art-gems-1 { background-image: url('/media/gempack-1.png'); }
.pack-art-gems-2 { background-image: url('/media/gempack-2.png'); }
.pack-art-gems-3 { background-image: url('/media/gempack-3.png'); }
.pack-art-gems-4 { background-image: url('/media/gempack-4.png'); }
.pack-art-gems-5 { background-image: url('/media/gempack-5.png'); }

/* Single sprites: these are credit rewards sold at one size, so there is no ladder to rank them on. */
.pack-art-pet-eggs { background-image: url('/media/pack-pet-eggs.png?v=2'); }
.pack-art-wish-tokens { background-image: url('/media/pack-wish-tokens.png?v=2'); }
.pack-art-obsidian-keys { background-image: url('/media/pack-obsidian-keys.png?v=2'); }
.pack-art-legendary-keys { background-image: url('/media/pack-legendary-keys.png?v=2'); }
.pack-art-advancement-scrolls { background-image: url('/media/pack-advancement-scrolls.png'); }
.pack-art-memory-stone { background-image: url('/media/pack-memory-stone.png'); }
.pack-art-awakening-crystals { background-image: url('/media/pack-awakening-crystals.png'); }
.pack-art-credits { background-image: url('/media/points.png'); }
.pack-art-legendary-pack { background-image: url('/media/pack-legendary-pack.png'); }
.pack-art-wild-sticker { background-image: url('/media/pack-wild-sticker.png'); }
.pack-art-epic-pack { background-image: url('/media/pack-epic-pack.png'); }
.pack-art-spirit-bones { background-image: url('/media/pack-spirit-bones.png'); }
.pack-art-rune-keys { background-image: url('/media/pack-rune-keys.png'); }
.pack-art-sentinel-hammers { background-image: url('/media/pack-sentinel-hammers.png'); }
.pack-art-mount-pieces { background-image: url('/media/pack-mount-pieces.png'); }

/* Sits over the bottom of the art, as it does in game. Outlined from the selector list at the top of
   this file, not here — a local text-shadow would replace that treatment rather than add to it. */
.pack-word {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 0 2px;
  text-align: center;
  font-weight: 800;
  /* The count, since it swapped with the name above. Bigger than the caption that used to sit here, and
     scaled to the art it sits on so it holds at any card width. The px value is the fallback for the
     cqi one below it. */
  font-size: 22px;
  font-size: clamp(13px, 17cqi, 26px);
  white-space: nowrap;
  color: #ffffff;
}

/* Every "+N bonus gems" line, wherever it appears — pass card, tier card, wide card, generic card. */
.bonus::before {
  content: '';
  display: inline-block;
  width: 1.15em;
  height: 1.15em;
  margin-right: 6px;
  vertical-align: -0.22em;
  background: url('/media/gem.png') center / contain no-repeat;
}

/* Credits earned sit under the gems line and must not read as a second gem grant. */
.bonus-credits { color: var(--pass-muted); }
.bonus-credits::before { background-image: none; content: ''; width: 1.15em; margin-right: 6px; }

/* The shared size for the points token wherever it sits inline with text. */
.credit-icon,
.bonus-credits::before {
  display: inline-block;
  width: 1.15em;
  height: 1.15em;
  vertical-align: -0.22em;
  background: url('/media/points-icon.png') center / contain no-repeat;
}

/* Bigger than the shared size above: on the price line the coin is what the row is about, and at the
   text's own height it read as punctuation. The baseline offset follows the height so it stays centred
   on the digits. */
.credit-icon { width: 2em; height: 2em; vertical-align: -0.62em; margin-right: 6px; }

/* What the purchase earns on top of the goods, as a tag stuck on the item's top-right corner - the
   same place, and the same read, as the "+200" flash on the reference card.
   The tag IS the game's Title_Ribbon_01_White_Half_Straight sprite, shipped unaltered and coloured
   here. The art is drawn white so it can be tinted, which is how the game uses it: an feComponentTransfer
   scaling each channel is a multiply by the tint, so the black outline stays black, the white body
   becomes the tint, and the lighter fold and the glint keep their step above it. Change the three slopes
   to change the colour - nothing has to be re-exported.
   Scaled whole, NOT nine-sliced. Slicing kept the point and the tail undistorted, but every boundary
   between two slices is a seam, and at a fractional device scale - a 125% browser zoom, a HiDPI screen -
   the two sides of that seam land either side of a device pixel and the card behind shows through it as
   a bluish vertical line. Measured: 0 seam pixels scaling the sprite whole at 1x/1.1x/1.25x/1.5x/1.75x/2x,
   against 425 sliced. The cost is that the point and the tail stretch with the width, and at the range of
   widths the figures actually produce that is not visible; a line through the middle of the tag was.
   It goes on ::before rather than on the tag itself because the tint is a filter, and on the tag it
   would take the text with it. */
.bonus-ribbon {
  /* One knob for the whole tag: borders ARE the slices, so they and the text scale together or the
     corners skew. The breakpoints set this and nothing else. */
  --tag: 1;
  position: absolute;
  top: 0;
  right: 0;
  z-index: 3;
  display: flex;
  align-items: flex-end;
  gap: calc(8px * var(--tag));
  /* The sprite's own furniture, reserved so nothing lands on it. ::before is absolute, so it adds
     nothing to the box and cannot reserve its own room.
     The point is on the LEFT here, not the right the border widths put it - that layer is mirrored, see
     the flip there. The tail lands bottom-right, but only the drop below is reserved for it: it hangs
     off the body's underside, so above it the face is solid and the numbers can run right out over that
     column. Reserving its full width instead left a third of the tag empty red. */
  padding:
    calc(6px * var(--tag)) calc(9px * var(--tag))
    calc(22px * var(--tag)) calc(19px * var(--tag));
  color: #ffffff;
  font-weight: 800;
  font-size: calc(14px * var(--tag));
  line-height: 1.1;
  white-space: nowrap;
  pointer-events: none;
}

/* Sized to the sprite's own slices at 0.6 scale, which is what lands its 5px outline near 3 - a hair
   under the card's 4px edge, so the tag reads as stuck on the card rather than framed like a second
   one. The left border is the widest because the tail is 58 of the sprite's 116 columns; it is the one
   value trimmed off proportion, because every pixel of it is width the numbers cannot use. */
.bonus-ribbon::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  /* Sliced left and right so the point and the tail keep their shape at any width - but WITHOUT `fill`,
     so the strip between them is not sampled from the sprite. Sampling it was the bug: at a fractional
     device scale (a 125% browser zoom, a HiDPI screen) the boundary between the strip and a cap lands
     astride a device pixel and the card shows through as a bluish hairline. Measured 130 such pixels at
     1.25x, 0 with this.
     The strip is drawn as a gradient instead. That is exact rather than an approximation: every column
     of the sprite between the slices is the same, so its one cross-section - outline, glint, face,
     glint, outline - is all the strip ever was, and a gradient has no seam to land astride anything.
     Generated from column 76 of the sprite; re-generate it if the art changes. */
  background-image: linear-gradient(to bottom, rgb(0 0 0 / 0.000) 0.000% 5.172%, rgb(13 3 2) 5.172% 7.759%, rgb(14 3 3) 7.759% 8.621%, rgb(232 51 43) 8.621% 9.483%, rgb(225 49 42) 9.483% 11.207%, rgb(227 50 42) 11.207% 12.069%, rgb(199 44 37) 12.069% 12.931%, rgb(197 43 37) 12.931% 13.793%, rgb(197 43 37) 13.793% 70.690%, rgb(207 45 39) 70.690% 71.552%, rgb(231 51 43) 71.552% 72.414%, rgb(232 51 43) 72.414% 74.138%, rgb(225 49 42) 74.138% 75.000%, rgb(0 0 0) 75.000% 75.862%, rgb(13 3 2) 75.862% 78.448%, rgb(0 0 0 / 0.000) 78.448% 100.000%);
  background-repeat: no-repeat;
  /* Run 3px in under each cap rather than stopping at the content box: an edge exactly on the
     cap boundary is the seam. The gradient carries the sprite's own alpha, so where a cap is
     transparent - above and below the body - this is transparent too and nothing leaks out. */
  background-origin: border-box;
  background-size: calc(100% - (25.3px + 48px) * var(--tag) + 6px) 100%;
  background-position: calc(48px * var(--tag) - 3px) 0;
  border-style: solid;
  border-width:
    0 calc(25.3px * var(--tag))
    0 calc(48px * var(--tag));
  border-image: url('/media/bonus-tag-red.png') 0 38 0 72 stretch;
  /* Mirrored: the sprite points right and the tag is pinned to a right-hand corner, so unflipped the
     point aims off the card and the tail hangs where the card is not. The flip is on this layer alone,
     which is why the text above it is not mirrored with it. */
  transform: scaleX(-1);
  /* The red is baked into bonus-tag-red.png and the gradient stops above. It used to be an SVG
     feComponentTransfer applied here, but iOS Safari does not resolve a filter reference from a data:
     URI, so on iOS the tint was dropped and the tag rendered white. */
}

/* A sweep of light across the tag, the same treatment the VIP card carries. Cut to the sprite's own
   silhouette rather than to the tag's box: the box is a rectangle and the tag is not, so unmasked the
   light showed in the empty corners around the point and the tail.
   The mask is the sprite sliced exactly as ::before slices it, and mirrored with it, so the two line up
   at any width. Scaling it whole instead let the light spill past the tag's outline, because an
   unsliced silhouette is not the sliced one. Behind @supports because mask-border is the one part of
   this with a real gap - Chrome and Safari have it, Firefox does not, and there the tag simply goes
   without a shine rather than wearing a rectangle of light. */
@supports (-webkit-mask-box-image: url('/media/bonus-tag.png')) or (mask-border: url('/media/bonus-tag.png')) {
  .bonus-ribbon::after {
    content: '';
    position: absolute;
    inset: 0;
    /* Over the figures as well as the sprite - it is light falling on the whole tag, not a layer inside
       it. Weak enough at its peak to leave them readable. */
    z-index: 2;
    pointer-events: none;
    transform: scaleX(-1);
    /* Straight across: a vertical band of light travelling left to right. `90deg` points right, so the
       stops run along the width and the band itself stays upright. */
    background: linear-gradient(90deg,
      rgba(255, 255, 255, 0) 42%,
      rgba(255, 255, 255, 0.42) 50%,
      rgba(255, 255, 255, 0) 58%);
    background-size: 220% 100%;
    background-repeat: no-repeat;
    /* Start off the near edge. The layer is mirrored to match the mask, so this end is the left one
       on screen and the sweep reads left to right. */
    background-position: 140% 0;
    -webkit-mask-box-image: url('/media/bonus-tag.png') 0 38 0 72 fill stretch;
    mask-border: url('/media/bonus-tag.png') 0 38 0 72 fill stretch;
    -webkit-mask-box-image-width:
      0 calc(25.3px * var(--tag))
      0 calc(48px * var(--tag));
    mask-border-width:
      0 calc(25.3px * var(--tag))
      0 calc(48px * var(--tag));
    /* The border widths the mask's slices are drawn at. Without these it defaults to `auto`, which is
       the slices' own pixel sizes in the file - 8/28/38/58 against a tag whose sprite is drawn at
       5/17/24/34 - so the mask came out larger than the shape it was masking and cut the point off one
       end while spilling past the outline at the other. Same numbers as ::before's border-width, which
       is what makes the two line up. Set after the shorthand: the shorthand resets this. */
    animation: bonus-tag-shine 7s linear infinite;
  }
}

/* Most of the cycle is the wait: the tag holds still for around five and a half seconds, then the light
   takes the last one and a half to cross it. The hold is written as a percentage, so it tracks the
   duration - 78 of 7s is the same 1.5s crossing as 83 of 9s was, which is what keeps the sweep the same
   speed while it comes round more often. Linear rather than eased - easing put most of the travel in
   the middle, which made the crossing flick past however long the cycle was. */
@keyframes bonus-tag-shine {
  0%, 78% { background-position: 140% 0; }
  100% { background-position: -80% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .bonus-ribbon::after { animation: none; }
}

/* Above the sprite layer, which is a ::before rather than a background of the tag itself. The icon
   leads and the figure sits under it rather than beside it: side by side, the icon can only be as tall
   as one line of text, and the icon is what says which currency this is. */
.bonus-ribbon-entry {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* Pulled up ONTO the icon, as the game labels a stack: the digits overlap its foot rather than sitting
   in a line under it, so the entry is only as tall as the icon plus what hangs below it. The outline
   every figure on this page carries is what holds them legible over the art. */
.bonus-ribbon-amount {
  /* Lifted over the icon it overlaps. Being the later sibling is not enough: the icon carries a
     drop-shadow, and a filter makes an element a stacking context, which paints above plain in-flow
     text beside it however the source is ordered. */
  position: relative;
  z-index: 1;
  margin-top: calc(-14px * var(--tag));
  font-size: calc(19px * var(--tag));
}

/* Bigger than the text, as the reference draws it: the icon is what says which currency, and the
   number is only how much. */
.bonus-ribbon-icon {
  flex: none;
  width: calc(42px * var(--tag));
  height: calc(42px * var(--tag));
  /* Bled sideways so the entry is only ever as wide as its figure. The icon is wider than a short one
     like "+30", and left to contribute its full width it grew the tag instead of just the icon. */
  margin-inline: calc(-5px * var(--tag));
  background: center / contain no-repeat;
  filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.35));
}

.bonus-ribbon-gems { background-image: url('/media/gem.png'); }

/* Nudged down to sit level with the gem beside it. The two sprites carry different margins inside their
   own files, so boxes of equal size do not put equal ink on the line. A transform, not a margin or an
   offset: a margin would retitle the entry's height, and position would put the icon back above the
   figure that overlaps it. */
.bonus-ribbon-credits {
  background-image: url('/media/points-icon.png');
  transform: translateY(calc(3px * var(--tag)));
}

/* On the corner, about half on the card and half off it, which is how the reference wears it. Anchored
   by its bottom rather than its top so the offset holds whether the tag carries one gain or two.
   What it covers of the card is the right end of the amount band, and the band's text is pulled clear
   of it - see --band-clear below. The room above comes from the grids - see .gem-grid further down. */
.gem-card .bonus-ribbon,
.reward-card .bonus-ribbon,
.pass-card .bonus-ribbon,
.privilege-card .bonus-ribbon {
  --tag: 0.88;
  top: auto;
  bottom: calc(100% - 56px * var(--tag));
  right: calc(-32px * var(--tag));
}

/* What the tag costs the amount band: the width it takes out of the strip, so the headline centres in
   what is left rather than running under the tag, and a step down in size to pay for it. The step is
   what buys the tag room to be legible - the card is 213px wide, and a headline at full size beside a
   tag carrying two currencies leaves neither of them enough. The width is measured off the tag's own
   footprint: what it spans, less the point, which is a triangle with nothing behind it, less the part
   that hangs off the card's edge. */
.gem-card:has(.bonus-ribbon),
.reward-card:has(.bonus-ribbon) { --band-clear: 90px; }

.gem-card:has(.bonus-ribbon) > .gem-amount,
.reward-card:has(.bonus-ribbon) > .gem-amount { font-size: 16px; }

/* Only when something in the grid actually carries a tag: this space is what the tags hang in, and
   reserving it on a grid whose cards earn nothing is dead space. The row gap covers every row but the
   first; the padding is the first row's share. Padding rather than margin: the block above ends in a
   margin of its own, and two margins collapse to the larger instead of adding up. */
.gem-grid:has(.bonus-ribbon),
.reward-grid:has(.bonus-ribbon) { padding-top: 8px; row-gap: 28px; }

.fp-band {
  position: relative;
  container-type: inline-size;
  margin: 0 calc(-1 * var(--card-pad));
  height: 46px;
  background:
    repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.24) 0 8px, rgba(255, 255, 255, 0) 8px 20px),
    linear-gradient(180deg, #ffc63f, #ef8a17);
  border-top: 2px solid rgba(90, 38, 0, 0.35);
  border-bottom: 2px solid rgba(90, 38, 0, 0.35);
  pointer-events: none;
  user-select: none;
}

/* Right inset reserves the artwork's column in px, not em: an em reserve grows with the type, which
   shrinks the space fitPackWords measures against and drives the fit into a runaway. */
.fp-text {
  position: absolute;
  left: 0;
  right: 58px;
  top: 50%;
  transform: translateY(-50%);
  padding: 0 6px;
  text-align: center;
  z-index: 1;
  font-weight: 900;
  font-size: clamp(10px, 11.5cqi, 19px);
  line-height: 1.05;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  animation: fp-swap 4.2s linear infinite;
}

/* Deliberately taller than the band and unclipped, so it breaks out over the artwork above. Explicit
   z-index on both, or the pseudo-element paints over the text it is supposed to sit behind. */
.fp-band::after {
  content: '';
  position: absolute;
  right: -6px;
  bottom: -8px;
  height: 215%;
  aspect-ratio: 1;
  z-index: 0;
  background: url('/media/topazpack-1.png') center / contain no-repeat;
  pointer-events: none;
}

.gem-card .fp-band { margin-top: auto; }
.gem-card .fp-band + .buy { margin-top: 0; }

.fp-extra { animation-delay: -2.1s; }

/* One arc handed between the two states. Both cross-dissolve over the same descent, at the same
   position and time, so the swap reads as a single line rising and falling rather than two elements
   trading places. The -50% is the base rule's centring, which the keyframes must carry; the jump back
   to the top happens while hidden, and 100% lands on 0% so the loop is seamless. */
@keyframes fp-swap {
  0% { opacity: 1; transform: translateY(calc(-50% - 3px)); animation-timing-function: ease-out; }
  2% { opacity: 1; transform: translateY(-50%); }
  45.5% { opacity: 1; transform: translateY(-50%); animation-timing-function: ease-out; }
  47.5% { opacity: 1; transform: translateY(calc(-50% - 6px)); }
  50% { opacity: 0; transform: translateY(calc(-50% - 3px)); }
  50.01%, 97.5% { opacity: 0; transform: translateY(calc(-50% - 6px)); }
  100% { opacity: 1; transform: translateY(calc(-50% - 3px)); }
}

@media (prefers-reduced-motion: reduce) {
  .fp-text { animation: none; }
  .fp-label { opacity: 0; }
  .fp-extra { opacity: 1; }
}

/* Balances sit left of the avatar, so the header reads: what you have, then who you are. */
.wallet {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-right: 4px;
}

.wallet-entry {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-weight: 700;
  font-size: 15px;
}

.wallet-icon {
  width: 20px;
  height: 20px;
  background: center / contain no-repeat;
}

.wallet-topaz { background-image: url('/media/topaz.png?v=2'); }
.wallet-credits { background-image: url('/media/points-icon.png'); }

/* Reward icons flying into the counter, mirroring the game's FlyingParticlesEffect. Above the header so
   they are still visible as they land on it, below #result (1100) so a modal is never flown over. */
.fly-layer {
  position: fixed;
  inset: 0;
  z-index: 1050;
  overflow: hidden;
  pointer-events: none;
}

/* After .wallet-icon on purpose: same specificity, so this size wins. In flight the icon is its own
   thing rather than a 20px toolbar glyph, and it shrinks into the counter on arrival. */
.fly-icon {
  position: absolute;
  width: 34px;
  height: 34px;
  will-change: transform, opacity;
}

/* The wallet shrinks rather than disappears: what you hold is what decides whether you can buy, so it has
   to survive on the screens most people shop from. The nickname gives up the room instead. */
@media (max-width: 640px) {
  .wallet { gap: 8px; padding-right: 2px; }
  .wallet-entry { gap: 4px; font-size: 13px; }
  .wallet-icon { width: 17px; height: 17px; }
  .account-name { max-width: 84px; }
}

@media (max-width: 440px) {
  .account-name { display: none; }
}

@media (max-width: 400px) {
  .wallet { gap: 6px; }
  .wallet-entry { font-size: 12px; }
  .wallet-icon { width: 15px; height: 15px; }
}

/* Sits on the top edge like a folder tab, which is why it is pulled out of the padding box. It reads as a
   badge floating above the card, so it must not carry the card's click through to a purchase.
   White on gold, not dark on white: the outline stack at the top of this file already applies here, and
   an outline behind dark text on a pale plate only muddied the letters. */
.gem-tag {
  pointer-events: none;
  position: absolute;
  top: -12px;
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: 11px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  background: var(--gold);
  color: #ffffff;
  border: 2px solid var(--outline);
  border-radius: 999px;
  padding: 3px 12px;
}

/* Sections: credit rewards and free daily rewards. Same card, different action. */

/* Flex, not a track grid: three credit rewards on a 1400px shell stretched to ~440px each under
   `1fr`, and auto-fill's empty tracks still hold the row off-centre. Wrapping flex items keep
   pack-card width and centre the short row under the section plaque. */
.reward-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 16px;
}

.reward-grid > * { flex: 0 1 260px; }

.shop-section-daily .section-title { margin-bottom: 18px; }

/* Seven tiles never fit a narrow screen, and wrapping them put the day in play wherever the wrap
   happened to land. One row that scrolls instead, opened on that day by renderShop's centreActiveDay.
   Not behind a breakpoint: a landscape phone and a small tablet are both too narrow for seven tiles at
   a readable size and too wide to match a phone media query, and both used to wrap.
   `width: fit-content` + `margin: 0 auto` is what lets one rule do both jobs — the row centres under
   the section plaque while it fits, and `max-width: 100%` turns it into a left-aligned scroller the
   moment it does not. `justify-content: center` cannot do that: with content overflowing both ends the
   first tile becomes unreachable. */
.reward-grid.reward-grid-daily {
  flex-wrap: nowrap;
  justify-content: flex-start;
  /* Shrink-wrapped and auto-centred, which is what lets one rule do both jobs: the row centres under
     the section plaque while it fits, and max-width turns it into a left-aligned scroller the moment it
     does not. `justify-content: center` cannot do that — with content overflowing both ends the first
     tile becomes unreachable. */
  width: fit-content;
  max-width: 100%;
  gap: 10px;
  overflow-x: auto;
  /* Explicit, because `overflow-x: auto` alone computes the other axis to `auto` as well — and the
     button that hangs below the tiles was enough to make the row scroll vertically by a few pixels. */
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  /* Room for .is-next's glow, which reaches 32px past the tile and would otherwise be sheared off by
     the scrollport. The top is given straight back as negative margin, so the row sits exactly where it
     did and only the scrollport grew. */
  padding: 24px 24px 46px;
  margin: -24px auto 0;
  /* Hidden, because the row is dragged rather than scrolled — see makeDragScrollable. A drag would
     otherwise select the labels it passes over on the way. */
  scrollbar-width: none;
  user-select: none;
  cursor: grab;
}

.reward-grid-daily:active { cursor: grabbing; }
.reward-grid-daily::-webkit-scrollbar { display: none; }

/* No scroll-snap. With seven tiles and proximity snapping, a short drag lands inside the current tile's
   snap zone and springs back to where it started, which reads as a row that will not scroll at all. */
/* Neither grow nor shrink: one tile size at every resolution. A tile that stretches on desktop and
   shrinks on a phone is the same tile drawn at three sizes, and the shrinking end of that is what made
   it unreadable. Anything that does not fit goes into the scroll instead.
   Both classes in the selector on purpose. `*` carries no specificity, so `.reward-grid > *` and
   `.reward-grid-daily > *` weigh the same and the later one simply wins — which handed the phone
   breakpoint's `flex-basis: calc(50% - 6px)`, meant for the two-up credit cards, to these tiles as
   well. That is what made the daily row size itself off the viewport. */
.reward-grid.reward-grid-daily > * { flex: 0 0 146px; }

.reward-card.is-locked { opacity: 0.62; }

/* The countdown belongs to one day, so the tile and its timer share a slot. The timer row is reserved on
   every slot, not only the one that has a timer, or the tiles would end up different heights. */
/* The gap clears the next-up tile, not the tile box: that one is scaled 1.05 and carries a 6px hard edge
   under it, so at the old 6px the card's own shadow sat on top of the countdown. */
/* No reserved row at all. The countdown belongs to one day, but a row held open on that day's slot makes
   its tile shorter than the other six, and a row held open on ALL seven — which is what it used to do to
   keep them equal — costs the whole strip 56px of empty gold for one pill. The pill is taken out of the
   layout instead and hung under its own tile, so every tile is the same height and the only space paid
   for is the grid's bottom padding, and only when a pill is actually there. */
.day-slot {
  position: relative;
  display: grid;
  grid-template-rows: 1fr;
}

/* Clear of the tile below it: the highlighted tile is scaled 1.05 and carries a 6px hard edge, and at a
   smaller offset its shadow sat on top of the countdown. */
.day-slot .day-timer {
  position: absolute;
  top: calc(100% + 14px);
  left: 50%;
  transform: translateX(-50%);
  /* Smaller than the plates on the cards: this one hangs in open space under the strip, where the whole
     band exists only to hold it, so every pixel it is not costs the section two. */
  font-size: 13px;
  padding: 4px 12px;
}

/* The band under the strip, held open only when a pill is hanging in it. */
/* Deeper than it was: the collect button now floats below the tile, and the countdown has to clear it
   rather than share the strip with it. */
/* The countdown only exists once the day is claimed, and a claimed day has no button — so the two never
   share a tile, and this only has to clear whichever one is showing. */
.reward-grid-daily.has-day-timer { padding-bottom: 52px; }

.day-timer {
  margin: 0;
  align-self: end;
  justify-self: center;
  width: fit-content;
  text-align: center;
  white-space: nowrap;
}

.pass-progress {
  margin: 10px 0 0;
  font-size: 13px;
  font-weight: 700;
  color: var(--gold);
  text-align: center;
}

/* Shorter than a shop card, and deliberately so: seven of these sit in a row a player scrolls past, not
   a single offer they study. The art keeps most of the height; the rest comes off the gaps and the
   padding under the sprite, which was sized for the taller card. */
.day-card { --card-pad: 10px; text-align: center; gap: 5px; }
.day-card .pack-art { min-height: 64px; padding-bottom: 18px; }
.day-card .gem-amount { font-size: 19px; }

/* The prize's name is sized off the art's own width, and shrinking the tile took it down with it — at
   8cqi of a 124px tile it was under 10px. Raised for these tiles only; a name too long for the box still
   gets stepped down by fitPackWords, so this is a ceiling rather than a promise. */
.day-card .pack-word { font-size: clamp(11px, 13cqi, 16px); }

/* A plaque straddling the tile's bottom edge, rather than the bled strip the other cards use — that
   strip IS the card's bottom edge, which is what made it read as a section of the tile instead of a
   button. Half of it hangs below the card, so it costs the tile half its height instead of all of it,
   and the grid's bottom padding carries the overhang. `auto` on top keeps it pinned to the bottom. */
.day-card .reward-action {
  /* Fully out of the tile, hanging just under it. Straddling the edge still made the tile as tall as the
     button, and a claimed day — which has no button — was left with 45px of empty gold where one would
     have been. Outside, every tile is exactly as tall as the prize it shows, and only the days that can
     be collected carry anything below them. */
  position: absolute;
  left: 50%;
  /* Just onto the tile's edge — enough that it reads as belonging to that day rather than floating loose
     beneath the row, but not so far that it covers the prize's name, which sits at the foot of the art
     with only the card's padding under it. */
  top: calc(100% - 8px);
  transform: translateX(-50%);
  margin: 0;
  width: max-content;
  min-width: 78%;
  padding: 8px 16px;
  border: 3px solid var(--outline);
  border-radius: 12px;
  box-shadow: 0 4px 0 var(--buy-edge);
  font-size: 16px;
  white-space: nowrap;
}

.day-card .reward-action:disabled { box-shadow: 0 4px 0 #3b3346; }
.day-card .reward-action:hover:not(:disabled) { box-shadow: 0 6px 0 var(--buy-edge); }
.day-card .reward-action:active:not(:disabled) { transform: translateX(-50%) translateY(3px); box-shadow: 0 1px 0 var(--buy-edge); }

/* Collected: the whole tile goes dull — art, amount and label included — and takes a tick. The dimming
   is on the card's contents rather than the card itself, so the tick painted over them stays bright. */
.day-card.is-claimed {
  background: #4b3363;
  border-color: #2e1c40;
  box-shadow: 0 6px 0 #2e1c40;
}

.day-card.is-claimed > * { filter: brightness(0.6) saturate(0.4); }

.day-card.is-claimed::after {
  content: '';
  position: absolute;
  top: 6px;
  right: 6px;
  width: 28px;
  height: 28px;
  background: center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.4 5.4 9.5 16.3 4.1 10.9' fill='none' stroke='%231a0f2b' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M20.4 5.4 9.5 16.3 4.1 10.9' fill='none' stroke='%234fd18b' stroke-width='3.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  pointer-events: none;
}

/* The day in play, claimable now or waiting on the clock. Declared after .is-locked so it wins the dim
   back: an upcoming next day carries both classes. */
.day-card.is-next {
  opacity: 1;
  /* A glow behind the tile rather than a gold rim: the card keeps its own edge, so the highlight reads
     as light spilling out instead of a second border competing with the outline. */
  box-shadow: 0 6px 0 var(--pack-edge), 0 0 24px 8px rgba(255, 206, 77, 0.5);
  transform: scale(1.05);
  z-index: 1;
}

/* No text-shadow here or in .day-reward — both are in the outline selector lists at the top of this
   file, and a later same-specificity declaration silently cancels the outline (see .gem-amount). */
.day-label { margin: 0; font-size: 15px; font-weight: 800; }
.day-reward { margin: 0; font-size: 13px; color: var(--muted); }

.reward-meta {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: 12px;
  color: var(--muted);
}

/* The whole tile is the click target, so it has to say so — and it answers as one plate, the way
   .daily-promo-card does. The inner strip only brightens, so the two never fight. */
.is-pressable {
  cursor: pointer;
  transition: transform 120ms ease, box-shadow 120ms ease;
}

/* The shadow keeps its size so the plate and its base travel as one. Growing it instead pins the
   bottom edge in place, which reads as half the card staying behind. */
.is-pressable:hover { transform: translateY(-3px); }
.is-pressable:active { transform: translateY(3px); }

.reward-action {
  align-self: stretch;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.reward-cost { font-size: 17px; font-weight: 700; }
.reward-locked { margin: 0; font-size: 13px; color: var(--muted); }

@media (max-width: 720px) {
  /* Two cards to a row leaves ~140px of text column, so the bonus lines need to come down a step. */
  .gem-card .bonus,
  .reward-card .bonus { font-size: 12px; line-height: 1.35; }
  .gem-amount { font-size: 15px; }
  .pack-art { min-height: 62px; }
  .bonus-ribbon { --tag: 0.74; }

  .gem-card .bonus-ribbon,
  .reward-card .bonus-ribbon,
  .pass-card .bonus-ribbon,
  .privilege-card .bonus-ribbon { --tag: 0.62; }

  .gem-card:has(.bonus-ribbon),
  .reward-card:has(.bonus-ribbon) { --band-clear: 58px; }

  .gem-card:has(.bonus-ribbon) > .gem-amount,
  .reward-card:has(.bonus-ribbon) > .gem-amount { font-size: 14px; }

  .gem-grid:has(.bonus-ribbon),
  .reward-grid:has(.bonus-ribbon) { padding-top: 6px; row-gap: 24px; }
  .fp-band { height: 34px; }
  .fp-text { right: 44px; }
  .section-title {
    font-size: 18px;
    padding: 0 12px 6px;
    margin: 20px auto 10px;
  }

  .shop-section .section-sub { margin: -2px 0 12px; font-size: 13px; }

  /* Same shape as the desktop — copy left, hero anchored bottom-right — but the hero moves into the
     card's own background rather than keeping a column. An art column this narrow shrinks him to a
     thumbnail; as a background layer he keeps his size and the copy simply stops short of him. */
  /* Sized by width, not height: the card's height is set by the copy, so a height-based hero grows
     precisely where there is least room — a narrower phone wraps more text, which would scale him up
     until he covered the card. The cap holds him steady once the card is wide enough, and the +95px
     runs that much of him off the right edge, as on the tablet. Centred vertically rather than stood
     on the card floor, which is where he collided with the CTAs. */
  .pass-card {
    grid-template-columns: 1fr;
    gap: 0;
    background-image: url('/media/pass-hero.png'), url('/media/pass-bg.jpg');
    background-size: min(80%, 272px) auto, cover;
    background-position: calc(100% + 95px) center, center;
    background-repeat: no-repeat;
  }

  .pass-art { display: none; }

  /* The card is one column here, so the desktop's grid-column: 2 conjures an implicit second track:
     126px of phantom width that squeezes the copy and, at phone widths, pushes the tile past the
     card's right edge. Out of flow instead, over the hero as on desktop — grid placement reset to auto
     so the containing block is the card rather than that phantom cell. Centred with top rather than a
     translate, because the bob animation owns transform. */
  /* The right edge of this card is part spoken for: the countdown pill owns the bottom from 163, and
     the size below was set against a 95px band that ran from 68 down to it. The band is taller now
     that the bonus flag has moved into the copy column, but the tile is kept at that size rather than
     grown into the space, so it stays clear of the title. Centred on what is drawn rather than on its
     box, the grade badge overhanging the bottom by 0.092 and the left by 0.15. */
  .pass-float {
    --fs: 76px;
    position: absolute;
    grid-column: auto;
    grid-row: auto;
    top: calc(50% - var(--fs) * 0.45);
    right: 3%;
    margin: 0;
  }

  /* Spans the single column, and the max-width is what keeps the copy clear of the hero. */
  .pass-body {
    grid-column: 1;
    grid-row: 1;
    max-width: 56%;
    padding: 16px;
    gap: 9px;
  }

  .pass-card > .countdown { grid-column: 1; grid-row: 1; white-space: nowrap; font-size: 12px; padding: 5px 10px; }

  /* The plate shrinks with the pass card's, so the four timers stay the same size as each other here too. */
  .wheel-timer,
  .pass-track-timer,
  .day-timer { font-size: 12px; padding: 5px 10px; }

  /* Same rule at the one-column sizing: the body is capped at 56% of the card here, so the title runs
     out of room again on a narrow phone and has to scale below the authored 28. */
  .pass-title { font-size: min(28px, 5.7vw); }
  .pass-desc { font-size: 15px; }
  .pass-body .bonus { font-size: 15px; }

  /* Exactly two lines: nowrap stops a long line breaking into a third. width:max-content is what makes
     the plate size to the un-wrapped text — without it the box stays clamped to the body's max-width
     and the background ends mid-sentence while the text runs on past it. */
  .pass-copy {
    width: max-content;
    max-width: none;
    padding: 7px 10px;
  }

  .pass-copy-line { font-size: 12px; white-space: nowrap; }

  /* Width by content, not full-bleed: a 100% button in a 60% column runs under the hero. */
  .pass-footer { align-items: flex-start; }

  .pass-buy, .pass-toggle {
    width: auto;
    margin: 0;
    padding: 11px 18px;
    font-size: 16px;
  }

  .pass-contents-block { grid-template-columns: 1fr; gap: 12px; padding: 20px; }

  .gem-grid { gap: 8px; }
  .reward-grid { gap: 8px; }

  /* One step down from the desktop tile, not a shrink-to-fit: the row still scrolls, and the point is
     that a tile stays readable rather than that more of them fit. Both classes again, or
     `.reward-grid`'s gap above wins on source order. */
  /* Full width of the screen, not of the column. `main.wrap` insets its children by 20px, and a
     horizontal scroller inside that inset is a scroller whose track stops short of both edges — tiles
     ran under the padding and looked clipped. The margin cancels whatever that inset happens to be, so
     the row reaches the screen edges while its own padding keeps the first and last tile off them. */
  .reward-grid.reward-grid-daily {
    gap: 8px;
    /* Exactly `.wrap`'s side padding, which is a flat 20px with no breakpoint overriding it. Written as
       `50% - 50vw` first, which also worked, but a viewport unit counts the scrollbar and left the page
       8px wider than its own viewport on any device that reserves one. */
    margin-inline: -20px;
    padding-inline: 20px;
    padding-bottom: 46px;
    /* Both, and both matter. The base rule shrink-wraps the row so it can centre while it fits; left at
       fit-content inside the widened box it simply grew to the full length of the strip and pushed the
       page sideways instead of scrolling. `auto` fills the box the negative margins opened up, and the
       tiles overflow it into the scroller. */
    width: auto;
    max-width: none;
  }

  .reward-grid.reward-grid-daily > * { flex: 0 0 124px; }
  .day-card.is-claimed::after { top: 3px; right: 3px; width: 20px; height: 20px; }
  .day-timer { font-size: 11px; }

  .gem-card, .reward-card { --card-pad: 7px; gap: 4px; }
  .day-card { --card-pad: 10px; }
  .day-card .gem-amount { font-size: 18px; }
  .day-card .pack-art { min-height: 62px; }
  .gem-art, .reward-art { min-height: 58px; }
  .gem-card > .gem-amount,
  .reward-card:not(.day-card) > .gem-amount,
  .day-card > .day-label { padding: 5px calc(var(--card-pad) + var(--band-clear, 0px)) 5px var(--card-pad); }

  .gem-card .buy, .reward-card .reward-action { font-size: 14px; line-height: 1.2; }
  .gem-card .buy { padding: 5px 6px; }
  .reward-card:not(.day-card) .reward-action { padding: 1px 6px; }
  .day-card .reward-action { padding: 5px 6px; }
  .reward-action .credit-icon { width: 1.8em; height: 1.8em; vertical-align: -0.52em; }
  .reward-cost { font-size: 13px; }
  .reward-amount { font-size: 18px; }
  .reward-name { font-size: 11px; }
  .reward-meta { font-size: 11px; }
  .shop-section { margin-bottom: 36px; }
}

@media (min-width: 721px) and (max-width: 1024px) {
  .pass-art { height: 300px; }
  .pass-body { padding: 32px; }
  .shop-section { margin-bottom: 44px; }
  .section-title { margin: 24px auto 12px; }

  .gem-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
  .reward-grid { gap: 12px; }
  .reward-grid > * { flex: 0 1 170px; }

  .gem-card, .reward-card { --card-pad: 9px; gap: 5px; }
  /* Restated, or .reward-card above wins on source order and the tiles land tighter here than on a phone. */
  .day-card { --card-pad: 11px; }
  .gem-amount { font-size: 16px; }
  .pack-art { min-height: 73px; }

  .gem-card .bonus-ribbon,
  .reward-card .bonus-ribbon,
  .pass-card .bonus-ribbon,
  .privilege-card .bonus-ribbon { --tag: 0.6; }

  .gem-card:has(.bonus-ribbon),
  .reward-card:has(.bonus-ribbon) { --band-clear: 63px; }

  .gem-card:has(.bonus-ribbon) > .gem-amount,
  .reward-card:has(.bonus-ribbon) > .gem-amount { font-size: 14px; }
  .gem-card > .gem-amount,
  .reward-card:not(.day-card) > .gem-amount,
  .day-card > .day-label { padding: 6px calc(var(--card-pad) + var(--band-clear, 0px)) 6px var(--card-pad); }

  .gem-card .buy, .reward-card .reward-action { font-size: 15px; line-height: 1.2; }
  .gem-card .buy { padding: 6px 8px; }
  .reward-card:not(.day-card) .reward-action { padding: 1px 8px; }
  .day-card .reward-action { padding: 5px 8px; }
  .reward-action .credit-icon { width: 1.8em; height: 1.8em; vertical-align: -0.52em; }
  .reward-cost { font-size: 15px; }
}

/* A lone card on the last row. With two columns and an odd number of packs the last one sat in the
   left half and left the right half empty. It takes the whole row instead, and rearranges to use it:
   the art beside the price rather than stacked above it, so the extra width carries something.
   550 is where this grid goes from two columns to three - measured, not guessed - and above it the last
   row is never a single card. */
@media (max-width: 550px) {
  .gem-grid > .gem-card:last-child:nth-child(odd) { grid-column: 1 / -1; }

  /* The only other change the wider box needs. The card keeps its own column layout, and with it the
     price plate as the card's bottom edge - laid out across two columns instead, that plate stopped
     being an edge and became a slab floating in the right half. */
  .gem-grid > .gem-card:last-child:nth-child(odd) .pack-art { min-height: 108px; }
}

@media (max-width: 520px) {
  .gem-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .reward-grid > * { flex-basis: calc(50% - 4px); }
}

@media (min-width: 521px) and (max-width: 720px) {
  .gem-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
  .reward-grid > * { flex: 0 1 170px; }
}

/* The header runs out of room before anything else does: logo plus the actions pill left 13px of
   slack at 390px, so a 360px phone wrapped it. */
@media (max-width: 560px) {
  .brand-logo { height: 48px; }
  .header-actions { gap: 2px; padding: 4px 6px; border-radius: 12px; }
  .site-nav a { padding: 10px 6px; font-size: 12px; }
  .btn-link { padding: 10px 6px; font-size: 14px; }
  .account { gap: 4px; }
}

/* The copy plate is width:max-content over two nowrap lines, so it holds 194px however narrow the card
   gets — on a 320px screen that is 77% of the card, and the ornament has nowhere left to stand without
   covering the sentence it is illustrating. It goes rather than the words. */
@media (max-width: 340px) {
  .pass-float { display: none; }
}

/* 320px phones. Must sit after the 560px block, which sets .brand-logo itself and would otherwise win on
   source order. Keeping the balances costs the bar ~24px, and the header is fixed, so an overhang here is
   clipped rather than scrolled to — the logo and the gaps give that width back. */
@media (max-width: 340px) {
  .brand-logo { height: 40px; }
  .header-bar { gap: 6px; }
  .wallet { gap: 5px; }
  .wallet-entry { font-size: 11px; }
  .wallet-icon { width: 14px; height: 14px; }
}

@media (max-width: 370px) {
  .bonus-ribbon { --tag: 0.64; }

  .gem-card .bonus-ribbon,
  .reward-card .bonus-ribbon,
  .pass-card .bonus-ribbon,
  .privilege-card .bonus-ribbon { --tag: 0.44; }

  .gem-card:has(.bonus-ribbon),
  .reward-card:has(.bonus-ribbon) { --band-clear: 48px; }

  .gem-card:has(.bonus-ribbon) > .gem-amount,
  .reward-card:has(.bonus-ribbon) > .gem-amount { font-size: 13px; }

  .gem-grid:has(.bonus-ribbon),
  .reward-grid:has(.bonus-ribbon) { padding-top: 6px; row-gap: 22px; }
}

@media (max-width: 320px) {
  .site-header { padding: 8px; }
  .brand-logo { height: 30px; }
}

/* Phone. Same arrangement as the band below rather than a stack of its own: cropped from the right so
   the blade runs off the edge, and the tile stands to his left, as it does at every other width. It only
   fits because the tile's vertical band is beside the ribbon and the CTA, not beside the copy plate,
   which is 194px wide and would leave nothing. The trade is that he shows about 84px here — the left of
   him, which is the half with the face. */
@media (max-width: 450px) {
  .pass-card {
    background-size: min(62%, 220px) auto, cover;
    background-position: calc(100% + 88px) bottom, center;
  }

  /* What the tile stands beside is the CTA column, not the copy plate above it, so every pixel taken
     off these buttons is a pixel of hero. One line for "Show contents" rather than two also stops the
     column growing taller than the art it sits on. */
  .pass-buy,
  .pass-toggle { padding: 10px 14px; font-size: 14px; }

  /* The copy column gained a row when the bonus flag became a ribbon inside it — the flag was absolute
     and cost nothing, this does not. Most of that row is taken back here rather than off the art: a
     tighter stack, and a ribbon sized to the narrow column it now sits in rather than to the wide card
     it was drawn for. */
  /* 44, not the 56 above: this is the width the CTA column is allowed, and every pixel it does not take
     is a pixel the tile and the hero share. The plate and the title are max-content and nowrap, so they
     still overflow it as they did — and they sit above the tile's band, where that costs nothing. */
  .pass-body { gap: 7px; max-width: 44%; }

  /* Visible width is background-size minus the offset, so the tile follows it: 76 in from the right is
     the same 88 back off, less 12 of clearance. */
  .pass-float {
    --fs: 50px;
    right: calc(min(62%, 220px) - 76px);
    /* Centred, but never above the copy plate. A pass with no contents has no "Show contents" row, and
       the shorter card that leaves pulls the centre up under the plate's second line — which is where
       the floor comes in. It only binds on that shorter card; with the row present the centre already
       sits below it. */
    top: max(calc(50% - var(--fs) * 0.45), 116px);
  }
}

/* Large phone and small tablet. Cropped from the right rather than shrunk: the character occupies the
   left of the PNG and the sword the right, so what runs off the edge is the blade. Half the card wide
   and capped at 300 — 74% put him at 97% of the card's height, which is what made him loom.
   Visible width is background-size minus the offset, so the tile follows it rather than needing a
   percentage of its own: 95 in from the right is the same 107 back off, less 12 of clearance. */
@media (min-width: 451px) and (max-width: 720px) {
  .pass-card {
    background-size: min(50%, 300px) auto, cover;
    background-position: calc(100% + 107px) bottom, center;
  }

  .pass-float {
    --fs: 58px;
    right: calc(min(50%, 300px) - 95px);
  }
}

/* Sign-in modal */

/* Popups follow the game's Generic_Popup: a cream panel inside a tan bevel inside a black rim, over a
   near-black dimmer. The frame is drawn in CSS rather than with the prefab's four stacked 9-sliced
   sprites — that art is four flat colours and a rounded corner, which borders and rings reproduce at any
   size without shipping and stretching the PNGs. The colours are the prefab's own. */
.modal {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.86);
  /* Above the sticky header, below the checkout overlay. */
  z-index: 1000;
  animation: popup-dim 120ms ease-out;
}

/* The checkout overlay's panel is not a .modal-card — it is a bare .panel in its own rule tree — so it
   is named here too, or it would be the one popup still wearing the old dark surface. */
.modal-card,
.overlay .panel {
  position: relative;
  width: 100%;
  max-width: 460px;
  margin: 0;
  max-height: 90vh;
  overflow-y: auto;
  padding: 26px 24px 22px;
  text-align: center;
  background: #ffefbe;
  color: #3f2d1a;
  border: 3px solid var(--outline);
  border-radius: 22px;
  box-shadow:
    inset 0 0 0 9px #e6c9a1,
    0 0 0 4px #3b2f4f,
    0 18px 36px rgba(0, 0, 0, 0.55);
  /* No fill mode on purpose. `both` pins the panel to the 0% keyframe — scale(0) — whenever the
     animation cannot run, and a popup that opens in a throttled or backgrounded tab then stays invisible
     with no way back. Without a fill the resting state is the panel at full size, and the pop is a bonus
     rather than a prerequisite for seeing it. */
  animation: popup-in 333ms cubic-bezier(0.33, 1, 0.68, 1);
}

/* The prefab's PopupInScale, normalised: held at nothing for the first quarter, overshoot to 1.05, then
   settle. The dimmer is up before the panel starts, as it is in game. */
@keyframes popup-in {
  0%, 25% { transform: scale(0); }
  75% { transform: scale(1.05); }
  100% { transform: scale(1); }
}

@keyframes popup-dim {
  from { background-color: rgba(0, 0, 0, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .modal,
  .modal-card { animation: none; }
}

/* Everything inside sits on cream now, and the page's light-on-dark tokens would vanish against it.
   The two text tokens are rebound for the panel rather than chased class by class — anything inside that
   reaches for var(--text) or var(--muted) gets a colour meant for this background, including parts of the
   page that get shown inside a popup and were never styled for one. */
.modal-card,
.overlay .panel {
  --text: #3f2d1a;
  --muted: #7a6247;
}

/* Except the wide one: its content sits on full-bleed artwork rather than on the cream, so it keeps the
   page's light-on-dark tokens. Rebound for the cream, its contents line went dark-on-dark. */
.modal-card-wide {
  --text: #ececf2;
  --muted: #a2a2b5;
}

.modal-card h2,
.modal-card h3 { color: #2f2140; }
.modal-card p { color: #5b4630; }

/* Buttons in a popup are plaques, like every other pressable thing in the game and on the rest of this
   page — a face, a dark rim and a hard bottom edge that the press drives into. The flat accent-purple
   .btn was built for the dark page and sat on the cream as a web control among game art. Primary takes
   the buy plaque's green; anything secondary takes the neutral grey one, so the pair reads as "do it"
   and "not that" without a second colour to learn. */
.modal-card .btn,
.overlay .panel .btn {
  border: 3px solid var(--outline);
  border-radius: 14px;
  padding: 12px 22px;
  background: var(--buy-face);
  color: var(--buy-text);
  font-size: 17px;
  font-weight: 800;
  box-shadow: 0 4px 0 var(--buy-edge);
}

.modal-card .btn:hover:not(:disabled) { box-shadow: 0 6px 0 var(--buy-edge); }
.modal-card .btn:active:not(:disabled) { transform: translateY(3px); box-shadow: 0 1px 0 var(--buy-edge); }

.modal-card .btn-secondary {
  background: var(--grey-face);
  color: var(--grey-text);
  box-shadow: 0 4px 0 var(--grey-edge);
}

.modal-card .btn-secondary:hover:not(:disabled) { box-shadow: 0 6px 0 var(--grey-edge); }
.modal-card .btn-secondary:active:not(:disabled) { box-shadow: 0 1px 0 var(--grey-edge); }


.modal-card h2 { margin-top: 0; }

#welcomeModal { z-index: 1150; }

.modal-card-welcome {
  position: relative;
  max-width: 500px;
  margin-top: min(150px, 18vh);
  overflow: visible;
  text-align: center;
}

.welcome-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.welcome-art {
  position: absolute;
  left: 50%;
  bottom: 100%;
  margin-bottom: -20px;
  transform: translateX(-50%);
  width: min(300px, 80%, 30vh);
  height: auto;
  pointer-events: none;
}

.modal-card-welcome .welcome-title { margin: 0; font-size: 26px; line-height: 1.15; }

.modal-card-welcome .result-rewards { margin: 0; }

.modal-card-welcome .welcome-line {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
}

.modal-card-welcome .welcome-gift {
  margin: 2px 0 -6px;
  font-size: 15px;
  font-weight: 800;
  color: #2f2140;
}

.welcome-cta {
  min-width: 200px;
  font-size: 20px;
}

/* The card alone is over 400px tall, so under roughly 620 there is no height left to hang art in and
   shrinking it further only yields a thumbnail. Dropped instead - the popup reads fine without it, and
   a decapitated shopkeeper reads as broken. */
@media (max-height: 620px) {
  .welcome-art { display: none; }
  .modal-card-welcome { margin-top: 0; }
}

@media (max-width: 560px) {
  .welcome-body { gap: 12px; }
  .modal-card-welcome { margin-top: min(120px, 16vh); }
  .welcome-art { width: min(230px, 72%, 26vh); margin-bottom: -16px; }
  .modal-card-welcome .welcome-title { font-size: 21px; }
  .welcome-cta { min-width: 0; width: 100%; font-size: 18px; }
}

/* Topaz explainer: each numbered line of copy sits above the in-game screenshot it describes. */
.modal-card-howto {
  max-width: 520px;
  text-align: left;
}

.howto-steps {
  margin: 0;
  padding-left: 22px;
}

.howto-steps li + li { margin-top: 22px; }

/* The example that names what the screenshot shows: part of step 2, but on its own line under the rule
   it illustrates rather than running on from it. A block span, not a <p> - the step's own copy is a
   bare text node in the <li>, and a paragraph beside it would carry margins the text node has not. */
.howto-example {
  display: block;
  margin-top: 8px;
}

/* Width-driven so the art dictates its own height; a missing file is removed rather than laid out. */
.howto-shot {
  display: block;
  width: 100%;
  max-width: 320px;
  margin-top: 12px;
  border-radius: 12px;
  border: 1px solid var(--line);
}

/* A pale coin beside the plaque title. Its own text-shadow reset is required: .section-title's outline
   stack inherits down, and eight 2px copies around a single glyph this size close the counter up. */
.howto-link {
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  /* The row centres on the title's full line box, which sits above the glyphs' optical centre. */
  margin-top: 4px;
  padding: 0;
  background: #dcdce4;
  border: 2px solid var(--outline);
  border-radius: 50%;
  color: #3a3a46;
  font-size: 15px;
  font-weight: 800;
  line-height: 1;
  text-shadow: none;
  cursor: pointer;
}

.howto-link:hover { background: #f1f1f6; }

/* Rides on the plaque beside the name. Its own text-shadow reset for the same reason .howto-link needs
   one: .section-title's outline stack inherits down and would fur the edges of type this small. */
.section-tag {
  flex: 0 0 auto;
  margin-top: 4px;
  padding: 3px 9px;
  background: var(--buy-face);
  border: 2px solid var(--buy-edge);
  border-radius: 999px;
  color: var(--buy-text);
  font-size: 12px;
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-shadow: none;
  white-space: nowrap;
}

@media (max-width: 720px) {
  .section-tag { padding: 2px 7px; font-size: 10px; }
}

/* The neutral twin of .pass-buy — same 3px edge and hard 4px drop, so it presses like every other
   plaque on the page. Sized off the packs above it rather than the page: centred, wide enough to read
   as the section's own footer, and capped so it does not stretch edge to edge on a desktop. */
.howto-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  width: min(100%, 360px);
  margin: 28px auto 0;
  padding: 16px 24px;
  background: var(--grey-face);
  border: 3px solid var(--grey-edge);
  border-radius: 12px;
  box-shadow: 0 4px 0 var(--grey-edge);
  color: var(--grey-text);
  font-size: 20px;
  font-weight: 800;
  cursor: pointer;
}

.howto-btn:hover { box-shadow: 0 6px 0 var(--grey-edge); }
.howto-btn:active { box-shadow: 0 2px 0 var(--grey-edge); }

/* Sunk into the face on its own darker disc, the way the game frames an icon inside a button. The
   ring is what makes it read as inset — at the disc's alpha alone the edge is invisible. */
.howto-btn-icon {
  flex: 0 0 auto;
  width: 48px;
  height: 48px;
  background: center / 40px 40px no-repeat url('/media/topaz.png?v=2') rgba(43, 37, 54, 0.22);
  border-radius: 50%;
  box-shadow: inset 0 0 0 2px rgba(43, 37, 54, 0.18);
}

@media (max-width: 1024px) {
  .howto-btn { margin-top: 20px; padding: 10px 18px; gap: 10px; font-size: 17px; }
  .howto-btn-icon { width: 37px; height: 37px; background-size: 34px 34px; }
}

@media (max-width: 720px) {
  .howto-btn { margin-top: 16px; padding: 8px 14px; gap: 8px; font-size: 15px; }
  .howto-btn-icon { width: 31px; height: 31px; background-size: 29px 29px; }
}

/* Above the sign-in modal: a checkout outcome can land while that one is still open. */
#result { z-index: 1100; }

/* The pass modal holds the pass card, which is a two-column layout and needs the room. */
.modal-card-wide {
  max-width: 1320px;
  text-align: left;
  padding: 0;
  overflow: hidden;
}

/* The card supplies its own border and radius, so the panel around it must not double them. */
.modal-card-wide .pass-card {
  border: 0;
  border-radius: 0;
}

/* Sits over the artwork rather than a panel, where the bare glyph disappears against the sky.
   z-index because .pass-card is positioned and comes later in the DOM, so it paints over this. */
.modal-card-wide .modal-close {
  z-index: 2;
  top: 12px;
  right: 12px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: rgba(10, 8, 24, 0.66);
  color: var(--text);
  backdrop-filter: blur(4px);
}

.modal-card-wide .modal-close:hover { background: rgba(10, 8, 24, 0.85); }

/* The art keeps its grid cell here, exactly as on the page: pinned to the card instead, it spans the
   full height and the contents block then paints across the hero's legs. He stands on the floor of
   row 1, which is the top edge of the contents strip. */

/* Its own scrim: the card's background photo runs on behind the strip, and the list needs a ground
   darker than the moonlit water to stay readable. */
.modal-card-wide .pass-contents-block {
  background: linear-gradient(rgba(14, 10, 32, 0.55), rgba(14, 10, 32, 0.82));
}

.result-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
  margin-top: 20px;
  /* The plaques carry a hard bottom edge and drop 3px when pressed; without this the lower one clips
     against the panel's bevel ring. */
  padding-bottom: 6px;
}

#resultExtra { margin-top: 18px; }
#resultExtra p { margin: 2px 0; }

/* A row of prizes, wrapping. One reward is the common case and centres on its own; several sit side by
   side rather than stacking, so the modal stays short enough to keep its buttons in view. */
.result-rewards {
  list-style: none;
  margin: 18px 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.result-rewards:empty { display: none; }

/* The prize, not a line item: art first at a size worth looking at, its name under it. */
.result-reward {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 14px 16px;
  /* A recess in the cream, not the dark-page tint it used to carry — over #FFEFBE that read as grey mud
     around the art. Warm and slightly darker than the panel, with the panel's own rim colour hairlined
     round it, the way the game insets its reward slots. */
  background: rgba(180, 138, 74, 0.22);
  border: 2px solid rgba(63, 45, 26, 0.18);
  border-radius: 14px;
  text-align: center;
}

.result-reward-icon {
  flex: 0 0 auto;
  width: 104px;
  height: 104px;
  background-position: center;
  background-size: contain;
  background-repeat: no-repeat;
}

.result-reward-label { font-weight: 800; font-size: 17px; }

@media (max-width: 720px) {
  .result-rewards { gap: 10px; }
  .result-reward { gap: 6px; padding: 10px 12px; }
  .result-reward-icon { width: 84px; height: 84px; }
  .result-reward-label { font-size: 15px; }
}

/* The game's close is a dark plaque with a cream glyph. Kept in the corner rather than moved below the
   panel where the prefab puts it: these popups carry their own action row down there, and a second
   dismiss under it would read as one more button to choose between. */
.modal-close {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #332747;
  border: 3px solid var(--outline);
  color: #fff1d7;
  font-size: 22px;
  line-height: 1;
  /* A rounded plaque, which is the shape the game's Close_Button actually is — a convex rectangle, not a
     bubble. Every other pressable thing on this page is a plaque with a hard bottom edge, and a lone
     circle among them read as a browser chrome control rather than part of the game's UI. */
  border-radius: 12px;
  box-shadow: 0 4px 0 #17101f;
}

.modal-close:hover { background: #43335e; box-shadow: 0 6px 0 #17101f; }
.modal-close:active { transform: translateY(3px); box-shadow: 0 1px 0 #17101f; }

/* Hero video: full-bleed at the very top, with the floating header over it. */

/* Height must NOT track width. With an aspect-ratio the hero collapsed to a sliver on a phone;
   a floor keeps it tall and the video crops at the sides instead (what matchmasters.com does). */
.video-hero {
  position: relative;
  width: 100%;
  height: clamp(480px, 50vw, 84vh);
  overflow: hidden;
  background: #000;
  line-height: 0;
}

.video-hero video {
  width: 100%;
  height: 100%;
  display: block;
  /* Crops rather than letterboxes; the 40% pulls the framing up off dead foreground. */
  object-fit: cover;
  object-position: center 40%;
  /* Nothing to interact with: no right-click player menu, no accidental pause. */
  pointer-events: none;
}

/* Keeps the logo and badges legible over a bright first frame. */
.video-hero::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 170px;
  background: linear-gradient(rgba(16, 16, 20, 0.72), rgba(16, 16, 20, 0));
  pointer-events: none;
}

/* Fades into the page background so buttons can sit over the bottom band. Proportional, because
   a fixed 260px swallowed the whole hero once it shrank on a phone. */
.video-hero::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 38%;
  max-height: 260px;
  background: linear-gradient(rgba(16, 16, 20, 0), rgba(16, 16, 20, 0.85) 62%, var(--bg));
  pointer-events: none;
  /* Above the video, below anything placed in .video-hero-actions. */
  z-index: 1;
}

/* Empty on purpose — drop the CTA buttons in here and they land on the faded band. */
.video-hero-actions {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 40px;
  z-index: 2;
  display: flex;
  justify-content: center;
  gap: 14px;
  flex-wrap: wrap;
  padding: 0 20px;
  line-height: 1.5;
}

/* Screenshot strip */

/* Promo band between the hero and the gallery. Pushes the gallery down so the band reads as its own
   beat rather than as the strip's header. */
/* z-index 2 clears .video-hero::before, the hero's bottom fade, which is z-index 1 and would otherwise
   paint over the part of the gift that breaks out above the card. With that fixed the band no longer
   needs to be pushed clear of the hero, so the top padding is small and the gift overlaps on purpose. */
.daily-promo {
  position: relative;
  z-index: 2;
  /* Pulled up into the hero's bottom fade, which is 38% of its height, so the band sits inside the
     darkened part of the video rather than in the gap below it. */
  margin-top: -104px;
  display: flex;
  justify-content: center;
  padding: 8px 20px 8px;
}

.daily-promo-card {
  display: flex;
  align-items: stretch;
  width: min(680px, 100%);
  border: 4px solid var(--outline);
  border-radius: 20px;
  background: var(--pack-face);
  box-shadow: 0 8px 0 0 var(--pack-edge);
  text-decoration: none;
  /* The whole plate lifts, so the shadow has to travel with it or the card looks unmoored. */
  transition: transform 120ms ease, box-shadow 120ms ease;
}

.daily-promo-card:hover { transform: translateY(-3px); box-shadow: 0 11px 0 0 var(--pack-edge); }
.daily-promo-card:active { transform: translateY(4px); box-shadow: 0 4px 0 0 var(--pack-edge); }

/* Flat block, not a burst: the game never uses gradients. Full-bleed left panel, flush to the card
   edge and full height. Left corners are rounded here (not by the card) because the card no longer
   clips — the gift <img> has to break out past the top edge. */
/* Shrinkable, unlike the copy beside it: the CTA never wraps, so on the narrowest phones something has
   to give, and the decorative half is what gives — down to a third of the card, past which the gift
   stops reading as a panel and starts reading as a stripe. */
.daily-promo-art {
  position: relative;
  flex: 0 1 46%;
  min-width: 33%;
  min-height: 160px;
  /* White sunburst behind the gift. A background can't overflow its box, so the rays are clipped by
     the panel (rounded corners and all) while the gift <img> sibling still pops out past the top. */
  background:
    repeating-conic-gradient(from 0deg at 50% 44%,
      rgba(255, 255, 255, 0.32) 0deg 11deg, transparent 11deg 30deg),
    var(--gold);
  border-right: 4px solid var(--outline);
  border-radius: 16px 0 0 16px;
}

/* Sits over the gold panel, anchored to its base and allowed to overflow the top edge so the bow
   pops above the card. Sized off the panel height so the break-out is stable across widths. */
.daily-promo-gift {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  height: 138%;
  width: auto;
  pointer-events: none;
  filter: drop-shadow(0 4px 3px rgba(20, 12, 34, 0.35));
}

.daily-promo-copy {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 20px;
  padding: 20px 24px;
}

.daily-promo-title {
  margin: 0;
  font-size: 40px;
  font-weight: 800;
  line-height: 1.02;
  text-align: center;
  color: var(--gold);
}

/* Not .btn: this is inside an anchor, so it must not pick up the button reset's own hit target.
   Reacts on its own within the card so the CTA reads as a pressable button, not painted-on text.
   Gold theme. The "!" is appended here so it holds across every locale's copy. */
/* Never two lines: a wrapped CTA reads as a broken button rather than a long word. The copy is Remote
   Config text in twelve locales, so the size and the side padding both give way before the line does —
   the vw terms only bind on the narrow widths where the authored 46px/24px will not fit. */
.daily-promo-cta {
  padding: 16px min(46px, 4vw);
  border: 3px solid var(--outline);
  border-radius: 16px;
  background: var(--gold);
  box-shadow: 0 6px 0 0 #b3801d;
  white-space: nowrap;
  font-size: min(24px, 3vw);
  font-weight: 800;
  color: #4a2e07;
  transition: transform 120ms ease, box-shadow 120ms ease;
}

.daily-promo-cta::after { content: "!"; }

.daily-promo-card:hover .daily-promo-cta { transform: translateY(-1px); box-shadow: 0 7px 0 0 #b3801d; }
.daily-promo-card:active .daily-promo-cta { transform: translateY(3px); box-shadow: 0 3px 0 0 #b3801d; }

@media (max-width: 560px) {
  /* The hero floors at 480px here, so its fade is shallower and the pull-up has to be too. */
  .daily-promo { margin-top: -56px; padding: 6px 14px 4px; }
  .daily-promo-art { flex-basis: 40%; min-height: 128px; }
  .daily-promo-copy { gap: 14px; padding: 16px 16px; }
  .daily-promo-title { font-size: 26px; }
  .daily-promo-cta { padding: 13px min(30px, 3.2vw); font-size: min(19px, 3.7vw); }
}

/* Below every shipping phone width except a folded Galaxy's cover screen. There the floor above is what
   pushes the copy past the card edge, so the panel gives up the rest of its width instead. */
@media (max-width: 300px) {
  .daily-promo-art { min-width: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .daily-promo-card,
  .btn,
  .is-pressable,
  .gem-card .buy,
  .reward-card .reward-action { transition: none; }
  .daily-promo-card:hover, .daily-promo-card:active,
  .is-pressable:hover, .is-pressable:active,
  .btn:hover, .btn:active { transform: none; }
}

.shots {
  padding: 4px 0 48px;
}

/* Scrolls horizontally rather than wrapping: portrait shots in a grid leave huge dead rows.
   No scroll-snap — it fights the wrap-around jump that makes the strip endless. */
.shots-row {
  list-style: none;
  margin: 0;
  /* Vertical padding gives the hover scale somewhere to grow without being clipped. */
  padding: 14px 20px;
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scrollbar-width: none;
  cursor: grab;
}

.shots-row.is-dragging { cursor: grabbing; }

.shots-row::-webkit-scrollbar { display: none; }

.shots-row li {
  flex: 0 0 auto;
}

.shots-row img {
  display: block;
  height: min(58vh, 440px);
  width: auto;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--surface);
  transition: transform 180ms ease, border-color 180ms ease;
  /* Stops the browser's native image drag from hijacking the pointer mid-swipe. */
  user-select: none;
  -webkit-user-drag: none;
}

.shots-row img:hover {
  transform: scale(1.05);
  border-color: var(--accent);
}

@media (prefers-reduced-motion: reduce) {
  .shots-row img { transition: none; }
  .shots-row img:hover { transform: none; }
}

/* Overlay */

.overlay {
  position: fixed;
  inset: 0;
  background: rgba(8, 8, 12, 0.86);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  /* Must sit above the Stash checkout iframe, which uses z-index 2147483000. */
  z-index: 2147483100;
}

.overlay .panel { max-width: 420px; margin: 0; text-align: center; }

.spinner {
  width: 28px;
  height: 28px;
  margin: 0 auto 12px;
  /* Track and head both restated for the cream panel: var(--line) is the page's near-black and read as a
     solid dark ring on it, with the accent head barely separable from the track. */
  border: 3px solid rgba(63, 45, 26, 0.22);
  border-top-color: #6f5cff;
  border-radius: 50%;
  animation: spin 0.9s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

/* A collect in flight. Everything the button already holds dims, and the ring sits over the middle of it,
   so the reward stays legible underneath rather than being replaced by a spinner. */
.is-collecting { position: relative; }
.is-collecting > *:not(.claim-spinner) { opacity: 0.3; }

.claim-spinner {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 24px;
  height: 24px;
  margin: -12px 0 0 -12px;
  border-width: 3px;
  border-color: rgba(255, 255, 255, 0.35);
  border-top-color: #fff;
}

@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; }
}

/* Footer */

.site-footer {
  border-top: 1px solid var(--line);
  padding: 24px 0;
  color: var(--muted);
}

.site-footer ul {
  list-style: none;
  display: flex;
  gap: 16px;
  padding: 0;
  margin: 0 0 8px;
  flex-wrap: wrap;
}

/* The global accent link colour reads as a stray blue down here; footer links are secondary. */
.site-footer a {
  color: var(--muted);
  text-decoration: none;
}

.site-footer a:hover {
  color: var(--text);
  text-decoration: underline;
}

/* Environment badge (W9) */

.env-badge {
  position: fixed;
  left: 12px;
  bottom: 12px;
  z-index: 200;
  background: #7a3b00;
  color: #ffd9a8;
  border: 1px solid #b06000;
  border-radius: 8px;
  padding: 4px 10px;
  font-size: 12px;
  font-weight: 700;
}

/* Install prompt: a fixed band above the header, which is itself fixed. Its height is measured into
   --install-bar-h rather than hardcoded, because the copy wraps to two lines in the longer locales. */
.install-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: var(--surface-2);
  border-bottom: 3px solid var(--outline);
  cursor: pointer;
}

/* The close glyph is the one part of the strip that does not install. */
.install-close { cursor: pointer; }

body.has-install-bar { padding-top: calc(var(--header-h) + var(--install-bar-h, 0px)); }
body.has-install-bar .site-header { top: var(--install-bar-h, 0px); }
body[data-page="landing"].has-install-bar { padding-top: var(--install-bar-h, 0px); }

.install-icon {
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  border: 2px solid var(--outline);
}

.install-copy {
  flex: 1 1 auto;
  min-width: 0;
}

.install-title { margin: 0; font-size: 15px; font-weight: 800; }
.install-body { margin: 0; font-size: 13px; color: var(--muted); }

.install-action {
  flex: 0 0 auto;
  background: var(--buy-face);
  border: 3px solid var(--buy-edge);
  border-radius: 12px;
  box-shadow: 0 4px 0 var(--buy-edge);
  color: var(--buy-text);
  font-weight: 800;
  font-size: 15px;
  padding: 8px 18px;
}

/* A plain glyph, not a plate: dismissing is the secondary action and must not compete with Install. */
.install-close {
  flex: 0 0 auto;
  background: none;
  border: 0;
  padding: 6px 8px;
  color: var(--muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}

@media (max-width: 560px) {
  .install-bar { gap: 10px; padding: 8px 12px; }
  .install-icon { width: 36px; height: 36px; }
  .install-title { font-size: 14px; }
  /* The subtitle is the first thing to go: the button and the app name carry the offer on their own. */
  .install-body { display: none; }
  .install-action { padding: 8px 14px; font-size: 14px; }
}

/* Section: privilege cards. The game scrolls them one per row at full width — Ad-Free and Supply are
   ~700x600, VIP ~700x800 — so the web stacks them the same way rather than pairing them off. */

.privilege-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Room for the corner tag, which hangs 23px above its card: the row gap is what it hangs in on every
     row but the first, and the padding is the first row's share. Same arrangement as .gem-grid. */
  column-gap: 20px;
  row-gap: 38px;
  padding-top: 8px;
  width: min(620px, 100%);
  margin: 0 auto;
}

/* Two to a row once there is room for a pair at close to their own width. Below this the second column
   would only shrink both cards, and the plaque is a wide banner — it is the first thing to suffer. */
@media (min-width: 1100px) {
  .privilege-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    width: min(1160px, 100%);
  }
}

/* The plaque. Sized in cqi throughout — 1cqi is 1% of the card — because the game lays this card out on
   a canvas scaler, so every plate, gap and type size on it is a fixed fraction of the card's width. Fixed
   pixels here are what made the web version stop looking like the prefab as soon as the column changed
   width. The prefab's Mask lives on the head and the face rather than here - see their radii - so that
   the corner tag can hang past this card's edge. */
.privilege-card {
  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  min-width: 0;
  border: 4px solid var(--outline);
  border-radius: 18px;
  box-shadow: 0 6px 0 var(--outline);
}

/* The header band: a solid strip across the top carrying the title and the state chip, drawn over the
   burst rather than floating on it. Its own top corners are rounded, and the face below carries the
   bottom pair: the card used to clip both off a single `overflow: hidden`, and that also clipped the
   corner tag, which has to hang past the card's edge as it does on a pack. 18 of card radius less its
   4px border. */
.privilege-head {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 2cqi;
  padding: 1.1cqi 4cqi;
  background: var(--pc-ribbon);
  border-bottom: 3px solid var(--outline);
  border-radius: 14px 14px 0 0;
}

/* The ray burst IS the card's face, at the full colour the sprite was authored in: the navy and gold
   plates the prefab draws behind it are covered by the art and never show through. Two columns, the
   copy left and the card art with its price right, which is how the prefab pins them. */
.privilege-face {
  position: relative;
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: minmax(0, 58fr) minmax(0, 42fr);
  gap: 2.6cqi;
  padding: 3cqi 4cqi 3.4cqi;
  background: var(--pc-rays) center / cover no-repeat, var(--pc-plate);
  border-radius: 0 0 14px 14px;
  /* The clip the card used to do. .privilege-glow is bled past its own box on purpose and reaches 12px
     past the card's right edge; the face's edge is the card's edge here, so cutting it at the face is
     the same cut, and it leaves the tag outside free to overhang. */
  overflow: hidden;
}

.privilege-main {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1.8cqi;
  min-width: 0;
}

.privilege-side {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 1.6cqi;
  min-width: 0;
}

/* Ribbon, purchase-ribbon, plate and bullet colours are the prefab's own Image tints, read off the
   serialized cards rather than eyeballed from a screenshot. */
.privilege-card-adfree {
  --pc-rays: url('/media/privilegecard-adfree-bg.png');
  --pc-plate: #0e396b;
  --pc-ribbon: #4c32ad;
  --pc-pill: #482ea5;
  --pc-check: #c5e53c;
  --pc-glow: #4fd9f4;
}

.privilege-card-supply {
  --pc-rays: url('/media/privilegecard-supply-bg.png');
  --pc-plate: #0e396b;
  --pc-ribbon: #7f1d45;
  --pc-pill: #7f2049;
  --pc-check: #c5e53c;
  --pc-glow: #4fd9f4;
}

/* The row's hero. Its surround is the game's own `ProfileFrame_01_BorderDeco_Gold_BlackBorder` sprite,
   9-sliced through border-image — the three inset rings that stood in for it never read as ornament. */
.privilege-card-vip {
  --pc-rays: url('/media/privilegecard-vip-bg.png');
  --pc-plate: #eb9b1e;
  /* Brown band, white title — the white here was the old centred title pill, and as a full-width band it
     read as a gap between the frame and the card. */
  --pc-ribbon: #7c4b13;
  --pc-ribbon-text: #ffffff;
  --pc-pill: #fdf4b1;
  --pc-pill-text: #633500;
  --pc-check: #ffd24a;
  --pc-glow: #ffc600;
}

/* Left-aligned in the band, not centred on a pill: the band is the plate. */
.privilege-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  color: var(--pc-ribbon-text, #ffffff);
  font-size: clamp(12px, 4.9cqi, 19px);
  font-weight: 800;
  line-height: 1.15;
  text-align: left;
}

/* The card art, at full size and full colour in its own column — in the prefab it is the hero of the
   plaque, tilted off square and standing on a cast shadow, not a watermark behind the copy. */
.privilege-icon {
  position: relative;
  flex: 1 1 auto;
  min-height: 29cqi;
  pointer-events: none;
}

/* The ribbon is the web's own row — the prefab has no line for the Stash bonus — so it rides the price
   column, scaled with the card like everything else on it rather than at its packs-row pixel size. Flown
   over the art instead, it landed on the ticket; given a row in the copy column it cost the plaque a
   whole band the game does not have. */
/* The prefab's glow is a sprite behind the icon; a radial gradient is the same shape for none of the
   bytes. Bled past the icon box so the falloff is not cut off square at its edge. */
.privilege-glow {
  position: absolute;
  inset: -18%;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--pc-glow), rgba(0, 0, 0, 0) 72%);
  opacity: 0.55;
}

/* Sized off the column's width, not fitted inside its box. The sprite is a square canvas holding a wide
   ticket, so `contain` in a short box fitted the empty canvas and drew a ticket half the prefab's size;
   the overflow this produces is that same transparent canvas and never shows. */
.privilege-icon-art {
  position: absolute;
  inset: 0;
  background-position: center;
  background-repeat: no-repeat;
  background-size: 108% auto;
  transform: rotate(var(--pc-tilt));
}

/* The ellipse the ticket floats over. The prefab draws it as a sprite; it is a flat oval, so a radial
   is the same mark. */
.privilege-icon-shadow {
  position: absolute;
  left: 18%;
  right: 18%;
  bottom: 0;
  height: 10%;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.55);
}

/* The prefab's idle hover, ported key for key: PrivilegeCards_FloatingElement runs 5s on loop, raising
   Card_Holder's anchored y from 57 to 70 at 2.5s and back, while Card_Shadow scales 1 -> 0.8 -> 1. Every
   key has flat tangents, so it eases at both ends and at the peak — one 2.5s ease-in-out alternating.
   The rise is in cqi so it stays 13/700ths of the card at every width, as the prefab's does. */
@keyframes privilege-float {
  from { transform: translateY(0) rotate(var(--pc-tilt)); }
  to { transform: translateY(-1.9cqi) rotate(var(--pc-tilt)); }
}

@keyframes privilege-float-shadow {
  from { transform: scale(1); }
  to { transform: scale(0.8); }
}

.privilege-icon-art,
.privilege-icon-word { animation: privilege-float 2.5s ease-in-out infinite alternate; }

.privilege-icon-shadow { animation: privilege-float-shadow 2.5s ease-in-out infinite alternate; }

@media (prefers-reduced-motion: reduce) {
  .privilege-icon-art,
  .privilege-icon-word,
  .privilege-icon-shadow { animation: none; }
}

/* The gold ticket sprite is blank; the prefab letters it with VIP_Tag, a sprite of its own. Same box and
   same tilt as the art, so the two ride the float together with one keyframe set between them. */
.privilege-icon-word {
  position: absolute;
  inset: 0;
  --pc-tilt: 10deg;
  background: center 44% / 14cqi auto no-repeat url('/media/privilegecard-vip-word.png');
  transform: rotate(var(--pc-tilt));
}

.privilege-card-adfree .privilege-icon-art { background-image: url('/media/privilegecard-adfree-icon.png'); }
.privilege-card-supply .privilege-icon-art { background-image: url('/media/privilegecard-supply-icon.png'); }
.privilege-card-vip .privilege-icon-art { background-image: url('/media/privilegecard-vip-icon.png'); }

/* The benefit plate, on the .pass-copy pattern: the terms carry the colour, the plate carries the
   contrast, which is what keeps white copy readable over a full-colour burst. VIP draws it as a solid
   inset panel rather than a scrim, and that is a large part of why it looks the richer card. */
.privilege-pill {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.6cqi;
  padding: 1.8cqi 2.6cqi;
  background: rgba(20, 12, 34, 0.62);
  border: 2px solid var(--outline);
  border-radius: 3cqi;
}

.privilege-card-vip .privilege-pill {
  background: #633500;
  border-color: #b7792e;
}

.privilege-line {
  position: relative;
  margin: 0;
  padding-left: 4.4cqi;
  color: #ffffff;
  font-size: clamp(9px, 3.05cqi, 14px);
  font-weight: 800;
  line-height: 1.25;
}

/* The prefab hangs a checkmark sprite off every benefit line; a glyph is the same mark for no bytes.
   VIP hangs a gem instead, so the glyph as well as the colour comes from the card. */
.privilege-line::before {
  content: '✓';
  position: absolute;
  left: 0;
  color: var(--pc-check);
}

.privilege-card-vip .privilege-line::before { content: '◆'; }

/* Every inline highlight the game tints, whatever colour the row's own tag carries — appendTagged writes
   it to the element, so this rule only has to keep it from inheriting the line's weight treatment. */
.tmp-color { font-weight: 800; }

/* Two different arrangements, both the prefab's. The purchase row is a plate running out of the card's
   left edge with the reward tiles riding its far end; the daily row is the label with its tiles under it. */
.privilege-rewards {
  display: flex;
  min-width: 0;
}

.privilege-rewards-purchase {
  align-items: center;
  /* Out to the card's inner edge, where the prefab anchors this plate — it is cut off by the frame, not
     inset from it. The face's own left padding is what is being cancelled. */
  margin-left: -4cqi;
  max-width: calc(100% + 4cqi);
}

.privilege-rewards-purchase .privilege-label {
  padding: 1.2cqi 7cqi 1.2cqi 3.4cqi;
  background: var(--pc-pill);
  border: 2px solid var(--outline);
  border-left: 0;
  border-radius: 0 2.4cqi 2.4cqi 0;
  color: var(--pc-pill-text, #fab9ff);
}

/* Riding the plate's end rather than sitting after it, which is the overlap the prefab draws. */
.privilege-rewards-purchase .privilege-reward-row { margin-left: -6cqi; }

.privilege-rewards-daily {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6cqi;
}

/* Brown on cream, so the shared dark ring is replaced rather than layered — the same swap the VIP title
   already makes. Applied to the text elements themselves; the outline list at the top of this file sets
   text-shadow on them directly, and a shadow on the container would never win. */
.privilege-card-vip .privilege-rewards-purchase .privilege-label {
  border-color: #b7792e;
  color: var(--pc-pill-text);
  text-shadow: 0 1px 0 rgba(255, 244, 214, 0.6);
}

/* Cream on every card: the per-card accents that used to sit here were picked against a flat navy plate
   and neither survives a magenta or orange burst. */
.privilege-label {
  margin: 0;
  color: #ffe9b8;
  font-size: clamp(9px, 3.3cqi, 15px);
  font-weight: 800;
  line-height: 1.25;
}

/* One left-aligned row, as the prefab lays it out. It wraps rather than scrolls: a row that runs off the
   plaque hides a reward the card is selling. */
.privilege-reward-row {
  display: flex;
  flex-wrap: wrap;
  gap: 2cqi;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The framed tile every reward sits in: a dark ring, a gold band inside it, and a warm inset the sprite
   is drawn on. The prefab draws this as one sliced sprite; three borders is the same mark. */
.privilege-reward {
  position: relative;
  padding: 1cqi;
  background: linear-gradient(180deg, #e08a4e, #c2632c);
  border: 2px solid #ffc600;
  border-radius: 2.4cqi;
  box-shadow: 0 0 0 2px var(--outline);
}

.privilege-reward-icon {
  display: block;
  width: 9.6cqi;
  height: 9.6cqi;
  background-position: center;
  background-size: contain;
  background-repeat: no-repeat;
}

/* Hung off the tile's foot, the way the prefab pins the count over the frame's bottom edge. */
.privilege-reward-amount {
  position: absolute;
  left: 50%;
  bottom: -1.4cqi;
  transform: translateX(-50%);
  color: #ffffff;
  font-size: clamp(8px, 2.9cqi, 13px);
  font-weight: 800;
  line-height: 1.1;
  white-space: nowrap;
}

/* Bottom-right, with the duration pill stacked over the button, exactly where the prefab pins them. */
.privilege-foot {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 1.4cqi;
}

.privilege-days {
  align-self: flex-end;
  padding: 0.8cqi 2.6cqi;
  background: rgba(20, 12, 34, 0.7);
  border: 2px solid var(--outline);
  border-radius: 999px;
  color: var(--gold);
  font-size: clamp(9px, 3cqi, 14px);
  font-weight: 800;
  line-height: 1.2;
}

.privilege-card-vip .privilege-days {
  background: #633500;
  border-color: #b7792e;
}

/* The game's lime plate, white label centred on it. The price and its tax rider stack, so the column is
   set here the way .buy and .pass-buy set theirs. */
.privilege-buy {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 1.6cqi 2.4cqi;
  background: #c5e53c;
  border: 3px solid var(--buy-edge);
  border-radius: 2.6cqi;
  box-shadow: 0 4px 0 var(--buy-edge);
  color: #ffffff;
  font-size: clamp(10px, 3.5cqi, 16px);
  font-weight: 800;
  line-height: 1.15;
  text-shadow: var(--text-outline), var(--text-drop);
}

.privilege-buy:hover:not(:disabled) { box-shadow: 0 6px 0 var(--buy-edge); }
.privilege-buy:active:not(:disabled) { box-shadow: 0 2px 0 var(--buy-edge); }

.privilege-status {
  margin: 0;
  color: var(--pass-muted);
  font-size: 15px;
  font-weight: 800;
}

/* The one animation kept from the prefab, and only on VIP. The game runs glitter, ray-light and a shine
   sweep together; all three at once on a static page is noise, so this is the sweep alone, slow, and
   with a long rest between passes. Above the plate, under the copy — .privilege-body is z-index 1. */
.privilege-shine {
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: 14px;
  background: linear-gradient(105deg,
    rgba(255, 255, 255, 0) 42%,
    rgba(255, 255, 255, 0.3) 50%,
    rgba(255, 255, 255, 0) 58%);
  background-size: 260% 100%;
  background-repeat: no-repeat;
  background-position: 140% 0;
  animation: privilege-shine 5s ease-in-out infinite;
}

@keyframes privilege-shine {
  0%, 60% { background-position: 140% 0; }
  100% { background-position: -70% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .privilege-shine { animation: none; opacity: 0; }
}

/* One per row on a phone, stacked, as the game does. Nothing else changes at a breakpoint: the whole
   card is measured in cqi, so it holds its proportions at every width on its own. */
@media (max-width: 720px) {
  .privilege-grid { column-gap: 14px; row-gap: 28px; padding-top: 8px; }
}


/* Built as a privilege card rather than as a flat purple plate: the same ray burst the Ad-Free card
   wears, over the same navy behind it, inside the same 4px outline with a hard drop rather than a
   coloured one. The art is named here a second time rather than read off .privilege-card-adfree,
   because that card sets it as a local variable on itself and this is not one of those cards - if the
   burst is ever re-authored, both lines change.
   The burst's light sits right of centre, and the columns are ordered around that: copy left on the
   deep end of it, wheel right in the glare. See renderWheelSection for the append order that does it. */
.wheel-teaser {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 26px;
  width: min(680px, 100%);
  margin: 0 auto;
  padding: 14px 22px;
  border: 4px solid var(--outline);
  border-radius: 18px;
  background: url('/media/privilegecard-adfree-bg.png') center / cover no-repeat, #0e396b;
  box-shadow: 0 6px 0 var(--outline);
  overflow: hidden;
}

.wheel-teaser-copy {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  /* Over art now, so it takes its own column width rather than whatever is left after the wheel: the
     burst is busiest on the wheel's side and the words need the calm half whole. */
  flex: 1 1 auto;
  min-width: 0;
}

.wheel-teaser-title {
  margin: 0;
  font-size: 30px;
  font-weight: 800;
  line-height: 1.05;
  text-align: center;
  color: var(--gold);
}

/* Bigger than it was: on a flat plate the wheel was an inset, and on the burst it is the card's artwork
   and carries the lit half of it. Sized off the card rather than the viewport below the breakpoint, so
   the two columns keep their split. */
.wheel-teaser .wheel-stage { flex: none; width: min(196px, 36vw); }

.wheel-teaser .wheel-pointer { display: none; }

/* The other state of the card - no spin left today. A bare pill in --muted vanished into the burst, so
   it gets the plate the timers on the pass and the packs wear. */
.wheel-teaser .wheel-timer {
  align-self: center;
  background: rgba(26, 15, 43, 0.72);
  border-color: var(--outline);
  color: #ffffff;
  font-size: 13px;
}

/* The preview is the spin window's wheel at a smaller size, less its pointer - see .wheel-pointer. The
   dark cap for a hub, which used to be the preview's other departure, is now how both are drawn. */

.wheel-cta,
.wheel-spin {
  background: var(--buy-face);
  border: 3px solid var(--outline);
  box-shadow: 0 4px 0 var(--buy-edge);
  color: var(--buy-text);
  font-size: 20px;
  font-weight: 800;
  text-transform: uppercase;
}

.wheel-cta { white-space: nowrap; }

.wheel-cta:hover:not(:disabled),
.wheel-spin:hover:not(:disabled) { box-shadow: 0 6px 0 var(--buy-edge); }

.wheel-cta:active:not(:disabled),
.wheel-spin:active:not(:disabled) { box-shadow: 0 2px 0 var(--buy-edge); }

/* Flush into the card's TOP-LEFT corner: only the inner corner is rounded and the two outer borders go,
   or they double up on the card's own frame. Not the right, either corner: the grand prize is centred on
   the plank and nearly as tall as the card, so it owns that whole edge. Not the bottom-left either — the
   strip under the plank belongs to the next-reward line, which is centred in it. */
.pass-track-timer {
  position: absolute;
  left: 0;
  top: 0;
  margin: 0;
  border-radius: 0 0 12px 0;
  border-left: 0;
  border-top: 0;
}

.modal-card-wheel { max-width: 560px; overflow-x: clip; }

.wheel-head {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
}

.wheel-head h2 { margin: 0; }

#wheelModalBody {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  margin-top: 18px;
}

.wheel-spin { width: min(100%, 320px); }
.wheel-spin:disabled { background: var(--surface-2); border-color: var(--line); box-shadow: none; }

.wheel-stage {
  position: relative;
  width: min(420px, 86vw, 48vh);
  max-width: 100%;
  aspect-ratio: 1;
  container-type: inline-size;
}

/* Every measurement on the wheel below is in cqi - 1% of the stage's own width - rather than pixels.
   The stage is the container, so the whole thing scales as one piece: rim, spokes, pointer and hub keep
   their share of the wheel at any size. In pixels they did not, which is what made the teaser's small
   wheel look like a different wheel rather than a smaller one - the same 34px pointer over a 108px
   wheel is three times the pointer it is over the modal's. The numbers are the pixel values that were
   here divided by 3, which is 1% of the modal's width on a phone, so that wheel is unchanged. */
/* On in the spin window, off in the shop's preview. The preview is a still picture of a wheel and a
   pointer on it promises a spin the card cannot give; in the window it is the thing the prize lands
   under, and the spin flaps it on every tick and lights it on landing. */
/* The gold ring running to a point. This element is the dark outline and ::after is the gold; both
   start at the same y - the ring's outer edge, 1.7cqi past the wheel - so no dark sits above the base
   and the two golds meet as one shape, while the dark shows only down the two sides and around the tip,
   the way the rim's own outline wraps everything else. Nothing reaches beyond the ring's outer edge, so
   the wheel's silhouette stays a circle. */
.wheel-pointer {
  position: absolute;
  left: 50%;
  top: -1.7cqi;
  transform: translateX(-50%);
  z-index: 3;
  width: 0;
  height: 0;
  border-left: 4.8cqi solid transparent;
  border-right: 4.8cqi solid transparent;
  border-top: 7cqi solid var(--outline);
}

/* Offsets are from the padding box, which on a border-drawn triangle sits at the apex - so `top` is the
   dark triangle's full height to put the gold's top edge level with the dark's, not a small inset. */
.wheel-pointer::after {
  content: "";
  position: absolute;
  left: -4cqi;
  top: -7cqi;
  width: 0;
  height: 0;
  border-left: 4cqi solid transparent;
  border-right: 4cqi solid transparent;
  border-top: 6cqi solid var(--gold);
}

.wheel {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2cqi solid var(--outline);
  box-shadow: 0 0 0 1.7cqi var(--gold), 0 0 0 3.7cqi var(--outline), 0 3.3cqi 0 0 var(--pack-edge);
  overflow: hidden;
}

.wheel-face {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(from -22.5deg, var(--pack-face) 0deg 45deg, var(--surface) 45deg 90deg,
    var(--surface-2) 90deg 135deg, var(--surface) 135deg 180deg, var(--surface-2) 180deg 225deg,
    var(--surface) 225deg 270deg, var(--surface-2) 270deg 315deg, var(--surface) 315deg 360deg);
}

.wheel-spokes { position: absolute; inset: 0; }

.wheel-spokes i {
  position: absolute;
  left: 50%;
  top: 0;
  width: 1cqi;
  height: 50%;
  background: var(--outline);
  opacity: 0.55;
  transform-origin: 50% 100%;
}

.wheel-slot { position: absolute; inset: 0; }

.wheel-slot > span {
  position: absolute;
  left: 50%;
  top: 7%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1cqi;
  width: 21%;
}

.wheel-slot .pack-art {
  width: 62%;
  aspect-ratio: 1;
  min-height: 0;
  padding-bottom: 0;
  background-position: center;
  background-size: contain;
}

.wheel-amount {
  font-weight: 800;
  font-size: clamp(9px, 3.4cqi, 15px);
  line-height: 1.1;
  white-space: nowrap;
  color: #ffffff;
}

.wheel-hub {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  /* A cap, not a control. The hub carries no label on either wheel - the spin window has its own SPIN
     button under the wheel - so a grey button face and its lift would only promise a press that does
     nothing. It takes the rim's own colour instead, so it reads as part of the wheel. */
  width: 23%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--outline);
  color: var(--grey-text);
  border: 1.3cqi solid var(--outline);
  box-shadow: none;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 6%;
  font-weight: 900;
  font-size: clamp(9px, 4.2cqi, 18px);
  line-height: 1.05;
  text-align: center;
  text-transform: uppercase;
}

#wheelModal { z-index: 1040; }
#wheelOdds { z-index: 1050; }

.wheel-odds {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
}

.wheel-odds-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 10px 14px;
  border-radius: 10px;
  background: rgba(26, 15, 43, 0.26);
}

.wheel-odds-row + .wheel-odds-row { margin-top: 8px; }

.wheel-odds-prize { font-weight: 700; }
.wheel-odds-chance { flex: 0 0 auto; font-weight: 800; color: var(--gold); }

@media (max-width: 720px) {
  .modal-card-wheel { padding-left: 12px; padding-right: 12px; }
  /* Still a row on a phone: stacked, the teaser was the tallest thing on the page for one free spin. */
  .wheel-teaser { gap: 12px; padding: 10px 12px; }
  .wheel-teaser .wheel-stage { width: min(124px, 34vw, 26vh); }
  /* Decoration at this size: the amounts collide into a smudge, and the modal shows the real wheel. */
  .wheel-teaser .wheel-amount { display: none; }
  .wheel-teaser-copy { gap: 10px; }
  .wheel-teaser-title { font-size: 18px; }
  .wheel-cta { font-size: 17px; }
  .wheel-spin { font-size: 17px; }
  #wheelModalBody { gap: 16px; margin-top: 14px; }
}


.promo-panel { margin-top: 24px; }

.promo-title { margin: 0 0 14px; font-size: clamp(18px, 4.2vw, 22px); }

.promo-form { display: flex; flex-wrap: wrap; gap: 10px; }

.promo-input {
  flex: 1 1 200px;
  min-width: 0;
  padding: 12px 14px;
  background: var(--bg);
  border: 2px solid var(--line);
  border-radius: var(--radius);
  color: var(--text);
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.promo-input::placeholder { color: var(--muted); letter-spacing: normal; text-transform: none; font-weight: 400; }
.promo-input:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }
.promo-input:disabled { opacity: 0.6; }

.promo-submit { flex: 0 0 auto; }

.promo-message {
  margin: 12px 0 0;
  font-size: clamp(13px, 3vw, 15px);
  font-weight: 700;
  line-height: 1.45;
}

.promo-message-ok { color: var(--ok); }
.promo-message-error { color: var(--danger); }


/* Section: rewards pass. A gold plaque holding a milestone plank: the timer pill above, the rewards
   standing on the plank, the grand prize at its end, and the next-reward line beneath. */
/* The bottom band is the corner tab's, so the next-reward line stops above it rather than running under. */
.pass-track-card {
  position: relative;
  /* Capped like the other plaques: the section runs to the page's 1400px wrap, and a plank stretched
     that far reads as a rule across the page rather than as a card. */
  width: min(760px, 100%);
  margin: 0 auto;
  container-type: inline-size;
  overflow: hidden;
  /* An even frame, so the card's height is the grand prize's plus this and nothing else: the prize is
     centred on the plank and the plank is centred in the card, which makes the prize the tallest thing
     in it. The timer and the next-reward line used to hold a reserved band under the plank, and that
     band was paid for twice — once below, then mirrored above to keep the plank centred. Both are
     absolute now, standing in gold the prize's overhang already reserves. */
  --pass-pad-x: 8px;
  padding: 8px var(--pass-pad-x);
  /* The colour the burst sprite's own left edge is, so the layer above can stop short of the card's far
     side and the join not show. Flat, because the sprite rides on ::before now — see there. */
  background: rgb(232, 80, 53);
  border: 3px solid #8b5a13;
  border-radius: 18px;
  /* A dropped shadow, not the hard offset edge the buy plaques carry: with that edge the whole track
     read as one giant button, and nothing about it is pressable. */
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.28);
}

/* The VIP card's own burst sprite, not a radial painted to imitate one. It rides on its own layer rather
   than on the card's background because its rays do not converge at its centre — they meet at 77% across
   and 37% down of the art — and `background-position` can only align a point of the image with the SAME
   point of the box, which is not where the prize is. As a positioned layer the convergence can be put
   anywhere: here, on the grand prize, so the card reads as lit by what it is selling.

   Container units resolve against the card here, exactly as they do for the track's own children, so the
   inset below is the prize's clamp halved and cannot drift from it. The width is what it takes for the
   layer to still cover the card once it has been shifted this far off-centre; the card clips the rest. */
.pass-track-card::before {
  /* Held at the width `cover` used to give it. Sized to reach the card's far edge instead, it had to be
     a quarter bigger again — the bright point is 77% across the art, so covering everything to its left
     means a lot of art — and the rays came out coarse. It stops short of that edge now and fades into the
     flat colour underneath, which is the sprite's own edge colour. The few percent over `cover` are what
     it takes to still reach the card's top and bottom on the narrowest card, where the card is shortest
     relative to its width. */
  --burst-w: 110cqi;
  --burst-h: calc(var(--burst-w) * 608 / 740);
  /* Half the grand prize — var(--pass-prize) halved, which clamp() allows term by term. */
  --burst-inset: clamp(44px, 13.5cqi, 66px);
  content: '';
  position: absolute;
  width: var(--burst-w);
  height: var(--burst-h);
  /* Solved so the sprite's bright point lands on the prize's centre: the prize sits
     (padding + half itself) in from the card's edge, and the bright point sits 0.77 across the layer. */
  right: calc(var(--pass-pad-x) + var(--burst-inset) - 0.23 * var(--burst-w));
  top: calc(50% - 0.373 * var(--burst-h));
  background: center / 100% 100% no-repeat url('/media/privilegecard-vip-bg.png');
  /* Only the near edge fades — the other three are the flat colour already, to the pixel. */
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 22%);
  mask-image: linear-gradient(to right, transparent 0, #000 22%);
  pointer-events: none;
}


.pass-track-scroller { overflow: visible; }

/* The top padding is headroom for the grand prize, which stands taller than the rewards leading to it.
   The left padding is the lane the currency icon stands in — the plank underneath still runs full width,
   because it is positioned against the padding box, not the content box. */
.pass-track {
  position: relative;
  /* The grand prize, and half of it. The plank is inset by that half at both ends, so the bar sits
     centred in the card and its right end falls exactly on the prize's centre — derived rather than
     written twice, so the two cannot drift apart. */
  --pass-prize: clamp(88px, 27cqi, 132px);
  --pass-inset: calc(var(--pass-prize) / 2);
  /* The near end is inset only enough to seat the currency icon that caps it — half its 30px, plus a
     hair of daylight. Only the FAR end has to clear half the grand prize, and matching the two put 83px
     of empty gold in front of the plank. */
  --pass-inset-start: 18px;
  /* The currency icon that caps the plank's near end. It is centred on that end, so it overhangs the
     start by half itself — which is what the tiers have to clear. */
  --pass-icon: 30px;
  /* The tiers leading up to it, named so the lane above the plank can be derived from the gap between
     the two rather than guessed: the prize stands that much taller than they do. */
  --pass-art: clamp(24px, 8cqi, 38px);
  /* The plank's thickness and the floor it stands on, shared by the rail, the node rows and the grand
     prize. The floor has to clear half that prize, which hangs below the bar it is centred on. */
  --pass-rail-h: 20px;
  /* Exactly the half of the prize that hangs below the bar it is centred on, so the prize's foot lands on
     the track's floor at every width — a fixed value left 18px of dead space under a smaller prize and
     threw the bar off centre on a phone. */
  --pass-overhang: calc(var(--pass-prize) / 2 - var(--pass-rail-h) / 2);
  /* Room for the timer's pill in the lane above the plank. The next-reward line keeps the strip below,
     centred: on a phone that strip is not wide enough for both it and the pill — with the grand prize
     taking its right end there is no middle left to centre anything in — so the pill is what moves. */
  --pass-chrome: 38px;
  /* The lane above the plank: whichever is larger of that text and the part of the prize's overhang the
     tiers do not already cover. The floor below then mirrors it — lane plus the tiers' own height is
     exactly what sits above the plank, so writing the floor that way is what keeps the plank centred,
     at any prize size, without reserving the same space twice. */
  --pass-lane: max(var(--pass-chrome), calc(var(--pass-overhang) - var(--pass-art)));
  --pass-rail-floor: calc(var(--pass-lane) + var(--pass-art));
  padding: var(--pass-lane) 0 var(--pass-rail-floor) var(--pass-inset-start);
}

.pass-track-start {
  position: absolute;
  /* Centred on the plank's start in both axes, so it reads as the cap on the end of the bar. */
  left: calc(var(--pass-inset-start) - var(--pass-icon) / 2);
  bottom: calc(var(--pass-rail-floor) + var(--pass-rail-h) / 2 - var(--pass-icon) / 2);
  z-index: 1;
  width: var(--pass-icon);
  height: var(--pass-icon);
  background: center / contain no-repeat url('/media/points-icon.png');
  filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.35));
}

/* The plank, measured from the bottom of the track so the reserved button row keeps it in place however
   tall the reward art above it grows. */
.pass-track-rail {
  position: absolute;
  left: var(--pass-inset-start);
  right: var(--pass-inset);
  bottom: var(--pass-rail-floor);
  height: var(--pass-rail-h);
  background: #6b3f1e;
  border-radius: 5px;
  box-shadow: inset 0 2px 0 #ab7442, 0 3px 0 rgba(0, 0, 0, 0.22);
  overflow: hidden;
}

/* Fills the plank rather than running a seam along its top edge. As a 5px hairline it landed on the same
   line as the plank's own inset highlight and read as part of the plank, not as progress — the earlier
   worry, that gold on gold disappears, is about the plaque; inside the dark plank a gold bar has all the
   contrast it needs. The right end rounds so the head of the run is legible; the left is clipped round by
   the rail's own radius. */
.pass-track-fill {
  height: 100%;
  border-radius: 5px;
  background: #f7c33e;
  /* Both inset: an outer shadow still paints when the run is 0%, leaving a stray tick at the plank's
     start before the player has spent anything. */
  box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.5), inset -1px 0 0 rgba(67, 36, 14, 0.55);
}

/* The first tier starts past the currency icon rather than at the plank's start: the icon is centred on
   that start and overhangs it by half itself, and on a phone the first tier landed on top of it. Padding
   the row, not the track, so the plank still runs the full width behind it. */
.pass-track-nodes {
  position: relative;
  display: flex;
  align-items: stretch;
  width: 100%;
  margin: 0;
  padding: 0 0 0 calc(var(--pass-icon) / 2 + 4px);
  list-style: none;
}

/* Two rows — the reward, then the plank's own band — fixed rather than auto so the absolutely positioned
   plank can be offset by a constant and still land exactly under every reward's feet. */
.pass-node {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  display: grid;
  grid-template-rows: 1fr var(--pass-rail-h);
  row-gap: 0;
  justify-items: center;
}

/* A notch on the plank under every tier but the last, so the track reads as measured out in steps
   rather than as one unbroken run. Drawn as a grid item in the node's own second row, which is exactly
   the plank's band and exactly the node's centre - the plank itself is absolutely positioned, so a tick
   parented to it would have to re-derive each tier's position and could drift from the art above it.
   The node row paints after the plank, so no z-index is needed. Not on .is-final: that end of the plank
   is under the grand prize, and a notch there reads as a seam in the bar rather than a step. */
.pass-node:not(.is-final)::after {
  content: '';
  grid-row: 2;
  justify-self: center;
  align-self: stretch;
  width: 3px;
  margin: 3px 0;
  border-radius: 2px;
  /* Dark enough to hold on the gold fill behind the run as well as on the bare plank ahead of it. */
  background: #4a2a12;
  opacity: 0.75;
  pointer-events: none;
}

.pass-node-art {
  position: relative;
  grid-row: 1;
  align-self: end;
  /* Dipped into the plank rather than standing on top of it — the rewards should overlap the bar. */
  transform: translateY(6px);
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

/* An explicit height, not min-height: .pack-art is sized by min-height elsewhere and carries 18px of
   bottom padding for the card rows, so zeroing the min-height left an 18px sliver with the sprite
   squeezed into it. Square and centred here, where it is an icon rather than a card's hero art. */
.pass-node-art .pack-art {
  width: var(--pass-art);
  height: var(--pass-art);
  min-height: 0;
  padding-bottom: 0;
  background-position: center;
}

/* The count on demand: seven permanent badges crowded a track that now has to fit all seven without
   scrolling, so the amount is a tooltip the reward opens. Anchored to the art so it clears the reward
   whatever slack the row above it has. */
.pass-node-tip {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 6px);
  transform: translateX(-50%);
  z-index: 3;
  padding: 2px 8px;
  border: 2px solid var(--outline);
  border-radius: 999px;
  background: rgba(20, 12, 34, 0.92);
  color: #ffe9c6;
  font-size: clamp(10px, 3cqi, 13px);
  font-weight: 800;
  line-height: 1.35;
  white-space: nowrap;
  pointer-events: none;
}

/* The sprite dims, not the box: the grand prize's frame and burst are drawn as pseudo-elements of the
   same box, and dimming those turned the gold disc grey. */
.pass-node.is-claimed .pass-node-art .pack-art { filter: brightness(0.7) saturate(0.4); }

/* On the node, not on the art: the art carries a filter, and a tick inside it would be dimmed too. */
.pass-node.is-claimed .pass-node-art::after {
  content: '';
  position: absolute;
  /* Over the sprite, and over the sparkles on the grand prize: that sprite takes z-index 1 to sit on the
     plank, so an un-layered tick was painted behind the art it is meant to mark. Only the final tier is
     large enough for the sprite to hide it, which is why it looked like a tick that only broke there. */
  z-index: 3;
  right: -8%;
  top: -8%;
  width: 52%;
  height: 52%;
  background: center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.4 5.4 9.5 16.3 4.1 10.9' fill='none' stroke='%231a0f2b' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M20.4 5.4 9.5 16.3 4.1 10.9' fill='none' stroke='%234fd18b' stroke-width='3.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  pointer-events: none;
}

.pass-node.is-available .pass-node-art .pack-art {
  filter: drop-shadow(0 0 8px #fff6d0) drop-shadow(0 0 16px var(--gold));
}

/* A handful of sparkles over the grand prize, instead of the S badge's sweeping streak. That streak is
   Shine_Button_Small, built for a small badge: blown up to a prize this size it read as a pale wash
   crossing the art rather than as a highlight. Each sparkle twinkles on its own offset so the group
   never pulses together, and all of them are sized off the prize so they hold their proportion. */
.pass-node-glitter {
  position: absolute;
  inset: 0;
  /* Above the sprite, which takes z-index 1 to sit over the plank. Below this the sparkles are painted
     behind opaque art and twinkle where nobody can see them. */
  z-index: 2;
  pointer-events: none;
}

.pass-node-glitter span {
  position: absolute;
  width: var(--glint, calc(var(--pass-prize) * 0.28));
  aspect-ratio: 1;
  background: #fffdf2;
  /* Four points with pinched sides — square-cut ones read as plus signs rather than as glints. */
  clip-path: polygon(50% 0, 57% 43%, 100% 50%, 57% 57%, 50% 100%, 43% 57%, 0 50%, 43% 43%);
  /* Two halos, not one: the sparkle is white and crosses both the purple art and the bright yellow of
     the burst behind it, and against the yellow a white star on a faint white glow disappeared. The warm
     outer halo is what separates it from that background. */
  filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.95)) drop-shadow(0 0 10px rgba(255, 190, 60, 0.9));
  opacity: 0;
  animation: pass-glitter 2.4s ease-in-out infinite;
}

.pass-node-glitter span:nth-child(1) { left: 6%; top: 22%; animation-delay: 0s; }
.pass-node-glitter span:nth-child(2) { left: 62%; top: 8%; animation-delay: 0.8s; --glint: calc(var(--pass-prize) * 0.21); }
.pass-node-glitter span:nth-child(3) { left: 40%; top: 62%; animation-delay: 1.6s; --glint: calc(var(--pass-prize) * 0.17); }

/* Each sparkle holds its peak for a beat rather than blinking through it, and the 0.8s stagger over a
   2.4s cycle keeps one lit at all times — at the old timing all three were dark for most of the loop,
   which is what made the effect easy to miss entirely. */
@keyframes pass-glitter {
  0%, 100% { opacity: 0; transform: scale(0.2) rotate(0deg); }
  14% { opacity: 1; transform: scale(1.15) rotate(30deg); }
  40% { opacity: 1; transform: scale(0.95) rotate(55deg); }
  60% { opacity: 0; transform: scale(0.2) rotate(80deg); }
}

@media (prefers-reduced-motion: reduce) {
  .pass-node-glitter span { animation: none; opacity: 0; }
}

/* The claimable tier is the only control on the track, so it has to announce itself: a slow turning ray
   burst behind the reward, masked off at the rim so it fades rather than ending on a hard circle. Painted
   as ::before, which sits under the art's own children without needing a stacking context of its own. */
.pass-node.is-available .pass-node-art::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 230%;
  aspect-ratio: 1;
  transform: translate(-50%, -50%);
  /* White, not cream: the card's own face is a gold burst, and warm rays on it read as a smudge. */
  background: repeating-conic-gradient(from 0deg,
    rgba(255, 255, 255, 0.95) 0deg 7deg, rgba(255, 255, 255, 0) 7deg 20deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 18%, rgba(0, 0, 0, 0) 72%);
  mask-image: radial-gradient(closest-side, #000 18%, rgba(0, 0, 0, 0) 72%);
  animation: pass-rays 11s linear infinite;
  pointer-events: none;
}

@keyframes pass-rays {
  to { transform: translate(-50%, -50%) rotate(360deg); }
}

/* A button, but it must not look like one — the art is the whole affordance. */
.pass-node-art {
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
}

button.pass-node-art { cursor: pointer; }

button.pass-node-art:focus-visible { outline: 3px solid var(--gold); outline-offset: 3px; }

button.pass-node-art:active .pack-art { transform: scale(0.94); }

button.pass-node-art:disabled { cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .pass-node.is-available .pass-node-art::before { animation: none; }
}

/* The end of the plank is the prize the season is sold on: a lit disc, a burst behind it, and enough
   size that it overhangs the rewards leading up to it. */
.pass-node.is-final { flex: 0 0 var(--pass-prize); }

/* Dropped onto the plank rather than standing on it: the art is pushed down until its feet reach the
   plank's middle, so it reads as the prize sitting at the end of the run. */
/* Taken out of flow and hung on the plank's centre line. In flow it set the height of every tier's row,
   which is where most of the card's height came from; here the row is sized by the ordinary rewards and
   the prize simply straddles the bar. */
.pass-node.is-final .pass-node-art {
  position: absolute;
  left: 50%;
  bottom: calc(var(--pass-rail-h) / 2);
  z-index: 2;
  transform: translate(-50%, 50%);
}


.pass-node.is-final .pass-node-art .pack-art {
  position: relative;
  z-index: 1;
  width: var(--pass-prize);
  height: var(--pass-prize);
  min-height: 0;
  padding-bottom: 0;
}

/* The ref's closing block: the label, and under it the points counted against the next tier's cost. */
/* Out of flow and onto one line. Stacked in flow it cost the card twice its own height — once here,
   once mirrored above to keep the plank centred — for two short strings. It stands in the lane above the
   plank, centred, with the timer's pill moved out of that strip so there is a middle to centre it in. */
.pass-progress {
  position: absolute;
  left: 50%;
  bottom: 6px;
  transform: translateX(-50%);
  display: flex;
  /* Centred, not baseline: the label is 11px against a 15px count and a 19px icon, and sharing a
     baseline dropped it far enough below them to read as its own line. */
  align-items: center;
  gap: 7px;
  margin: 0;
  white-space: nowrap;
  color: #4a2a0c;
  font-weight: 800;
  text-align: center;
  text-shadow: 0 1px 0 rgba(255, 244, 214, 0.7);
}

.pass-progress-label {
  margin: 0;
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* On the narrowest cards the lane is not wide enough for both the label and the grand prize, and the
   line ran into the prize's corner. The count alone carries it — it is already under a heading that
   says what the track is. Keyed to the card, not the viewport: the card is the container that shrinks. */
@container (max-width: 300px) {
  .pass-progress-label { display: none; }
}

.pass-progress-value {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  margin: 0;
  font-size: 15px;
}

.pass-progress-icon {
  width: 19px;
  height: 19px;
  background: center / contain no-repeat url('/media/points-icon.png');
}

@media (min-width: 721px) {
  .pass-track-card { --pass-pad-x: 14px; }
}


.shop-tabbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 55;
  /* The header's plate, mirrored: same fill, same hairline, with the glow spilling upward instead of
     down - so the two strips read as one frame around the store rather than as two bars. */
  background: var(--bg);
  border-top: 1px solid var(--line);
  box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.38);
  padding-bottom: max(0px, env(safe-area-inset-bottom));
}

/* Top padding leaves room for the badges, which overhang the tabs the way the game's alert dot does. */
.shop-tabbar-track {
  display: flex;
  gap: 8px;
  padding: 12px 10px 8px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}

.shop-tabbar-track::-webkit-scrollbar { display: none; }

/* The game's tab: a fixed plate with the name centred on it, dark and bordered when inactive, a bright
   convex yellow plaque when active. Text only — the game's tabs carry no icon either. */
.shop-tab {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 88px;
  min-height: 46px;
  padding: 8px 14px;
  background: #291f21;
  border: 2px solid var(--outline);
  border-radius: 12px;
  box-shadow: inset 0 3px 0 rgba(0, 0, 0, 0.45), inset 0 -3px 0 rgba(71, 58, 58, 0.9);
  color: #a79e9f;
  font: inherit;
  font-size: 14px;
  font-weight: 800;
  cursor: pointer;
}

.shop-tab:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }

/* The game swaps the whole background for a convex yellow plaque, so this is a lit top edge and a hard
   bottom edge rather than a flat fill. */
.shop-tab.is-active {
  background: linear-gradient(180deg, #ffe482, var(--gold));
  box-shadow: 0 3px 0 #a9760c, inset 0 2px 0 rgba(255, 255, 255, 0.55);
  color: #ffffff;
  text-shadow: var(--text-outline), var(--text-drop);
}

.shop-tab-label {
  max-width: 132px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Overhangs the top-left corner — written out rather than the game's alert dot, since a dot cannot say
   which of the two states it means. */
.shop-tab-badge {
  position: absolute;
  top: -9px;
  left: -6px;
  padding: 1px 7px;
  border: 2px solid var(--outline);
  border-radius: 999px;
  color: var(--outline);
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 0.04em;
  line-height: 1.4;
  text-shadow: none;
  text-transform: uppercase;
  white-space: nowrap;
}

.shop-tab-badge-free { background: var(--buy-face); }
.shop-tab-badge-hot { background: var(--danger); }

/* Both at once: HOT keeps the corner and FREE steps down beside it, rather than stacking on top of it. */
.shop-tab.has-free.has-hot .shop-tab-badge-free { top: auto; bottom: -9px; }

body.has-shop-tabbar { padding-bottom: var(--shop-tabbar-h, 0px); }
body.has-shop-tabbar .env-badge { bottom: calc(12px + var(--shop-tabbar-h, 0px)); }

@media (max-width: 720px) {
  .shop-tab {
    min-width: 74px;
    min-height: 42px;
    padding: 7px 11px;
    font-size: 13px;
  }

  .shop-tab-label { max-width: 96px; }
}

@media (min-width: 721px) {
  .shop-tabbar-track { max-width: 1100px; margin: 0 auto; }

  /* Auto margins rather than justify-content: centring a scroller that way clips its first tab once the
     strip overflows. */
  .shop-tab:first-child { margin-left: auto; }
  .shop-tab:last-child { margin-right: auto; }
}


.wheel-pointer.is-ticking { animation: wheel-flap 90ms ease-out; }

@keyframes wheel-flap {
  0% { transform: translateX(-50%) rotate(0deg); }
  40% { transform: translateX(-50%) rotate(-13deg); }
  100% { transform: translateX(-50%) rotate(0deg); }
}

.wheel-pointer.is-landed::after { border-top-color: #fff3c4; }

.wheel-slot.is-winner > span {
  animation: wheel-win 550ms ease-out;
  filter: drop-shadow(0 0 8px var(--gold)) drop-shadow(0 0 18px var(--gold));
}

@keyframes wheel-win {
  0% { transform: translate(-50%, 0) scale(1); filter: none; }
  35% { transform: translate(-50%, 0) scale(1.28); filter: drop-shadow(0 0 12px #fff3c4) drop-shadow(0 0 26px var(--gold)); }
  100% { transform: translate(-50%, 0) scale(1.08); filter: drop-shadow(0 0 8px var(--gold)) drop-shadow(0 0 18px var(--gold)); }
}

@media (prefers-reduced-motion: reduce) {
  .wheel-pointer.is-ticking { animation: none; }
  .wheel-slot.is-winner > span { animation: none; }
}

[hidden] { display: none !important; }
