/* =============================================================================
   RPG Inventory Portfolio — polished / juicy UI
   Baseline pre-polish slots were 56–64px; desktop now ~88–96px.
   Integer-friendly scale + image-rendering: pixelated
   Spring easings approximate Balatro-like elasticity
   ============================================================================= */

:root {
  /* Theme — overridable via js/config.js → applyConfigToDocument() */
  --bg-deep: #0a0e14;
  --text-main: #e8e4d9;
  --text-muted: #9aabbc;
  --text-dim: #6b7c8f;
  --accent: #d4af58;
  --accent-soft: rgba(212, 175, 88, 0.18);
  --common: #9e9e9e;
  --rare: #4a9eff;
  --legendary: #ffb347;
  --success: #5dbe7a;
  --danger: #e07070;
  --body-bg: #0b1020;
  --panel-fill: #0d1520;
  --panel-fill-alt: #121a24;
  --panel-edge: #1a2332;
  --slot-empty: #121a24;
  --slot-empty-inset: #0a1018;
  --slot-empty-rim: #2a3a50;
  --hard-shadow: #0a0804;
  --button-face: #243044;
  --button-shadow: #12213b91;
  --preview-border: #d8e0ec;
  --dropzone-fill: rgba(12, 18, 32, 0.28);
  --dropzone-fill-active: rgba(12, 18, 32, 0.36);
  --dropzone-border: rgba(201, 162, 39, 0.4);
  --dropzone-border-active: rgba(248, 214, 136, 0.55);
  --status-fill: rgba(12, 18, 32, 0.78);
  --status-border: rgba(201, 162, 39, 0.5);
  --status-error: #ffc9a8;
  --status-error-border: rgba(220, 96, 64, 0.7);
  --status-loading: #d8e0ec;
  --status-loading-border: rgba(120, 160, 220, 0.55);
  --particle-ambient: #f0d078;
  --particle-ambient-alt: #e8e4d9;
  --particle-trail: #ffffff;
  --particle-impact: #d4af58;
  --particle-button: #f8d688;
  --particle-model-spawn: #f0d078;
  --font-pixel: "Press Start 2P", "Courier New", "Lucida Console", monospace;
  --font-body: "Press Start 2P", "Courier New", monospace;
  --font-body-readable: "Segoe UI", system-ui, -apple-system, sans-serif;
  /*
   * Pixel density / chunkiness — pre-chunk baselines: bg-tile 128, idle 4,
   * pixel-border 4, panel-fill 32. ~3× larger individual pixels (nearest-neighbour).
   */
  /* BG tile: prior 384 −40% → 230; nearest-neighbour (not smooth) */
  --bg-tile-size: 230px;
  --bg-scroll-speed: 28s;
  --panel-fill-tile: 86px;
  --pixel-chunk-scale: 3;

  /* Scale — main UI ~10% smaller than prior 112 / 1120 */
  --pixel-scale: 1;
  --slot-size: 101px;
  --grid-gap: 11px;
  --window-max: 1008px;
  --frame-inset: 2px;
  --hover-scale: 1.2;
  --modal-duration: 0.38s;
  --pixel-border: 16px;
  --panel-pad: 1.2rem 1.35rem;
  --ui-gap: 0.9rem;
  --control-min-height: 50px;
  /* Controls: 4-art-px corners × 4 = 16px (integer; was a fractional 18px) */
  --control-slice-width: 16px;
  --loadout-slice-width: 6px;
  --slice-loadout: 3;
  /* Hard max hover preview box (large sources scale down with contain).
   * JS (applyConfigScale) rewrites these from CONFIG.preview × the
   * viewport fit ratio — the values here are only the no-JS fallback. */
  --preview-width: 360px;
  --preview-max-height: 400px;
  /* Right-hand hover/controls column + pack shelf width — also rewritten
   * by applyConfigScale with the viewport fit ratio. */
  --side-col-width: 300px;
  --booster-pack-width: 360px;
  /* Prior multi-bounce tab spring was 0.95s; snappy activate is shorter */
  --tab-pop-duration: 0.32s;
  --idle-duration: 4.2s;
  --idle-duration-large: 4.8s;
  --text-wobble-duration: 3.2s;
  /* Hover preview image bob only — half of --idle-duration (50% less time between steps) */
  --preview-bob-duration: calc(var(--idle-duration) * 0.5);
  /* Primary panel open — calm, aligned with shell re-center (not bouncy) */
  --panel-open-duration: 0.34s;
  --panel-close-duration: 0.24s;
  /* Controls (instructions) side panel — snappy hide so Inventory→Status leaves no ghost */
  --instructions-close-duration: 0.12s;
  /* Shell vertical re-center + side chrome follow (no overshoot — spring-soft was dipping) */
  --shell-recenter-duration: 0.4s;
  --ease-shell: cubic-bezier(0.25, 0.85, 0.35, 1);
  /* Inspect windows — bouncy overshoot (keep elastic slam) */
  --popup-open-duration: 0.32s;
  --popup-close-duration: 0.24s;

  /* Spring / elastic timing — tab uses snappy low-overshoot (not bouncy multi-stage) */
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-spring-soft: cubic-bezier(0.22, 1.4, 0.36, 1);
  --ease-tab-spring: cubic-bezier(0.22, 0.92, 0.28, 1);
  --ease-snap: cubic-bezier(0.2, 0.9, 0.3, 1.35);
  --ease-out-back: cubic-bezier(0.34, 1.3, 0.64, 1);
  --ease-sine: cubic-bezier(0.37, 0, 0.63, 1);

  /* Focus ring disabled site-wide (no selection-box chrome on click/focus). */
  --focus-ring: none;

  --ui-background: url("../assets/ui/background-placeholder.png");
  --ui-panel: url("../assets/ui/ui-panel-background.png");
  --ui-panelBorder: url("../assets/ui/ui-panel-border.png");
  --ui-headerPanel: url("../assets/ui/ui-header-panel.png");
  --ui-headerBorder: url("../assets/ui/ui-header-border.png");
  --ui-statusPanel: url("../assets/ui/ui-status-panel.png");
  --ui-statusBorder: url("../assets/ui/ui-status-border.png");
  --ui-inventoryPanel: url("../assets/ui/ui-inventory-panel.png");
  --ui-inventoryBorder: url("../assets/ui/ui-inventory-border.png");
  --ui-instructionsPanel: url("../assets/ui/ui-instructions-panel.png");
  --ui-instructionsBorder: url("../assets/ui/ui-instructions-border.png");
  --ui-slotFrame: url("../assets/ui/ui-slot-frame.png");
  --ui-emptySlot: url("../assets/ui/empty-slot.png");
  --ui-portraitFrame: url("../assets/ui/ui-portrait-frame.png");
  --ui-loadoutSlot: url("../assets/ui/ui-loadout-slot.png");
  --ui-buttonNormal: url("../assets/ui/ui-button-normal.png");
  --ui-buttonHover: url("../assets/ui/ui-button-hover.png");
  --ui-buttonActive: url("../assets/ui/ui-button-active.png");
  --ui-navButtonNormal: url("../assets/ui/ui-nav-button-normal.png");
  --ui-navButtonHover: url("../assets/ui/ui-nav-button-hover.png");
  --ui-navButtonActive: url("../assets/ui/ui-nav-button-active.png");
  --ui-tabNormal: url("../assets/ui/ui-tab-normal.png");
  --ui-tabActive: url("../assets/ui/ui-tab-active.png");
  --ui-closeButton: url("../assets/ui/ui-close-button.png");
  --ui-rarityCommon: url("../assets/ui/ui-rarity-common.png");
  --ui-rarityRare: url("../assets/ui/ui-rarity-rare.png");
  --ui-rarityLegendary: url("../assets/ui/ui-rarity-legendary.png");
  --ui-modalFrame: url("../assets/ui/ui-modal-frame.png");
  --ui-highlight: url("../assets/ui/ui-slot-highlight.png");
  --ui-resizeGrip: url("../assets/ui/ui-resize-grip.png");
  /* Idle: small float pieces (stronger) vs large chrome (subtler) */
  --idle-float-y: 12px;
  --idle-float-y-large: 3px;

  /* Social skill-icon column (square contact buttons; larger than prior 52px baseline) */
  --social-btn-size: 76px;
}

/* Self-hosted pixel font (OFL Press Start 2P) — offline / file:// friendly */
@font-face {
  font-family: "Press Start 2P";
  src: url("../assets/fonts/press-start-2p.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: 15px;
  height: 100%;
  /* Game shell: never show document scrollbar (Inventory→Status must not jump) */
  overflow: hidden;
}

/* Hide scrollbars everywhere while keeping overflow scroll */
.hide-scrollbar,
.inventory-grid,
.modal-dialog,
.quest-list,
.ui-panel {
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge legacy */
}
.hide-scrollbar::-webkit-scrollbar,
.inventory-grid::-webkit-scrollbar,
.modal-dialog::-webkit-scrollbar,
.ui-panel::-webkit-scrollbar {
  width: 0;
  height: 0;
  display: none;
}

/*
 * Custom pixel-art RPG cursors (64×64). Paths are stylesheet-relative to css/.
 *
 * IMPORTANT: only put the url(...) in custom properties. Full multi-token
 * cursor values inside var() (url + hotspot + fallback) are rejected by
 * several browsers, so the entire cursor falls back to the system arrow.
 * Hotspots live on the cursor declaration next to the var.
 */
:root {
  --cursor-default-img: url("../assets/ui/ui-cursor-default.png");
  --cursor-pointer-img: url("../assets/ui/ui-cursor-pointer.png");
  --cursor-grab-img: url("../assets/ui/ui-cursor-grab.png");
  --cursor-grabbing-img: url("../assets/ui/ui-cursor-grabbing.png");
  --cursor-rip-img: url("../assets/ui/ui-cursor-rip.png");
  --cursor-ripping-img: url("../assets/ui/ui-cursor-ripping.png");
}

html {
  cursor: var(--cursor-default-img) 2 2, auto;
}

body {
  margin: 0;
  height: 100%;
  min-height: 100%;
  min-height: 100vh;
  /* Lock axes first — Status↔Inventory must not flash a document scrollbar */
  overflow: hidden;
  overflow-x: hidden;
  overflow-y: hidden;
  font-family: var(--font-body-readable);
  color: var(--text-main);
  background-color: var(--body-bg, #0b1020);
  /* Scrolling tile handled by .bg-scroll — keep body solid for contrast */
  background-image: none;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  line-height: 1.55;
  /* Custom RPG cursor: url in --cursor-*-img; hotspot outside the var */
  cursor: var(--cursor-default-img) 2 2, auto;
}

/*
 * Drag-heavy UI: dragging items/packs/popups must never paint the blue
 * text/image selection tint. Selection is off site-wide (inherits from
 * html/body); real text-entry fields re-enable it below.
 */
html,
body {
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}
input,
textarea,
select,
[contenteditable="true"] {
  -webkit-user-select: text;
  -moz-user-select: text;
  user-select: text;
}
/* No native image ghost-drag either — it fights the custom drag gestures */
img {
  -webkit-user-drag: none;
  user-drag: none;
}

/*
 * Kill browser selection/focus outlines site-wide.
 * Thin focus rings were lingering until a click elsewhere; no outline ever.
 */
:focus,
:focus-visible,
:focus-within {
  outline: none !important;
  outline-width: 0 !important;
  outline-style: none !important;
  outline-color: transparent !important;
}

button:focus,
button:focus-visible,
a:focus,
a:focus-visible,
[tabindex]:focus,
[tabindex]:focus-visible,
.nav-btn:focus,
.nav-btn:focus-visible,
.btn:focus,
.btn:focus-visible,
.cat-tab:focus,
.cat-tab:focus-visible,
.grid-item:focus,
.grid-item:focus-visible,
.social-btn:focus,
.social-btn:focus-visible,
.modal-close-btn:focus,
.modal-close-btn:focus-visible,
.popup-close:focus,
.popup-close:focus-visible,
[role="button"]:focus,
[role="button"]:focus-visible,
[data-nav]:focus,
[data-nav]:focus-visible,
.panel-title:focus,
.panel-title:focus-visible,
[data-view-heading]:focus,
[data-view-heading]:focus-visible {
  outline: none !important;
  /* Do not apply --focus-ring selection chrome */
  box-shadow: none;
}

/* Interactive: clickable chrome — distinct pointing-hand art */
a,
button,
.nav-btn,
.ui-btn,
.btn,
.cat-tab,
.social-btn,
.popup-close,
.modal-close-btn,
[role="button"],
[data-nav] {
  cursor: var(--cursor-pointer-img) 24 8, pointer;
}

body.is-dragging,
body.is-dragging * {
  cursor: var(--cursor-grabbing-img) 24 24, grabbing !important;
}

h1,
h2,
h3,
.site-brand,
.site-sub,
.panel-title,
.char-name,
.char-title,
.nav-btn,
.ui-btn,
.btn,
.cat-tab,
.inv-count,
.instructions-title,
.instructions-list,
.instructions-hint,
.loadout-label,
.loadout-item-label,
.item-label,
.item-size,
.modal-header h2,
.modal-meta-row,
.tool-tag,
.site-footer {
  font-family: var(--font-pixel);
}

body.modal-is-open {
  overflow: hidden;
}

/* Suppress hover visuals while any drag is active (prevents sticky hover) */
body.is-dragging .grid-item:hover,
body.is-dragging .grid-item.is-hover,
body.is-dragging .grid-item.is-highlighted,
body.is-dragging .grid-item.is-selected {
  transform: none !important;
  filter: none !important;
  box-shadow: none !important;
  z-index: 2 !important;
}

body.is-dragging {
  cursor: var(--cursor-grabbing-img) 24 24, grabbing !important;
  user-select: none;
}

/*
 * Smooth scrolling background tile (transform, not stepped background-position).
 * 16px art enlarged to --bg-tile-size with nearest-neighbour.
 */
.bg-scroll {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  background-color: var(--body-bg, #0b1020);
}

.bg-scroll-inner {
  position: absolute;
  /* Overscan sized in TILES, not percent: the loop animation travels exactly
   * one tile diagonally, so each side needs ≥1 tile of extra coverage. A
   * percentage overscan shrank below one tile on narrow/mobile viewports and
   * exposed bare body background at the right/bottom edges mid-loop. */
  inset: calc(var(--bg-tile-size, 230px) * -1.25);
  width: calc(100% + var(--bg-tile-size, 230px) * 2.5);
  height: calc(100% + var(--bg-tile-size, 230px) * 2.5);
  z-index: 0;
  background-image: var(--ui-background);
  background-repeat: repeat;
  background-size: var(--bg-tile-size) var(--bg-tile-size);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  -ms-interpolation-mode: nearest-neighbor;
  animation: bgScrollTL var(--bg-scroll-speed) linear infinite;
  /* Single continuous layer — no permanent will-change (compositor thrash). */
  backface-visibility: hidden;
}

@keyframes bgScrollTL {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(calc(var(--bg-tile-size) * -1), calc(var(--bg-tile-size) * -1), 0);
  }
}

/*
 * Ambient particle host (Pixi canvas) — child of .bg-scroll, z above the tile.
 * Must paint IN FRONT of .bg-scroll-inner so dust is never covered by the image.
 */
.bg-dust {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  overflow: hidden;
}

.bg-dust canvas {
  display: block;
  width: 100% !important;
  height: 100% !important;
  pointer-events: none;
  /* Smooth, not pixelated: ambient dust is soft glow sprites (radial
   * gradient), meant as an atmospheric layer behind the crisp pixel-art UI,
   * not more hard pixel content itself — pixelation would band the glow. */
  image-rendering: auto;
  /*
   * No mix-blend-mode: it forces the compositor to render this (continuous,
   * full-viewport) canvas and everything beneath it into an isolated
   * blending buffer every frame instead of promoting it to its own cheap
   * GPU layer — a real, measurable cost some Chromium builds pay more for
   * than others. Background here is near-black, so plain alpha compositing
   * reads almost identically anyway.
   */
  opacity: 1;
}

/* Non-blocking window layer — does not freeze inventory */
.window-layer {
  position: fixed;
  inset: 0;
  z-index: 50;
  pointer-events: none;
  overflow: visible;
}

/* Impact / drag-trail particle host (Pixi canvas; above UI chrome) */
.fx-layer {
  position: fixed;
  inset: 0;
  z-index: 200;
  pointer-events: none;
  overflow: hidden;
}

.fx-layer canvas {
  display: block;
  width: 100% !important;
  height: 100% !important;
  pointer-events: none;
  image-rendering: pixelated;
}

/*
 * Subtle CRT glass — scanlines, center glow, vignette, mild phosphor.
 * Full-viewport, pointer-events:none. No RGB split / blur / mix-blend-mode.
 * Mild screen curvature is on .game-window (perspective + tiny rotateX).
 *
 * Static CRT (no continuous phosphor pulse) for cheaper idle FPS.
 * Center glow is more obvious (peak ~0.08) but tight ellipse so mid-UI stays vivid.
 * Perf: contain:strict + translateZ(0); all CRT layers static.
 */
.crt-overlay {
  position: fixed;
  inset: 0;
  z-index: 210;
  pointer-events: none;
  overflow: hidden;
  contain: strict;
  transform: translateZ(0);
}

/* Fine scanlines — dark lines only (no milky bright fill). Tunable via CONFIG.crt */
.crt-scanlines {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, 0) var(--crt-scanline-clear-px, 2px),
    rgba(4, 8, 14, var(--crt-scanline-dark-alpha, 0.12)) var(--crt-scanline-clear-px, 2px),
    rgba(4, 8, 14, var(--crt-scanline-dark-alpha, 0.12))
      calc(var(--crt-scanline-clear-px, 2px) + var(--crt-scanline-dark-px, 1px))
  );
  opacity: var(--crt-scanline-opacity, 0.72);
}

/*
 * Old-monitor center bloom — more noticeable than prior ~0.055 peak, but
 * tightly focused (small ellipse) so accents/art stay vibrant (peak ≤0.09).
 * Peak/mid alpha + ellipse size from CONFIG.crt → --crt-center-glow-*.
 */
.crt-center-glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse var(--crt-center-glow-size-x, 42%) var(--crt-center-glow-size-y, 36%)
      at 50% var(--crt-center-glow-at-y, 40%),
    rgba(var(--crt-center-glow-rgb, 255, 250, 230), var(--crt-center-glow-peak, 0.08)) 0%,
    rgba(var(--crt-center-glow-mid-rgb, 210, 235, 215), var(--crt-center-glow-mid, 0.04)) 48%,
    rgba(0, 0, 0, 0) 78%
  );
}

/* Soft edge vignette — CONFIG.crt vignette* alphas / stops */
.crt-vignette {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse 90% 86% at 50% 48%,
    rgba(3, 2, 34, 0) var(--crt-vignette-clear-stop, 50%),
    rgba(3, 2, 34, var(--crt-vignette-mid-alpha, 0.12)) var(--crt-vignette-mid-stop, 76%),
    rgba(3, 2, 34, var(--crt-vignette-edge-alpha, 0.34)) 100%
  );
}

/* Mild static phosphor wash — peak alpha from CONFIG.crt */
.crt-phosphor {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse var(--crt-phosphor-size-x, 90%) var(--crt-phosphor-size-y, 80%)
      at 50% var(--crt-phosphor-at-y, 40%),
    rgba(var(--crt-phosphor-rgb, 150, 210, 170), var(--crt-phosphor-peak, 0.03)) 0%,
    rgba(0, 0, 0, 0) 58%
  );
  opacity: 1;
}

/* Pause continuous motion when the tab is hidden (free perf; features intact) */
body.is-page-hidden .bg-scroll-inner,
body.is-page-hidden .portrait-frame,
body.is-page-hidden .idle-float,
body.is-page-hidden .idle-float-subtle,
body.is-page-hidden .idle-float-slow,
body.is-page-hidden .idle-float-large,
body.is-page-hidden .idle-float-preview,
body.is-page-hidden .letter-wobble,
body.is-page-hidden .social-btn {
  animation-play-state: paused !important;
}

.fx-particle,
.fx-impact-pixel,
.fx-drag-pixel {
  position: absolute;
  /* Square only — size set inline (~1.5× prior average) */
  width: 8px;
  height: 8px;
  border-radius: 0;
  left: 0;
  top: 0;
  background: var(--accent);
  image-rendering: pixelated;
  animation: particleBurst 0.45s steps(6, end) forwards;
  /* No permanent will-change — short-lived particles promote themselves */
  pointer-events: none;
}

