  :root {
    --bg0: #150e1f;
    --ink: #f4effa;
    --muted: #a795c2;
    --line: rgba(255, 255, 255, 0.09);
    --panel: rgba(255, 255, 255, 0.04);
    --red: #ff2d55;
    /* One page width, named once. The column grew from 1000px so the banner can
       hold two columns without either of them getting cramped. */
    --page: 1120px;
    --focus: rgba(255, 45, 85, 0.75);
    --c-n: #9aa3b2;
    --c-r: #57b0ff;
    --c-sr: #c77dff;
    --c-ssr: #ffd166;
    --c-ur: #ff2d55;
    --font-display: 'Anton', 'Arial Narrow', Impact, sans-serif;
    --font-body: 'Space Grotesk', system-ui, sans-serif;
    --font-mono: 'Space Mono', ui-monospace, monospace;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: var(--font-body);
    color: var(--ink);
    background:
      radial-gradient(1100px 620px at 50% -10%, #33224e 0%, transparent 60%),
      radial-gradient(900px 520px at 105% 110%, #2a1030 0%, transparent 55%),
      var(--bg0);
    min-height: 100vh;
  }

  ::selection { background: var(--red); color: #fff; }

  header, main, footer {
    max-width: var(--page);
    margin: 0 auto;
    padding-left: 20px;
    padding-right: 20px;
  }

  /* A visible ring on every interactive thing, keyboard-only. Several controls
     previously had nothing at all, which is both an accessibility hole and the
     kind of unfinished edge that reads as a prototype. */
  :where(button, a, select, input, [tabindex]):focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 2px;
    border-radius: 6px;
  }

  .site-head { padding-top: 26px; padding-bottom: 2px; }

  .brand { display: flex; align-items: center; gap: 14px; }

  h1 {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: clamp(30px, 6vw, 46px);
    letter-spacing: 1px;
    text-transform: uppercase;
    line-height: 1;
  }

  .play-glyph {
    flex: none;
    width: 0; height: 0;
    border-left: 22px solid var(--red);
    border-top: 14px solid transparent;
    border-bottom: 14px solid transparent;
    filter: drop-shadow(0 0 10px rgba(255, 45, 85, 0.55));
  }

  .tagline { color: var(--muted); margin-top: 8px; font-size: 15px; }

  /* Three flat statements, styled as one quiet row rather than three badges —
     they are reassurance, not features, and should not compete with the banner. */
  main { display: grid; gap: 18px; padding-bottom: 28px; padding-top: 18px; }

  .panel {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 18px;
    padding: 22px 24px;
  }

  .panel-banner { padding: 24px 26px 26px; }

  /* The banner is where the page is used; the collection is where it is read
     back. Giving the two the same weight left the visitor no cue about which to
     look at first, so the banner now carries a warmer field and a faint lift. */
  .panel-banner {
    position: relative;
    overflow: hidden; /* contains the ambient layer below */
    background:
      radial-gradient(120% 140% at 82% -20%, rgba(255, 45, 85, 0.11) 0%, transparent 58%),
      linear-gradient(180deg, rgba(255, 255, 255, 0.065) 0%, rgba(255, 255, 255, 0.028) 100%);
    border-color: rgba(255, 255, 255, 0.13);
    box-shadow: 0 18px 46px -28px rgba(0, 0, 0, 0.9);
  }

  .panel-banner > *:not(.hero-amb) { position: relative; z-index: 1; }

  /* ---- ambient hero lighting -----------------------------------------------
     One slow radial breath behind the pack plus six drifting motes. Six, not
     sixty: the brief is a PC-client lobby, and the difference between "premium"
     and "mobile storefront" here is almost entirely restraint. Everything is
     transform/opacity only, so it stays on the compositor. */
  .hero-amb {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    overflow: hidden;
  }

  .amb-glow {
    position: absolute;
    right: -6%;
    top: 50%;
    width: 460px;
    height: 460px;
    margin-top: -230px;
    border-radius: 50%;
    background: radial-gradient(closest-side, rgba(255, 45, 85, 0.2), rgba(160, 60, 190, 0.09) 55%, transparent 76%);
    filter: blur(18px);
    animation: amb-breathe 7s ease-in-out infinite;
  }

  @keyframes amb-breathe {
    0%, 100% { opacity: 0.62; transform: scale(0.96); }
    50%      { opacity: 1;    transform: scale(1.06); }
  }

  .amb-mote {
    position: absolute;
    width: 3px;
    height: 3px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(255, 220, 235, 0.95) 0%, rgba(255, 45, 85, 0.5) 45%, transparent 72%);
    opacity: 0;
    animation: amb-drift var(--dur, 13s) linear var(--delay, 0s) infinite;
  }

  /* Hand-placed rather than randomised, so the drift never bunches and the whole
     field is reproducible between loads. */
  .amb-mote:nth-child(2) { left: 62%; bottom: -6px; --dur: 12s; --delay: 0s; }
  .amb-mote:nth-child(3) { left: 74%; bottom: -6px; --dur: 16s; --delay: 2.4s; }
  .amb-mote:nth-child(4) { left: 86%; bottom: -6px; --dur: 14s; --delay: 5.1s; }
  .amb-mote:nth-child(5) { left: 68%; bottom: -6px; --dur: 18s; --delay: 7.6s; }
  .amb-mote:nth-child(6) { left: 92%; bottom: -6px; --dur: 15s; --delay: 3.3s; }
  .amb-mote:nth-child(7) { left: 56%; bottom: -6px; --dur: 17s; --delay: 9.2s; }

  @keyframes amb-drift {
    0%   { opacity: 0;    transform: translate3d(0, 0, 0) scale(0.6); }
    12%  { opacity: 0.85; }
    78%  { opacity: 0.5; }
    100% { opacity: 0;    transform: translate3d(-26px, -320px, 0) scale(1); }
  }

  .panel-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
    margin-bottom: 14px;
  }

  h2 {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: 19px;
    letter-spacing: 1.5px;
    text-transform: uppercase;
  }

  h2::before {
    content: '';
    display: inline-block;
    width: 8px; height: 8px;
    background: var(--red);
    margin-right: 10px;
    transform: rotate(45deg) translateY(-1px);
  }

  .hint { color: var(--muted); font-size: 12.5px; }

  /* The collection head holds three things (title, summary, clear). Pushing the
     summary right keeps it grouped with the button instead of stranding it in
     the middle of a space-between row. */
  #coll-summary { margin-left: auto; }

  /* A text button for a destructive-but-minor action: deliberately quieter than
     anything in the banner, so "Clear collection" can never be mistaken for part
     of the pull flow. It only appears when there is something to clear. */
  .linkish {
    background: none;
    border: 0;
    padding: 0;
    color: var(--muted);
    font-family: inherit;
    font-size: 12.5px;
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 3px;
    opacity: 0.75;
  }

  .linkish:hover,
  .linkish:focus-visible { color: var(--red); opacity: 1; }

  /* Mode toggle. Demoted: "Live API" is a power-user route that needs a Google
     Cloud key, and for the player this build is aimed at it is noise. It stays
     reachable — removing a working feature to tidy a screen is the wrong trade —
     but it no longer wears the accent colour the pull controls own. */
  .mode-toggle {
    display: flex;
    background: rgba(0, 0, 0, 0.24);
    border: 1px solid var(--line);
    border-radius: 999px;
    padding: 2px;
    opacity: 0.75;
    transition: opacity 0.2s ease;
  }

  .mode-toggle:hover, .mode-toggle:focus-within { opacity: 1; }

  .mode-btn {
    border: none;
    background: transparent;
    color: var(--muted);
    font-family: var(--font-body);
    font-weight: 600;
    font-size: 12px;
    padding: 6px 13px;
    border-radius: 999px;
    cursor: pointer;
  }

  .mode-btn.active { background: rgba(255, 255, 255, 0.11); color: var(--ink); }

  /* ---- the stage -----------------------------------------------------------
     One object, centred, with one instruction under it. The two-column layout
     this replaces was a form: a set of controls on the left and artwork on the
     right, with the pull as a submit button. A gacha is not a form. Everything
     that described the machine — set size, band counts, drop table, completion —
     is gone, and what is left is the pack. */
  .stage {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 8px 0 4px;
  }

  /* The two controls that must stay reachable, pushed to the top edge and kept
     quiet. A player picks a set once and then never looks at this row again. */
  .stage-top {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 6px;
  }

  .set-pick { max-width: 260px; }

  .set-pick .field {
    background: rgba(0, 0, 0, 0.22);
    border-color: rgba(255, 255, 255, 0.1);
    font-size: 13.5px;
    padding: 9px 34px 9px 13px;
  }

  /* Visually hidden but present for screen readers and the aria-labelledby on
     the section — the heading is redundant to anyone who can see the pack. */
  .sr-only {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  /* ---- the pack ------------------------------------------------------------
     Three fanned card backs. Same vocabulary as the back the reveal flips over
     (concentric rings, red play glyph, wordmark) so it is a preview of the real
     thing rather than unrelated decoration. Static by default; the fan opens on
     hover of the whole banner, which quietly connects it to the pull buttons. */
  /* The pack is a real <button>: it is the primary action, so it gets the
     semantics, the keyboard behaviour and the focus ring for free rather than a
     div with a click handler bolted on. */
  .pack {
    position: relative;
    display: block;
    width: min(300px, 74vw);
    aspect-ratio: 5 / 7;
    perspective: 1000px;
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
    transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.3, 1.05);
  }

  .pack:disabled { cursor: default; opacity: 0.45; }
  .pack:not(:disabled):hover { transform: translateY(-6px); }
  .pack:not(:disabled):active { transform: translateY(-1px) scale(0.985); }

  .pack-card {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    align-content: center;
    border-radius: 14px;
    border: 1px solid rgba(255, 255, 255, 0.13);
    background: radial-gradient(circle at 50% 42%, #3a2757 0%, #1c1229 72%);
    box-shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.75);
    transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.3, 1.05);
    overflow: hidden;
  }

  .pack-card::after {
    content: '';
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(115deg, transparent 0 14px, rgba(255, 255, 255, 0.025) 14px 16px);
  }

  /* Back two first so the front card stacks last and stays fully legible. */
  .pack-card:nth-child(1) { transform: rotate(-9deg) translate(-14px, 4px) scale(0.96); filter: brightness(0.62); }
  .pack-card:nth-child(2) { transform: rotate(5deg) translate(10px, 1px) scale(0.98); filter: brightness(0.8); }
  .pack-card:nth-child(3) { transform: rotate(-1deg); }

  /* The fan opens on the pack's own hover/focus now, not the whole panel — the
     pack is the control, so it should be the thing that answers you. */
  .pack:not(:disabled):hover .pack-card:nth-child(1),
  .pack:not(:disabled):focus-visible .pack-card:nth-child(1) { transform: rotate(-17deg) translate(-38px, 10px) scale(0.96); }
  .pack:not(:disabled):hover .pack-card:nth-child(2),
  .pack:not(:disabled):focus-visible .pack-card:nth-child(2) { transform: rotate(12deg) translate(34px, 4px) scale(0.98); }

  .pack-rings {
    position: absolute;
    width: 58px; height: 58px;
    border-radius: 50%;
    box-shadow:
      0 0 0 1px rgba(255, 255, 255, 0.1),
      0 0 0 24px rgba(255, 255, 255, 0.045),
      0 0 0 50px rgba(255, 255, 255, 0.03);
  }

  .pack-play {
    position: relative;
    width: 0; height: 0;
    margin-left: 6px;
    border-left: 26px solid var(--red);
    border-top: 16px solid transparent;
    border-bottom: 16px solid transparent;
    filter: drop-shadow(0 0 12px rgba(255, 45, 85, 0.55));
  }

  .pack-word {
    position: absolute;
    bottom: 14px;
    left: 0; right: 0;
    text-align: center;
    font-family: var(--font-display);
    font-size: 10px;
    letter-spacing: 3px;
    text-transform: uppercase;
    color: rgba(203, 184, 230, 0.6);
  }

  /* The pack breathes so the stage is never fully still. Stops on hover, where
     the lift and the fan take over. */
  .pack-card:nth-child(3) { animation: pack-float 5.5s ease-in-out infinite; }

  @keyframes pack-float {
    0%, 100% { transform: rotate(-1deg) translateY(0); }
    50%      { transform: rotate(-1deg) translateY(-8px); }
  }

  .pack:hover .pack-card:nth-child(3),
  .pack:focus-visible .pack-card:nth-child(3) { animation: none; }

  /* ---- the instruction -----------------------------------------------------
     Wikigacha's "TAP TO OPEN" does the entire job of a call to action without
     being a button, because the object above it already is one. */
  .tap-hint {
    margin-top: 18px;
    font-family: var(--font-display);
    font-size: 13px;
    letter-spacing: 3.5px;
    text-transform: uppercase;
    color: var(--muted);
    animation: tap-pulse 2.6s ease-in-out infinite;
  }

  .pack:disabled + .tap-hint { visibility: hidden; }

  @keyframes tap-pulse {
    0%, 100% { opacity: 0.5; }
    50%      { opacity: 1; }
  }

  /* ---- how many ------------------------------------------------------------
     A size choice, not a second pull button. Two segments, and the pack does
     the pulling. */
  .count-toggle {
    display: inline-flex;
    margin-top: 16px;
    padding: 3px;
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.32);
    border: 1px solid var(--line);
  }

  .count-btn {
    min-width: 62px;
    padding: 8px 18px;
    border: 0;
    border-radius: 999px;
    background: none;
    color: var(--muted);
    font-family: var(--font-mono);
    font-weight: 700;
    font-size: 13px;
    letter-spacing: 1px;
    cursor: pointer;
    transition: background 0.18s ease, color 0.18s ease;
  }

  .count-btn:hover { color: var(--ink); }

  .count-btn.on {
    background: linear-gradient(135deg, #ff2d55, #c9184a);
    color: #fff;
    box-shadow: 0 4px 16px -6px rgba(255, 45, 85, 0.8);
  }

  @media (max-width: 899px) {
    .pack-word { bottom: 10px; font-size: 9px; letter-spacing: 2px; }
  }

  /* inputs */
  .field {
    width: 100%;
    padding: 11px 14px;
    border-radius: 10px;
    background: rgba(0, 0, 0, 0.35);
    border: 1px solid rgba(255, 255, 255, 0.14);
    color: var(--ink);
    font-family: var(--font-body);
    font-size: 14px;
  }

  .field:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }

  /* The native select kept the OS arrow and stretched the full page width for a
     two-item list. Capped and given the page's own chevron instead. */
  .select-wrap { position: relative; max-width: 420px; }

  .select-wrap::after {
    content: '';
    position: absolute;
    right: 15px;
    top: 50%;
    width: 8px; height: 8px;
    margin-top: -6px;
    border-right: 2px solid var(--muted);
    border-bottom: 2px solid var(--muted);
    transform: rotate(45deg);
    pointer-events: none;
  }

  .select-wrap .field {
    appearance: none;
    padding-right: 38px;
    font-weight: 500;
    /* The popup is drawn by the OS, not by us. Chrome derives its colours from
       the control's own computed background, and .field's is a translucent
       rgba() — with `appearance: none` on top there is nothing opaque to read,
       so it fell back to the light system menu against a dark page. Declaring
       the scheme here is what makes the popup dark; the option rules below are
       the belt to that braces, since Windows Chrome honours them and some
       versions still ignore the scheme on a restyled select. */
    color-scheme: dark;
  }

  /* Options are CHILDREN of the optgroup, so the header's mono-caps below
     cascades into them unless it is undone here — which is how the collapsed
     control came to read "Rarity" while the open list read "RARITY". The type
     reset is the point of this rule; the colours are the lesser half. */
  .select-wrap .field option {
    background-color: #1b1426;
    color: var(--ink);
    font-family: var(--font-body);
    font-weight: 400;
    font-size: 13.5px;
    letter-spacing: normal;
    text-transform: none;
  }

  /* The "Sort by" header. Chrome renders optgroup labels bold-italic grey by
     default, which reads as a disabled row rather than a heading — so it takes
     the same mono-caps treatment every other small label on the page uses. */
  .select-wrap .field optgroup {
    background-color: #150f1f;
    color: var(--muted);
    font-family: var(--font-mono);
    font-style: normal;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 1.4px;
    text-transform: uppercase;
  }

  /* Stored statistics have to be shown in their time context, so the snapshot
     date is a permanent fixture — it reads better as a stamp than as a stray
     line of grey text. */
  .stamp:empty { display: none; }

  .stamp {
    display: inline-block;
    margin-top: 9px;
    padding: 3px 9px;
    border: 1px solid var(--line);
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.25);
    font-family: var(--font-mono);
    font-size: 11px;
    color: var(--muted);
  }

  #live-controls { display: grid; gap: 10px; }
  #live-controls[hidden] { display: none; }
  .add-row { display: flex; gap: 10px; }
  .add-row .field { flex: 1; }

  /* Magic Search tier row (dev) — one button per sourcing pool. */
  .ms-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
  .ms-row .btn { padding: 8px 14px; font-size: 13px; }
  .ms-label {
    font-family: var(--font-mono);
    font-size: 11px;
    letter-spacing: 1.5px;
    text-transform: uppercase;
    color: var(--muted);
    margin-right: 2px;
  }

  /* Block, not grid: as a grid item the snapshot stamp stretched to the full
     column width and read as an empty input rather than a small tag. Spacing is
     owned by the children instead. */
  #sets-controls { display: block; }
  #sets-controls[hidden] { display: none; }
  #set-select { cursor: pointer; }
  #set-meta { font-family: var(--font-mono); }

  /* The dev deck-size readout. Styled to look like an instrument rather than
     part of the game: mono, dimmer than the snapshot stamp above it, and never
     competing with the pack for attention. `[hidden]` is what config.js sets
     outside dev, and a <p> would otherwise still take a line box. */
  #set-count {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    opacity: 0.55;
    margin-top: 0.15rem;
  }
  #set-count[hidden] { display: none; }

  /* buttons */
  .btn {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 15px;
    padding: 12px 24px;
    border-radius: 12px;
    border: 1px solid transparent;
    cursor: pointer;
    color: var(--ink);
    transition: transform 0.12s ease, filter 0.15s ease;
  }

  .btn:hover:not(:disabled) { filter: brightness(1.1); }
  .btn:active:not(:disabled) { transform: translateY(1px); }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }

  .btn.primary {
    background: linear-gradient(135deg, #ff2d55, #c9184a);
    color: #fff;
    box-shadow: 0 4px 20px rgba(255, 45, 85, 0.25);
  }

  .btn.ghost { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.14); }

  /* Magic Search's tier buttons keep the old .dev look — they sit inside the
     dev-only Live panel where there is no primary action to compete with. */
  .btn.dev {
    background: rgba(34, 197, 94, 0.1);
    border-color: rgba(34, 197, 94, 0.45);
    color: #86efac;
    box-shadow: none;
    font-size: 13px;
    padding: 9px 15px;
  }

  .btn.dev:hover:not(:disabled) { background: rgba(34, 197, 94, 0.18); }

  .status { min-height: 18px; margin-top: 14px; font-size: 13px; color: #9be29b; text-align: center; }
  .status.error { color: #ff9db0; }
  .status:empty { min-height: 0; margin-top: 0; }

  /* Everything left on the stage below the count toggle is either transient
     (status) or instrumentation (Dev Pull) or a legal necessity (the snapshot
     stamp). All three are centred and quiet so none of them competes. */
  .stage .btn.debug { margin-top: 14px; }
  .stage .stamp { margin-top: 16px; opacity: 0.65; }

  /* banner chips */
  .chips { display: flex; flex-wrap: wrap; gap: 8px; }
  .chips:empty { display: none; }

  .chip {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 8px;
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid var(--line);
    border-radius: 999px;
    font-size: 13px;
  }

  /* The pfp slot is a rarity-tinted initial; a usable avatar covers it. That
     makes "no picture" a designed state rather than a broken image — the same
     monogram idea the card uses, sized down. */
  .chip-pfp {
    position: relative;
    flex: none;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    overflow: hidden;
    display: grid;
    place-items: center;
    font-family: var(--font-display);
    font-size: 11px;
    line-height: 1;
    color: var(--t-ink, #0d1120);
    background: var(--rc, var(--muted));
  }

  .chip-pfp img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .chip .dot,
  .filter-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--rc, var(--muted)); }

  /* Real channel names run long (and CJK titles longer still), and one of them
     blowing the row onto a second line is not the chip's call to make. The full
     title stays available as a tooltip. */
  .chip-name {
    max-width: 18ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .chip-x {
    background: none;
    border: none;
    color: var(--muted);
    cursor: pointer;
    font-size: 15px;
    line-height: 1;
    padding: 0 2px;
  }

  .chip-x:hover { color: var(--ink); }

  /* The empty collection is the first thing most visitors will see in this
     panel, and it was a large blank box with one grey sentence in the corner.
     Centred, given some air and a dashed edge, it reads as a slot waiting to be
     filled rather than as a section that failed to load. */
  .empty { color: var(--muted); font-size: 14px; }

  /* An id selector beats the UA's `[hidden] { display: none }`, so the display
     below would pin this open forever once collection.js sets .hidden. Same
     guard #live-controls and #sets-controls already carry. */
  #coll-empty[hidden] { display: none; }

  #coll-empty {
    display: grid;
    place-items: center;
    gap: 4px;
    padding: 34px 20px;
    border: 1px dashed rgba(255, 255, 255, 0.12);
    border-radius: 14px;
    background: rgba(0, 0, 0, 0.15);
    text-align: center;
  }

  #coll-empty::before {
    content: '';
    width: 30px;
    height: 42px;
    margin-bottom: 8px;
    border-radius: 5px;
    border: 1px dashed rgba(255, 255, 255, 0.2);
    background: repeating-linear-gradient(115deg, transparent 0 7px, rgba(255, 255, 255, 0.035) 7px 9px);
  }

  /* The grid and the empty state are mutually exclusive; collapsing the grid
     when it has no cards stops it reserving a gap above the empty box. */
  .cards-grid:empty { display: none; }

  /* Rarity → YouTube Creator Award tier. --rc (chip dots + accents) and the
     --t-* set (bevel, glow, badge) both derive from the tier, so chips and
     cards always agree. N=Graphite, R=Silver, SR=Gold, SSR=Diamond,
     UR=Red Diamond — the same 100K / 1M / 10M / 50M thresholds YouTube's own
     play buttons use, which is exactly where the rarity bands sit. */
  .r-N   { --t-hi:#c9ced8; --t-mid:#828a97; --t-lo:#4d535e; --t-soft:#a7adb8; --t-glow:#9aa3b2; --t-ink:#12151b; --rc: var(--t-glow); }
  .r-R   { --t-hi:#f6f9fd; --t-mid:#c2ccda; --t-lo:#93a3b8; --t-soft:#e7eef7; --t-glow:#cdd8ea; --t-ink:#0f1622; --rc: var(--t-glow); }
  .r-SR  { --t-hi:#ffe9a6; --t-mid:#eec04b; --t-lo:#cf992a; --t-soft:#ffd977; --t-glow:#ffcf6b; --t-ink:#241a05; --rc: var(--t-glow); }
  .r-SSR { --t-hi:#eaf7ff; --t-mid:#a9d4e8; --t-lo:#7fbfe0; --t-soft:#dff3ff; --t-glow:#7fe7ff; --t-ink:#0c1524; --rc: var(--t-glow); }
  .r-UR  { --t-hi:#ffe4bf; --t-mid:#ff5566; --t-lo:#b3162e; --t-soft:#ffb066; --t-glow:#ff3b57; --t-ink:#2a060c; --rc: var(--t-glow); }

  /* card — a metal-bevel frame (the seam) around a dark inner face. Internal
     sizes are container-query units, so one card scales cleanly from the
     collection grid to the big inspector view without re-typesetting. */
  .card {
    container-type: inline-size;
    position: relative;
    aspect-ratio: 5 / 7;
    border-radius: clamp(11px, 6cqw, 22px);
    padding: clamp(3px, 1.7cqw, 6px);
    background: conic-gradient(from 210deg,
      var(--t-mid), var(--t-hi), var(--t-lo), var(--t-soft),
      var(--t-mid), var(--t-hi), var(--t-lo), var(--t-mid));
    box-shadow:
      0 10px 30px rgba(0, 0, 0, 0.5),
      0 0 0 1px rgba(255, 255, 255, 0.12) inset,
      0 0 22px -8px var(--t-glow);
    transition: transform 0.2s ease;
  }

  .card-inner {
    position: relative;
    height: 100%;
    display: flex;
    flex-direction: column;
    border-radius: clamp(8px, 4.4cqw, 17px);
    overflow: hidden;
    isolation: isolate;
    background:
      radial-gradient(120% 90% at 50% -12%, color-mix(in srgb, var(--t-glow) 14%, #1a2130) 0%, transparent 55%),
      linear-gradient(180deg, #141a29 0%, #0d1120 100%);
  }

  .monogram {
    position: absolute;
    inset: 0;
    z-index: 0;
    display: grid;
    place-items: center;
    font-family: var(--font-display);
    font-size: 92cqw;
    line-height: 1;
    color: color-mix(in srgb, var(--accent, #ffffff) 8%, transparent);
    user-select: none;
    pointer-events: none;
  }

  .card-top {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: flex-start;
    gap: clamp(5px, 2.6cqw, 10px);
    padding: clamp(8px, 4cqw, 15px) clamp(8px, 4cqw, 15px) clamp(4px, 2.4cqw, 9px);
  }

  .badge-col { flex: none; display: flex; flex-direction: column; align-items: flex-start; }

  .rarity-badge {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: clamp(11px, 5.2cqw, 18px);
    letter-spacing: 0.5px;
    line-height: 1;
    padding: clamp(3px, 1.6cqw, 6px) clamp(5px, 2.4cqw, 9px);
    border-radius: clamp(5px, 2.2cqw, 8px);
    color: var(--t-ink);
    background: linear-gradient(160deg, var(--t-hi), var(--t-lo));
    box-shadow: 0 0 clamp(6px, 3cqw, 12px) -2px var(--t-glow);
  }

  .tier-label {
    margin-top: clamp(2px, 1cqw, 4px);
    font-family: var(--font-mono);
    font-size: clamp(6.5px, 3cqw, 10px);
    letter-spacing: clamp(1px, 0.8cqw, 2px);
    text-transform: uppercase;
    color: var(--t-glow);
    white-space: nowrap;
  }

  .title-wrap { min-width: 0; flex: 1; text-align: right; }

  .card-name {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: clamp(12px, 5cqw, 19px);
    line-height: 1.1;
    letter-spacing: 0.2px;
    /* A long single-word channel name ("TheBackgroundNPC") has no break
       opportunity, so it ran past this box and overflow:hidden sheared it
       mid-word with no ellipsis. `anywhere` also lets the box shrink to
       min-content inside its min-width:0 flex parent. The 2-line clamp below
       then does the truncating, which is what should be doing it. */
    overflow-wrap: anywhere;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .card-handle {
    display: block;
    margin-top: clamp(1px, 0.8cqw, 3px);
    font-family: var(--font-mono);
    font-size: clamp(8px, 3.2cqw, 12px);
    color: var(--muted);
    /* The name got this treatment and the handle never did, so "@pocketgamer-
       videos" ran past its box and `.card-inner { overflow: hidden }` sheared it
       mid-word — a truncation with no ellipsis reads as a rendering fault rather
       than an abbreviation. One line, cut honestly. */
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .avatar-stage {
    position: relative;
    z-index: 3; /* above the holo (z-index 2) so the finish never tints the real avatar */
    flex: 1;
    display: grid;
    place-items: center;
    padding: clamp(2px, 1.5cqw, 8px) 0;
    /* Let this slot actually shrink. A flex item defaults to min-height:auto, so
       the stage refused to go below the ring's own height and pushed the stats
       out through the bottom of the card instead. */
    min-height: 0;
  }

  .avatar-ring {
    position: relative;
    width: 54cqw;
    height: 54cqw;
    /* THE RING IS A WIDTH-DERIVED SIZE IN A HEIGHT-CONSTRAINED SLOT, which is
       what buried the subs line: 54cqw is read off the card's WIDTH, but the
       stage's height is whatever `.card-top` and `.card-bottom` leave over. Let
       the title wrap to two lines and the ring outgrew its slot and sat on top
       of "7.6M subs". The container query below removes the cause; these caps
       are the backstop, and they fail the right way — a ring a few percent oval
       is a far smaller sin than a statistic nobody can read. */
    max-width: 100%;
    max-height: 100%;
    border-radius: 50%;
    padding: clamp(2px, 1.5cqw, 6px);
    background: conic-gradient(from 140deg,
      var(--t-lo), var(--t-hi), var(--t-glow), var(--t-soft),
      var(--t-lo), var(--t-hi), var(--t-lo));
    box-shadow: 0 0 clamp(8px, 4cqw, 24px) -4px var(--t-glow), 0 8px 20px rgba(0, 0, 0, 0.45);
  }

  .avatar-ring::after {
    content: '';
    position: absolute;
    inset: clamp(2px, 1.5cqw, 6px);
    border-radius: 50%;
    box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35) inset;
    pointer-events: none;
  }

  .avatar {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    object-fit: cover;
    display: block;
    background: #223049;
  }

  .count-badge, .new-badge {
    position: absolute;
    z-index: 3;
    font-family: var(--font-mono);
    font-size: clamp(9px, 3cqw, 12px);
    font-weight: 700;
    padding: clamp(1px, 0.8cqw, 3px) clamp(5px, 2.4cqw, 9px);
    border-radius: clamp(6px, 2.5cqw, 9px);
  }

  .count-badge { right: clamp(6px, 5cqw, 22px); bottom: clamp(2px, 2cqw, 10px); background: rgba(0, 0, 0, 0.62); border: 1px solid var(--line); }
  .new-badge { left: clamp(6px, 5cqw, 22px); top: clamp(2px, 2cqw, 10px); background: var(--red); color: #fff; }

  .card-bottom {
    position: relative;
    z-index: 1;
    padding: clamp(4px, 2cqw, 8px) clamp(8px, 4cqw, 14px) clamp(8px, 4cqw, 14px);
  }

  .subs-line {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 6px;
    margin-bottom: clamp(5px, 2.6cqw, 9px);
    font-family: var(--font-mono);
    font-size: clamp(8px, 3.3cqw, 12px);
    color: var(--muted);
  }

  .stats { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(5px, 2.5cqw, 9px); }

  /* The stat panel is the card's only block of hard data, so it gets a real
     surface: a slight vertical gradient and a top highlight, which separates it
     from the card face instead of sitting on it as a flat grey rectangle. */
  .stat {
    border-radius: clamp(6px, 3cqw, 11px);
    padding: clamp(5px, 2.6cqw, 10px) clamp(6px, 3cqw, 12px);
    background: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(0, 0, 0, 0.42) 45%);
    border: 1px solid rgba(255, 255, 255, 0.1);
    box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) inset;
    text-align: center;
  }

  .stat em {
    display: block;
    font-style: normal;
    font-family: var(--font-mono);
    font-size: clamp(7px, 3cqw, 11px);
    letter-spacing: clamp(1px, 1cqw, 3px);
    margin-bottom: clamp(1px, 0.6cqw, 3px);
  }

  .stat.atk em { color: #ff8095; }
  .stat.def em { color: #7cc4ff; }

  .stat b {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: clamp(17px, 9cqw, 34px);
    line-height: 1;
    letter-spacing: 0.5px;
  }

  /* ---- THE CARD AT SMALL SIZES --------------------------------------------
     Every font-size above is `clamp(floor, Ncqw, ceiling)`, which scales the
     card beautifully down to about 240px and then stops: below that the floors
     take over one after another — the handle at 250px, the name at 240, the
     tier label at 217, the rarity badge at 211, the ATK/DEF numerals at 189 —
     until the type has stopped shrinking while the box has not. Two cards per
     row on a 393px phone lands at ~170px, which is deep inside that range, and
     the result was a card whose header ate the space its own avatar needed.

     The floors are correct and must not be lowered; 8px type that fits is not
     an improvement on 8px type that does not. So the card SHEDS DETAIL instead.
     It already declares `container-type: inline-size`, so it can be asked its
     own width directly — which is the only honest question here, because the
     same card renders at 170px in a pull and 300px in the binder and a viewport
     media query cannot tell those apart. */
  /* 190px, and the number is chosen to leave the desktop card alone. A x10 at
     five across caps its tracks at 200px, where the ring still has ~40px of
     slack and nothing is crowded — so degrading there would be throwing away
     detail Ash tuned in exchange for nothing. Below ~190px the arithmetic goes
     the other way and the ring stops fitting. */
  @container (max-width: 190px) {
    /* "◆ RED DIAMOND" is `white-space: nowrap`, so it set the width of the whole
       badge column and left the title about 77px to live in. Dropping it is what
       buys the name a single line — and the tier is already said, louder, by the
       rarity badge directly above it and by the frame's own metal. */
    .tier-label { display: none; }

    /* One line, not two. The second line is what pushed the avatar down into the
       subs figure; a name that needs two lines at this size was going to be
       ellipsised either way. */
    .card-name { -webkit-line-clamp: 1; line-clamp: 1; }

    /* Clear of the ring at this size — at 170px the old `left: 5cqw` put the
       pill's trailing edge underneath the avatar's left shoulder. */
    .new-badge, .count-badge { font-size: 8.5px; padding: 1px 4px; border-radius: 5px; }
    .new-badge { left: 4px; top: 4px; }
    .count-badge { right: 4px; bottom: 4px; }

    /* Buy the numbers a little room back from the padding they no longer need. */
    .card-top { padding: 7px 7px 3px; }
    .card-bottom { padding: 3px 7px 7px; }
  }

  /* ---- collection toolbar --------------------------------------------------
     Always present: the rarity chips and the search box ship from the first
     visit. Only sort is still held back until the binder is large enough to need
     it (ui/collection.js), so at low card counts this row is the search field
     and the chips with the divider under them. */
  .coll-tools {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 12px 16px;
    margin-bottom: 18px;
    padding-bottom: 18px;
    border-bottom: 1px solid var(--line);
  }

  .coll-tools[hidden] { display: none; }

  .tool-search { flex: 1 1 220px; min-width: 0; }
  .tool-search .field { padding: 9px 13px; font-size: 13.5px; }
  .tool-sort { flex: none; }
  .select-sm { width: 168px; }
  .select-sm .field { padding: 9px 13px; font-size: 13.5px; }

  .filter-row { display: flex; flex-wrap: wrap; gap: 7px; }

  .filter-chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 7px 12px;
    border-radius: 999px;
    border: 1px solid var(--line);
    background: rgba(0, 0, 0, 0.24);
    color: var(--muted);
    font-family: var(--font-body);
    font-weight: 500;
    font-size: 12.5px;
    cursor: pointer;
    transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.14s ease;
  }

  .filter-chip em { font-style: normal; font-family: var(--font-mono); opacity: 0.6; }
  .filter-chip:hover { color: var(--ink); border-color: rgba(255, 255, 255, 0.22); transform: translateY(-1px); }

  /* The active chip borrows the band's own colour, so the filter state and the
     thing being filtered are never described in two different languages. */
  .filter-chip.on {
    color: var(--ink);
    border-color: color-mix(in srgb, var(--rc, var(--red)) 60%, transparent);
    background: color-mix(in srgb, var(--rc, var(--red)) 16%, rgba(0, 0, 0, 0.3));
    box-shadow: 0 0 16px -8px var(--rc, var(--red));
  }

  .filter-chip.on em { opacity: 0.85; }

  .coll-none { padding: 26px 4px; }
  .coll-none[hidden] { display: none; }

  /* collection */
  .cards-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
    gap: 16px;
  }

  /* Card hover polish. Deliberately shadow-and-glow only, never transform:
     .card.lit (the WP3 pointer tilt) already owns the transform property, and a
     hover lift here would simply be overwritten the moment the tilt engages —
     or worse, fight it on the frame where .lit is added. Depth by light instead,
     which also costs nothing on the compositor. */
  .cards-grid .card {
    transition: transform 0.2s ease, box-shadow 0.25s ease;
  }

  .cards-grid .card:hover,
  .cards-grid .card:focus-visible {
    box-shadow:
      0 20px 44px -14px rgba(0, 0, 0, 0.72),
      0 0 0 1px rgba(255, 255, 255, 0.2) inset,
      0 0 30px -6px var(--t-glow);
  }

  /* The rarer the card, the further its glow reaches on hover — the same
     escalation the reveal uses, carried into the binder so a UR still announces
     itself once it is sitting in a grid of forty. */
  .cards-grid .card.r-SSR:hover { box-shadow: 0 20px 46px -14px rgba(0, 0, 0, 0.72), 0 0 0 1px rgba(255, 255, 255, 0.22) inset, 0 0 40px -6px var(--t-glow); }
  .cards-grid .card.r-UR:hover  { box-shadow: 0 22px 50px -14px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.24) inset, 0 0 52px -6px var(--t-glow); }

  /* reveal overlay */

  /* THE OVERLAY IS THE SCROLL CONTAINER, and this reverses an earlier call.
     It used to be `overflow: hidden` with the box capped at 92vh, on the
     reasoning that ten cards have a fixed footprint and the beam/ray glow should
     be clipped rather than allowed to spawn scrollbars. That holds on a desktop
     and fails completely on a phone: a fixed footprint inside a fixed height can
     only be paid for by shrinking the cards, and at 393px it paid by rendering
     them at 138px — small enough that every clamp() in the card bottomed out at
     once, so stats sat under the avatar and handles were sheared with no
     ellipsis. A gacha whose payoff screen crops the payoff is broken.

     So the footprint stops being fixed. Cards get a readable size and the
     overlay scrolls. What the old comment was actually protecting — no reflow
     wobble when a scrollbar appears — is kept by `scrollbar-gutter: stable`,
     which reserves the gutter up front instead of stealing width mid-animation. */
  #reveal {
    position: fixed;
    inset: 0;
    z-index: 50;
    display: flex;
    background: rgba(11, 6, 20, 0.9);
    backdrop-filter: blur(8px);
    overflow-y: auto;
    /* Horizontal stays clipped: seam glow, bloom and the UR aura's motes all
       spill sideways by design and must never become a sideways scroll. */
    overflow-x: hidden;
    overscroll-behavior: contain;  /* don't chain the scroll to the page behind */
    scrollbar-gutter: stable;
    /* Ash's rule: never leave half a row showing at the fold. `proximity` nudges
       a row into alignment when the user lands near one and otherwise keeps out
       of the way — `mandatory` would fight the scroll on a screen this tall. */
    scroll-snap-type: y proximity;
    scroll-padding: 18px;
  }

  #reveal[hidden] { display: none; }

  .reveal-box {
    /* `margin: auto` rather than the parent's old `place-items: center`. Both
       centre a box that fits; only this one stays reachable when it does not.
       Centring via the container's alignment puts overflow above the scrollport
       with no way to scroll up to it — the classic centred-modal trap, and it
       would have hidden the first row of a x10 the moment cards grew. */
    margin: auto;
    flex: none;               /* never let the centring squeeze the box */
    display: flex;
    flex-direction: column;
    padding: 32px 32px 0;
    text-align: center;
    width: min(96vw, 1160px);
    animation: rise 0.25s ease;
  }

  @keyframes rise {
    from { opacity: 0; transform: translateY(10px); }
  }

  .reveal-title {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: 22px;
    letter-spacing: 2.5px;
    text-transform: uppercase;
    margin-bottom: 20px;
    /* Makes the very top a snap point too. Without it the nearest snap position
       from scrollTop 0 is the first row of cards, and `proximity` would quietly
       pull the title off screen the moment the overlay opened. */
    scroll-snap-align: start;
  }

  /* Done never scrolls out of reach. Sticky rather than fixed so it belongs to
     the box — when the results fit without scrolling it simply sits under the
     last row, and it only starts holding station once there is something to
     scroll. The veil is a gradient, not a flat fill, so a card passing beneath
     it fades rather than meeting a hard edge; the grid's foot clearance means
     no card is ever parked under it at rest.

     env(safe-area-inset-bottom) is the gesture bar on a modern phone. Without
     it the button sits correctly in Chrome's chrome and under the home
     indicator the moment the URL bar collapses. */
  .reveal-foot {
    position: sticky;
    bottom: 0;
    z-index: 5;
    display: flex;
    justify-content: center;
    padding: 16px 0 calc(16px + env(safe-area-inset-bottom, 0px));
    /* Near-solid under the button itself. The fade is not this element's job —
       see ::before — because anything that fades here has to be paid for in
       layout height, and layout height is the resting gap above the button. */
    background: linear-gradient(to top,
      rgba(11, 6, 20, 0.98) 0 60%,
      rgba(11, 6, 20, 0.92));
  }

  /* The scrim, hung ABOVE the bar and out of flow, so the veil can be as tall as
     it needs to be while costing the layout nothing. This is what decouples "the
     button sits on a clean base" from "the last row is not marooned 140px above
     it" — the two requirements that were fighting when a single padded element
     tried to do both. */
  .reveal-foot::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 100%;
    height: 58px;
    pointer-events: none;
    background: linear-gradient(to top,
      rgba(11, 6, 20, 0.92),
      rgba(11, 6, 20, 0.55) 45%,
      transparent);
  }

  .reveal-grid {
    display: grid;
    /* Column count is still pinned by JS (--reveal-cols) rather than auto-fit,
       so a x1 gets one column instead of one card stranded in a five-track grid.
       reveal.js now caps that count by viewport — 2 on phones, 3 on tablets, 5
       on desktop — because five columns at 700px is as unreadable as two at
       393px was. `minmax(0, --reveal-max)` lets a track shrink below its cap
       when the width simply is not there, and never grow past it. */
    grid-template-columns: repeat(var(--reveal-cols, 5), minmax(0, var(--reveal-max, 200px)));
    gap: var(--reveal-gap, 18px);
    justify-content: center;
    /* Clearance for the sticky foot, so the LAST row still lands entirely above
       the Done button when the scroll bottoms out. Padding on the grid rather
       than margin on the foot: this space belongs to the scrollable content, so
       it has to be inside the thing that scrolls. */
    /* Breathing room under the last row — and only that.

       This was 112px (128 on phones) on the theory that it had to clear the
       whole sticky bar, which was simply wrong about how sticky works: at
       maximum scroll a sticky element rests at its natural place in flow, so
       the bar is already BELOW the last row without any help. The padding was
       clearing a bar that was no longer overhead, and the result was ~144px of
       dead space at the bottom of every pull. 32px + the bar's own 16px reads
       as a finished list instead of an abandoned one. */
    padding-bottom: calc(var(--reveal-foot-clear, 32px) + env(safe-area-inset-bottom, 0px));
  }

  .reveal-cell {
    position: relative;
    width: 100%;
    aspect-ratio: 5 / 7;
    cursor: pointer;
    border-radius: 15px; /* shapes the reveal seam-glow to match the card */
    /* The snap unit is a card, so a row can never come to rest half off-screen. */
    scroll-snap-align: start;
  }

  .flip { position: relative; z-index: 1; width: 100%; height: 100%; perspective: 900px; }

  .flip-inner {
    position: relative;
    width: 100%;
    height: 100%;
    transform-style: preserve-3d;
    /* The base turn, used by N and R. Slowed from 0.55s: a common's turn was
       quicker than the eye wants for the thing the whole overlay is built
       around, and at the old cadence several were mid-turn at once. */
    transition: transform 0.72s cubic-bezier(0.2, 0.7, 0.3, 1.1);
  }

  .reveal-cell.flipped .flip-inner { transform: rotateY(180deg); }

  /* Face-up, a card opens in the inspector — so it advertises that the way the
     collection's cards do, rather than keeping the plain pointer it wore while
     it was still just a thing to skip ahead. */
  .reveal-cell.flipped { cursor: zoom-in; }

  .face { position: absolute; inset: 0; backface-visibility: hidden; }
  .face.front { transform: rotateY(180deg); }
  .face .card { height: 100%; }

  /* card back */
  .card-back {
    height: 100%;
    border-radius: 14px;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    background: radial-gradient(circle at 50% 42%, #3a2757 0%, #1c1229 72%);
    border: 1px solid rgba(255, 255, 255, 0.13);
    transition: box-shadow 0.3s ease;
  }

  .card-back::after {
    content: '';
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(115deg, transparent 0 14px, rgba(255, 255, 255, 0.025) 14px 16px);
  }

  .back-rings {
    position: absolute;
    width: 64px; height: 64px;
    border-radius: 50%;
    box-shadow:
      0 0 0 1px rgba(255, 255, 255, 0.1),
      0 0 0 26px rgba(255, 255, 255, 0.045),
      0 0 0 54px rgba(255, 255, 255, 0.03);
  }

  .back-play {
    width: 0; height: 0;
    border-left: 28px solid var(--red);
    border-top: 17px solid transparent;
    border-bottom: 17px solid transparent;
    margin-left: 6px;
    filter: drop-shadow(0 0 12px rgba(255, 45, 85, 0.55));
  }

  .back-word {
    position: absolute;
    bottom: 13px;
    left: 0;
    right: 0;
    font-family: var(--font-display);
    font-size: 10px;
    letter-spacing: 3px;
    color: rgba(203, 184, 230, 0.6);
  }

  /* Backs stay identical face-down — no rarity tell until the turn (suspense).
     The rarity glow rises AROUND the card as it's revealed, then settles to a
     steady halo. SR+ only; N/R reveal with no seam. */
  .glow-SR.reveal-cell.flipped  { --seam: rgba(255, 207, 107, 0.42); --seam-peak: 30px 4px; --seam-rest: 16px 2px; }
  .glow-SSR.reveal-cell.flipped { --seam: rgba(127, 231, 255, 0.6);  --seam-peak: 46px 7px; --seam-rest: 26px 4px; }
  /* UR rests hotter and wider than it peaks elsewhere — it has an aura to sit
     inside, so the settled halo carries more than SR/SSR's do. */
  .glow-UR.reveal-cell.flipped  { --seam: rgba(255, 70, 95, 0.78);   --seam-peak: 64px 11px; --seam-rest: 44px 9px; }
  /* SSR/UR turn over 0.95s (below), so their halo peaks later — otherwise it has
     already started fading by the time the face is actually facing you. The
     face crosses into view around the halfway point of the turn, so these
     delays move whenever the flip durations do. */
  .reveal-cell.flipped { animation: seam-light 0.8s ease-out var(--seam-delay, 0.15s) both; }
  .glow-SSR.reveal-cell.flipped { --seam-delay: 0.5s; }
  /* UR waits longer still: its halo has to flood in BEHIND the ignition (below)
     rather than beat it, so the fire reads as the thing lighting the frame. */
  .glow-UR.reveal-cell.flipped { --seam-delay: 0.85s; }
  @keyframes seam-light {
    from { box-shadow: 0 0 0 0 transparent; }
    45%  { box-shadow: 0 0 var(--seam-peak, 38px 6px) var(--seam, transparent); }
    to   { box-shadow: 0 0 var(--seam-rest, 22px 3px) var(--seam, transparent); }
  }

  /* twinkling stars — static points of light over SSR/UR cards (no motion to
     lag on). Placed in JS so they dodge the avatar circle; count rises with
     rarity. Clipped to the card's rounded rect. */
  .stars {
    position: absolute;
    inset: 0;
    z-index: 6;
    border-radius: 15px;
    overflow: hidden;
    pointer-events: none;
    /* The twinkle runs infinite with a negative delay (so the dots are desynced),
       which meant they were already at full strength the instant the card landed.
       Fading the whole layer in gives them an arrival. */
    opacity: 0;
  }
  .reveal-cell.flipped .stars { animation: stars-in 0.7s ease-out 0.52s both; }
  @keyframes stars-in { to { opacity: 1; } }
  .star {
    position: absolute;
    width: var(--sz, 3px);
    height: var(--sz, 3px);
    margin: calc(var(--sz, 3px) / -2); /* centre the dot on its point */
    border-radius: 50%;
    background: radial-gradient(circle, #fff 0%, var(--star, rgba(255, 255, 255, 0.9)) 42%, transparent 72%);
    box-shadow: 0 0 6px var(--star, rgba(255, 255, 255, 0.55));
    opacity: 0;
    animation: twinkle var(--tw, 2.4s) ease-in-out infinite;
  }
  /* Tints track the frame tokens: SR gold, SSR cold diamond, UR hot amber — so
     UR never reads as "SSR with more dots". */
  .glow-SR  .star { --star: rgba(255, 214, 140, 0.85); }
  .glow-SSR .star { --star: rgba(190, 240, 255, 0.9); }
  .glow-UR  .star {
    --star: rgba(255, 210, 172, 0.98);
    box-shadow: 0 0 8px var(--star), 0 0 17px var(--star); /* UR dots carry a second, wider halo */
  }

  @keyframes twinkle {
    0%, 100% { opacity: 0.1; transform: scale(0.45); }
    50%      { opacity: 1;   transform: scale(1.08); }
  }

  /* pre-flip beam — telegraphs rarity in its own colour before the card turns.
     Only R+ light it (N never gets the .beaming class); colour per glow tier. */
  .beam {
    position: absolute;
    /* Collapsed to the cell unless it is actually lit. The beam is half a card
       taller than the card at each end, and now that the overlay scrolls, that
       overhang below the LAST row would count as scrollable content — several
       hundred pixels of empty space you could scroll into, on a screen whose
       whole complaint was wasted vertical space. Invisible either way (opacity
       0 until `.beaming`), so nothing about the effect changes. */
    inset: 0 30%;
    z-index: 0;
    pointer-events: none;
    border-radius: 50% / 42%;
    background: linear-gradient(to top,
      transparent, var(--beam, #fff) 42%, #fff 50%, var(--beam, #fff) 58%, transparent);
    filter: blur(7px);
    opacity: 0;
    transform: scaleY(0.14);
    transform-origin: 50% 50%;
    mix-blend-mode: screen;
  }
  .glow-R   .beam { --beam: #cdd8ea; }
  .glow-SR  .beam { --beam: #ffcf6b; }
  .glow-SSR .beam { --beam: #7fe7ff; }
  .glow-UR  .beam { --beam: #ff5566; }
  /* Back-loaded curve: ease-out spent the beam in its first third and left the
     rest as a near-static hold — at UR's ~1s that read as dead air. Accelerating
     into the flip makes the long beams tension instead of waiting. */
  .reveal-cell.beaming .beam { inset: -45% 30%; animation: beam-build var(--beam-ms, 500ms) cubic-bezier(0.5, 0, 0.85, 0.35) forwards; }
  /* A revealed card never wears its own telegraph. The scheduler already refuses
     to light a beam on a turned card (reveal.js), but that is a rule about
     timing; this makes the state unrepresentable, so no future path can strand a
     cone of light above a card that has already landed. Declared after the rule
     above so it wins on equal specificity. */
  .reveal-cell.flipped .beam { animation: none; opacity: 0; }

  @keyframes beam-build {
    0%   { opacity: 0;   transform: scaleY(0.1); }
    30%  { opacity: 0.6; }
    100% { opacity: 1;   transform: scaleY(1); }
  }

  /* Rarer cards flip slower — a beat of drama on the turn. Now a three-step
     ladder rather than two: SR used to share the common turn and only differed
     in what landed after it, so the escalation started a tier late. */
  .glow-SR  .flip-inner { transition-duration: 0.82s; }
  .glow-SSR .flip-inner,
  .glow-UR  .flip-inner { transition-duration: 0.95s; }

  /* reveal-moment specular sweep — a single slow light pass across the card
     face as it lands (SR+ only), a faint tint per glow tier. Not a loop. */
  .sweep {
    position: absolute;
    inset: 0;
    z-index: 5;
    border-radius: 15px;
    overflow: hidden;
    pointer-events: none;
  }
  .sweep::before {
    content: '';
    position: absolute;
    top: -25%;
    left: 0;
    width: 42%;
    height: 150%;
    background: linear-gradient(100deg,
      transparent 22%, var(--sweep, rgba(255, 255, 255, 0.9)) 50%, transparent 78%);
    transform: skewX(-16deg) translateX(-190%);
    opacity: 0;
    filter: blur(1.5px);
  }
  /* Delay is folded in per tier rather than set in a later rule: SR flips over
     0.82s and SSR/UR over 0.95s, so their pass has to start later to cross a
     card that's facing you. Rarer = slower and brighter. */
  .glow-SR  .sweep { --sweep: rgba(255, 240, 205, 0.72); --sweep-ms: 0.9s;  --sweep-delay: 0.46s; }
  .glow-SSR .sweep { --sweep: rgba(224, 246, 255, 0.92); --sweep-ms: 1s;    --sweep-delay: 0.62s; }
  .glow-UR  .sweep { --sweep: rgba(255, 232, 214, 1);    --sweep-ms: 1.15s; --sweep-delay: 0.62s; }
  .glow-UR  .sweep::before { width: 52%; filter: blur(2.2px); } /* broader, softer band */
  .reveal-cell.flipped .sweep::before { animation: sweep-pass var(--sweep-ms, 0.85s) ease-in-out var(--sweep-delay, 0.4s) 1; }

  /* ---- UR only: the ignition ------------------------------------------------
     A white-hot point races once around the frame's bevel as the card lands,
     trailing amber into crimson, and the seam halo floods in behind it. UR's
     exclusive: every other effect here is a wash of light shared with a lower
     tier, so the top tier claims the one shape nothing else uses — a travelling
     line. It also gives the card's ambient ember (ur-ember, below) an origin.

     .fuse is a STATIC ring: the two-layer mask paints the padding band only
     (content-box XOR border-box), punching the middle out. The <i> inside
     carries the conic gradient and spins — rotating the child rather than the
     ring is what keeps the band still while the head appears to travel. Rotate
     the wrapper instead and you'd see the rounded rect itself tumble. */
  .fuse {
    position: absolute;
    inset: 0;
    z-index: 7;
    border-radius: 12px; /* the card's own outer radius at reveal size */
    padding: 3px;        /* = ring thickness, sat on the metal bevel */
    pointer-events: none;
    opacity: 0;
    -webkit-mask-image: linear-gradient(#000 0 0), linear-gradient(#000 0 0);
    -webkit-mask-clip: content-box, border-box;
    -webkit-mask-composite: xor;
            mask-image: linear-gradient(#000 0 0), linear-gradient(#000 0 0);
            mask-clip: content-box, border-box;
            mask-composite: exclude;
  }

  .fuse i {
    position: absolute;
    top: 50%;
    left: 50%;
    /* Square, and wider than the cell's diagonal (1.72x its width at 5:7), so
       the gradient still covers every corner at any rotation. */
    width: 180%;
    aspect-ratio: 1;
    /* Colours mirror the .r-UR frame tokens — those live on .card, a sibling
       subtree, so they can't be inherited here. */
    background: conic-gradient(from 0deg,
      transparent 0 52%,
      rgba(179, 22, 46, 0.55) 68%,
      #ff5566 83%,
      #ffb066 93%,
      #ffe4bf 97.5%,
      #fff 99.4%,
      transparent 100%);
  }

  .reveal-cell.flipped .fuse { animation: fuse-hold 1.15s linear 0.55s both; }
  .reveal-cell.flipped .fuse i { animation: fuse-run 1.15s cubic-bezier(0.45, 0, 0.4, 1) 0.55s both; }

  @keyframes fuse-hold {
    0%   { opacity: 0; }
    9%   { opacity: 1; }
    82%  { opacity: 1; }
    100% { opacity: 0; }
  }
  /* Starts a touch before 12 o'clock and runs a full turn past it, so the head
     enters and leaves off the top edge rather than popping on mid-run. */
  @keyframes fuse-run {
    from { transform: translate(-50%, -50%) rotate(-22deg); }
    to   { transform: translate(-50%, -50%) rotate(345deg); }
  }

  /* ---- UR only: the discharge -----------------------------------------------
     A ghost of the card's silhouette pushes outward once and dissolves, timed
     to the moment the fuse closes its loop. It sits BEHIND the card (z-index 0,
     alongside .beam) so it's only ever visible in the margin — an emanation
     rather than a red wash over a real creator's face. */
  .bloom {
    position: absolute;
    inset: 0;
    z-index: 0;
    border-radius: 12px;
    pointer-events: none;
    opacity: 0;
    background: radial-gradient(closest-side, rgba(255, 90, 110, 0.2), transparent 76%);
    box-shadow: 0 0 0 2px rgba(255, 140, 130, 0.85), 0 0 40px 8px rgba(255, 70, 95, 0.5);
  }
  .reveal-cell.flipped .bloom { animation: bloom-out 0.8s cubic-bezier(0.12, 0.7, 0.3, 1) 1.62s 1; }

  @keyframes bloom-out {
    from { opacity: 0.9; transform: scale(1);    filter: blur(0); }
    to   { opacity: 0;   transform: scale(1.42); filter: blur(7px); }
  }

  /* ---- UR only: the aura ----------------------------------------------------
     A wide, slowly breathing field of heat that settles in behind the card once
     the discharge has passed, and keeps shedding small motes so a UR never fully
     comes to rest. Sits at z-index 0 with .beam/.bloom — BEHIND the card — which
     is the whole trick: the glow and every mote it emits are only visible in the
     margin, so they can't crowd the stars on the card face or drift across the
     avatar. The seam glow hugs the frame; this is the larger field outside it. */
  .aura {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: 0;
  }
  .reveal-cell.flipped .aura { animation: aura-in 1s ease-out 1.35s both; }
  @keyframes aura-in { to { opacity: 1; } }

  .aura::before {
    content: '';
    position: absolute;
    inset: -18%;
    border-radius: 50% / 42%;
    background: radial-gradient(closest-side,
      rgba(255, 80, 100, 0.4) 0%,
      rgba(255, 120, 80, 0.15) 58%,
      transparent 78%);
    filter: blur(10px);
    animation: aura-breathe 3.8s ease-in-out infinite;
  }

  @keyframes aura-breathe {
    0%, 100% { opacity: 0.55; transform: scale(0.97); }
    50%      { opacity: 0.95; transform: scale(1.06); }
  }

  /* Motes spawn on the card's perimeter and drift outward along their own
     angle, so they read as shed by the card rather than scattered around it.
     Placement, drift vector, size and stagger all come from JS. */
  .mote {
    position: absolute;
    width: var(--sz, 2px);
    height: var(--sz, 2px);
    margin: calc(var(--sz, 2px) / -2);
    border-radius: 50%;
    background: radial-gradient(circle, #fff4e2 0%, #ffab72 45%, transparent 74%);
    box-shadow: 0 0 5px rgba(255, 150, 110, 0.8);
    opacity: 0;
  }

  .reveal-cell.flipped .mote { animation: mote-drift var(--float, 3.6s) ease-out infinite; }

  @keyframes mote-drift {
    0%   { opacity: 0;   transform: translate3d(0, 0, 0) scale(0.4); }
    22%  { opacity: 0.9; }
    100% { opacity: 0;   transform: translate3d(var(--tx, 0), var(--ty, -20px), 0) scale(1); }
  }

  @keyframes sweep-pass {
    0%   { transform: skewX(-16deg) translateX(-190%); opacity: 0; }
    12%  { opacity: 1; }
    88%  { opacity: 1; }
    100% { transform: skewX(-16deg) translateX(330%);  opacity: 0; }
  }

  footer {
    border-top: 1px solid var(--line);
    padding-top: 22px;
    padding-bottom: 44px;
    color: var(--muted);
    font-size: 12.5px;
    line-height: 1.6;
  }

  /* Standing legal text on one side, the two links a person might act on pulled
     out on the other. Previously three paragraphs of identical weight, which
     buried the opt-out in the middle of the stack — the one line here that has a
     promise attached to it. */
  .footer-grid { display: grid; gap: 18px; }

  @media (min-width: 760px) {
    .footer-grid { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 46px; }
    .footer-links { text-align: right; }
  }

  .footer-legal { max-width: 62ch; }

  /* Privacy and Terms. Dimmer than the opt-out on purpose — they are the links
     that have to EXIST, not the ones anybody is looking for, and the opt-out is
     the one line down here with a promise attached to it. */
  .footer-docs { margin-top: 10px; }

  .footer-docs a {
    color: var(--muted);
    text-decoration: none;
    border-bottom: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
    transition: color 0.15s ease, border-color 0.15s ease;
  }

  .footer-docs a:hover,
  .footer-docs a:focus-visible { color: var(--ink); border-bottom-color: currentColor; }

  .footer-optout { margin-top: 0; }

  /* Underlined by default, unlike the coffee link one line below it. The two
     footer links want opposite postures: the coffee is deliberately passive and
     easy to miss, while the opt-out is a promise to someone who may be unhappy
     to be here — it has to be findable on the first look. */
  .optout-link {
    color: var(--muted);
    text-decoration: underline;
    text-underline-offset: 2px;
    transition: color 0.15s ease;
  }

  .optout-link:hover,
  .optout-link:focus-visible { color: var(--ink); }

  .footer-coffee { margin-top: 12px; }

  .coffee-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    color: var(--muted);
    text-decoration: none;
    border-bottom: 1px solid transparent;
    transition: color 0.15s ease, border-color 0.15s ease;
  }

  .coffee-link:hover,
  .coffee-link:focus-visible {
    color: var(--c-ssr);
    border-bottom-color: var(--c-ssr);
  }

  @media (max-width: 560px) {
    /* Two per row, and the card gets every pixel that buys. The old rule here
       capped cells at 138px and let the rest of the width go to padding; on a
       393px phone that is the difference between a 138px card and a 170px one,
       which is the difference between the type scale being floored and it
       merely being small. Padding drops to 12px for the same reason. */
    .reveal-box { padding: 16px 12px 0; width: min(100vw - 10px, 620px); }
    .reveal-title { font-size: 18px; letter-spacing: 2px; margin-bottom: 14px; }
    .reveal-grid {
      --reveal-gap: 12px;
      /* Higher than the desktop 200px cap: on a 560px phone two columns have
         room for it, and 240px puts the card back inside the width its own
         clamp() scale was designed for. Narrower phones just shrink below it. */
      --reveal-max: 240px;
      --reveal-foot-clear: 36px;
    }
    .coll-tools { gap: 10px 12px; }
    .tool-search { flex-basis: 100%; }
    .select-sm { width: 100%; }
    .tool-sort { flex: 1; }
  }

  @media (prefers-reduced-motion: reduce) {
    .flip-inner { transition: none; }
    .reveal-box { animation: none; }
    /* The fan still opens on hover — it is a state change, not an ambient
       animation — it just arrives instead of sliding. */
    .pack-card { transition: none; }
    /* Everything ambient stops entirely. The glow keeps its lighting (it is a
       gradient, not motion); the motes, the float, the odds pulse and the CTA
       sweep are all pure motion and simply do not run. */
    .amb-glow { animation: none; opacity: 0.8; }
    .amb-mote { animation: none; opacity: 0; }
    .pack-card:nth-child(3) { animation: none; }
    .pack { transition: none; }
    .tap-hint { animation: none; opacity: 0.85; }
    .count-btn, .filter-chip, .cards-grid .card { transition: none; }
    /* Still wear the rarity halo, just arrived-at rather than animated-to. */
    .reveal-cell.flipped { animation: none; box-shadow: 0 0 var(--seam-rest, 22px 3px) var(--seam, transparent); }
    .btn { transition: none; }
    .beam { animation: none; opacity: 0; }
    .sweep::before { animation: none; }
    /* All three UR effects are opacity:0 at rest, so killing the animation is
       enough to leave the card lit but still. */
    .fuse, .fuse i { animation: none; }
    .bloom { animation: none; }
    /* .aura is opacity:0 at rest too, so the field and its motes stay dark. */
    .aura, .aura::before, .mote { animation: none; }
    /* .stars is opacity:0 by default and faded in by stars-in — with that
       animation off, the layer has to be turned back on explicitly. */
    .stars { animation: none; opacity: 1; }
    .star { animation: none; opacity: 0.5; transform: scale(0.85); }
  }

/* ===========================================================================
   WP3 — holographic finish + pointer tilt
   ---------------------------------------------------------------------------
   Two problems kept separate (PLAN.md): the per-rarity FRAME already lives in
   the .card / .rarity-badge rules above; this block is the FINISH and MOTION —
   a masked rainbow holo sweep and a 3D tilt, all gated by rarity so a pull
   *feels* rarer the rarer it is.

   Intensity is two custom properties, set once per rarity band and read by
   the shared rules below, so the gating lives in exactly one place:
     --tilt-max        peak tilt at the card edge
     --holo-strength   peak opacity of the rainbow holo sweep
   N leaves both at zero, so N stays flat and matte no matter what.

   The white specular glare (a pointer/tilt-tracked radial highlight, layered
   under the holo) was removed 2026-08-03 — Ash's call, after trying the phone
   tilt: "that white halo ball moving around" was reading as a distraction
   rather than part of the finish, on desktop and mobile alike. The rainbow
   holo sweep is the finish now; --mx/--my (still fed by both enableCardTilt
   and enableDeviceTilt in holo.js) exist to move ITS background-position,
   not to feed a second, separate highlight.
   =========================================================================== */

  .card { transition: transform 0.2s ease; }

  .card.r-R   { --tilt-max: 7deg;  --holo-strength: 0;    }
  .card.r-SR  { --tilt-max: 9deg;  --holo-strength: 0.30; }
  .card.r-SSR { --tilt-max: 11deg; --holo-strength: 0.50; }
  .card.r-UR  { --tilt-max: 13deg; --holo-strength: 0.42; }

  /* Finish layer: painted above the card body (so the shine sweeps across the
     art and the text) but below the badges at z-index 3, which stay crisp. */
  .holo {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.25s ease;
    background: repeating-linear-gradient(115deg,
      rgba(255, 120, 180, 0.5)  0%,
      rgba(120, 220, 255, 0.5) 12%,
      rgba(180, 255, 190, 0.5) 24%,
      rgba(255, 225, 140, 0.5) 36%,
      rgba(255, 120, 180, 0.5) 48%);
    background-size: 200% 200%;
    background-position: var(--mx, 50%) var(--my, 50%);
    mix-blend-mode: color-dodge;
    filter: saturate(1.25);
  }

  /* Lit: JS adds .lit feeding --px/--py (-1..1) for tilt and --mx/--my (%) for
     the holo sweep's position — from a fine pointer (holo.js's enableCardTilt)
     or, on a phone, from device orientation (enableDeviceTilt). The tilt eases
     back to flat when .lit is removed, because transform is transitioned and
     the base rule is untransformed.

     The OPACITY half of this (.holo strength) is declared further down, after
     the touch fallback block, on purpose — see the note there. */
  .card.lit {
    transform:
      perspective(800px)
      rotateX(calc(var(--py, 0) * var(--tilt-max, 0deg) * -1))
      rotateY(calc(var(--px, 0) * var(--tilt-max, 0deg)));
  }

  /* UR's signature molten sheen — a warm gold→crimson holo (not the cool
     rainbow), so the top tier reads hotter and richer than SSR's diamond
     instead of competing on the same rainbow. */
  .card.r-UR .holo {
    background: repeating-linear-gradient(115deg,
      rgba(255, 214, 130, 0.46)  0%,
      rgba(255, 135, 95, 0.46)  12%,
      rgba(255, 90, 120, 0.46)  24%,
      rgba(255, 165, 90, 0.46)  36%,
      rgba(255, 214, 130, 0.46) 48%);
    filter: saturate(1.25);
  }

  /* UR only: a low, smouldering flicker at the seam — the "Red Diamond" tier
     reading as legendary without a distracting full-card colour sweep. Only the
     frame glow moves; irregular keyframe stops make it flicker like heat. */
  @media (prefers-reduced-motion: no-preference) {
    .card.r-UR { animation: ur-ember 4.2s ease-in-out infinite; }
  }
  @keyframes ur-ember {
    0%, 100% { box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.12) inset, 0 0 20px -8px var(--t-glow); }
    28%      { box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,120,90,0.20) inset, 0 0 34px -6px #ff5a3c; }
    52%      { box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.12) inset, 0 0 25px -8px var(--t-glow); }
    76%      { box-shadow: 0 10px 30px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,150,60,0.17) inset, 0 0 40px -6px #ff7a33; }
  }

  /* Touch / no-hover devices can't poke-holo, so give the rarer bands a gentle
     static finish instead of nothing — rarity still shows, never looks broken.
     This is the FALLBACK for when device-tilt is unavailable, declined, or
     simply not wired up in this view (the scrolling collection grid never
     requests motion permission — see holo.js). */
  @media (hover: none) {
    .card.r-SR  .holo, .card.r-SSR .holo, .card.r-UR  .holo { opacity: calc(var(--holo-strength) * 0.5); }
  }

  /* .holo full strength under .lit — deliberately placed AFTER the touch
     fallback above rather than beside the rest of the .lit rules, and that
     placement is load-bearing rather than cosmetic. `.card.lit .holo` and
     `.card.r-SR .holo` (inside the block above) carry EQUAL specificity —
     three classes each — so a media query alone does not let one beat the
     other; source order decides the tie. Declaring this AFTER the fallback is
     what lets device-tilt (holo.js's enableDeviceTilt, which adds .lit on a
     phone exactly like pointer-tilt does on desktop) win with the SAME full
     strength pointer-tilt gets, rather than being silently capped at the
     touch fallback's muted 0.5x the moment a phone earns the real thing. */
  .card.lit .holo { opacity: var(--holo-strength, 0); }

  /* Reduced motion: no tilt, no shimmer, no fades. JS also skips binding, so a
     reduced-motion desktop simply keeps its cards flat and calm. */
  @media (prefers-reduced-motion: reduce) {
    .card { transition: none; }
    .holo { transition: none; }
    .card.r-UR { animation: none; }
  }

/* ===========================================================================
   Card inspector — click a collection card to admire it large
   =========================================================================== */

  #inspect {
    position: fixed;
    inset: 0;
    z-index: 60;
    display: grid;
    place-items: center;
    padding: 24px;
    background: rgba(11, 6, 20, 0.58);
    backdrop-filter: blur(5px);
    -webkit-backdrop-filter: blur(5px);
  }

  #inspect[hidden] { display: none; }

  .inspect-box {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 18px;
    animation: rise 0.25s ease;
  }

  /* The hero card renders at ~2x the grid size; its container-query units do
     all the scaling, so no per-element overrides are needed here. */
  .inspect-holder { width: min(82vw, 340px); }
  .inspect-holder .card { width: 100%; cursor: default; }

  /* Collection cards advertise that they open. */
  .cards-grid .card { cursor: zoom-in; }

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

  /* ───────────────────────────────────────────────────────────────────────────
     THE DOCUMENT PAGES — privacy.html and terms.html.

     They reuse the site's header, footer and palette so a visitor never feels
     handed off to a legal appendix that forgot it was part of the same thing,
     but they drop the panel chrome entirely: a policy is prose, and prose wants
     a measure and some air, not a card. Nothing here is loaded by index.html
     beyond these few rules, which is why it sits at the end rather than being
     threaded through the layout section above. */
  .doc-page .brand h1 a {
    color: inherit;
    text-decoration: none;
  }

  .doc {
    max-width: 74ch;
    padding-top: 26px;
    padding-bottom: 56px;
    line-height: 1.72;
    color: color-mix(in srgb, var(--ink) 88%, transparent);
  }

  .doc h2 {
    font-family: var(--font-body);
    font-size: 17px;
    font-weight: 700;
    letter-spacing: 0.2px;
    color: var(--ink);
    margin-top: 34px;
    margin-bottom: 10px;
  }

  .doc p { margin-bottom: 12px; }

  .doc ul {
    margin: 0 0 14px 0;
    padding-left: 20px;
    list-style: disc;
  }

  .doc li { margin-bottom: 6px; }

  .doc strong { color: var(--ink); }

  .doc code {
    font-family: var(--font-mono);
    font-size: 0.9em;
    padding: 1px 5px;
    border-radius: 5px;
    background: var(--panel);
    border: 1px solid var(--line);
  }

  .doc a {
    color: var(--ink);
    text-decoration: underline;
    text-underline-offset: 2px;
    text-decoration-color: color-mix(in srgb, var(--red) 60%, transparent);
    transition: text-decoration-color 0.15s ease;
  }

  .doc a:hover,
  .doc a:focus-visible { text-decoration-color: var(--red); }

  /* Dated at the top, in the mono face the stats use — it is a fact about the
     document, and the same typographic move the card snapshot stamp makes. */
  .doc-updated {
    font-family: var(--font-mono);
    font-size: 12px;
    color: var(--muted);
    letter-spacing: 0.3px;
  }

  .doc-back {
    margin-top: 40px;
    padding-top: 18px;
    border-top: 1px solid var(--line);
  }