.fx-drag-pixel {
  animation: dragPixelFade 0.22s steps(4, end) forwards;
  opacity: 0.7;
}

@keyframes particleBurst {
  0% {
    opacity: 1;
    transform: translate3d(var(--ox, 0px), var(--oy, 0px), 0)
      rotate(var(--rot0, 0deg)) scale(1);
  }
  100% {
    opacity: 0;
    transform: translate3d(
        calc(var(--ox, 0px) + var(--dx, 12px)),
        calc(var(--oy, 0px) + var(--dy, -16px)),
        0
      )
      rotate(var(--rot1, 180deg)) scale(0.25);
  }
}

@keyframes dragPixelFade {
  0% {
    opacity: 0.75;
    transform: translate3d(0, 0, 0) scale(1);
  }
  100% {
    opacity: 0;
    transform: translate3d(0, 6px, 0) scale(0.4);
  }
}

/*
 * Holo click flash — button/loadout click & tap feedback. Appended as a
 * CHILD of the clicked control (see spawnHoloClick in app.js), sized to
 * exactly match its box via inset:0. Because it's a DOM child rather than a
 * fixed-position particle, it moves for free with the control through any
 * layout change (shell recenter, view switch) — no JS position tracking
 * needed. Pure transform/opacity CSS, no canvas/Pixi involved, so it costs
 * nothing beyond a single short-lived compositor layer per click.
 */
.click-holo {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  /* Plain alpha white, no mix-blend-mode (isolated-blend compositing cost
   * some Chromium builds pay more for than others) — a short opacity flash
   * reads the same for this purpose. */
  background: rgba(255, 255, 255, 0.75);
  opacity: 0;
  transform: scale(0.92);
  animation: clickHoloFlash 0.4s ease-out forwards;
}

@keyframes clickHoloFlash {
  0% {
    opacity: 0.85;
    transform: scale(0.92);
  }
  100% {
    opacity: 0;
    transform: scale(1.28);
  }
}

/*
 * Chrome-button variant: sits BEHIND the button's own (opaque) background —
 * invisible under the button face itself — and expands well past its box so
 * the glow still reads as a halo peeking out around the edges. Needs the
 * button to establish its own stacking context (spawnHoloClick ensures a
 * non-auto z-index) or a negative z-index child would escape to the wrong
 * ancestor instead of stacking under this element's own background.
 */
.click-holo.is-behind {
  z-index: -1;
  animation-name: clickHoloFlashBehind;
}

@keyframes clickHoloFlashBehind {
  0% {
    opacity: 0.9;
    transform: scale(0.64);
  }
  100% {
    opacity: 0;
    transform: scale(1.39);
  }
}

/* Screen shake — keep mild CRT curve in the transform stack (perspective()
 * itself now lives on the shared ancestor .crt-plane, see .game-window) */
.game-window.is-shaking {
  animation: screenShake 0.28s var(--ease-snap);
}

@keyframes screenShake {
  0%,
  100% {
    transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale))
      translate(0, 0);
  }
  20% {
    transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale))
      translate(-4px, 2px);
  }
  40% {
    transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale))
      translate(4px, -2px);
  }
  60% {
    transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale))
      translate(-2px, -3px);
  }
  80% {
    transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale))
      translate(2px, 1px);
  }
}

/*
 * Boot / power-on intro — a full-viewport overlay that sits ABOVE the real
 * shell and never touches #game-window's own transform/box. The actual UI
 * (game-window, social column, model preview host) is laid out and
 * positioned correctly from frame 1; this overlay only hides then reveals
 * it. That avoids the old approach's bug where side chrome measured
 * #game-window's rect mid-transform and froze in the wrong spot.
 * One-shot, transform/opacity only (no blur/filter) — negligible cost once
 * the class is removed. Skipped entirely under reduced motion (JS guard +
 * media query below).
 */
.boot-overlay {
  /* Transparent positioning root only — the two panels below provide all the
   * actual black coverage, so there is nothing left to fade once they slide
   * clear (no redundant container-level opacity animation needed).
   *
   * VISIBLE BY DEFAULT (not gated on .is-active): the static panels cover
   * the page from the very first paint, so slow-loading art can never show
   * a half-loaded shell before JS gets to run the boot sequence. JS then
   * adds .is-active to play the animated reveal and .is-done to tear the
   * cover down afterward (playBootIntro) — including on the reduced-motion
   * and boot-skip paths, which must still remove this cover explicitly. */
  position: fixed;
  inset: 0;
  z-index: 500;
  pointer-events: none;
  display: block;
}

.boot-overlay.is-done {
  display: none;
}

/* Terminal-style loading line — pixel font + phosphor glow, CRT warm-up flicker-in */
.boot-overlay-text {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  font-family: var(--font-pixel);
  font-size: 0.8rem;
  letter-spacing: 0.14em;
  color: var(--accent);
  text-shadow: 0 0 2px rgba(248, 209, 136, 0.75), 0 0 8px var(--accent-soft);
  white-space: nowrap;
  opacity: 0;
  z-index: 2;
}

.boot-overlay.is-active .boot-overlay-text {
  animation: bootTextShow var(--boot-duration, 2.5s) steps(1, end) forwards;
}

@keyframes bootTextShow {
  0% {
    opacity: 0;
    transform: translate(-50%, -50%) translateX(0);
  }
  3% {
    opacity: 0.7;
    transform: translate(-50%, -50%) translateX(3px);
  }
  6% {
    opacity: 0.15;
    transform: translate(-50%, -50%) translateX(-2px);
  }
  10% {
    opacity: 0.85;
    transform: translate(-50%, -50%) translateX(1px);
  }
  14% {
    opacity: 1;
    transform: translate(-50%, -50%) translateX(0);
  }
  /* Synced with bootScanlineGlitchX so the text tears sideways in the same
   * instants as the background scanlines — one shared glitch, not two. */
  15% {
    transform: translate(-50%, -50%) translateX(8px);
  }
  16% {
    transform: translate(-50%, -50%) translateX(-5px);
  }
  17% {
    transform: translate(-50%, -50%) translateX(0);
  }
  28% {
    transform: translate(-50%, -50%) translateX(-12px);
  }
  29% {
    transform: translate(-50%, -50%) translateX(7px);
  }
  30% {
    transform: translate(-50%, -50%) translateX(0);
  }
  40% {
    transform: translate(-50%, -50%) translateX(6px);
  }
  41% {
    transform: translate(-50%, -50%) translateX(-4px);
  }
  42% {
    transform: translate(-50%, -50%) translateX(0);
  }
  46% {
    opacity: 1;
  }
  54% {
    opacity: 0;
  }
  100% {
    opacity: 0;
  }
}

.boot-dot {
  display: inline-block;
  opacity: 0;
}

.boot-overlay.is-active .boot-dot-1 {
  animation: bootDotBlink var(--boot-dot-blink, 1.05s) steps(1, end) infinite;
}

.boot-overlay.is-active .boot-dot-2 {
  animation: bootDotBlink var(--boot-dot-blink, 1.05s) steps(1, end) infinite;
  animation-delay: var(--boot-dot-stagger, 0.15s);
}

.boot-overlay.is-active .boot-dot-3 {
  animation: bootDotBlink var(--boot-dot-blink, 1.05s) steps(1, end) infinite;
  animation-delay: calc(var(--boot-dot-stagger, 0.15s) * 2);
}

@keyframes bootDotBlink {
  0%,
  100% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
}

/* Seam glow right as the screen splits — accent, not white; small glow radius only */
.boot-overlay-bar {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 3px;
  margin-top: -1.5px;
  background: var(--accent);
  box-shadow: 0 0 8px 1px var(--accent-soft);
  opacity: 0;
  z-index: 2;
}

.boot-overlay.is-active .boot-overlay-bar {
  animation: bootBarBlip var(--boot-duration, 2.5s) ease forwards;
}

@keyframes bootBarBlip {
  0%,
  50% {
    opacity: 0;
  }
  57% {
    opacity: 0.85;
  }
  66% {
    opacity: 0.4;
  }
  76% {
    opacity: 0;
  }
  100% {
    opacity: 0;
  }
}

/*
 * Rolling interference band — a soft horizontal glow that sweeps once
 * through the dark screen while the loading text is up, like an old set's
 * picture rolling before vertical hold locks. Pure transform/opacity, one
 * element, one pass — same negligible one-shot cost as the rest of the
 * sequence.
 */
.boot-overlay-scan {
  position: absolute;
  left: 0;
  right: 0;
  top: -12%;
  height: 14%;
  background: linear-gradient(
    to bottom,
    rgba(248, 209, 136, 0) 0%,
    rgba(248, 209, 136, 0.16) 50%,
    rgba(248, 209, 136, 0) 100%
  );
  opacity: 0;
  z-index: 1;
}

.boot-overlay.is-active .boot-overlay-scan {
  animation: bootScanRoll 1.5s linear forwards;
}

@keyframes bootScanRoll {
  0% {
    transform: translateY(0);
    opacity: 0;
  }
  8% {
    opacity: 0.9;
  }
  44% {
    transform: translateY(720%);
    opacity: 0.5;
  }
  50% {
    opacity: 0;
  }
  100% {
    transform: translateY(720%);
    opacity: 0;
  }
}

/*
 * The two screen halves — old-monitor dark fill (--bg-deep, not flat #000)
 * plus a faint scanline sheen for CRT texture. Hold in place through the
 * loading text, then slide apart (transform only) to reveal the shell,
 * like an old CRT's picture splitting open down the middle.
 */
.boot-overlay-panel {
  position: absolute;
  left: 0;
  right: 0;
  /* +22px = the curved-seam radius below: each panel overlaps 22px PAST the
   * center line at rest, so the curve (which recedes at the left/right
   * edges back to exactly the center line) never opens a gap before the
   * panels actually start sliding apart — see .boot-overlay-panel-top/-bottom. */
  height: calc(50% + 22px);
  background: var(--bg-deep, #0a0e14);
  overflow: hidden;
  /* Softens the clip-path curve into a fuzzy edge instead of a razor vector
   * line — small radius so it reads as a soft CRT glow bleed, not a smooth
   * photo blur, and stays cheap (2 elements, one-shot ~2.5s, gone via
   * display:none right after — not a permanent/continuous filter). */
  filter: blur(1.5px);
}

.boot-overlay-panel::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(248, 209, 136, 0) 0px,
    rgba(248, 209, 136, 0) 2px,
    rgba(248, 209, 136, 0.09) 2px,
    rgba(248, 209, 136, 0.09) 3px
  );
}

/*
 * bad-tv-shader (github.com/felixturner/bad-tv-shader) inspired: its two
 * signature motions — continuous vertical roll (rollSpeed) and noise-driven
 * horizontal sync-tear (distortion) — ported as CSS keyframes rather than
 * the original WebGL/simplex-noise fragment shader. This overlay is two
 * flat panels + a scanline texture with no real "picture" for a per-pixel
 * noise shader to distort; these keyframes reproduce its two motions
 * directly on that texture. background-position + transform only (same
 * transform/opacity-only budget as the rest of this sequence — no filter).
 */
.boot-overlay.is-active .boot-overlay-panel::after {
  animation:
    bootScanlineRoll var(--boot-duration, 2.5s) linear forwards,
    bootScanlineGlitchX var(--boot-duration, 2.5s) steps(1, end) forwards;
}

@keyframes bootScanlineRoll {
  from {
    background-position-y: 0;
  }
  to {
    /* 3px tile period — any multiple wraps with no visible seam. Distance
     * scaled up with the duration below (×5/3) so the roll keeps the same
     * speed instead of just slowing down over the longer sequence. */
    background-position-y: 1000px;
  }
}

/*
 * Spikes continue past the panel-split judder (48%, see bootPanelTopSlide)
 * through ~82% (≈0.5s after parting starts) so the tear is still glitching
 * while the panels are visibly sliding apart, not just during the hold.
 */
@keyframes bootScanlineGlitchX {
  0%,
  14%,
  17%,
  27%,
  30%,
  39%,
  42%,
  58%,
  61%,
  78%,
  83%,
  100% {
    transform: translateX(0);
  }
  15% {
    transform: translateX(8px);
  }
  16% {
    transform: translateX(-5px);
  }
  28% {
    transform: translateX(-12px);
  }
  29% {
    transform: translateX(7px);
  }
  40% {
    transform: translateX(6px);
  }
  41% {
    transform: translateX(-4px);
  }
  59% {
    transform: translateX(-9px);
  }
  60% {
    transform: translateX(5px);
  }
  81% {
    transform: translateX(10px);
  }
  82% {
    transform: translateX(-6px);
  }
}

/*
 * Curved seam — old CRT tube glass profile: FLUSH (full reach, meeting the
 * other panel exactly) at the left/right screen edges, receding 22px back
 * toward the center — a shallow tube-glass curve, deepest/most-open right
 * in the middle, tapering to nothing at the corners. That's a notch cut
 * from the middle of a straight edge, which plain border-radius corners
 * can't express (corners only live at the box's actual 4 corners) — needs
 * clip-path. overflow:hidden on .boot-overlay-panel (above) is now
 * redundant here but harmless; clip-path already clips the scanline layer.
 */
.boot-overlay-panel-top {
  top: 0;
  clip-path: polygon(
    0% 0%,
    100% 0%,
    100% 100%,
    87.5% calc(100% - 8px),
    75% calc(100% - 16px),
    62.5% calc(100% - 20px),
    50% calc(100% - 22px),
    37.5% calc(100% - 20px),
    25% calc(100% - 16px),
    12.5% calc(100% - 8px),
    0% 100%
  );
}

.boot-overlay-panel-bottom {
  bottom: 0;
  clip-path: polygon(
    0% 0%,
    12.5% 8px,
    25% 16px,
    37.5% 20px,
    50% 22px,
    62.5% 20px,
    75% 16px,
    87.5% 8px,
    100% 0%,
    100% 100%,
    0% 100%
  );
}

.boot-overlay.is-active .boot-overlay-panel-top {
  animation: bootPanelTopSlide var(--boot-duration, 2.5s)
    var(--boot-panel-easing, cubic-bezier(0.22, 0.61, 0.36, 1)) forwards;
}

.boot-overlay.is-active .boot-overlay-panel-bottom {
  animation: bootPanelBottomSlide var(--boot-duration, 2.5s)
    var(--boot-panel-easing, cubic-bezier(0.22, 0.61, 0.36, 1)) forwards;
}

/*
 * Brief vertical-hold judder right before the split (48-54%) — a few quick
 * px of jitter, like an old set's vertical sync slipping for an instant
 * before the picture "catches" and pulls open. Transform-only, still one
 * short animation, no added cost.
 */
@keyframes bootPanelTopSlide {
  0%,
  48% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-5px);
  }
  51.5% {
    transform: translateY(3px);
  }
  53% {
    transform: translateY(-2px);
  }
  54% {
    transform: translateY(0);
  }
  /* Slide compressed into 54%→88% (was 54%→100%) — reaches fully open
   * sooner, then just holds off-screen for the remaining runtime. */
  88%,
  100% {
    transform: translateY(-100%);
  }
}

@keyframes bootPanelBottomSlide {
  0%,
  48% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(5px);
  }
  51.5% {
    transform: translateY(-3px);
  }
  53% {
    transform: translateY(2px);
  }
  54% {
    transform: translateY(0);
  }
  88%,
  100% {
    transform: translateY(100%);
  }
}

/* ----- Main UI fully centred; height changes re-center via JS FLIP (smoothShellRecenter) ----- */
:root {
  /* Subtle CRT barrel — defaults; overridden by CONFIG.crt via applyConfigToDocument */
  --crt-perspective: 1500px;
  --crt-curve-x: 0.75deg;
  --crt-scanline-opacity: 0.72;
  --crt-scanline-dark-alpha: 0.12;
  --crt-scanline-clear-px: 2px;
  --crt-scanline-dark-px: 1px;
  --crt-center-glow-peak: 0.08;
  --crt-center-glow-mid: 0.04;
  --crt-center-glow-size-x: 42%;
  --crt-center-glow-size-y: 36%;
  --crt-center-glow-at-y: 40%;
  --crt-center-glow-rgb: 255, 250, 230;
  --crt-center-glow-mid-rgb: 210, 235, 215;
  --crt-vignette-mid-alpha: 0.12;
  --crt-vignette-edge-alpha: 0.34;
  --crt-vignette-clear-stop: 50%;
  --crt-vignette-mid-stop: 76%;
  --crt-phosphor-peak: 0.03;
  --crt-phosphor-rgb: 150, 210, 170;
  --crt-phosphor-size-x: 90%;
  --crt-phosphor-size-y: 80%;
  --crt-phosphor-at-y: 40%;
}

/*
 * Shared 3D scene for the social column, main game window, and side overlay
 * (see index.html) — one `perspective` here, instead of each element
 * carrying its own `perspective()` transform function, so all three tilt
 * as ONE plane around a common vanishing point. Before this, each element
 * rotated around its own center with its own (independent) perspective
 * distance, so their curves visibly didn't line up with each other.
 * `perspective` (like `transform`) makes this the containing block for its
 * position:fixed descendants — harmless here since this box exactly
 * covers the viewport (inset:0), so their JS-computed top/left math (always
 * viewport-relative via getBoundingClientRect) still lands in the same
 * place. #model-preview-host stays OUTSIDE this plane on purpose — it
 * hosts a real 3D <model-viewer> camera, and warping that with an outer
 * fake 2D perspective would reintroduce the same flat-plane-vs-real-camera
 * mismatch fixed in the model spawn-in animation.
 */
.crt-plane {
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  perspective: var(--crt-perspective);
  perspective-origin: 50% 50%;
}

/*
 * Real-shell echo of the boot glitch: the whole plane (game-window, social
 * column, side overlay — everything sharing this perspective) jolts
 * sideways a few times right as the black panels start parting, like the
 * picture is still tuning in as it's revealed. animation-delay is what
 * times this to "parting start" — playBootIntro() (app.js) adds this class
 * at t=0 alongside the boot overlay, so the delay does the waiting.
 * .crt-plane carries no transform of its own outside this, so it can't
 * collide with game-window/social-column/side-overlay's individually
 * JS-driven inline transforms (each still tilts independently as before).
 */
.crt-plane.is-boot-glitching {
  animation: siteBootGlitch var(--boot-glitch-duration, 1s) steps(1, end)
    var(--boot-glitch-delay, 1.2s) 1 both;
}

@keyframes siteBootGlitch {
  0%,
  8%,
  11%,
  22%,
  25%,
  40%,
  43%,
  100% {
    transform: translateX(0);
  }
  9% {
    transform: translateX(14px);
  }
  10% {
    transform: translateX(-9px);
  }
  23% {
    transform: translateX(-18px);
  }
  24% {
    transform: translateX(11px);
  }
  41% {
    transform: translateX(9px);
  }
  42% {
    transform: translateX(-7px);
  }
}

.viewport-center {
  position: relative;
  /* Above ambient dust (z:1) so UI stays crisp; ambient still visible in margins */
  z-index: 2;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.25rem;
  box-sizing: border-box;
  /* Shell does not capture hits — free-floating left 3D host can receive drag/orbit.
   * .game-window re-enables pointer events for inventory / status UI. */
  pointer-events: none;
  /* Only the game-window is a flex child — nothing pushes the centre.
   * Perspective lives on the shared ancestor .crt-plane, not here — but
   * that perspective only reaches DIRECT children of .crt-plane. Without
   * preserve-3d here, this element (a direct child) flattens #game-window
   * (its own child, i.e. a grandchild of .crt-plane) into a plain 2D plane
   * before compositing, so #game-window's rotateX rendered with no
   * perspective at all (just an imperceptible ~1.4% orthographic squish,
   * no visible keystone) while the social column and side overlay — true
   * direct children of .crt-plane — tilted correctly. */
  transform-style: preserve-3d;
}

.game-window {
  width: min(100%, var(--window-max));
  max-width: var(--window-max);
  flex: 0 0 auto;
  /* Mild screen curvature + pixel scale (FLIP JS must keep the same stack).
   * perspective() lives on ancestor .crt-plane, not here (see screenShake). */
  transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale));
  transform-origin: center center;
  image-rendering: pixelated;
  /* FLIP recenter: ease-shell has no overshoot (spring-soft caused dip→shoot) */
  transition: transform var(--shell-recenter-duration) var(--ease-shell);
  position: relative;
  z-index: 2;
  /* Re-enable clicks for main UI (parent .viewport-center is pointer-events:none) */
  pointer-events: auto;
  /* will-change only while shell is recentering (see .is-shell-recentering) */
  /* Panel hard shadows (+20px X) must not be clipped by the shell */
  overflow: visible;
  border-radius: 0.35%;
}

/* Active while playing the invert→0 recenter (cleared after transitionend) */
.game-window.is-shell-recentering {
  /* Promote only during the short FLIP — not permanently on idle shell */
  will-change: transform;
}



/*
 * Social skill-icon column: positioned by JS left of #game-window, top-aligned
 * to the main content UI panel (#main-content / active view) — not the site header.
 * Shell pointer-events:none; links re-enable clicks.
 * Square controls reuse button 9-slice chrome (RPG skill-icon look).
 */
.social-column {
  position: fixed;
  z-index: 5;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  gap: 1.25rem;
  width: auto;
  margin: 0;
  padding: 0;
  overflow: visible;
  pointer-events: none;
  box-sizing: border-box;
  /* Shares .crt-plane's perspective (see ancestor) — same rotateX/scale
   * function shape as #game-window so all three tilt as one plane. */
  transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale));
  transform-origin: center center;
  /* Smooth follow when shell height re-centers (disabled mid-FLIP — rAF tracks 1:1) */
  transition:
    top var(--shell-recenter-duration) var(--ease-shell),
    left var(--shell-recenter-duration) var(--ease-shell);
}

body.is-shell-recentering .social-column {
  transition: none;
}

/*
 * Compact layouts: JS reparents the contact buttons into the shell as an
 * in-flow horizontal row (between header and main content), so they scroll
 * with the UI instead of floating fixed over it. Static position + no own
 * transform — the row inherits the shell's tilt (flat on compact anyway).
 */
.social-column.is-inline {
  position: static;
  transform: none;
  flex-direction: row;
  justify-content: flex-end;
  align-items: center;
  gap: 0.9rem;
  /* Full row width, right-aligned within it — box-sizing:border-box so the
   * padding below doesn't add to game-window's own width. */
  width: 100%;
  box-sizing: border-box;
  /* No padding-right: this row already spans game-window's own full width
   * (box-sizing:border-box, no side margins), which is the SAME outer
   * edge the header/status/inventory .ui-panels themselves sit at. An
   * earlier attempt padded this in to match the PANELS' inner content
   * column (~52px, where their own border-image + padding pushes text/
   * buttons in) — technically consistent, but with nothing else drawing
   * the eye at the same depth up here above the header, that inset just
   * read as a stray gap. Flush to the shared panel edge reads as
   * "aligned to the UI" instead. */
  margin: 0 0 0.65rem;
  z-index: auto;
  pointer-events: auto;
  transition: none;
}

/*
 * "Use a PC" nudge — phone width only. Same look as .booster-dialogue (dark
 * panel, gold rim, pixel font) so it reads as part of this game's own UI
 * language instead of a generic web banner, but IN-FLOW (not fixed/
 * overlaid) like .social-column.is-inline right above it — it just takes
 * its place in the scrolling page instead of floating on top of content.
 * JS (initMobilePcNotice) gates showing it on isMobileLayout(); this media
 * query is a backstop so it can never show past that width even if JS
 * mis-toggles the hidden attribute.
 */
.mobile-pc-notice {
  display: none;
}
@media (max-width: 560px) {
  .mobile-pc-notice:not([hidden]) {
    position: relative;
    display: block;
    box-sizing: border-box;
    width: 100%;
    margin: 0 0 0.9rem;
    padding: 0.8rem 2.2rem 0.8rem 1rem;
    font-family: var(--font-pixel);
    font-size: 0.72rem;
    line-height: 1.6;
    letter-spacing: 0.02em;
    color: var(--text-main, #f4efe4);
    background: rgba(12, 18, 32, 0.94);
    box-shadow:
      0 0 0 3px var(--dropzone-border-active, rgba(248, 214, 136, 0.55)),
      0 6px 0 rgba(0, 0, 0, 0.4);
    border-radius: 10px;
    /* Own keyframe, not a reuse of .booster-dialogue's boosterDialoguePop —
     * that one's translateX(-50%) is only correct paired with its
     * left:50% centering, which this in-flow, full-width box doesn't use;
     * reusing it as-is would shove this box sideways by half its own
     * width instead of popping in straight. */
    animation: mobileNoticePop 0.3s cubic-bezier(0.3, 1.4, 0.5, 1);
  }
}
@keyframes mobileNoticePop {
  0% {
    transform: translateY(10px) scale(0.9);
    opacity: 0;
  }
  100% {
    transform: translateY(0) scale(1);
    opacity: 1;
  }
}
.mobile-pc-notice p {
  margin: 0;
}
.mobile-pc-notice-dismiss {
  position: absolute;
  top: 0.4rem;
  right: 0.4rem;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text-dim, #a9b4c4);
  font-family: var(--font-pixel);
  font-size: 0.85rem;
  line-height: 1;
  cursor: var(--cursor-pointer-img) 24 8, pointer;
}
.mobile-pc-notice-dismiss:hover,
.mobile-pc-notice-dismiss:focus-visible {
  color: var(--text-main, #f4efe4);
}

/*
 * Left-of-inventory 3D model preview host (free-floating, like social column).
 * JS sizes height to inventory panel; width fills free space LEFT of inventory
 * (no underlap). Transparent so the page/bg shows through.
 */
.model-preview-host {
  position: fixed;
  /* Above .viewport-center shell so orbit/zoom receive hits; below social (5)
   * and side-overlay (4). Host never overlaps inventory (JS clamps right edge). */
  z-index: 3;
  box-sizing: border-box;
  /* Fallbacks before JS measures — wide left stage; JS fills free width precisely */
  width: min(70vw, 900px);
  height: 420px;
  min-width: 240px;
  min-height: 280px;
  margin: 0;
  padding: 0;
  overflow: visible;
  pointer-events: none;
  background: transparent;
  background-color: transparent;
  /* Smooth follow when shell height re-centers */
  transition:
    top var(--shell-recenter-duration) var(--ease-shell),
    left var(--shell-recenter-duration) var(--ease-shell),
    height var(--shell-recenter-duration) var(--ease-shell),
    width var(--shell-recenter-duration) var(--ease-shell);
}

body.is-shell-recentering .model-preview-host {
  transition: none;
}

.model-preview-host[hidden] {
  display: none !important;
}

.model-preview-stage {
  position: absolute;
  inset: 0;
  background: transparent;
  background-color: transparent;
  /* Only re-enable when a model is loaded (orbit/zoom/pan); shell stays none */
  pointer-events: none;
  overflow: hidden;
  /* Non-zero paint surface so WebGL is never collapsed */
  min-width: 120px;
  min-height: 200px;
}

.model-preview-host.has-model .model-preview-stage,
.model-preview-host.has-model #model-preview-viewer {
  pointer-events: auto;
  touch-action: none;
}

/* Google <model-viewer> — full host size; transparent canvas; framing set in JS */
.model-preview-stage model-viewer,
#model-preview-viewer {
  display: block;
  position: absolute;
  inset: 0;
  width: 100% !important;
  height: 100% !important;
  min-width: 120px;
  min-height: 200px;
  background: transparent;
  background-color: transparent;
  --poster-color: transparent;
  --progress-bar-color: rgba(201, 177, 39, 0);
  --progress-bar-height: 2px;
  outline: none;
  pointer-events: auto;
  touch-action: none;
  cursor: grab;
  /* Settled rest pose — matches spawn keyframe 100% (no snap after animation) */
  transform: none;
  /* Ensure internal canvas fills the host */
  --progress-mask: transparent;
}

.model-preview-host.has-model #model-preview-viewer:active {
  cursor: grabbing;
}

/*
 * Model spawn entrance: scale 0 → exactly 1 while rotating into rest pose.
 * End keyframe MUST match the post-animation transform (none / identity)
 * so removing .is-spawning never snaps scale/rotation.
 */
/*
 * No animated rotateY: <model-viewer> renders its own real 3D camera
 * independently of this CSS transform, so a fake 2D-plane "card spin" here
 * never actually matches the model's real orientation at any given moment —
 * the swing (-200deg → 0deg) was a foreshortened flat-plane rotation of the
 * whole element, not the model's real camera, so the instant it finished
 * and this transform settled to identity, the true (always-static-orbit)
 * render suddenly looked different from the CSS-warped last frame. rotateY
 * stays here only as an inert 0deg constant (never animated away from 0),
 * so there is nothing left that can fail to match the real render.
 */
@keyframes modelPreviewSpawn {
  0% {
    transform: scale(0) rotateY(0deg);
    opacity: 0.12;
  }
  100% {
    transform: scale(1) rotateY(0deg);
    opacity: 1;
  }
}

.model-preview-stage model-viewer.is-spawning,
#model-preview-viewer.is-spawning {
  transform-origin: center center;
  /* ease-out only — no overshoot past scale(1) */
  animation: modelPreviewSpawn 0.7s cubic-bezier(0.2, 0.85, 0.25, 1) both;
  will-change: transform, opacity;
}

/* Visible fallback when load fails or model does not paint */
.model-preview-status {
  position: absolute;
  left: 8%;
  right: 8%;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  box-sizing: border-box;
  padding: 0.85rem 0.75rem;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 2rem;
  line-height: 2;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: var(--status-fill, rgba(12, 18, 32, 0.78));
  border: 2px dashed var(--status-border, rgba(201, 162, 39, 0.5));
  pointer-events: none;
  image-rendering: pixelated;
}

.model-preview-status.is-error {
  color: var(--status-error, #ffc9a8);
  border-color: var(--status-error-border, rgba(220, 96, 64, 0.7));
}

.model-preview-status.is-loading {
  color: var(--status-loading, #d8e0ec);
  border-color: var(--status-loading-border, rgba(120, 160, 220, 0.55));
}

.model-preview-status[hidden] {
  display: none !important;
}

/*
 * Semi-transparent drop target — shown for the full drag of a previewable
 * item. Sized/positioned by JS (syncModelPreviewChrome) to cover
 * #model-preview-host, but lives in .crt-plane, not inside that host (see
 * index.html) — softer glass + rounded corners.
 */
.model-preview-dropzone {
  position: fixed;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding: 1.25rem 1rem;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 1.1rem;
  line-height: 1.5;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background-color: var(--dropzone-fill, rgba(15, 39, 99, 0.349));
  background: var(--dropzone-fill, rgba(15, 39, 99, 0.349));
  border: 5px dashed var(--dropzone-border, rgba(247, 198, 36, 0.514));
  border-radius: 14px;
  pointer-events: none;
  image-rendering: pixelated;
  transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale));
  transform-origin: center center;
}

.model-preview-dropzone.is-drop-active {
  background-color: var(--dropzone-fill-active, rgba(12, 18, 32, 0.36));
  background: var(--dropzone-fill-active, rgba(12, 18, 32, 0.36));
  border-color: var(--dropzone-border-active, rgba(248, 214, 136, 0.55));
  border-radius: 14px;
}

.model-preview-dropzone[hidden] {
  display: none !important;
}

/* ============================================================================
 * BOOSTER PACKS — right-edge pack shelf, center rip stage, trash bin
 * Desktop + Inventory view only (JS toggles hidden; compact hides in the
 * 900px media block below). All chrome is 2D so it lives in .crt-plane.
 * ========================================================================== */

/* --- Shelf: ONE big pack (active category) docked half-off the right edge,
 * hover slides it in. Category switches swap the pack out/in (JS). --- */
.booster-shelf {
  position: fixed;
  top: 50%;
  right: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  gap: 18px;
  transform: translateY(-50%);
  transition: transform 0.3s cubic-bezier(0.4, 0.8, 0.4, 1);
  pointer-events: none;
}

.booster-shelf[hidden] {
  display: none !important;
}

/* Parked while a pack is docked center: fully out of frame, untouchable */
.booster-shelf.is-parked {
  transform: translate(130%, -50%);
  pointer-events: none;
}

.booster-shelf.is-parked .booster-pack-stack {
  pointer-events: none;
}

/*
 * Fanned stack (gap5 successor): one pack per remaining PULL (pool size /
 * revealCount, rounded up), not one pack with an item-count badge. This
 * wrapper carries the shelf-level behavior the single pack used to own —
 * off-frame at rest, sliding in on hover/focus, swapping between
 * categories — while each .booster-pack child inside it only owns its own
 * depth-in-the-stack offset (see --stack-i below). Sized to the pack
 * ART's own aspect ratio (64:100, same across all three category images)
 * with extra bottom padding so the label text every front pack renders
 * below its image stays inside the hover/focus hit area.
 */
.booster-pack-stack {
  position: relative;
  width: var(--booster-pack-width, 360px);
  aspect-ratio: 64 / 100;
  padding-bottom: 26px;
  pointer-events: auto;
  /* Right half hidden off-frame; hover reveals (gap5) */
  transform: translateX(52%);
  transition: transform 0.28s cubic-bezier(0.22, 1.2, 0.36, 1);
}

/* Category-switch swap: old stack slides fully out, new one slides back in */
.booster-pack-stack.is-swapping {
  transform: translateX(160%);
}

/* JS-owned (initBoosterStackSpring's setFanned), NOT :hover/:focus-within:
 * the fan stays open across a generous grace ZONE around the stack — a
 * region, not an element — so the cursor can roam between/off the packs
 * while choosing. :hover would end at the element's own edge and collapse
 * the hand the moment the pointer stepped into the gap between two fanned
 * cards, which is exactly the tightrope feel this replaces. */
.booster-pack-stack.is-fanned {
  /* Slide in AND lean toward the play area (left) — reads as "pick me up" */
  transform: translateX(4%) rotate(-4deg);
  transform-origin: 85% 90%;
}

/* Measurement helper (JS, one forced reflow): yields the stack's RESTING
 * rect immediately. forceRestNow drops .is-fanned, but this element's
 * transform TRANSITION (0.28s) would still be mid-slide when the fly-back
 * measures a beat later — this pins the rest transform with no transition
 * for that one reflow. Must mirror the base transform above. */
.booster-pack-stack.is-measuring-rest {
  transform: translateX(52%) !important;
  transition: none !important;
}

/* Depth-in-stack position/rotation/scale is NOT set here — it's driven
 * every frame by initBoosterStackSpring's rAF loop (js/app.js) writing
 * inline `transform`/`z-index`, a real velocity+spring simulation instead
 * of a CSS transition. A transition reaches its declared end state and
 * then goes fully still; Balatro-style card-hand motion needs to keep
 * responding continuously (a gentle idle sway while fanned, an
 * individual pop when a specific card gets hovered) which a fire-and-
 * forget transition can't express. CSS only owns the parts that stay
 * genuinely binary/one-shot — layout, cursor, and the whole-stack
 * off-frame slide above. */
.booster-pack {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

/* "Pull this toward the center" cue (gap5): a small chevron tugging left,
 * toward the play area, on a loop. Hides while the pack is actually being
 * dragged (is-dragging-source) — see beginBoosterPackDrag — and while a
 * rip is active the whole shelf parks off-frame anyway, taking this with
 * it, so no separate rip-state check is needed here. */
.booster-pull-hint {
  position: absolute;
  top: 50%;
  left: -44px;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 10px;
  pointer-events: none;
  z-index: 5;
  transition: opacity 0.2s ease;
}

.booster-pack.is-dragging-source .booster-pull-hint {
  opacity: 0;
}

.booster-pull-hint-arrow {
  display: block;
  width: 26px;
  height: 30px;
  /* Opaque white, big — reads from across the screen */
  background: #ffffff;
  /* clip-path triangle, not a border trick — this design system reserves
   * solid CSS borders for nothing (9-slice frames only); a test enforces it. */
  clip-path: polygon(100% 0%, 0% 50%, 100% 100%);
  animation: boosterPullTug 1.3s ease-in-out infinite;
}

/* Cascade: each triangle in the column tugs slightly after the one above */
.booster-pull-hint-arrow:nth-child(2) {
  animation-delay: 0.12s;
}

.booster-pull-hint-arrow:nth-child(3) {
  animation-delay: 0.24s;
}

@keyframes boosterPullTug {
  0%,
  100% {
    transform: translateX(0);
    opacity: 0.75;
  }
  50% {
    transform: translateX(-14px);
    opacity: 1;
  }
}

.booster-pack img {
  display: block;
  width: 100%;
  height: auto;
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(-4px 4px 0 rgba(0, 0, 0, 0.35));
}

.booster-pack .booster-pack-count {
  position: absolute;
  top: -8px;
  left: -8px;
  min-width: 26px;
  padding: 3px 6px;
  box-sizing: border-box;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 0.72rem;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.85);
  /* box-shadow ring, not border: solid CSS borders are banned on chrome
   * (design system uses 9-slice pixel frames; a test enforces this) */
  box-shadow: 0 0 0 2px var(--dropzone-border, rgba(247, 198, 36, 0.514));
  border-radius: 8px;
  pointer-events: none;
}

.booster-pack .booster-pack-label {
  margin: 4px 0 0;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  color: var(--text-main, #f4efe4);
  text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.6);
  pointer-events: none;
}

.booster-pack.is-disabled {
  cursor: not-allowed;
  filter: grayscale(1) brightness(0.6);
}

.booster-pack.is-dragging-source {
  opacity: 0.35;
}

/* Refused rip (nothing fits / pool empty): quick head-shake. Animates
 * `transform` only — the pack's stack-depth offset lives in the separate
 * `translate`/`rotate` properties above, so this composes on top of
 * wherever the pack currently sits (fanned or resting) instead of
 * fighting it. */
.booster-pack.is-refused {
  animation: boosterPackShake 0.4s ease;
}

@keyframes boosterPackShake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-10px); }
  40% { transform: translateX(8px); }
  60% { transform: translateX(-6px); }
  80% { transform: translateX(4px); }
}

/* --- Floater: pack clone following the pointer during drag --- */
.booster-pack-floater {
  position: fixed;
  z-index: 60;
  width: var(--booster-pack-width, 360px);
  pointer-events: none;
  image-rendering: pixelated;
  filter: drop-shadow(-6px 8px 0 rgba(0, 0, 0, 0.35));
  transition: none;
}

.booster-pack-floater.is-returning {
  transition: left 0.32s cubic-bezier(0.4, 0.8, 0.4, 1), top 0.32s cubic-bezier(0.4, 0.8, 0.4, 1);
}

/* --- Center rip zone (mirrors model-preview dropzone look) --- */
.booster-rip-zone {
  position: fixed;
  z-index: 5;
  top: 50%;
  left: 50%;
  width: 460px;
  height: 400px;
  transform: translate(-50%, -50%) rotateX(var(--crt-curve-x)) scale(var(--pixel-scale));
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  padding: 1.25rem 1rem;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 1.1rem;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: var(--dropzone-fill, rgba(15, 39, 99, 0.349));
  border: 5px dashed var(--dropzone-border, rgba(247, 198, 36, 0.514));
  border-radius: 14px;
  pointer-events: none;
  image-rendering: pixelated;
}

.booster-rip-zone.is-hot {
  background: var(--dropzone-fill-active, rgba(12, 18, 32, 0.36));
  border-color: var(--dropzone-border-active, rgba(248, 214, 136, 0.55));
}

.booster-rip-zone[hidden] {
  display: none !important;
}

/* --- Backdrop: full-viewport pointer trap while a pack is docked ---
 * Nothing behind/near the pack is interactive mid-rip; clicking it cancels. */
.booster-rip-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(4, 6, 12, 0.45);
  pointer-events: auto;
  opacity: 0;
  transition: opacity 0.25s ease;
}

.booster-rip-backdrop.is-showing {
  opacity: 1;
}

.booster-rip-backdrop[hidden] {
  display: none !important;
}

/* --- Rip stage: docked pack + manual mouse tear --- */
.booster-rip-stage {
  position: fixed;
  z-index: 61;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  pointer-events: auto;
}

.booster-rip-stage[hidden] {
  display: none !important;
}

/* Docking entrance: pack drops in from above with a bounce settle. Every
 * number is a custom property (--dock-*, set from CONFIG.boosterPacks.
 * dockAnim by dockPackForRip in app.js) with a fallback matching the
 * original hard-coded values, so this still works with no JS involvement. */
.booster-rip-pack.is-docking {
  animation-name: boosterDockIn;
  animation-duration: var(--dock-anim-duration, 0.55s);
  animation-timing-function: var(--dock-anim-easing, cubic-bezier(0.28, 1.2, 0.42, 1));
}

@keyframes boosterDockIn {
  0% {
    transform:
      translateY(var(--dock-drop, -70vh))
      rotate(var(--dock-start-rotate, -9deg))
      scale(var(--dock-start-scale, 0.7));
    opacity: 0;
  }
  62% {
    transform:
      translateY(var(--dock-bounce-y, 14px))
      rotate(var(--dock-bounce-rotate, 2.5deg))
      scale(var(--dock-bounce-scale, 1.02));
    opacity: 1;
  }
  82% {
    transform:
      translateY(var(--dock-settle-y, -6px))
      rotate(var(--dock-settle-rotate, -1deg))
      scale(1);
  }
  100% {
    transform: translateY(0) rotate(0deg) scale(1);
  }
}

/* --- RPG dialogue box under the docked pack (typewriter text via JS) --- */
.booster-dialogue {
  position: absolute;
  top: calc(100% + 1.4rem);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: 62vw;
  min-width: 300px;
  box-sizing: border-box;
  padding: 0.85rem 1.15rem;
  font-family: var(--font-pixel);
  font-size: 0.98rem;
  line-height: 1.7;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.94);
  box-shadow:
    0 0 0 3px var(--dropzone-border-active, rgba(248, 214, 136, 0.55)),
    0 8px 0 rgba(0, 0, 0, 0.4);
  border-radius: 10px;
  pointer-events: none;
  animation: boosterDialoguePop 0.3s cubic-bezier(0.3, 1.4, 0.5, 1);
}

.booster-dialogue p {
  margin: 0;
  white-space: pre-wrap;
}

/* Blinking caret while the typewriter runs (JS removes .is-typing at end) */
.booster-dialogue.is-typing p::after {
  content: "▌";
  margin-left: 2px;
  animation: bootDotBlink 0.8s steps(1) infinite;
}

@keyframes boosterDialoguePop {
  0% {
    transform: translateX(-50%) translateY(10px) scale(0.85);
    opacity: 0;
  }
  100% {
    transform: translateX(-50%) translateY(0) scale(1);
    opacity: 1;
  }
}

/* --- Unlock-All confirm: standalone RPG dialogue box + Yes/No (app.js:
 * showUnlockAllConfirm/hideUnlockAllConfirm/confirmUnlockAllForever) ---
 * Same dark pixel-panel look as .booster-dialogue above, but centered and
 * unattached to a pack (the header button can be clicked from any category)
 * and above everything else — see z-index vs. .booster-rip-backdrop (60). */
.unlock-confirm-backdrop {
  position: fixed;
  inset: 0;
  z-index: 70;
  background: rgba(4, 6, 12, 0.55);
  pointer-events: auto;
}

.unlock-confirm-backdrop[hidden] {
  display: none !important;
}

.unlock-confirm-dialogue {
  position: fixed;
  z-index: 71;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: max-content;
  /* Wide enough that the Yes/Cancel row (see .unlock-confirm-actions) never
   * has to wrap onto two lines — this only ever shows on desktop (booster
   * mode is desktop-only), so 90vw is plenty of headroom on any screen it
   * actually appears on. */
  max-width: min(90vw, 560px);
  box-sizing: border-box;
  padding: 1.1rem 1.3rem;
  font-family: var(--font-pixel);
  font-size: 0.9rem;
  line-height: 1.7;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.96);
  box-shadow:
    0 0 0 3px var(--dropzone-border-active, rgba(248, 214, 136, 0.55)),
    0 8px 0 rgba(0, 0, 0, 0.4);
  border-radius: 10px;
  pointer-events: auto;
  animation: boosterDialoguePopCentered 0.3s cubic-bezier(0.3, 1.4, 0.5, 1);
}

.unlock-confirm-dialogue[hidden] {
  display: none !important;
}

.unlock-confirm-dialogue p {
  margin: 0 0 1rem;
  /* Plain flowing text (not .booster-dialogue's pre-wrap) — this box is
   * static copy, not typewritten, so it should wrap naturally instead of
   * preserving the HTML source's own line breaks/indentation verbatim. */
  white-space: normal;
  text-align: left;
}

.unlock-confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.6rem;
  /* One row always (never Yes atop Cancel) — this dialogue only ever shows
   * on desktop (booster mode is desktop-only), so there's always room. */
  flex-wrap: nowrap;
}

/* Slightly tighter than the base .btn.ui-btn (that padding, plus "YES,
 * UNLOCK ALL"'s own length, was what pushed the pair onto two rows) —
 * font-size untouched. */
.unlock-confirm-actions .btn.ui-btn {
  padding: 0.85rem 1.1rem;
  white-space: nowrap;
}

@keyframes boosterDialoguePopCentered {
  0% {
    transform: translate(-50%, -50%) translateY(10px) scale(0.85);
    opacity: 0;
  }
  100% {
    transform: translate(-50%, -50%) translateY(0) scale(1);
    opacity: 1;
  }
}

/* Rainbow wobble on the tutorial dialogue's opening phrase (gap8) — the
 * rest of the sentence stays plain text. Two independent loops per letter
 * (color cycle + bob/rotate), staggered via inline animation-delay so the
 * letters ripple instead of pulsing in lockstep. */
.booster-rainbow-letter {
  display: inline-block;
  animation:
    boosterRainbowHue 2.4s linear infinite,
    boosterRainbowWobble 1.3s ease-in-out infinite;
}

@keyframes boosterRainbowHue {
  0% {
    color: #ff6b6b;
  }
  16% {
    color: #ffb86b;
  }
  33% {
    color: #f5e66b;
  }
  50% {
    color: #6bffa0;
  }
  66% {
    color: #6bd4ff;
  }
  83% {
    color: #b98bff;
  }
  100% {
    color: #ff6b6b;
  }
}

@keyframes boosterRainbowWobble {
  0%,
  100% {
    transform: translateY(0) rotate(0deg);
  }
  25% {
    transform: translateY(-3px) rotate(-6deg);
  }
  50% {
    transform: translateY(1px) rotate(4deg);
  }
  75% {
    transform: translateY(-2px) rotate(-3deg);
  }
}

.booster-rip-pack {
  position: relative;
  width: var(--booster-rip-width, 380px);
  /* No blanket crosshair — only the actual tear-start band gets the rip
   * cursor (see body.is-rip-cursor-zone below); JS toggles it live. */
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
  /* Smooth hover3d-style tilt (JS drives rotateX/rotateY; resets on leave) */
  transition: transform 0.16s ease-out;
  /* Children keep real 3D positions (back panel + edge faces = thickness) */
  transform-style: preserve-3d;
}

/* --- Pack thickness: a darkened back panel + four edge faces, so the
 * hover tilt reads as a real slab instead of a paper-thin sheet. The back
 * panel doubles as the pack "interior" once the flap tears off. --- */
.booster-rip-back {
  position: absolute;
  inset: 0;
  transform: translateZ(calc(-1 * var(--pack-thickness, 18px)));
}

.booster-rip-back .booster-rip-layer {
  filter: brightness(0.4) saturate(0.7);
}

.booster-rip-side {
  position: absolute;
  pointer-events: none;
  /* Layered-cardboard edge — bright enough to read as a slab side even at
   * modest tilt angles, with fine "stacked paper" striations. */
  background:
    repeating-linear-gradient(
      90deg,
      rgba(255, 255, 255, 0.12) 0 2px,
      transparent 2px 5px
    ),
    linear-gradient(rgba(214, 168, 110, 1), rgba(140, 100, 58, 1));
}

.booster-rip-side-right {
  top: 0;
  left: 100%;
  width: var(--pack-thickness, 18px);
  height: 100%;
  transform-origin: left center;
  transform: rotateY(90deg);
}

.booster-rip-side-left {
  top: 0;
  right: 100%;
  width: var(--pack-thickness, 18px);
  height: 100%;
  transform-origin: right center;
  transform: rotateY(-90deg);
}

.booster-rip-side-top {
  left: 0;
  bottom: 100%;
  width: 100%;
  height: var(--pack-thickness, 18px);
  transform-origin: center bottom;
  transform: rotateX(90deg);
}

.booster-rip-side-bottom {
  left: 0;
  top: 100%;
  width: 100%;
  height: var(--pack-thickness, 18px);
  transform-origin: center top;
  transform: rotateX(-90deg);
}

/* --- Foil: chrome + holo rendered per-pixel onto ONE canvas from a normal
 * map that JS generates from the pack texture itself (Sobel over
 * luminance). Lighting is recomputed from the live tilt, so the specular
 * chrome and the iridescent rainbow only appear where the light actually
 * catches the art's contours and sweep across as the pack rotates — and
 * transparent texels output nothing. mix-blend-mode: screen means black
 * pixels are a no-op over the art, so the canvas only ever brightens. --- */
.booster-rip-foil {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  image-rendering: pixelated;
  mix-blend-mode: screen;
}

/* Once the flap is torn off, the foil layers only cover the pack BODY —
 * the torn strip region shows the darkened back panel ("interior"), and
 * the top edge face goes with the flap. Eased rather than an instant snap —
 * the interior should reveal itself over a couple frames, not pop into
 * existence the same tick the flap starts flying. */
.booster-rip-foil,
.booster-rip-side-left,
.booster-rip-side-right {
  transition: clip-path 0.22s ease-out;
}

.booster-rip-pack.is-ripped-open .booster-rip-foil,
.booster-rip-pack.is-ripped-open .booster-rip-side-left,
.booster-rip-pack.is-ripped-open .booster-rip-side-right {
  clip-path: inset(var(--tear-y, 13%) 0 0 0);
}

.booster-rip-pack.is-ripped-open .booster-rip-side-top {
  display: none;
}

/* Rip cursor: JS toggles this on <body> whenever the pointer sits inside
 * the tear-start band — the pack itself AND the backdrop area to its left
 * both qualify (gap1 from the prior round), so both need the override. */
body.is-rip-cursor-zone,
body.is-rip-cursor-zone .booster-rip-pack,
body.is-rip-cursor-zone .booster-rip-backdrop {
  cursor: var(--cursor-rip-img) 33 31, crosshair;
}

/* Mid-tear (mouse held down, actively ripping): dedicated cursor. Placed
 * AFTER the zone rule so it wins if both classes ever coexist. */
body.is-ripping,
body.is-ripping .booster-rip-pack,
body.is-ripping .booster-rip-backdrop {
  cursor: var(--cursor-ripping-img) 33 31, grabbing;
}

/* Tutorial pack cannot be dismissed — blocked cancel attempts wiggle it */
.booster-rip-pack.is-nudged {
  animation: boosterDockedNudge 0.35s ease;
}

@keyframes boosterDockedNudge {
  0%, 100% { transform: none; }
  25% { transform: rotate(-2deg) translateX(-7px); }
  50% { transform: rotate(1.5deg) translateX(6px); }
  75% { transform: rotate(-1deg) translateX(-3px); }
}

/* Two stacked copies of the SAME pack art: flap above the tear line,
 * body below it — clip-path splits them (boot panels proved this trick). */
.booster-rip-layer {
  display: block;
  width: 100%;
  height: auto;
  image-rendering: pixelated;
  pointer-events: none;
}

.booster-rip-flap {
  position: absolute;
  inset: 0;
  /* --tear-y set inline from CONFIG.boosterPacks.tearRatio */
  clip-path: inset(0 0 calc(100% - var(--tear-y, 13%)) 0);
  /* JS moves the origin to the live tear tip during the peel so the freed
   * strip hinges where it is still attached (see applyProgress) */
  transform-origin: 92% 100%;
  transition: transform 0.08s linear;
}

.booster-rip-body {
  clip-path: inset(var(--tear-y, 13%) 0 0 0);
  filter: drop-shadow(-6px 10px 0 rgba(0, 0, 0, 0.35));
}

/* The widening white-hot slit that follows the mouse along the dotted line */
.booster-rip-slit {
  position: absolute;
  left: 0;
  top: calc(var(--tear-y, 13%) - 4px);
  width: 0;
  height: 9px;
  background: #ffffff;
  box-shadow:
    0 0 8px rgba(255, 255, 255, 0.95),
    0 0 20px rgba(190, 225, 255, 0.65);
  /* Jagged leading edge */
  clip-path: polygon(
    0 0, 100% 0, 96% 55%, 100% 100%, 0 100%, 3% 50%
  );
  pointer-events: none;
  /* Above the dashed line + flash — the actual cut visually replaces them */
  z-index: 3;
}

/* Code-drawn dashed tear line (art no longer bakes one in) — a thin
 * repeating-gradient dash row sitting exactly at the tear boundary, always
 * visible pre-tear; the widening .booster-rip-slit above naturally covers
 * the already-torn portion as progress advances. */
.booster-tear-line {
  position: absolute;
  left: 0;
  /* Unified 8px thickness with the flash below — recentered on the same row */
  top: calc(var(--tear-y, 13%) - 4px);
  width: 100%;
  height: 8px;
  background-image: repeating-linear-gradient(
    to right,
    rgba(20, 22, 30, 0.85) 0,
    rgba(20, 22, 30, 0.85) 10px,
    transparent 10px,
    transparent 18px
  );
  pointer-events: none;
  z-index: 1;
}

/* Traveling glow flash along the dashed line — teaches "drag here,
 * left to right" before the player has touched anything. Head (leading,
 * right-hand edge — the direction of travel) is bright near-white; tail
 * (trailing, left-hand edge) is dimmer and more saturated blue. JS stops
 * it the instant a real tear stroke starts (see tryStartTear). */
.booster-tear-flash {
  position: absolute;
  left: -35%;
  top: calc(var(--tear-y, 13%) - 4px);
  width: 35%;
  height: 8px;
  pointer-events: none;
  z-index: 2;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(90, 170, 255, 0.4) 12%,
    rgba(150, 205, 255, 0.75) 45%,
    rgba(255, 255, 255, 1) 88%,
    transparent 100%
  );
  filter:
    drop-shadow(0 0 6px rgba(255, 255, 255, 0.9))
    drop-shadow(0 0 14px rgba(110, 180, 255, 0.7));
  animation: boosterTearFlashSweep 1.9s ease-in-out infinite;
  /* Only takes effect once JS kills the animation (see stopTearFlash) —
   * a still-running CSS animation overrides a plain opacity in the same
   * rule set regardless of this transition, which is exactly why the
   * flash used to look "stuck" instead of fading on rip. */
  transition: opacity 0.16s ease;
}

.booster-tear-flash.is-stopped {
  opacity: 0;
}

/* "Hold left mouse button here" placeholder icon (gap6), sitting left of
 * the dashed line — swap the source PNG later, sizing/position stay put.
 * Fades with the same stopTearFlash() call as the sweep, once real tearing
 * starts — the player found it, they don't need the icon anymore. */
.booster-hold-hint {
  position: absolute;
  left: -78px;
  top: calc(var(--tear-y, 13%) - 26px);
  width: 52px;
  height: 52px;
  pointer-events: none;
  z-index: 2;
  image-rendering: pixelated;
  transition: opacity 0.16s ease;
}

.booster-hold-hint img {
  display: block;
  width: 100%;
  height: 100%;
}

.booster-hold-hint.is-stopped {
  opacity: 0;
}

@keyframes boosterTearFlashSweep {
  0% {
    transform: translateX(0%);
    opacity: 0;
  }
  12% {
    opacity: 1;
  }
  85% {
    opacity: 1;
  }
  100% {
    transform: translateX(385%);
    opacity: 0;
  }
}

/* Flap ripped free: the tear travels left→right, so the strip follows the
 * pull — flinging up-RIGHT with a clockwise tumble (not up-left/counter).
 * EVERY property is strictly monotonic in ONE direction start to finish —
 * rotation only increases, translate x/y only grow, scale only shrinks.
 * The previous version had scale bump UP (1 → 1.02) before shrinking back
 * down, and that non-monotonic reversal is exactly what read as a wiggle,
 * even though rotation/translate never doubled back. No more reversals of
 * ANY kind here — just one clean, single-direction toss. */
.booster-rip-flap.is-torn {
  animation: boosterFlapFly 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}

@keyframes boosterFlapFly {
  0% {
    /* Continues the peel's clockwise build-up (rotate(+p*8deg) in JS) —
     * no direction flip at the moment of release */
    transform: rotate(8deg) translate(0, 0) scale(1);
    opacity: 1;
  }
  100% {
    transform: rotate(46deg) translate(236px, -240px) scale(0.85);
    opacity: 0;
  }
}

/* Independent of the flap's fly-off: the slab itself jolts the instant the
 * strip lets go. The tear travels left→right, so the release recoil follows
 * the SAME direction — a rightward kick + clockwise nudge that settles back,
 * not a generic side-to-side wobble that reads as fighting the tear. */
.booster-rip-pack.is-tear-impact {
  animation: boosterPackTearJolt 0.3s var(--ease-snap, ease-out);
}

@keyframes boosterPackTearJolt {
  0% { transform: translate(0, 0) rotate(0deg) scale(1, 1); }
  30% { transform: translate(5px, -1px) rotate(0.9deg) scale(1.03, 0.975); }
  60% { transform: translate(-1px, 1px) rotate(-0.2deg) scale(0.99, 1.008); }
  100% { transform: translate(0, 0) rotate(0deg) scale(1, 1); }
}

/* Contained camera shake on the whole rip stage at the moment of the tear —
 * base transform keeps the fixed 50%/50% centering, only the jitter is new. */
.booster-rip-stage.is-tear-shake {
  animation: boosterTearShake 0.3s var(--ease-snap, ease-out);
}

@keyframes boosterTearShake {
  0%, 100% { transform: translate(-50%, -50%) translate(0, 0); }
  25% { transform: translate(-50%, -50%) translate(-5px, 3px); }
  50% { transform: translate(-50%, -50%) translate(6px, -2px); }
  75% { transform: translate(-50%, -50%) translate(-3px, 2px); }
}

/* Shockwave ring at the tear point — a genuine "shockwave shader" (see
 * #booster-tear-warp in index.html), not a decorative gradient ring. Every
 * number is a custom property (--sw-*, set from CONFIG.boosterPacks.
 * shockwave by completeBoosterRip in app.js) with a fallback matching the
 * original values, EXCEPT `background`, which app.js sets directly as an
 * inline style — the iridescent sweep is built from a owner-configurable
 * COLOR LIST (shockwave.colors), and a CSS gradient can't loop over an
 * arbitrary-length array, only JS can.
 *   1. backdrop-filter drives the actual effect — every pixel behind the
 *      ring gets pushed radially outward from the blast center, refracting
 *      the real page content as the ring passes over it. That distortion
 *      IS the shockwave; the ring itself barely needs paint of its own.
 *   2. mask-image is now a SINGLE smooth ramp (transparent at the band's
 *      inner edge → fully opaque exactly at the ring's outer edge) instead
 *      of the old two-stop hard cutoff — that hard step was the "solid"
 *      inner edge; both the warp strength and the paint now fade in
 *      gradually across the whole band instead of switching on all at once.
 *   3. background (app.js) layers a soft white "crest" highlight over an
 *      iridescent conic-gradient sweep — angle-based color, like light on
 *      a soap-bubble ring, which a radial gradient alone can't produce.
 * filter: drop-shadow (not box-shadow) is used for the glow because
 * drop-shadow follows the mask's actual ring shape — a box-shadow would
 * glow from the full square box, ignoring the masked-out center. */
.booster-tear-shockwave {
  position: fixed;
  z-index: 62;
  width: var(--sw-start-size, 40px);
  height: var(--sw-start-size, 40px);
  margin-left: calc(var(--sw-start-size, 40px) / -2);
  margin-top: calc(var(--sw-start-size, 40px) / -2);
  border-radius: 50%;
  pointer-events: none;
  -webkit-mask-image: radial-gradient(
    closest-side,
    transparent calc(100% - var(--sw-thickness, 130px)),
    #000 100%
  );
  mask-image: radial-gradient(
    closest-side,
    transparent calc(100% - var(--sw-thickness, 130px)),
    #000 100%
  );
  -webkit-backdrop-filter: url(#booster-tear-warp);
  backdrop-filter: url(#booster-tear-warp);
  filter:
    drop-shadow(0 0 16px rgba(255, 255, 255, 0.85))
    drop-shadow(0 0 34px rgba(248, 214, 136, 0.5));
  animation-name: boosterTearShockwave;
  animation-duration: var(--sw-duration, 0.5s);
  animation-timing-function: var(--sw-easing, cubic-bezier(0.08, 0.7, 0.18, 1));
  animation-fill-mode: forwards;
}

@keyframes boosterTearShockwave {
  0% {
    width: var(--sw-start-size, 40px);
    height: var(--sw-start-size, 40px);
    margin-left: calc(var(--sw-start-size, 40px) / -2);
    margin-top: calc(var(--sw-start-size, 40px) / -2);
    opacity: 1;
  }
  100% {
    width: var(--sw-end-size, 1230px);
    height: var(--sw-end-size, 1230px);
    margin-left: calc(var(--sw-end-size, 1230px) / -2);
    margin-top: calc(var(--sw-end-size, 1230px) / -2);
    opacity: 0;
  }
}

/* Whole pack falls away after the reveal finishes — a brief upward
 * anticipation dip before it drops, instead of falling the instant the
 * class lands, plus more rotation/shrink on the way down for depth. */
.booster-rip-pack.is-spent {
  animation: boosterPackDrop 0.5s ease-in forwards;
}

@keyframes boosterPackDrop {
  0% { transform: translateY(0) rotate(0deg) scale(1); opacity: 1; }
  25% { transform: translateY(-12px) rotate(-3deg) scale(0.98); opacity: 1; }
  100% { transform: translateY(210px) rotate(15deg) scale(0.8); opacity: 0; }
}

/* Reveal floaters: item icons arcing from the pack to their grid slots */
/* Reuses .grid-item's own border-image frame + rarity gem (JS gives it
 * "grid-item rarity-X" classes, same as a real slot) so it already looks
 * like the landed item, not a bare icon, from the moment it appears.
 * MUST be the two-class selector: .grid-item alone (defined LATER in this
 * file) sets position:relative at equal specificity, which silently beat a
 * plain .booster-reveal-floater rule and dumped the "fixed" floaters into
 * normal flow at the bottom of the page — no visible arc at all. */
.grid-item.booster-reveal-floater {
  position: fixed;
  left: 0;
  top: 0;
  margin: 0;
  z-index: 62;
  pointer-events: none;
  overflow: hidden;
  image-rendering: pixelated;
  /* Kill .grid-item's transform transition — the arc tween drives
   * left/top/width/height by rAF and must not be smoothed/fought by CSS. */
  transition: none;
}

/* Newly revealed items hide until their floater lands */
.grid-item.booster-incoming {
  opacity: 0;
}

/* --- Empty inventory hint: a dialogue bubble beside the shelf, with an
 * animated triangle pointing at the packs (gap4). JS (syncBoosterEmptyHint)
 * positions it live next to the shelf and NEVER shows it while a pack is
 * docked — it would otherwise compete with the rip tutorial for attention. */
.booster-empty-hint {
  position: fixed;
  z-index: 4;
  max-width: 260px;
  box-sizing: border-box;
  padding: 0.85rem 1.15rem;
  font-family: var(--font-pixel);
  font-size: 0.92rem;
  line-height: 1.6;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.94);
  box-shadow:
    0 0 0 3px var(--dropzone-border, rgba(247, 198, 36, 0.514)),
    0 6px 0 rgba(0, 0, 0, 0.4);
  border-radius: 10px;
  pointer-events: none;
  animation: boosterDialoguePop 0.3s cubic-bezier(0.3, 1.4, 0.5, 1);
}

.booster-empty-hint[hidden] {
  display: none !important;
}

.booster-empty-hint p {
  margin: 0;
}

.booster-empty-hint-arrow {
  position: absolute;
  top: 50%;
  right: -14px;
  width: 16px;
  height: 20px;
  background: var(--dropzone-border, rgba(247, 198, 36, 0.514));
  /* clip-path triangle, not a border trick — see .booster-pull-hint-arrow */
  clip-path: polygon(0% 0%, 100% 50%, 0% 100%);
  transform: translateY(-50%);
  animation: boosterEmptyHintPoint 1.2s ease-in-out infinite;
}

@keyframes boosterEmptyHintPoint {
  0%,
  100% {
    transform: translateY(-50%) translateX(0);
  }
  50% {
    transform: translateY(-50%) translateX(6px);
  }
}

/* --- Toast (refusals / trash feedback) --- */
.booster-toast {
  position: fixed;
  z-index: 63;
  top: 18%;
  left: 50%;
  transform: translateX(-50%);
  padding: 0.5rem 1rem;
  font-family: var(--font-pixel);
  font-size: 0.95rem;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.9);
  /* box-shadow ring, not border (see .booster-pack-count) */
  box-shadow: 0 0 0 3px var(--dropzone-border-active, rgba(248, 214, 136, 0.55));
  border-radius: 10px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.booster-toast.is-showing {
  opacity: 1;
}

.booster-toast[hidden] {
  display: none !important;
}

/* --- Trash bin: slides in bottom-right while dragging a grid item.
 * z above the side-overlay (4) and bottom offset raised by JS so it sits
 * ABOVE the Controls panel instead of hiding behind/below it. --- */
.trash-bin {
  position: fixed;
  z-index: 6;
  right: 22px;
  bottom: 20px; /* JS raises this above the Controls panel when visible */
  width: var(--trash-bin-size, 96px);
  pointer-events: none;
  transform: translateY(140%);
  transition: transform 0.28s cubic-bezier(0.22, 1.2, 0.36, 1);
  /* Raised bottom offset means translateY(140%) alone no longer clears the
   * viewport — hide outright unless armed (or mid-chomp after a drop). */
  visibility: hidden;
}

.trash-bin[hidden] {
  display: none !important;
}

.trash-bin.is-armed,
.trash-bin.is-chomping {
  visibility: visible;
}

.trash-bin.is-armed {
  transform: translateY(0);
}

.trash-bin.is-hot .trash-bin-img {
  filter: brightness(1.3) drop-shadow(0 0 10px rgba(248, 214, 136, 0.6));
}

.trash-bin-img {
  display: block;
  width: 100%;
  height: auto;
  image-rendering: pixelated;
  filter: drop-shadow(-4px 4px 0 rgba(0, 0, 0, 0.35));
}

/* Label above the bin — a plain CHILD of .trash-bin, so it inherits the
 * bin's own visibility/slide-in for free and needs no separate JS wiring;
 * "present" here means whatever makes the bin itself present (armed
 * mid-drag, or still mid chomp/vanish after a drop). Centering (this
 * element, static) and the wobble (the inner <span>, animated) are split
 * across two elements — same trick as .booster-rainbow-letter — because a
 * single element can't hold both `translateX(-50%)` centering AND a
 * keyframe animation that drives its own `transform` without one clobbering
 * the other every frame. */
.trash-bin-label {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 0.5rem);
  transform: translateX(-50%);
  margin: 0;
  padding: 0.3rem 0.7rem;
  white-space: nowrap;
  font-family: var(--font-pixel);
  font-size: 0.7rem;
  letter-spacing: 0.03em;
  color: var(--text-main, #f4efe4);
  background: rgba(12, 18, 32, 0.94);
  box-shadow:
    0 0 0 2px var(--dropzone-border, rgba(247, 198, 36, 0.514)),
    0 4px 0 rgba(0, 0, 0, 0.4);
  border-radius: 8px;
  pointer-events: none;
}

.trash-bin-label span {
  display: inline-block;
  animation: boosterRainbowWobble 1.3s ease-in-out infinite;
}

/* Chomp: bounce + tilt when something is trashed (decision3) */
.trash-bin.is-chomping {
  animation: trashBinChomp 0.5s cubic-bezier(0.3, 1.4, 0.5, 1);
}

@keyframes trashBinChomp {
  0% { transform: translateY(0) rotate(0deg) scale(1); }
  25% { transform: translateY(-16px) rotate(-9deg) scale(1.08); }
  50% { transform: translateY(2px) rotate(6deg) scale(0.96); }
  75% { transform: translateY(-6px) rotate(-3deg) scale(1.03); }
  100% { transform: translateY(0) rotate(0deg) scale(1); }
}

/* Vanish: plays once the chomp bounce settles (see trashGridItem) — a
 * distinct shrink-up-and-fade "gulp" instead of just sliding off like a
 * normal un-arm, so trashing an item visibly reads as "eaten". */
.trash-bin.is-vanishing {
  animation: trashBinVanish 0.32s cubic-bezier(0.5, 0, 0.85, 0.3) forwards;
}

@keyframes trashBinVanish {
  0% { transform: translateY(0) scale(1); opacity: 1; }
  100% { transform: translateY(-26px) scale(0.3); opacity: 0; }
}

/* Reduced-motion rules for boosters live in the shared reduced-motion block
 * near the end of this file — tests (and sanity) require ONE such block. */

.social-btn {
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: var(--social-btn-size, 76px);
  height: var(--social-btn-size, 76px);
  min-width: var(--social-btn-size, 76px);
  min-height: var(--social-btn-size, 76px);
  padding: 0;
  margin: 0;
  text-decoration: none;
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 0.55rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-main);
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  background-color: var(--button-face, #243044);
  background-image: none;
  border: var(--control-slice-width) solid transparent;
  border-image-source: var(--ui-buttonNormal);
  border-image-slice: var(--slice-button) fill;
  border-image-width: var(--control-slice-width);
  border-image-repeat: var(--slice-repeat);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  box-shadow: 5px 10px 0px #12213b91;
  transform: translate3d(0, 0, 0) scale(1);
  /* Continuous idle float — stepped for cheaper idle composites */
  animation: floatSubtle var(--idle-duration) steps(6, end) infinite;
  transition:
    box-shadow 0.28s var(--ease-spring-soft),
    color 0.15s ease,
    border-image-source 0.05s steps(1);
  flex: 0 0 auto;
  aspect-ratio: 1 / 1;
}

/* Phase offsets: buttons must not bob in lockstep */
.social-column .social-btn:nth-child(1) {
  animation-name: floatSubtle;
  animation-duration: var(--idle-duration);
  animation-delay: 0s;
  animation-timing-function: steps(6, end);
  animation-iteration-count: infinite;
}

.social-column .social-btn:nth-child(2) {
  animation-name: floatSubtle;
  /* Different duration + negative delay so peaks never stay aligned */
  animation-duration: calc(var(--idle-duration) * 1.22);
  animation-delay: calc(var(--idle-duration) * -0.48);
  animation-timing-function: steps(6, end);
  animation-iteration-count: infinite;
}

.social-btn:hover {
  border-image-source: var(--ui-buttonHover);
  color: var(--accent);
  transform: translate3d(0, -6px, 0) scale(1.2) rotate(4deg);
  box-shadow: 8px 15px 0px #12213b91;
  /* Hover pop replaces idle while pointer is over the control */
  animation: btnHoverPop 0.4s var(--ease-spring-soft);
}

.social-btn:active {
  border-image-source: var(--ui-buttonActive);
  transform: translate3d(0, 20px, 0) scale(0.96);
  box-shadow: 8px 15px 0px #12213b91;
  animation: none;
}

.social-btn:focus-visible {
  outline: none !important;
  /* No selection-box focus ring; keep resting hard shadow only via :hover if needed */
  box-shadow: none;
}

.social-btn-glyph {
  display: block;
  line-height: 1;
  pointer-events: none;
  image-rendering: pixelated;
}

/*
 * Platform icons nearly fill the square skill button face.
 * Size against --social-btn-size (outer face), not the content box inside
 * thick 9-slice borders — percentage of content box looked tiny (~48% of btn).
 * Prior baseline was 60% of the content box.
 */
.social-btn-icon {
  display: block;
  /* ~90% of outer button face; do not use max-width % of content box (9-slice shrinks it). */
  width: calc(var(--social-btn-size, 76px) * 0.9);
  height: calc(var(--social-btn-size, 76px) * 0.9);
  object-fit: contain;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  pointer-events: none;
  flex-shrink: 0;
}

/*
 * Side overlay: positioned by JS to match game-window bounds.
 * Shell and hover-preview stay pointer-events:none so a large free-floating
 * preview never steals clicks from Inventory / Status nav on the main window.
 * Only the instructions panel re-enables pointer events.
 */
.side-overlay {
  position: fixed;
  z-index: 4;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  gap: 0.75rem;
  width: var(--side-col-width, 300px);
  /* Allow large preview art to extend beyond the column */
  overflow: visible;
  pointer-events: none;
  box-sizing: border-box;
  /* Shares .crt-plane's perspective (see ancestor) — same rotateX/scale
   * function shape as #game-window so all three tilt as one plane. */
  transform: rotateX(var(--crt-curve-x)) scale(var(--pixel-scale));
  transform-origin: center center;
  /* Smooth match to game-window bounds on Status↔Inventory */
  transition:
    top var(--shell-recenter-duration) var(--ease-shell),
    left var(--shell-recenter-duration) var(--ease-shell),
    height var(--shell-recenter-duration) var(--ease-shell);
}

body.is-shell-recentering .side-overlay {
  transition: none;
}

/* Narrow-viewport stacked layout: horizontally centred below the game
 * window (left:50% set by JS). Composes translateX(-50%) with the same
 * CRT warp instead of an inline style.transform replacing it outright. */
.side-overlay.is-stacked {
  transform: translateX(-50%) rotateX(var(--crt-curve-x))
    scale(var(--pixel-scale));
}

.side-overlay .hover-preview {
  pointer-events: none;
}

.side-overlay .instructions-panel {
  pointer-events: auto;
}

/* Free-floating hover preview: image + hint only (no panel chrome) */
.hover-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: 1 1 auto;
  width: auto;
  max-width: var(--preview-width);
  min-height: 0;
  margin: 0;
  padding: 0;
  overflow: visible;
  background: none !important;
  border: none !important;
  box-shadow: none !important;
  outline: none !important;
}

/*
 * Hover preview: hard offset shadow (same family as buttons/tabs, larger than
 * their 6–10px), thin light edge, hard max box so big sources stay capped.
 * Shadow sits a bit to the right with lower opacity than solid #0a0804.
 * z-index 0 so title/hint paint above the offset shadow (not under it).
 */
.preview-image {
  display: block;
  box-sizing: border-box;
  position: relative;
  z-index: 0;
  width: auto;
  height: auto;
  max-width: var(--preview-width);
  max-height: var(--preview-max-height);
  object-fit: contain;
  object-position: center;
  image-rendering: pixelated;
  background: #0a1018;
  border: 2px solid #d8e0ec;
  /* Prior baseline: 0 14px 0 #0a0804 — now +X and translucent */
  box-shadow: 20px 20px 0 rgba(10, 8, 4, 0.45);
  filter: none;
}

.preview-image[hidden],
.preview-title[hidden],
.preview-hint[hidden] {
  display: none !important;
}

/* Project title on hover — stacks above image hard shadow; above “click to inspect” */
.preview-title {
  position: relative;
  z-index: 2;
  margin: 0.55rem 0 0;
  padding: 0;
  font-family: var(--font-pixel);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.03em;
  line-height: 1.25;
  color: var(--text-main);
  text-align: center;
  text-shadow: 0 2px 0 #0a0804;
  background: none;
  border: none;
  max-width: var(--preview-width);
}

.preview-hint {
  position: relative;
  z-index: 2;
  margin: 0.35rem 0 0;
  padding: 0;
  font-family: var(--font-pixel);
  font-size: 0.55rem;
  font-weight: 400;
  letter-spacing: 0.04em;
  color: var(--accent);
  text-align: center;
  text-shadow: 0 2px 0 #0a0804;
  background: none;
  border: none;
}

/*
 * Hover-preview image bob only — half of --idle-duration so each steps()
 * interval is ~50% shorter; other idle floats keep the longer cycle.
 */
.idle-float-preview {
  animation: previewBob var(--preview-bob-duration, calc(var(--idle-duration) * 0.5))
    steps(6, end) infinite;
}

@keyframes previewBob {
  0%,
  100% {
    transform: translate3d(0, 0, 0) rotate(-1.5deg) scale(1);
  }
  50% {
    transform: translate3d(0, calc(var(--idle-float-y) * -1.15), 0) rotate(1.5deg) scale(1.04);
  }
}

.instructions-title {
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 0.65rem;
  padding-bottom: 0.4rem;
  box-shadow: inset 0 -2px 0 rgba(201, 162, 39, 0.4);
}

/* Instructions lower on the right; bottom aligned via flex-end on overlay */
.instructions-panel {
  width: 100%;
  margin: 0;
  margin-top: auto;
  padding: 1rem 1.1rem 1.15rem;
  flex: 0 0 auto;
}

.instructions-panel[hidden] {
  display: none !important;
}

.instructions-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

.instructions-list li {
  font-size: 0.58rem;
  line-height: 1.55;
  color: var(--text-muted);
  letter-spacing: 0.02em;
}

.instructions-list kbd {
  display: inline-block;
  font-family: var(--font-pixel);
  font-size: 0.52rem;
  color: #0a0e14;
  background: var(--accent);
  border: none;
  box-shadow: 0 0 0 2px #1a1208, 0 2px 0 #0a0804;
  padding: 0.2rem 0.35rem;
  margin-right: 0.25rem;
  text-transform: uppercase;
}

.instructions-hint {
  margin: 0.85rem 0 0;
  font-size: 0.5rem;
  line-height: 1.5;
  color: var(--text-dim);
  letter-spacing: 0.02em;
}

/* Idle on smaller float pieces (loadout, instructions accent, etc.) */
.idle-float,
.idle-float-subtle {
  animation: floatSubtle var(--idle-duration) steps(6, end) infinite;
  /* No permanent will-change — browsers promote while animating without keeping
   * layers after the effect ends / while off-view. */
}

.idle-float-slow {
  animation: floatSubtle calc(var(--idle-duration) * 1.25) steps(6, end) infinite;
}

/*
 * Large chrome (header / main panels): very slight continuous move + rotate.
 * Amplitude from --idle-float-y-large (well below small-piece float). Nav/tabs
 * themselves stay idle-free for click stability; only the panel shells float.
 */
.idle-float-large {
  /* Slightly longer + stepped = fewer intermediate composite frames on big panels */
  animation: floatLarge var(--idle-duration-large) steps(8, end) infinite;
  transform-origin: 50% 50%;
}

@keyframes floatSubtle {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  50% {
    transform: translate3d(0, calc(var(--idle-float-y) * -1), 0);
  }
}

/*
 * Large chrome idle: vertical bob only (no twist).
 * Prior peak rotate was ~±0.1deg after the last reduce; loadout sits inside
 * these panels so removing rotate stops the loadout from twisting too.
 */
@keyframes floatLarge {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  25% {
    transform: translate3d(
      0.5px,
      calc(var(--idle-float-y-large) * -0.45),
      0
    );
  }
  50% {
    transform: translate3d(0, calc(var(--idle-float-y-large) * -1), 0);
  }
  75% {
    transform: translate3d(
      -0.4px,
      calc(var(--idle-float-y-large) * -0.4),
      0
    );
  }
}

/*
 * Per-letter vertical bob (no rotate). Letters are <span class="letter-wobble">
 * stamped by refreshLetterWobbles(); delays are set per-letter for desync.
 */
@keyframes letterBob {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  50% {
    transform: translate3d(0, -3.5px, 0);
  }
}

.letter-wobble {
  display: inline-block;
  /* Stepped bob is cheaper than smooth ease across many letter layers */
  animation: letterBob var(--text-wobble-duration, 5.2s) steps(5, end) infinite;
  /* Never permanent will-change — dozens of letter layers would lag idle Status */
}

/* Programmatic + keyboard focus: never show selection/focus outline chrome */
.panel-title:focus,
.panel-title:focus-visible,
[data-view-heading]:focus,
[data-view-heading]:focus-visible {
  outline: none !important;
  box-shadow: none !important;
}

/* Nav + category tabs never use continuous idle transform (click stability) */
.nav-btn,
.nav-btn.ui-btn,
.main-nav,
.category-tabs,
.cat-tab {
  animation: none !important;
}

/* Active tab spring is re-enabled after the none rule */
.cat-tab-active {
  animation: tabPop var(--tab-pop-duration) var(--ease-tab-spring) both !important;
}

/* Responsive steps mirror CONFIG.scale.* (edit both if retuning) */
@media (min-width: 1280px) and (min-height: 860px) {
  :root {
    --slot-size: 108px;
    --window-max: 1116px;
    --pixel-scale: 1;
  }
}

@media (min-width: 1100px) and (max-width: 1279px) {
  :root {
    --slot-size: 94px;
    --window-max: 972px;
  }
}

@media (max-width: 900px) {
  :root {
    --slot-size: 79px;
    --window-max: 100%;
    /* Slot frame drops to 3× art px (from desktop 4×) so the frame doesn't
     * eat the shrunken tiles — JS mirrors this (resolveScaleForViewport). */
    --slice-slot-width: 12px;
  }
  /* Compact text was unreadably small — all rem type rides the root up 20% */
  html {
    font-size: 18px;
  }
  .viewport-center {
    align-items: flex-start;
    padding: 0.65rem;
  }
  .game-window {
    width: 100%;
    /* Keep tilting (no transform:none override) — the social column and
     * side overlay have no such override at this breakpoint either, and
     * they all need to stay in the same .crt-plane for the tilt to line
     * up (see .crt-plane). Dropping it here alone is what caused the main
     * window to stop tilting while its plane-mates kept tilting.
     * (JS flattens the whole plane together on compact layouts by setting
     * the shared curve var to 0deg — uniform, so nothing goes out of sync.) */
  }
  /*
   * Compact layout: the shell is taller than the viewport, and the document
   * body locks scrolling (Status↔Inventory scrollbar-flash fix), so the
   * fixed full-viewport plane wrapper becomes the vertical scroller here.
   * Touches land on the shell (pointer-events auto) and scroll-chain up.
   */
  .crt-plane {
    overflow-y: auto;
    overflow-x: hidden;
    /* iOS Safari: a pointer-events:none scroller is skipped by WebKit's
     * scroll hit-testing even when the touch lands on a pointer-events:auto
     * descendant — Android chains to it, iOS silently doesn't. The plane
     * must accept hits itself here to be scrollable at all on iOS. Safe on
     * compact: everything interactive lives inside the plane (the 3D host
     * behind it — the reason for desktop's none — is display:none here). */
    pointer-events: auto;
    -webkit-overflow-scrolling: touch;
  }
  /* Hover preview + Controls describe mouse/keyboard interactions — hidden
   * on compact layouts (JS hides too; this is the belt-and-suspenders). */
  .side-overlay {
    display: none;
  }
  /* 3D preview + drop zone are desktop-only (JS keeps them hidden as well) */
  .model-preview-host,
  .model-preview-dropzone {
    display: none !important;
  }
  /* Booster packs are desktop-only too (gap6): compact shows the FULL
   * portfolio in a taller grid instead — no shelf, rip stage, or bin. */
  .booster-shelf,
  .booster-rip-zone,
  .booster-rip-stage,
  .booster-rip-backdrop,
  .booster-toast,
  .trash-bin {
    display: none !important;
  }
}

@media (max-width: 560px) {
  :root {
    --slot-size: 61px;
    --grid-gap: 6px;
    /* Phone tier: 2× art px — keeps the image ~70% of the slot face like
     * desktop instead of the frame swallowing it (JS mirrors this). */
    --slice-slot-width: 8px;
  }
}

/*
 * Short desktop monitors (1366×768-class laptops and similar): the JS fit
 * pass (resolveScaleForViewport) shrinks slot/gap/font to make the shell
 * fit, but it's a heuristic estimate against chrome height that doesn't
 * shrink in a clean, predictable way (text reflow can even ADD a line at
 * some widths) — on the shortest/most-loaded screens the shell can still
 * end up taller than the viewport, and desktop layouts otherwise have no
 * scroll fallback at all (html/body lock scrolling; only the ≤900px
 * compact layout above gets one). min-width excludes that compact rule's
 * own range so the two never double up.
 *
 * pointer-events flips to auto (measured: a pointer-events:none ancestor
 * does NOT reliably pick up wheel-scroll-chaining from an auto descendant
 * in this shell, it's not just the compact rule's iOS touch-hit-testing
 * quirk) — so the desktop-only left 3D preview host (z-index 3, a sibling
 * OUTSIDE .crt-plane, see index.html) needs its z-index raised above the
 * now-clickable plane, or the plane would swallow its orbit/drag hits.
 */
@media (min-width: 901px) and (max-height: 850px) {
  .crt-plane {
    overflow-y: auto;
    overflow-x: hidden;
    pointer-events: auto;
  }
  .model-preview-host {
    z-index: 6;
  }
}

/* ----- Panels: 9-slice on half-size sources; thicker display width ----- */
:root {
  /* Source slice (ART px kept as corner). Panel/modal frames carry 8×8
   * art-px corners on extended 44/46px canvases; controls keep 4. Display
   * widths below are always slice × 4 — one art pixel = 4 screen pixels. */
  --slice-panel: 8;
  --slice-slot: 4;
  --slice-modal: 8;
  --slice-button: 4;
  /* On-screen frame thickness — always source slice × 4 (integer pixels) */
  --slice-panel-width: 32px;
  --slice-slot-width: 16px;
  --slice-modal-width: 32px;
  --slice-repeat: repeat;
}

.ui-panel {
  position: relative;
  z-index: 1;
  background-color: #1a2332;
  background-image: var(--ui-panel);
  background-size: var(--panel-fill-tile) var(--panel-fill-tile);
  background-repeat: repeat;
  /* Transparent host only — no solid CSS border chrome */
  border: var(--slice-panel-width) solid transparent;
  border-image-source: var(--ui-panelBorder);
  border-image-slice: var(--slice-panel) fill;
  border-image-width: var(--slice-panel-width);
  border-image-repeat: var(--slice-repeat);
  box-shadow:
    0 0 0 0px #854613,
    0 0 0 4px #1a1208,
    20px 25px 0px #12213b91;
  padding: var(--panel-pad);
  margin-bottom: var(--ui-gap);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  transition: transform 0.35s var(--ease-spring);
}

/* Role-specific panel fills/borders (dedicated PNGs — no cross-role reuse) */
.ui-header-panel {
  background-image: var(--ui-headerPanel);
  border-image-source: var(--ui-headerBorder);
}

.ui-status-panel {
  background-image: var(--ui-statusPanel);
  border-image-source: var(--ui-statusBorder);
}

.ui-inventory-panel {
  background-image: var(--ui-inventoryPanel);
  border-image-source: var(--ui-inventoryBorder);
}

.ui-instructions-panel {
  background-image: var(--ui-instructionsPanel);
  border-image-source: var(--ui-instructionsBorder);
}

.ui-panel > * {
  position: relative;
  z-index: 1;
}

/*
 * Panel idle = glow/brightness only on .ui-panel (defined above).
 * Portrait float carries the “floating UI” motion.
 */

/*
 * Header stays put in the shell while only main views animate open/close.
 * z-index below .main-content so the header panel hard box-shadow paints
 * *behind* the Status/Inventory face (not over it). Nav stays clickable —
 * main-content is a following sibling in normal flow (no overlap of chrome).
 */
.site-header {
  position: relative;
  z-index: 1;
  flex-shrink: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.site-brand {
  font-family: var(--font-pixel);
  font-size: 1.15rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0;
}

.site-sub {
  margin: 0.15rem 0 0;
  font-family: var(--font-pixel);
  font-size: 0.75rem;
  color: var(--text-dim);
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.header-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
}

.main-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  position: relative;
  z-index: 30;
  pointer-events: auto;
}

/*
 * Primary buttons — full 9-slice chrome (repeat middles), not thin auto-100% bg.
 * Matches panel/slot framed weight so controls no longer look undersized.
 */
.ui-btn,
.btn {
  font-family: var(--font-pixel);
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 0.85rem 1.35rem;
  min-height: var(--control-min-height);
  min-width: 4rem;
  color: var(--text-main);
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  background-color: var(--button-face, #243044);
  background-image: none;
  border: var(--control-slice-width) solid transparent;
  border-image-source: var(--ui-buttonNormal);
  border-image-slice: var(--slice-button) fill;
  border-image-width: var(--control-slice-width);
  border-image-repeat: var(--slice-repeat);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  box-shadow: 0 7px 0 #0a0804;
  transform: translate3d(0, 0, 0) scale(1);
  transition:
    transform 0.28s var(--ease-spring-soft),
    box-shadow 0.28s var(--ease-spring-soft),
    color 0.15s ease,
    border-image-source 0.05s steps(1);
  will-change: transform;
}

/* Nav: same 9-slice weight; no continuous idle transform (click stability) */
.nav-btn {
  position: relative;
  z-index: 31;
  pointer-events: auto;
  font-family: var(--font-pixel);
  font-size: 0.72rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 0.85rem 1.35rem;
  min-height: var(--control-min-height);
  min-width: 7.25rem;
  color: var(--text-main);
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  /* Same fix as .cat-tab below: at the ≤560px breakpoint (flex:1,
   * min-width:0, smaller font/padding) "PORTFOLIO" is a single unbreakable
   * word that can end up wider than its shrunk half of the nav row — with
   * the default overflow-wrap it spilled sideways past the button's own
   * border instead of wrapping inside it. */
  overflow-wrap: break-word;
  word-break: break-word;
  background-color: var(--button-face, #243044);
  background-image: none;
  border: var(--control-slice-width) solid transparent;
  border-image-source: var(--ui-navButtonNormal);
  border-image-slice: var(--slice-button) fill;
  border-image-width: var(--control-slice-width);
  border-image-repeat: var(--slice-repeat);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  box-shadow: 8px 8px 0 #04130b9a;
  transform: none;
  animation: none !important;
  transition:
    box-shadow 0.22s var(--ease-spring-soft),
    color 0.12s ease,
    border-image-source 0.05s steps(1),
    filter 0.12s ease;
  will-change: auto;
}

.cat-tab {
  font-family: var(--font-pixel);
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  /* Horizontal padding trimmed 20% (1.1rem → 0.88rem) for a thinner tab. */
  padding: 0.85rem 0.88rem;
  min-height: var(--control-min-height);
  color: var(--text-main);
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  /* At narrow widths the three tabs (flex:1, min-width:0) can get thinner
   * than a single unbroken word — "Illustration" has no space to wrap at,
   * so with the default overflow-wrap it just overflowed the tab's own box
   * sideways into its neighbor instead of wrapping. "3D Models"/"Pixel Art"
   * never showed it (they wrap for free at their space), which is why this
   * only ever surfaced on phone widths. */
  overflow-wrap: break-word;
  word-break: break-word;
  transform: translate3d(0, 0, 0) scale(1);
  transition:
    transform var(--tab-pop-duration) var(--ease-tab-spring),
    box-shadow 0.4s var(--ease-tab-spring),
    color 0.18s ease,
    border-image-source 0.05s steps(1);
}

.ui-btn:hover,
.btn:hover {
  border-image-source: var(--ui-buttonHover);
  color: #8a9cff;
  transform: translate3d(0, -4px, 0) scale(1.08) ;
  box-shadow: 8px 8px 0 #13040a9a;
  animation: btnHoverPop 0.4s var(--ease-spring-soft);
}

/* Social skill icons stay strictly square (override any shared control min-size) */
a.social-btn {
  width: var(--social-btn-size, 76px);
  height: var(--social-btn-size, 76px);
  min-width: var(--social-btn-size, 76px);
  min-height: var(--social-btn-size, 76px);
  max-width: var(--social-btn-size, 76px);
  max-height: var(--social-btn-size, 76px);
  padding: 0;
  aspect-ratio: 1 / 1;
}

.nav-btn:hover {
  border-image-source: var(--ui-navButtonHover);
  color: #ffed8a;
  box-shadow: 0 9px 0 8px 8px 0 #09290d88;
  filter: brightness(1.08);
  transform: none;
  animation: none !important;
}

.cat-tab:hover {
  color: #ffc38a;
  transform: translate3d(0, -3px, 0) scale(1.06);
  box-shadow: 12px 12px 0 #0704139a;
  transform: scale(1.08) rotate(4deg);
}

.ui-btn:active,
.btn:active {
  border-image-source: var(--ui-buttonActive);
  color: #fff0c0;
  transform: translate3d(0, 3px, 0) scale(0.94, 0.96);
  box-shadow: 8px 8px 0 #0704139a;
  transition-duration: 0.1s;
}

.cat-tab:active {
  border-image-source: var(--ui-tabActive);
  color: #fff0c0;
  transform: translate3d(0, 2px, 0) scale(0.97);
  box-shadow: 8px 8px 0 #0704139a;
  transition-duration: 0.12s;
}

.nav-btn:active {
  border-image-source: var(--ui-navButtonActive);
  color: #ffe4c0;
  box-shadow: 8px 8px 0 #180f4d88;
  filter: brightness(0.95);
  transform: none;
  animation: none !important;
}

.nav-btn.nav-active {
  border-image-source: var(--ui-navButtonActive);
  color: #ffe08a;
  box-shadow: 8px 8px 0 #0e092988;
  transform: none;
  animation: none !important;
}

/* Phone width: Status/Portfolio wrapped onto separate stacked rows —
 * min-width: 7.25rem (116px) EACH plus padding is a desktop-tuned floor
 * that two buttons plus a gap can't fit under ~250px available. flex:1 1 0
 * (mirrors .cat-tab's own mobile treatment) shares whatever width the nav
 * actually has instead of wrapping, with padding/font trimmed so
 * "Portfolio" — the longer label — still fits comfortably inside its half.
 * .header-meta itself was the real culprit behind both the off-center
 * look AND the tight fit: with no explicit width it only ever shrink-wrapped
 * to its buttons' natural size, so .main-nav's width:100% had a tiny
 * shrunk box to be 100% OF — width:100% here on .header-meta first is what
 * actually gives the nav row the full ~250px bag width to work with
 * (matching .brand-block's own left edge, hence "centered"), leaving each
 * button roughly 25% wider than before — enough headroom that the earlier
 * font-size/padding trim could be eased back too. */
@media (max-width: 560px) {
  .header-meta {
    width: 100%;
    justify-content: center;
  }
  .main-nav {
    flex-wrap: nowrap;
    width: 100%;
    justify-content: center;
  }
  .nav-btn {
    /* Content-sized, not an even flex:1 1 0 split of the row — that forced
     * "PORTFOLIO" (the longer label) onto two lines whenever half the row
     * fell short of its one-line width. JS (equalizeNavButtons in app.js)
     * measures both labels post-font-load and sets an explicit, IDENTICAL
     * width on both buttons sized to fit the longer one, so they stay the
     * same size without either wrapping. */
    flex: 0 0 auto;
    white-space: nowrap;
    padding: 0.6rem 0.5rem;
    font-size: 0.68rem;
    min-height: 44px;
    border-width: 8px;
    border-image-width: 8px;
  }
}

@keyframes btnHoverPop {
  0% {
    transform: translate3d(0, 0, 0) scale(1);
  }
  55% {
    transform: translate3d(0, -5px, 0) scale(1.1);
  }
  100% {
    transform: translate3d(0, -4px, 0) scale(1.08);
  }
}

.nav-btn:focus-visible,
.btn:focus-visible,
.cat-tab:focus-visible,
.grid-item:focus-visible,
.modal-close-btn:focus-visible {
  outline: none !important;
  box-shadow: none !important;
}

.panel-title {
  font-family: var(--font-pixel);
  font-size: 1.05rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 0.9rem;
  padding-bottom: 0.5rem;
  box-shadow: inset 0 -2px 0 rgba(201, 162, 39, 0.35);
}

.view[hidden] {
  display: none !important;
}

/*
 * View stack: only the active (in-flow) view sizes the game window.
 * Exiting views are taken out of flow so Status+Inventory never stack and
 * shove social / side chrome during open/close.
 * z-index above .site-header so primary panels cover the header's downward
 * hard box-shadow where the two meet.
 *
 * overflow must stay visible: .ui-panel hard shadow is offset +20px X / +25px Y
 * and was cropped on the right (and bottom) when main-content used overflow:hidden.
 * Exit ghosting is handled on .view.panel-exiting (max-height + overflow), not here.
 */
.main-content {
  position: relative;
  z-index: 2;
  width: 100%;
  min-height: 0;
  overflow: visible;
}

/*
 * Primary views: idle-only by default. One-shot enter/exit via
 * .panel-entering / .panel-exiting so transform does not permanently kill floatLarge.
 * Enter is soft (matches shell FLIP re-center); popups keep their own bouncy open.
 */
.view {
  transform-origin: 50% 42%;
  width: 100%;
}

.view.view-active.idle-float-large:not(.panel-entering):not(.panel-exiting),
.view.idle-float-large:not(.panel-entering):not(.panel-exiting) {
  animation: floatLarge var(--idle-duration-large) steps(8, end) infinite;
}

/* Soft open — scale only, ease-shell (no spring overshoot fighting FLIP) */
.view.panel-entering {
  animation: panelOpen var(--panel-open-duration) var(--ease-shell) both !important;
  pointer-events: none;
}

/*
 * Exit is absolute-overlay so layout height stays that of the incoming view only.
 * max-height:100% of .main-content (sized by the in-flow active view) + overflow
 * clips a taller Inventory under shorter Status — without cropping the active
 * panel's hard box-shadow (which main-content overflow:hidden used to do).
 * Full fade to 0 — never leave a semi-transparent ghost.
 */
.view.panel-exiting {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  width: 100%;
  max-height: 100%;
  overflow: hidden;
  z-index: 1;
  margin: 0;
  animation: panelClose var(--panel-close-duration) var(--ease-shell) both !important;
  pointer-events: none;
}

/*
 * Controls exit: short dedicated fade (not full panelClose 0.28s).
 * Idle float is killed by this one-shot so the panel cannot bob while ghosting.
 */
.instructions-panel.panel-exiting {
  animation: instructionsClose var(--instructions-close-duration) ease-in both !important;
  pointer-events: none;
}

/* Controls panel enter: soft fade+scale (side chrome, not main shell flash risk) */
.instructions-panel.panel-entering {
  animation: instructionsOpen var(--panel-open-duration) var(--ease-shell) both !important;
}

.view.panel-entering,
.view.view-active:not(.panel-exiting) {
  position: relative;
  z-index: 2;
}

/*
 * Soft primary panel open: scale only (opacity stays 1) so Status↔Inventory
 * never blanks a frame — outgoing is clipped under, incoming is solid.
 * Peak scale ≤ 1.0; inspect popups keep their own elastic overshoot path.
 */
@keyframes panelOpen {
  0% {
    opacity: 1;
    transform: scale(0.992);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

/*
 * Exit fully to opacity 0 under the incoming panel (clipped by main-content).
 * No residual semi-transparent ghost when finishExit sets hidden.
 */
@keyframes panelClose {
  0% {
    opacity: 1;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: scale(0.99);
  }
}

/* Controls (instructions) open on Inventory — soft appear */
@keyframes instructionsOpen {
  0% {
    opacity: 0;
    transform: translate3d(0, 10px, 0) scale(0.96);
  }
  100% {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
}

/*
 * Controls close on Inventory→Status — fast out so it does not linger as a ghost
 * beside the Status sheet (prior panelClose 0.28s felt sticky).
 */
@keyframes instructionsClose {
  0% {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
  100% {
    opacity: 0;
    transform: translate3d(0, 6px, 0) scale(0.98);
  }
}

/* Legacy aliases kept for any external/theme references */
@keyframes viewIn {
  0% {
    opacity: 0;
    transform: translate3d(0, 28px, 0) scale(0.72);
  }
  55% {
    opacity: 1;
    transform: translate3d(0, -6px, 0) scale(1.08);
  }
  100% {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
}

@keyframes viewFadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* ----- Status ----- */
.status-layout {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 1.5rem;
  align-items: start;
}

@media (max-width: 640px) {
  .status-layout {
    grid-template-columns: 1fr;
    justify-items: center;
    text-align: center;
  }
  .loadout-gear {
    justify-content: center;
  }
}

.portrait-frame {
  position: relative;
  width: 200px;
  height: 200px;
  padding: 10px;
  background-image: var(--ui-portraitFrame);
  background-size: 100% 100%;
  background-color: var(--panel-fill-alt, #121a24);
  border: none;
  image-rendering: pixelated;
  /* Continuous fluid float — stepped for cheaper idle composites */
  animation: floatSoft var(--idle-duration) steps(6, end) infinite;
  box-shadow: 0 12px 2px #070416c4;
}

/* Portrait frame: bob only (no rotate — keeps status chrome steadier) */
@keyframes floatSoft {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  50% {
    transform: translate3d(0, calc(var(--idle-float-y) * -1), 0);
  }
}

.portrait-frame img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  image-rendering: pixelated;
}

.char-name {
  font-family: var(--font-pixel);
  font-size: 1.4rem;
  margin: 0 0 0.2rem;
}

.char-title {
  font-family: var(--font-pixel);
  font-size: 0.95rem;
  color: var(--accent);
  margin: 0 0 0.7rem;
}

.char-bio {
  color: var(--text-muted);
  font-size: 0.98rem;
  margin: 0 0 1.1rem;
  max-width: 42em;
}

/* Loadout — thinner frames so icons/labels are never clipped */
.loadout-block {
  margin-top: 0.35rem;
}

.loadout-label {
  font-family: var(--font-pixel);
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 0.55rem;
}

.loadout-gear {
  display: flex;
  flex-wrap: wrap;
  gap: 0.55rem;
  align-items: stretch;
}

.loadout-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 0.3rem;
  box-sizing: border-box;
  /* Grows to fit whatever the label needs (see .loadout-item-label's
   * white-space:nowrap) instead of a fixed width that forced longer tool
   * names like "Photoshop" onto two lines. */
  width: auto;
  min-width: 5.25rem;
  min-height: 5.5rem;
  /* Inner padding only — frame is thin 9-slice, not inventory-slot thickness */
  padding: 0.45rem 0.35rem 0.4rem;
  background-color: var(--panel-fill, #0d1520);
  background-image: none;
  border: var(--loadout-slice-width) solid transparent;
  border-image-source: var(--ui-loadoutSlot);
  border-image-slice: var(--slice-loadout) fill;
  border-image-width: var(--loadout-slice-width);
  border-image-repeat: var(--slice-repeat);
  image-rendering: pixelated;
  box-shadow: 0 9px 2px #070416c4;
  overflow: visible;
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  transition: transform 0.22s var(--ease-spring), filter 0.15s ease,
    box-shadow 0.2s ease;
}

.loadout-item:hover,
.loadout-item:focus-visible {
  transform: translate3d(0, -3px, 0) scale(1.06);
  filter: brightness(1.16);
}

/* One-shot "equip" pop timed with the particle burst (see triggerLoadoutFlourish) */
.loadout-item.is-equip-flourish .loadout-icon {
  animation: loadoutEquipPop 0.32s var(--ease-spring);
}

@keyframes loadoutEquipPop {
  0% {
    transform: scale(1);
  }
  45% {
    transform: scale(1.22);
  }
  100% {
    transform: scale(1);
  }
}

.loadout-icon {
  display: block;
  width: 40px;
  height: 40px;
  max-width: 100%;
  flex: 0 0 auto;
  object-fit: contain;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}

.loadout-item-label {
  font-family: var(--font-pixel);
  font-size: 0.48rem;
  line-height: 1.25;
  letter-spacing: 0.02em;
  text-align: center;
  color: var(--text-muted);
  /* One line always — .loadout-item grows (width:auto) to fit this instead
   * of the label wrapping/shrinking to fit a fixed-width box. */
  max-width: none;
  width: max-content;
  overflow: visible;
  text-overflow: clip;
  white-space: nowrap;
}

/* ----- Inventory ----- */
/*
 * Shared bag width: tabs + grid + return share the same outer width and
 * horizontal centering so left/right edges line up with the inventory grid.
 * Formula matches .inventory-grid content (slots×cols + gaps) + pad both sides.
 */
.ui-inventory-panel,
#view-inventory {
  --inv-grid-cols: 8;
  --inv-grid-pad: 0.85rem;
  --inv-bag-inner: calc(
    (var(--inv-grid-cols) * var(--slot-size)) +
      ((var(--inv-grid-cols) - 1) * var(--grid-gap))
  );
  --inv-bag-width: calc(var(--inv-bag-inner) + (var(--inv-grid-pad) * 2));
}

/* Same outer width + centering as .category-tabs/.inventory-grid below it
 * (see the shared --inv-bag-width formula above) so the Unlock All button's
 * right edge lines up with the Illustration tab / grid's right edge instead
 * of trailing off against the wider panel edge. */
.inv-header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.55rem;
  width: var(--inv-bag-width);
  max-width: 100%;
  margin: 0 auto;
  box-sizing: border-box;
}

.inv-header .panel-title {
  margin-bottom: 0.4rem;
  border: 0;
  padding: 0;
}

/* Smaller than the base .btn.ui-btn (that size reads oversized sat inline
 * next to the panel title) — same 9-slice chrome, just less padding. */
.unlock-all-btn {
  flex: 0 0 auto;
  font-size: 0.62rem;
  padding: 0.55rem 0.85rem;
  min-height: 0;
  min-width: 0;
  align-self: center;
}

/* Hides the button WITHOUT collapsing its layout box (unlike [hidden]'s
 * display:none) — .inv-header's own height must never change depending on
 * whether this button happens to be shown, since that changes the whole
 * panel's size (see syncBoosterChrome in app.js, which toggles this class
 * instead of the .hidden attribute for this one element). */
.unlock-all-btn.is-invisible {
  visibility: hidden;
  pointer-events: none;
}

.inv-count {
  font-family: var(--font-pixel);
  font-size: 0.75rem;
  color: var(--text-dim);
}

/* Three category tabs span exactly the inventory bag outer width */
.category-tabs {
  display: flex;
  flex-wrap: nowrap;
  align-items: stretch;
  gap: 0.45rem;
  box-sizing: border-box;
  width: var(--inv-bag-width);
  max-width: 100%;
  margin: 0.7rem auto 1.15rem;
}

/* Category tabs — 9-slice frames (same system as panels/buttons), sized to match chrome */
.cat-tab {
  flex: 1 1 0;
  min-width: 0;
  margin: 0;
  background-color: var(--button-face, #243044);
  background-image: none;
  border: var(--control-slice-width) solid transparent;
  border-image-source: var(--ui-tabNormal);
  border-image-slice: var(--slice-button) fill;
  border-image-width: var(--control-slice-width);
  border-image-repeat: var(--slice-repeat);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  box-shadow: 0 6px 0 #0a0804;
}

.cat-tab-active {
  border-image-source: var(--ui-tabActive);
  /* Snappy activate: short settle, low overshoot (prior was 0.95s multi-bounce) */
  animation: tabPop var(--tab-pop-duration) var(--ease-tab-spring) both;
  transform: translate3d(0, 0, 0) scale(1.03);
}

/*
 * Snappy tab activate: less bouncy than multi-stage spring; crisp settle.
 */
@keyframes tabPop {
  0% {
    transform: translate3d(0, 3px, 0) scale(0.93);
  }
  55% {
    transform: translate3d(0, -2px, 0) scale(1.08);
  }
  100% {
    transform: translate3d(0, 0, 0) scale(1.03);
  }
}

/* Category accent via inset shadow only (no CSS border) */
.cat-tab[data-category="3d"].cat-tab-active {
  box-shadow: 0 4px 0 #0a0804, inset 0 -5px 0 #6b9fff;
}
.cat-tab[data-category="pixel"].cat-tab-active {
  box-shadow: 0 4px 0 #0a0804, inset 0 -5px 0 #7dcea0;
}
.cat-tab[data-category="illustration"].cat-tab-active {
  box-shadow: 0 4px 0 #0a0804, inset 0 -5px 0 #e8a0c8;
}

/* Phone-width tabs: small square icons, no visible label — a full text row
 * (even wrapped, see the base .cat-tab rule above) still ran three tabs
 * tall just to fit "Illustration"; icons sidestep the word-fitting problem
 * entirely instead of shrinking it further, AND are much shorter. Each
 * icon reuses that category's own booster-pack art (already shipped, no
 * new assets, and already what a player associates with "3D"/"Pixel"/
 * "Illustration" elsewhere in the UI) as a cover-fit background-image.
 * The label stays in the DOM as a real child <span> (see
 * renderCategoryTabs in app.js) — only its visual rendering is suppressed
 * here (standard sr-only clip), so it's still announced by a screen
 * reader and still available to .title's native hover/long-press tooltip. */
@media (max-width: 560px) {
  .category-tabs {
    width: auto;
    justify-content: center;
    gap: 0.6rem;
    margin: 0.7rem auto 0.9rem;
  }
  .cat-tab {
    flex: 0 0 auto;
    position: relative;
    width: 52px;
    height: 52px;
    padding: 0;
    border-width: 8px;
    border-image-width: 8px;
    background-color: var(--button-face, #243044);
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
  }
  .cat-tab[data-category="3d"] {
    background-image: url("../assets/ui/booster-pack-3d.png");
  }
  .cat-tab[data-category="pixel"] {
    background-image: url("../assets/ui/booster-pack-pixel.png");
  }
  .cat-tab[data-category="illustration"] {
    background-image: url("../assets/ui/booster-pack-illustration.png");
  }
  .cat-tab-label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
}

.inv-hint {
  font-size: 0.85rem;
  color: var(--text-dim);
  margin: 0 0 0.75rem;
}

/* Multi-slot CSS grid — large readable slots; outer width = --inv-bag-width */
.inventory-grid {
  --grid-cols: 8;
  --grid-rows: 6;
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), var(--slot-size));
  grid-template-rows: repeat(var(--grid-rows), var(--slot-size));
  /* Zero-size implicit tracks: OOB drop-highlight spans must not grow the bag */
  grid-auto-columns: 0;
  grid-auto-rows: 0;
  gap: var(--grid-gap);
  position: relative;
  box-sizing: border-box;
  width: var(--inv-bag-width, max-content);
  max-width: 100%;
  margin: 0.35rem auto 0.75rem;
  padding: var(--inv-grid-pad, 0.85rem);
  background-color: var(--slot-empty-inset, #0a1018);
  background-image: var(--ui-panel);
  background-size: var(--panel-fill-tile) var(--panel-fill-tile);
  border: none;
  outline: none;
  box-shadow:
    0 0 0 2px #89afc5,
    0 0 0 2px #0b0a13,
    inset 0 0 0 0px #1a1208,
    0 8px 0px #0a0804;
  /* Visible (not hidden): a hovered/tilted edge item must be able to render
   * outside the grid's own box instead of getting clipped at the boundary —
   * see .grid-item.is-hover z-index below. Drag-time OOB preview is still
   * clipped separately (see body.is-dragging rule) since that's a real
   * functional concern (reflow/scroll), not just this cosmetic one. */
  overflow: visible;
  touch-action: none;
  image-rendering: pixelated;
}

/* During drag, never allow the inventory bag to reflow or scroll from OOB preview */
body.is-dragging .inventory-grid {
  overflow: hidden;
}

/* Unlock All (app.js: renderGrid) can grow GRID_ROWS well past what the
 * fixed desktop window has room for — the game shell itself never scrolls
 * (html/body overflow:hidden, see mobile-scroll-architecture memory), so
 * once unlocked the grid caps its own height (see syncOverflowGridMaxHeight
 * in app.js — a fixed px value, not the max-height below, which is only
 * the no-JS/pre-JS fallback) and scrolls internally instead.
 *
 * A hover/focus-popped item (.grid-item.is-hover/.is-selected: translateY
 * + scale from center) in the first/last visible row would have nowhere
 * to pop into here — the default rule's overflow:visible gives it that
 * room for free, but a scroll container must clip SOMEWHERE. Tried
 * padding it out of clipping range first; even sized for just a 1×1 item
 * that ate a visible dead margin at the top/bottom of every tier (looked
 * like the whole grid had shifted down) — a permanent cost for an
 * edge-case pop. Real fix lives in app.js instead: popGridItemOutOfClip /
 * restoreGridItemToGrid actually MOVE the hovered/focused item into
 * #grid-hover-portal (a plain, unclipped fixed-position layer) for the
 * duration of the pop, frozen at its captured on-screen rect first so the
 * move itself is invisible, then let the normal CSS transform apply
 * unclipped. Padding here stays exactly the base amount — no buffer
 * needed since the item isn't clipped in the first place anymore. */
.inventory-grid.is-overflow-scroll {
  max-height: 60vh;
  overflow-y: auto;
  overflow-x: hidden;
}

/* Compact: vertical swipes on the bag's own chrome/empty cells scroll the
 * page; .grid-item keeps touch-action:none so item drags still win on items. */
@media (max-width: 900px) {
  .inventory-grid {
    touch-action: pan-y;
  }
}

/* Phone-width layout: GRID_COLS itself drops to MOBILE_GRID_COLS (app.js —
 * initLayouts re-packs every placement against it, this isn't cosmetic) so
 * the fixed 8-wide bag doesn't have to cram into a screen it can't fit —
 * 3 wide, however tall it needs to be. Columns use `fr` tracks (not
 * --slot-size px) so they always divide the frame's actual width exactly,
 * with no leftover/shortfall to overflow or letterbox — "scale the slot
 * size to fit the frame perfectly" IS just "let the grid do that division
 * itself" once the column count is fixed. Rows follow: grid-auto-rows:auto
 * plus aspect-ratio:1 on the always-present unit .grid-cells is what makes
 * each row exactly as tall as a column is wide (a grid item spanning
 * multiple cells then naturally fills a correctly-proportioned area — see
 * the .grid-cell rule below, not this one, for why non-square footprints
 * like a 3×1 banner still render correctly instead of being squished). */
@media (max-width: 560px) {
  .inventory-grid {
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: none;
    grid-auto-rows: auto;
    /* Defensive backstop only — fr tracks shouldn't overflow their box, but
     * if rounding ever leaves a sliver short, swipe beats silent clipping. */
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  /* Every .grid-item lands on top of GRID_COLS × GRID_ROWS of these —
   * always exactly 1×1, unlike items, so this is the one element that can
   * safely force a square via aspect-ratio without ever fighting a
   * non-square footprint. */
  .grid-cell {
    width: 100%;
    height: auto;
    aspect-ratio: 1 / 1;
  }
}

.grid-cell.empty-cell {
  background-color: var(--panel-fill-alt, #121a24);
  background-image: var(--ui-emptySlot);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  border: none;
  box-shadow: inset 0 0 0 2px var(--slot-empty-inset, #0a1018),
    inset 0 0 0 3px var(--slot-empty-rim, #2a3a50);
  image-rendering: pixelated;
  pointer-events: none;
}

.grid-cell.cell-occupied-hl {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  z-index: 1;
  background-image: var(--ui-highlight), var(--ui-emptySlot);
  background-size: 100% 100%;
  transition: outline-color 0.2s var(--ease-spring);
}

/*
 * Grid items: 9-slice frame edges only (no fill); thumbnail paints on the
 * child .item-thumb-layer (see below), not this element's own background —
 * a separate layer is what lets bindItemTilt() (app.js) shift the art
 * independently of the frame for an obvious border-vs-image parallax.
 * --item-thumb is set per item in renderGrid (inherits down to the child).
 */
.grid-item {
  position: relative;
  z-index: 2;
  display: block;
  padding: 0;
  margin: 0;
  border: var(--slice-slot-width) solid transparent;
  border-image-source: var(--ui-slotFrame);
  /* No "fill" — center of frame PNG is opaque and would hide the thumbnail */
  border-image-slice: var(--slice-slot);
  border-image-width: var(--slice-slot-width);
  border-image-repeat: var(--slice-repeat);
  outline: 2px solid transparent;
  cursor: var(--cursor-grab-img) 24 20, grab;
  color: inherit;
  font: inherit;
  background-color: var(--panel-fill, #0d1520);
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  touch-action: none;
  transform-origin: center center;
  transition:
    transform 0.32s var(--ease-spring),
    outline-color 0.2s ease;
  /* Permanent will-change removed — only promote on hover (see below). */
}

/* Unclipped landing layer for a hover-popped item while the grid is a real
 * scroll container (Unlock All) — see popGridItemOutOfClip in app.js. Plain
 * fixed overlay, no perspective/tilt of its own (the CRT plane's rotateX is
 * subtle enough that a frozen, flat pop doesn't read as wrong for the brief
 * moment it's active). Kept AFTER the .grid-item base rule above — the
 * source-scan tests locate that rule via indexOf(".grid-item {"), which
 * this selector's text would otherwise match first. */
#grid-hover-portal {
  position: fixed;
  inset: 0;
  z-index: 55;
  /* The full-viewport layer itself must never eat hits — but pointer-events
   * is an INHERITED property, so the relocated item inside needs an explicit
   * auto or it silently stops receiving pointer events the moment it pops in
   * (no click/drag, and no pointerleave either — which left the tilt frozen
   * and .is-hover stuck since nothing could ever fire to reset them). */
  pointer-events: none;
}

#grid-hover-portal > * {
  pointer-events: auto;
}

/*
 * Thumbnail layer: fills the padding-box (inside the 9-slice frame), same
 * paint as the old .grid-item background but on its own element so
 * bindItemTilt() (app.js) can shift it independently of the frame — that
 * small XY offset (no Z, no scale) is the border-vs-image parallax; it still
 * inherits the parent's own rotate/scale like any other in-flow child.
 */
.item-thumb-layer {
  position: absolute;
  inset: 0;
  z-index: 0;
  background-image: var(--item-thumb, none);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  pointer-events: none;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  transition: transform 0.32s var(--ease-spring);
}

/* Decorative overlay frame kept for rarity gems; 9-slice is on the button */
.grid-item .item-frame {
  display: none;
}

/*
 * Structure node for tests / future a11y; idle paint is CSS --item-thumb only
 * (app omits img.src for animated GIFs to avoid dual decode). Opacity 0.
 */
.grid-item .item-icon {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  object-fit: cover;
  object-position: center;
  opacity: 0;
  pointer-events: none;
  z-index: 1;
}

/* On-slot titles removed — names show in hover preview above “click to inspect” */
.grid-item .item-label {
  display: none !important;
}

.grid-item .item-size {
  position: absolute;
  top: 2px;
  right: 2px;
  z-index: 3;
  font-family: var(--font-pixel);
  font-size: 0.55rem;
  color: rgba(255, 255, 255, 0.75);
  background: rgba(0, 0, 0, 0.45);
  padding: 0 0.15rem;
  pointer-events: none;
}

/*
 * "New" ribbon (gap6): flags an item just pulled from a booster pack.
 * Diagonal corner banner — classic ribbon trick (rotated strip, clipped by
 * the item's own overflow:hidden). Only shown once per id ever (see
 * boosterEverRevealedIds); dismissed for good on first hover/focus (see
 * dismissNewRibbon), never by a timer, so it never disappears unseen.
 */
.item-new-ribbon {
  position: absolute;
  top: 11px;
  left: -32px;
  z-index: 5;
  width: 104px;
  padding: 1px 0;
  transform: rotate(-45deg);
  text-align: center;
  font-family: var(--font-pixel);
  font-size: 0.5rem;
  letter-spacing: 0.05em;
  color: #2a1604;
  background: linear-gradient(180deg, #ffe9a8, #f3b93d);
  box-shadow:
    0 0 0 1px #7a4a09,
    0 2px 3px rgba(0, 0, 0, 0.45);
  pointer-events: none;
  animation: itemNewRibbonPulse 1.6s ease-in-out infinite;
}

@keyframes itemNewRibbonPulse {
  0%, 100% { filter: brightness(1); }
  50% { filter: brightness(1.35); }
}

/*
 * Hover: elastic lift + scale. JS .is-hover + CSS :hover when not dragging.
 * Mouse/pen hover additionally gets a hover3d-style cursor-follow 3D tilt —
 * bindItemTilt() in app.js sets the full transform (lift+scale+rotate) as an
 * inline style on pointermove, which simply wins over this rule outright.
 * This rule is what actually renders for keyboard focus (no cursor to tilt
 * toward) and is the fallback shape/timing tilt overrides sync with.
 * will-change only while elevated — not permanent on every idle slot.
 */
.grid-item.is-hover:not(.is-origin-ghost):not(.is-dragging),
.grid-item.is-highlighted:not(.is-origin-ghost):not(.is-dragging),
.grid-item.is-selected:not(.is-origin-ghost):not(.is-dragging),
body:not(.is-dragging) .grid-item:hover:not(.is-origin-ghost):not(.is-dragging) {
  transform: translate3d(0, -10px, 0) scale(var(--hover-scale));
  z-index: 6;
  will-change: transform;
}

/* Quick press squash — the same tactile "push then spring back" feedback
 * buttons already get, extended to grid items (a click here opens inspect;
 * a drag past the threshold takes over via .is-origin-ghost/.is-dragging). */
.grid-item:active:not(.is-origin-ghost):not(.is-dragging) {
  transform: translate3d(0, -3px, 0) scale(calc(var(--hover-scale) * 0.92));
  transition-duration: 0.08s;
}

/* Hard rarity outline on hover (pixel edge, not glow aura) */
.grid-item.rarity-common.is-hover:not(.is-origin-ghost),
body:not(.is-dragging) .grid-item.rarity-common:hover:not(.is-origin-ghost),
.grid-item.rarity-common.is-highlighted:not(.is-origin-ghost),
.grid-item.rarity-common.is-selected:not(.is-origin-ghost) {
  outline: 3px solid var(--common);
  outline-offset: 1px;
}

.grid-item.rarity-rare.is-hover:not(.is-origin-ghost),
body:not(.is-dragging) .grid-item.rarity-rare:hover:not(.is-origin-ghost),
.grid-item.rarity-rare.is-highlighted:not(.is-origin-ghost),
.grid-item.rarity-rare.is-selected:not(.is-origin-ghost) {
  outline: 3px solid var(--rare);
  outline-offset: 1px;
}

.grid-item.rarity-legendary.is-hover:not(.is-origin-ghost),
body:not(.is-dragging) .grid-item.rarity-legendary:hover:not(.is-origin-ghost),
.grid-item.rarity-legendary.is-highlighted:not(.is-origin-ghost),
.grid-item.rarity-legendary.is-selected:not(.is-origin-ghost) {
  outline: 3px solid var(--legendary);
  outline-offset: 1px;
}

/* Origin ghost while dragging — opacity only (grayscale filter was drag-costly) */
.grid-item.is-origin-ghost {
  opacity: 0.4;
  transform: scale(0.96);
  cursor: var(--cursor-grabbing-img) 24 24, grabbing;
  outline: 2px dashed #c9a227;
  outline-offset: 0;
  pointer-events: none;
}

.grid-item.is-origin-ghost .item-icon {
  opacity: 0.5;
}

/* Floating drag clone (pointer follow) — transform-only motion (no left/top thrash) */
.drag-floater {
  position: fixed;
  left: 0;
  top: 0;
  /* Above #fx-layer (200) so white trail particles paint BEHIND the dragged item */
  z-index: 220;
  pointer-events: none;
  margin: 0;
  padding: var(--frame-inset);
  border: none;
  background-color: var(--panel-fill, #0d1520);
  background-image: var(--item-thumb, none), var(--ui-slotFrame);
  background-size: cover, 100% 100%;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
  image-rendering: pixelated;
  overflow: hidden;
  box-shadow: 0 10px 0 var(--hard-shadow, #0a0804);
  will-change: transform;
  contain: layout style paint;
}

/* Item-silhouette ghosts removed — drag trails are white Pixi smear streaks */

.drag-floater img {
  position: absolute;
  inset: var(--frame-inset);
  width: calc(100% - 2 * var(--frame-inset));
  height: calc(100% - 2 * var(--frame-inset));
  object-fit: cover;
  image-rendering: pixelated;
}

.drag-floater .floater-label {
  position: absolute;
  left: var(--frame-inset);
  right: var(--frame-inset);
  bottom: var(--frame-inset);
  font-family: var(--font-pixel);
  font-size: 0.6rem;
  text-align: center;
  color: #fff;
  background: rgba(8, 12, 18, 0.75);
  padding: 0.1rem;
  z-index: 2;
}

/* Drop punch: squash-and-stretch overshoot (transform only) */
.grid-item.drop-punch {
  animation: dropPunch 0.48s var(--ease-spring) both;
  z-index: 8;
}

@keyframes dropPunch {
  0% {
    transform: translate3d(0, -12px, 0) scale(1.35, 0.75);
  }
  35% {
    transform: translate3d(0, 2px, 0) scale(0.88, 1.18);
  }
  60% {
    transform: translate3d(0, -3px, 0) scale(1.12, 0.92);
  }
  80% {
    transform: translate3d(0, 0, 0) scale(0.97, 1.04);
  }
  100% {
    transform: translate3d(0, 0, 0) scale(1);
  }
}

/* Category tints (subtle edge) */
.grid-item.cat-3d {
  outline: 1px solid rgba(107, 159, 255, 0.35);
}
.grid-item.cat-pixel {
  outline: 1px solid rgba(125, 206, 160, 0.35);
}
.grid-item.cat-illustration {
  outline: 1px solid rgba(232, 160, 200, 0.35);
}

/*
 * Rarity glow: an inset box-shadow ON THE BUTTON paints with its own
 * background (i.e. BEHIND every child, including .item-thumb-layer) — no
 * z-index fixes that, box-shadow paint order isn't reorderable that way. So
 * the glow lives on ::after instead, a real stacked box positioned over the
 * same padding-box the thumbnail fills, with a z-index above it (0).
 */
.grid-item::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}
.grid-item.rarity-common::after {
  box-shadow: inset 0 -3px 10px var(--common);
}
.grid-item.rarity-rare::after {
  box-shadow: inset 0 -3px 10px var(--rare);
}
.grid-item.rarity-legendary::after {
  box-shadow: inset 0 -3px 10px var(--legendary);
}

.grid-item.rarity-common::before,
.grid-item.rarity-rare::before,
.grid-item.rarity-legendary::before {
  content: "";
  position: absolute;
  left: calc(var(--frame-inset) + 2px);
  top: calc(var(--frame-inset) + 2px);
  width: 12px;
  height: 12px;
  background-size: contain;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 4;
}
.grid-item.rarity-common::before {
  background-image: var(--ui-rarityCommon);
}
.grid-item.rarity-rare::before {
  background-image: var(--ui-rarityRare);
}
.grid-item.rarity-legendary::before {
  background-image: var(--ui-rarityLegendary);
}

.drop-highlight {
  z-index: 4;
  pointer-events: none;
  background-image: var(--ui-highlight);
  background-size: 100% 100%;
  opacity: 0.55;
  border: none;
  outline: 2px dashed var(--accent);
  outline-offset: -2px;
  transition:
    opacity 0.2s var(--ease-spring),
    outline-color 0.2s var(--ease-spring),
    transform 0.2s var(--ease-spring);
}

.drop-highlight.drop-valid {
  outline-color: var(--success);
  background-color: rgba(93, 190, 122, 0.22);
  /* No scale — scale grew the max-content grid / caused edge scroll */
  transform: none;
}

.drop-highlight.drop-invalid {
  outline-color: var(--danger);
  background-color: rgba(224, 112, 112, 0.22);
}

/* ----- Non-blocking image inspect pop-ups (titlebar + tight image body) ----- */
.popup-window {
  position: absolute;
  /* Default size overridden by JS fitPopupToImage to natural image + titlebar */
  width: 320px;
  height: 280px;
  max-width: calc(100vw - 24px);
  max-height: calc(100vh - 48px);
  min-width: 160px;
  min-height: 120px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  pointer-events: auto;
  margin-bottom: 0;
  padding: 0;
  border: var(--slice-modal-width) solid transparent;
  border-image-source: var(--ui-modalFrame);
  border-image-slice: var(--slice-modal) fill;
  border-image-width: var(--slice-modal-width);
  border-image-repeat: var(--slice-repeat);
  box-shadow: 0 0 0 2px #1a1208, 0 10px 0 #0a0804;
  animation: none;
  transform-origin: 50% 40%;
  image-rendering: pixelated;
  background-color: var(--slot-empty-inset, #0a1018);
}

/* Image window: in-window titlebar (original style) + image body fills remainder */
.popup-window.popup-image-window {
  overflow: hidden;
}

.popup-window.popup-image-window .popup-titlebar {
  flex: 0 0 40px;
  height: 40px;
  box-sizing: border-box;
}

.popup-window.popup-image-window .popup-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
}

.popup-window.popup-image-window .popup-media {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  display: flex;
  flex-direction: column;
}

/*
 * Fill the body region. Override legacy .popup-main-image-wrap max-height:
 * min(52vh, 28rem) which shrank art below the JS-sized window.
 */
.popup-window.popup-image-window .popup-main-image-wrap {
  flex: 1 1 auto;
  min-height: 0;
  max-height: none;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: stretch;
  justify-content: stretch;
  aspect-ratio: auto;
  box-shadow: none;
  border: none;
  overflow: hidden;
}

.popup-window.popup-image-window .popup-main-image {
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  /* Box already matches natural aspect from fitPopupToImage — contain fills it */
  object-fit: contain;
  object-position: center;
  display: block;
  background: #0a1018;
}

.popup-window.popup-entering {
  animation: popupOpen var(--popup-open-duration) var(--ease-spring) both;
}

.popup-window.popup-exiting {
  animation: popupClose var(--popup-close-duration) var(--ease-spring-soft) both;
  pointer-events: none;
}

/*
 * The decorative 9-slice border (.popup-window's own border-image) sits
 * OUTSIDE .popup-titlebar/.popup-body's boxes and is only thinly
 * overlapped by the .popup-resize edge/corner strips (8-22px) — most of
 * its visible width did nothing when dragged. A separate absolutely-
 * positioned overlay child can't reach that area: CSS resolves an
 * absolute child's inset against the PADDING box of its containing
 * block, which — with .popup-window's padding:0 — sits at the INNER edge
 * of the border, same place .popup-resize already starts; pushing one out
 * past that with negative insets just gets clipped by .popup-window's own
 * overflow:hidden. So .popup-window ITSELF is a drag handle instead (see
 * openPopup/bindWindowDrag in app.js) — it already receives pointer
 * events over its whole border-box for free, since nothing else was
 * claiming that area.
 */
.popup-window {
  cursor: var(--cursor-grab-img) 24 20, grab;
}

.popup-window:active {
  cursor: var(--cursor-grabbing-img) 24 24, grabbing;
}

/* Edge / corner resize handles */
.popup-resize {
  position: absolute;
  z-index: 5;
  background: transparent;
}
.popup-resize-n {
  top: 0;
  left: 10px;
  right: 10px;
  height: 8px;
  cursor: n-resize;
}
.popup-resize-s {
  bottom: 0;
  left: 10px;
  right: 10px;
  height: 8px;
  cursor: s-resize;
}
.popup-resize-e {
  top: 10px;
  right: 0;
  bottom: 10px;
  width: 8px;
  cursor: e-resize;
}
.popup-resize-w {
  top: 10px;
  left: 0;
  bottom: 10px;
  width: 8px;
  cursor: w-resize;
}
.popup-resize-ne {
  top: 0;
  right: 0;
  width: 12px;
  height: 12px;
  cursor: ne-resize;
}
.popup-resize-nw {
  top: 0;
  left: 0;
  width: 12px;
  height: 12px;
  cursor: nw-resize;
}
.popup-resize-se {
  bottom: 2px;
  right: 2px;
  width: 22px;
  height: 22px;
  cursor: se-resize;
  /* Visible pixel-art grip (not transparent hit-only) */
  background-color: #1a1208;
  background-image: var(--ui-resizeGrip);
  background-size: 12px 12px;
  background-repeat: no-repeat;
  background-position: center;
  border: none;
  box-shadow: 0 0 0 1px #1a1208, 0 0 0 3px #c9a227, 0 2px 0 #0a0804;
  image-rendering: pixelated;
  opacity: 0.95;
  transition:
    transform 0.15s var(--ease-spring),
    background-color 0.12s ease,
    box-shadow 0.15s ease;
  z-index: 6;
}

.popup-resize-se:hover,
.popup-resize-se:focus-visible {
  background-color: #3a2a10;
  box-shadow: 0 0 0 2px #1a1208, 0 0 0 4px #ffe08a, 0 3px 0 #0a0804;
  transform: scale(1.15);
  opacity: 1;
}

.popup-resize-se:active {
  transform: scale(0.95);
  box-shadow: 0 0 0 2px #1a1208, 0 0 0 4px #fff0c0, 0 1px 0 #0a0804;
}

/* Inner pixel-grip decoration */
.popup-resize-se::after {
  content: "";
  position: absolute;
  right: 3px;
  bottom: 3px;
  width: 10px;
  height: 10px;
  image-rendering: pixelated;
  pointer-events: none;
}

.popup-resize-sw {
  bottom: 0;
  left: 0;
  width: 12px;
  height: 12px;
  cursor: sw-resize;
}

/*
 * Inspect window open — stronger than prior popupIn (scale 0.92 / 0.28s).
 * Overshoot then settle for RPG window slam.
 */
@keyframes popupOpen {
  0% {
    opacity: 0;
    transform: scale(0.78) translate3d(0, 22px, 0);
  }
  58% {
    opacity: 1;
    transform: scale(1.07) translate3d(0, -4px, 0);
  }
  100% {
    opacity: 1;
    transform: scale(1) translate3d(0, 0, 0);
  }
}

@keyframes popupClose {
  0% {
    opacity: 1;
    transform: scale(1) translate3d(0, 0, 0);
  }
  100% {
    opacity: 0;
    transform: scale(0.86) translate3d(0, 14px, 0);
  }
}

/* Legacy name maps to dramatic open for any residual references */
@keyframes popupIn {
  0% {
    opacity: 0;
    transform: scale(0.78) translate3d(0, 22px, 0);
  }
  58% {
    opacity: 1;
    transform: scale(1.07) translate3d(0, -4px, 0);
  }
  100% {
    opacity: 1;
    transform: scale(1) translate3d(0, 0, 0);
  }
}

.popup-titlebar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.55rem 0.65rem;
  cursor: var(--cursor-grab-img) 24 20, grab;
  background: rgba(10, 14, 24, 0.55);
  border: none;
  box-shadow: inset 0 -2px 0 rgba(201, 162, 39, 0.35);
  user-select: none;
  touch-action: none;
}

.popup-titlebar:active {
  cursor: var(--cursor-grabbing-img) 24 24, grabbing;
}

.popup-title {
  font-family: var(--font-pixel);
  font-size: 0.62rem;
  color: var(--text-main);
  letter-spacing: 0.03em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.popup-close,
.modal-close-btn {
  flex-shrink: 0;
  width: 2.25rem;
  height: 2.25rem;
  border: none;
  outline: none;
  background-color: var(--panel-fill-alt, #121a24);
  background-image: var(--ui-closeButton);
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  box-shadow: 0 0 0 2px #1a1208, 0 0 0 5px #c05050;
  cursor: var(--cursor-pointer-img) 24 8, pointer;
  image-rendering: pixelated;
  padding: 0;
  transition: transform 0.2s var(--ease-spring);
}

.popup-close:hover,
.modal-close-btn:hover {
  transform: scale(1.08) rotate(8deg);
}

.popup-body {
  padding: 0.75rem 0.85rem 0.9rem;
  overflow-y: auto;
  flex: 1 1 auto;
  min-height: 0;
  scrollbar-width: none;
}

.popup-body::-webkit-scrollbar {
  display: none;
}

/* Pop-up media: 2D project imagery only — full image always visible (no crop) */
.popup-media {
  margin-bottom: 0.65rem;
}

.popup-main-image-wrap {
  width: 100%;
  /* No fixed aspect crop: wrap grows with contained image */
  aspect-ratio: auto;
  min-height: 8rem;
  max-height: min(52vh, 28rem);
  background: #0a1018;
  border: none;
  box-shadow: 0 0 0 2px #3d4f66;
  overflow: visible;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}

.popup-main-image {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%;
  max-height: min(52vh, 28rem);
  object-fit: contain;
  object-position: center;
  image-rendering: pixelated;
}

.popup-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
  margin-bottom: 0.5rem;
  font-family: var(--font-pixel);
  font-size: 0.55rem;
  color: var(--text-muted);
}

.modal-rarity {
  text-transform: uppercase;
}
.modal-rarity.rarity-common {
  color: var(--common);
}
.modal-rarity.rarity-rare {
  color: var(--rare);
}
.modal-rarity.rarity-legendary {
  color: var(--legendary);
}

.popup-tools {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-bottom: 0.55rem;
}

.tool-tag {
  font-size: 0.5rem;
  padding: 0.2rem 0.4rem;
  border: none;
  box-shadow: 0 0 0 1px #3d4f66;
  background: #0a1018;
  color: var(--text-muted);
  font-family: var(--font-pixel);
}

.popup-desc {
  color: var(--text-muted);
  font-size: 0.8rem;
  font-family: var(--font-body-readable);
  margin: 0 0 0.65rem;
  line-height: 1.5;
}

.popup-secondary {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.popup-sec-img {
  width: calc(50% - 0.2rem);
  max-width: 140px;
  aspect-ratio: 1;
  object-fit: cover;
  border: none;
  box-shadow: 0 0 0 1px #3d4f66;
  image-rendering: pixelated;
  background: #0a1018;
}

/* body.modal-is-open must NOT unlock document scroll (game shell stays locked) */
body.modal-is-open {
  overflow: hidden;
}

/* Reduced motion: keep structure, kill continuous life */
@media (prefers-reduced-motion: reduce) {
  .bg-scroll-inner,
  .portrait-frame,
  .idle-float,
  .idle-float-subtle,
  .idle-float-slow,
  .idle-float-large,
  .idle-float-preview,
  .nav-btn.ui-btn,
  .social-btn,
  .social-column .social-btn,
  .letter-wobble,
  .game-window,
  .game-window.is-shell-recentering,
  .boot-overlay,
  .boot-overlay-bar,
  .boot-overlay-text,
  .boot-overlay-panel-top,
  .boot-overlay-panel-bottom,
  .boot-overlay-scan,
  .boot-dot-1,
  .boot-dot-2,
  .boot-dot-3 {
    animation: none !important;
  }
  /* CRT stack is already static (no continuous phosphor pulse) */
  .game-window,
  .game-window.is-shell-recentering {
    transition: none !important;
  }
  .grid-item,
  .ui-btn,
  .nav-btn,
  .btn,
  .social-btn,
  .cat-tab,
  .modal,
  .modal-dialog,
  .unlock-confirm-dialogue,
  .view,
  .view.panel-entering,
  .view.panel-exiting,
  .instructions-panel.panel-entering,
  .instructions-panel.panel-exiting,
  .popup-window,
  .popup-window.popup-entering,
  .popup-window.popup-exiting {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
  .view.panel-entering,
  .view.panel-exiting,
  .instructions-panel.panel-entering,
  .instructions-panel.panel-exiting,
  .popup-window.popup-entering,
  .popup-window.popup-exiting {
    animation: none !important;
  }
  /* Boot overlay base too, not just .is-active: it's default-visible now
   * (pre-JS art cover) and reduced-motion skips the whole boot ceremony —
   * JS marks .is-done as well; this is the CSS backstop. */
  .boot-overlay,
  .boot-overlay.is-active {
    display: none !important;
  }
  /* Model spawn entrance — no spin/scale when reduced motion is preferred */
  .model-preview-stage model-viewer.is-spawning,
  #model-preview-viewer.is-spawning {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
  .grid-item.is-hover,
  body:not(.is-dragging) .grid-item:hover,
  .grid-item.is-highlighted,
  .grid-item.is-selected {
    transform: none;
  }
  .game-window.is-shaking {
    animation: none !important;
  }
  .loadout-item:hover,
  .loadout-item:focus-visible {
    transform: none;
    box-shadow: 0 9px 2px #070416c4;
  }
  .loadout-item.is-equip-flourish .loadout-icon {
    animation: none !important;
  }
  .click-holo {
    display: none !important;
  }
  /* Booster packs: keep the user-driven tear itself, drop the flourishes */
  .booster-pack,
  .booster-pack-stack,
  .booster-pack-floater.is-returning,
  .booster-reveal-floater,
  .booster-shelf,
  .booster-rip-backdrop,
  .trash-bin {
    transition: none !important;
  }
  .booster-pack.is-refused,
  .booster-dialogue,
  .mobile-pc-notice,
  .booster-empty-hint,
  .booster-empty-hint-arrow,
  .booster-pull-hint-arrow,
  .booster-rainbow-letter,
  .booster-rip-pack.is-docking,
  .booster-rip-pack.is-nudged,
  .booster-rip-pack.is-tear-impact,
  .booster-rip-stage.is-tear-shake,
  .booster-tear-flash,
  .trash-bin.is-chomping,
  .trash-bin.is-vanishing,
  .trash-bin-label span,
  .item-new-ribbon,
  .booster-rip-pack.is-spent {
    animation: none !important;
  }
  .booster-tear-shockwave {
    display: none !important;
  }
  .booster-rainbow-letter {
    color: inherit !important;
  }
  .booster-rip-pack {
    transition: none !important;
  }
  .booster-rip-foil,
  .booster-rip-side-left,
  .booster-rip-side-right {
    transition: none !important;
  }
  .booster-rip-flap.is-torn {
    animation: none !important;
    opacity: 0;
  }
}

/* Ensure game window sits above ambient dust (z:1) and scrolling tile */
.viewport-center {
  z-index: 2;
}
