/* Public profile pages.
   Uses ezcoins.shop's own design language rather than a second one of its own:
   the same tokens, the dot grid, the glass header and the emerald accent from
   styles.css. A profile reached from the main site should not feel like a
   different product. The tokens are redeclared rather than imported because
   styles.css carries the whole marketing page with it, and this page needs
   none of that. */
:root {
  --bg: #08090e;
  --bg-2: #0d0f16;
  --surface: rgba(255, 255, 255, 0.03);
  --surface-hover: rgba(255, 255, 255, 0.06);
  --border: rgba(255, 255, 255, 0.06);
  --border-hover: rgba(255, 255, 255, 0.12);
  --text: #f0f1f5;
  --text-2: #6b7085;
  --accent: #10b981;
  --gold: #f5b23d;
  --radius: 14px;
  --radius-sm: 10px;
  --font: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  /* The game's own slot is 18 texture pixels at 2x. This is that, near enough. */
  --slot-size: 2.25rem;
  --gui-frame: #c6c6c6;
  --gui-panel: #8b8b8b;
  --gui-light: #ffffff;
  --gui-shadow: #55555580;
}

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

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* --- The wallpaper behind the page ----------------------------------------
   One of four shader screenshots, chosen from the slug in lib/background.js so
   a listing always looks like itself.

   It lives in .bg-decor below rather than in a layer of its own. An earlier
   attempt put it on body::before at z-index -2 and it was invisible at ANY
   opacity, because .bg-decor is a fixed, full-viewport, z-index 0 element
   whose gradient ended in an opaque colour — it was painting over the
   wallpaper the whole time. One decorative layer, not two fighting.

   --dim is how hard that layer holds the image down; the two bright sunsets
   need more of it than the two dark scenes or the text sits on glare. */
body.bg-0 { --wallpaper: url('/backgrounds/bg-0.webp'); --dim: .70; } /* aurora over ice */
body.bg-1 { --wallpaper: url('/backgrounds/bg-1.webp'); --dim: .72; } /* night village */
body.bg-2 { --wallpaper: url('/backgrounds/bg-2.webp'); --dim: .88; } /* sunset forest */
body.bg-3 { --wallpaper: url('/backgrounds/bg-3.webp'); --dim: .90; } /* sunset valley */

/* With scenery behind them the panels can no longer be 3% white — text sat
   directly on the image and the muted labels washed out. They become real
   glass: dark enough to read against, blurred so the wallpaper still shows
   through as colour rather than as detail.

   Only things that are actually CARDS get this. #sorted was in this list and
   should never have been: it is a full-width section wrapper around a grid
   that sizes to its contents, so giving it a panel background painted a black
   slab across the page with a narrow grid floating on it. Its chest frames
   already carry their own opaque styling and sit on the wallpaper the same
   way the unsorted ones do. */
.listing-header,
.profile-card {
  background: rgba(10, 12, 18, .74);
  backdrop-filter: blur(16px) saturate(1.15);
  -webkit-backdrop-filter: blur(16px) saturate(1.15);
  border: 1px solid rgba(255, 255, 255, .07);
}
#search, #sort { background: rgba(10, 12, 18, .62); }

a { color: inherit; text-decoration: none; }

/* Two soft glows rather than a repeating pattern.
   The dot grid is the marketing page's texture and it fought these pages: a
   profile is a dense wall of coloured tiles, and a grid behind it reads as
   noise. This gives the page depth without competing with the content. */
.bg-decor {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Layers paint top-first: glows, then the darkening wash, then the
     wallpaper, then the flat colour underneath it all. The wash is the only
     thing standing between a shader screenshot and unreadable body text, so
     it is opaque enough to matter and translucent enough to still be scenery.
     The blur is baked into the webp rather than applied with filter: blurring
     a fixed layer costs a repaint on every scroll, and it compresses better. */
  background:
    radial-gradient(60rem 32rem at 15% -10%, rgba(16, 185, 129, .10), transparent 60%),
    radial-gradient(48rem 28rem at 92% 4%, rgba(56, 130, 246, .07), transparent 62%),
    linear-gradient(180deg,
      rgba(10, 12, 18, calc(var(--dim, 1) * .82)) 0%,
      rgba(8, 9, 14, var(--dim, 1)) 55%),
    var(--wallpaper, none) center / cover no-repeat,
    var(--bg);
}

/* A page with no wallpaper class keeps exactly the look it had before: the
   var() falls back to `none`, --dim to 1, and the wash goes fully opaque. */

/* --- Header --------------------------------------------------------------- */

/* The floating island, matching the main site (island.css). A full-width bar
   cut the page in half above a wallpaper; a pill lets the scenery run behind
   and under it, which is the whole reason the wallpaper is there.

   Restyled rather than importing island.css: that file carries the marketing
   page's light theme, its scroll-contract states and the JS that drives them.
   These are the values that matter, copied. */
.site-header {
  /* ABSOLUTE, not fixed: the pill is anchored to the top of the PAGE and
     scrolls away with it, rather than riding along in front of the content.
     Fixed put a translucent bar permanently across a wall of item tiles, with
     rows sliding under and showing through it.
     Absolute rather than static because the pill is a floating capsule with
     its own inset and centring, and .listing already reserves the space for it
     with padding-top - taking it out of flow keeps that arrangement intact. */
  position: absolute; z-index: 100;
  top: 14px; left: 50%; transform: translateX(-50%);
  width: min(1040px, calc(100vw - 28px));
  border: 1px solid rgba(255, 255, 255, .09);
  border-radius: 999px;
  background: rgba(16, 18, 27, .55);
  backdrop-filter: blur(20px) saturate(1.6);
  -webkit-backdrop-filter: blur(20px) saturate(1.6);
  box-shadow: 0 4px 20px rgba(0, 0, 0, .18);
}
.site-header-inner {
  max-width: none; margin: 0; padding: 0 10px 0 20px;
  display: flex; align-items: center; gap: 24px; height: 54px;
}
/* The header no longer occupies space in the flow, so the content makes room
   for it by hand — see the padding-top on .listing below. Setting it here
   would do nothing: this block sits ABOVE .listing in the file, so the base
   rule's shorthand would win on equal specificity. */
.brand-name { font-weight: 800; letter-spacing: -.02em; font-size: 1.05rem; }
.brand-name .accent { color: var(--accent); }
.site-nav { display: flex; gap: 20px; margin-left: auto; font-size: .875rem; color: var(--text-2); }
.site-nav a { transition: color .15s ease; }
.site-nav a:hover, .site-nav a[aria-current] { color: var(--text); }

/* --- Layout --------------------------------------------------------------- */

.listing {
  position: relative; z-index: 1;
  max-width: 1040px; margin: 0 auto;
  /* The top value clears the floating pill, which is fixed and so takes up no
     space of its own: 14px offset + 54px tall + breathing room. */
  padding: 5.75rem 1.25rem 5rem;
}

.listing-header {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.5rem 1.6rem 1.35rem;
  margin-bottom: 1.75rem;
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
}

.title-row { display: flex; align-items: baseline; gap: .75rem; flex-wrap: wrap; }
.listing-header h1 {
  font-size: 1.5rem; font-weight: 800; letter-spacing: -.02em;
  word-break: break-word; line-height: 1.25;
}
.listing-meta { color: var(--text-2); font-size: .85rem; margin-top: .15rem; }

.price-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin: 1.15rem 0 1.4rem; }
.price {
  font-size: 2.1rem; font-weight: 800; letter-spacing: -.03em; line-height: 1;
  background: linear-gradient(135deg, var(--text) 30%, var(--accent) 100%);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent;
}
.sold-badge {
  font-size: .7rem; font-weight: 800; letter-spacing: .12em;
  color: #f87171; border: 1px solid rgba(248, 113, 113, .35);
  background: rgba(248, 113, 113, .08);
  border-radius: 999px; padding: .25rem .7rem;
}

.buy {
  display: inline-flex; align-items: center; gap: .5rem;
  padding: .65rem 1.3rem; border-radius: var(--radius-sm);
  background: linear-gradient(135deg, #34d399, #059669);
  color: #04140d; font-weight: 700; font-size: .875rem;
  transition: filter .15s ease, transform .15s ease;
}
.buy:hover { filter: brightness(1.08); transform: translateY(-1px); }

/* Flat label-over-value. No cards - they fought the numbers for attention. */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8.5rem, max-content));
  gap: .5rem 2.5rem;
  margin-bottom: .85rem;
}
.stats-secondary { gap: .5rem 2rem; }
.stat { display: flex; flex-direction: column; gap: .05rem; }
.stat-label {
  font-size: .66rem; font-weight: 600; letter-spacing: .09em;
  text-transform: uppercase; color: var(--text-2); white-space: nowrap;
}
.stat-value { font-size: 1.05rem; font-weight: 650; line-height: 1.3; }
.stat-hero .stat-value { font-size: 1.4rem; font-weight: 800; color: var(--gold); letter-spacing: -.01em; }

.stats-caveat {
  color: var(--text-2); font-size: .74rem; line-height: 1.5;
  padding-top: .85rem; border-top: 1px solid var(--border);
}

/* --- Controls ------------------------------------------------------------- */

.controls { position: relative; z-index: 1; margin-bottom: 1.5rem; }
.controls-row { display: flex; gap: .6rem; flex-wrap: wrap; }
#search, #sort {
  padding: .7rem .9rem; font-size: .9rem; font-family: inherit;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  transition: border-color .15s ease, background .15s ease;
}
#search { flex: 1 1 15rem; }
#sort { flex: 0 0 auto; cursor: pointer; }

/* The dropdown list itself is drawn by the operating system, not by the page,
   so the styles above never reached it: the closed control was dark while the
   open list rendered light-on-light and the options were unreadable. Telling
   the browser this control is dark makes it draw a dark popup with legible
   text; the explicit option colours are the fallback for the browsers that
   ignore color-scheme. Both are needed. */
#sort { color-scheme: dark; }
/* Opaque literals, NOT --surface/--text. The first attempt used the tokens and
   made it worse: --surface is rgba(255,255,255,.03), so the options were 3%
   white painted on the popup's own white base, with near-white text on top —
   the list went from unreadable to invisible. A popup drawn by the OS has no
   page behind it to be translucent against. */
#sort option { background: #12141c; color: #f0f1f5; }
#search:hover, #sort:hover { border-color: var(--border-hover); background: var(--surface-hover); }
#search:focus, #sort:focus { outline: none; border-color: var(--accent); }
#search::placeholder { color: var(--text-2); }
/* `overflow-wrap: anywhere` is the one that matters: a run of a single letter
   has no break opportunity in it, so normal wrapping cannot help and the line
   grows the page sideways instead. The echo is truncated in listing.js too —
   this is the guard that does not depend on the text being short. */
.search-count {
  color: var(--text-2); font-size: .8rem; margin-top: .5rem; min-height: 1.2em;
  overflow-wrap: anywhere;
}

/* --- The item grid -------------------------------------------------------- */

.slot-item                              { --r: #ffffff; --r-prev: #ffffff; }
.slot-item[data-rarity="common"]        { --r: #b8b8b8; --r-prev: #b8b8b8; }
.slot-item[data-rarity="uncommon"]      { --r: #55ff55; --r-prev: #b8b8b8; }
.slot-item[data-rarity="rare"]          { --r: #5555ff; --r-prev: #55ff55; }
.slot-item[data-rarity="epic"]          { --r: #aa00aa; --r-prev: #5555ff; }
.slot-item[data-rarity="legendary"]     { --r: #ffaa00; --r-prev: #aa00aa; }
.slot-item[data-rarity="mythic"]        { --r: #ff55ff; --r-prev: #ffaa00; }
.slot-item[data-rarity="divine"]        { --r: #55ffff; --r-prev: #ff55ff; }
.slot-item[data-rarity="special"]       { --r: #ff5555; --r-prev: #55ffff; }
.slot-item[data-rarity="very-special"]  { --r: #ff5555; --r-prev: #ff5555; }
.slot-item[data-rarity="supreme"]       { --r: #aa0000; --r-prev: #ff5555; }

.container { position: relative; z-index: 1; margin-bottom: 1.1rem; }

.grid {
  display: grid;
  grid-template-columns: repeat(9, var(--slot-size));
  justify-content: start;
  gap: 3px;
  padding: 7px;
  width: max-content;
  max-width: 100%;
  background: var(--gui-panel);
  border: 3px solid var(--gui-frame);
  border-radius: 3px;
  box-shadow: inset 2px 2px 0 var(--gui-light), inset -2px -2px 0 var(--gui-shadow);
  /* overflow-y MUST be stated. Setting only overflow-x to a non-visible value
     makes the browser compute the other axis to `auto` too, which gave every
     grid a vertical scrollbar it can never need. */
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.grid::-webkit-scrollbar { display: none; }

.slot {
  position: relative;
  width: var(--slot-size); height: var(--slot-size);
  border-radius: 22%;
  background: #00000026;
  box-shadow: inset 1px 1px 0 #37373733;
}

.slot-item {
  background: var(--r);
  cursor: pointer;
  overflow: hidden;
  transition: transform .08s ease;
}
.slot-item:hover, .slot-item:focus-visible { outline: none; transform: scale(1.08); z-index: 2; }

/* The recombobulated marker: a square rotated 45 degrees and pushed into the
   corner so only the triangle shows, in the rarity it had BEFORE the upgrade. */
.slot-item[data-recomb]::after {
  content: '';
  position: absolute; top: -28%; right: -28%;
  width: 56%; height: 56%;
  background: var(--r-prev);
  transform: rotate(45deg);
}

.sprite {
  position: absolute; inset: 14%;
  width: 72%; height: 72%;
  image-rendering: pixelated;
  z-index: 1;
}

/* A PRE-RENDERED isometric cube. The picture below is the same one the .cube
   rules further down build out of twelve transformed spans - same angles, same
   --face, same hat shell - but drawn once at build time by
   scripts/build_head_cubes.mjs instead of by the compositor on every frame.
   That is the whole change: 114 cubes on a geared profile is 1,368 elements and
   45% of the document, and swapping them for one <img> each took scrolling on
   the deployed page from 61fps to 235.

   Only two things differ from a flat item icon, and both are consequences of it
   being a cube rather than a 16x16 sprite:
     - it fills the slot rather than the 72% an icon takes. The cube's size is
       defined AGAINST the slot (--face is 0.48 of it) and its silhouette is
       0.734 x 0.818 of it - taller than 72%, so an inset box would shrink it.
       The canvas is the slot box, so this is a 1:1 placement, not a scale.
     - `pixelated` comes off. The texels inside a face are still nearest
       neighbour - the renderer samples them that way - but the silhouette and
       the seams between faces are antialiased, exactly as the browser
       antialiases its own composited 3D layers. Snapping that to whole pixels
       puts a staircase back on every diagonal. */
.sprite-iso {
  inset: 0;
  width: 100%; height: 100%;
  image-rendering: auto;
}

/* A player head is a 64x64 skin sheet and only the 8x8 face at (8,8) shows.
   Blow the sheet up 8x and shift by one box: one skin pixel becomes an eighth
   of a box, the face starts one box in, and its 8 pixels fill the box exactly. */
.sprite-head { overflow: hidden; }
.sprite-head img {
  position: absolute;
  width: 800%; height: 800%;
  left: -100%; top: -100%;
  image-rendering: pixelated;
}
/* Creeper and skeleton sheets are 64x32: half the vertical scale, or the crop
   lands on the ear instead of the face. */
.sprite-sheet-32 img { height: 400%; }

/* A pet head is the flat NET of a cube: the head's six 8x8 faces sit in a strip
   inside the same 64x64 skin the flat crop already loads. Three of them placed
   in 3D turn the net back into the object it describes, which is what makes a
   pet row read as things rather than stickers.

   Every face is the SAME image at 800% - eight cells across - shifted to its
   own cell. CSS percentage background-position aligns the image's X% with the
   box's X%, so a cell at column c of eight is c/7*100%, not c*12.5%: getting
   that wrong lands between two faces and shows half an ear.

   The cube is sized from --slot-size rather than a percentage because
   translateZ cannot take one - a percentage there is invalid and the whole
   transform is dropped, collapsing the cube back to a flat square. */
.sprite-cube {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  z-index: 1;
  /* NO perspective, deliberately. The reference render is orthographic, and
     that is not only a look: under perspective the faces sit at different
     depths, so the cube's silhouette is no longer centred on the element the
     grid centres, and it drifts up and left until the slot's overflow:hidden
     shaves its edges. Orthographic is symmetric about the origin, so centring
     the element centres the cube, and no correction is needed.

     Sizes taken from the reference itself: its 128x128 render carries the cube
     in a 108x124 box and sits in a 72px tile at 56px, so the visible cube is
     0.656 of the tile wide and 0.753 tall. A cube at these angles spans
     1.366x its face, so 0.656 / 1.366 = 0.48. Height lands at 0.744. */
  --face: calc(var(--slot-size) * 0.48);
  --half: calc(var(--face) / 2);
}
.cube {
  position: relative;
  width: var(--face); height: var(--face);
  transform-style: preserve-3d;
  /* 30 and 30 - the classic isometric, and what the reference actually uses.
     Solved rather than guessed: an orthographic cube's silhouette is
     face*(cos t + sin t) wide and face*(cos f + sin f*(cos t + sin t)) tall, so
     the reference's measured 124/108 = 1.148 aspect fixes the angles at 31/32,
     which is 30/30 inside the error of reading a bounding box off a PNG.
     The first attempt used 20deg here and rendered at 1.03 - a cube with the
     top face too shallow, which is what read as "off".
     The leading translate RE-CENTRES the silhouette. What gets centred is this
     element - the face plane - and the cube's outline is not centred on it: the
     top face reaches up past it and the side face reaches out past it, so the
     shape you see sits up and to the left of the box the browser centred, so
     it is pushed back by 0.292 of a face across and 0.277 down. Both were
     MEASURED off the rendered DOM and confirmed to land at exactly 0.00 - do
     not re-derive them by mirroring when the angles change, which is wrong:
     turning the cube the other way moved the side face to the left and left
     this correction pointing the same way, not the opposite way. The Y figure
     is the smaller of the two because the hat shell, scaled 1.12 about each
     face's own centre, reaches further down than up.
     Left uncorrected it is not merely off-centre - the slot's overflow:hidden
     shaves the edges it overruns.

     It turns RIGHT: the front face sits toward the viewer's right and the
     head's own RIGHT side (sheet column 0, not the LEFT at column 2) fills the
     left of the cube. That is the way round the reference renders it - an
     ocelot's green eyes and white muzzle land on the right-hand face. */
  transform:
    translate(calc(var(--face) * 0.292), calc(var(--face) * 0.277))
    rotateX(-30deg) rotateY(30deg);
}
/* background-size is 800% AUTO, and the offsets are LENGTHS, not percentages.
   Both halves of that matter, and together they are what makes one rule serve
   the two skin formats.

   A skin is 64x64 today but the old 64x32 format is still out there - Jerry and
   Ocelot are 64x32 on this very site. At `800% 800%` the sheet is forced to
   eight rows whatever its real height, so on a half-height sheet every face
   lands somewhere else and the cube is garbage. `auto` keeps the pixels square,
   so one cell is exactly one element either way: eight rows on a 64x64, four on
   a 64x32, and the head's faces sit in the same cells in both.

   Percentage background-position could not follow that: it aligns the image's
   X% with the box's X%, so the same cell is 14.2857% on an eight-row sheet and
   33.3333% on a four-row one. A length is just a cell count, and one cell is
   --face in both. (transform does not affect background painting, so the hat's
   scale() leaves these offsets alone.) */
.cube-face {
  position: absolute; inset: 0;
  background-image: var(--skin);
  background-size: 800% auto;
  image-rendering: pixelated;
}
/* The head's six faces, in the cells the skin format puts them:
   row 0 - top at column 1, bottom at column 2
   row 1 - right 0, front 1, left 2, back 3
   The far three matter because a transparent skin shows them through the near
   ones; preserve-3d sorts the depth, so nothing else is needed to see inside. */
.cube-front    { background-position: calc(var(--face) * -1) calc(var(--face) * -1); transform: translateZ(var(--half)); }
.cube-back     { background-position: calc(var(--face) * -3) calc(var(--face) * -1); transform: rotateY(180deg) translateZ(var(--half)); }
.cube-side     { background-position: 0 calc(var(--face) * -1);                      transform: rotateY(-90deg) translateZ(var(--half)); }
.cube-side-far { background-position: calc(var(--face) * -2) calc(var(--face) * -1); transform: rotateY(90deg) translateZ(var(--half)); }
.cube-top      { background-position: calc(var(--face) * -1) 0;                      transform: rotateX(90deg) translateZ(var(--half)); }
.cube-bottom   { background-position: calc(var(--face) * -2) 0;                      transform: rotateX(-90deg) translateZ(var(--half)); }

/* The hat layer: a second shell 32 pixels along the sheet, worn slightly proud
   of the head. It is what gives a pet its fuzzy outline - a Bee's stripes, an
   Enderman's brow - and it is fully transparent on skins that do not use one,
   so it costs nothing when it is not there. */
.cube-hat { transform-style: preserve-3d; }
.cube-hat-front    { background-position: calc(var(--face) * -5) calc(var(--face) * -1); transform: translateZ(calc(var(--half) + 0.5px)) scale(1.12); }
.cube-hat-back     { background-position: calc(var(--face) * -7) calc(var(--face) * -1); transform: rotateY(180deg) translateZ(calc(var(--half) + 0.5px)) scale(1.12); }
.cube-hat-side     { background-position: calc(var(--face) * -4) calc(var(--face) * -1); transform: rotateY(-90deg) translateZ(calc(var(--half) + 0.5px)) scale(1.12); }
.cube-hat-side-far { background-position: calc(var(--face) * -6) calc(var(--face) * -1); transform: rotateY(90deg) translateZ(calc(var(--half) + 0.5px)) scale(1.12); }
.cube-hat-top      { background-position: calc(var(--face) * -5) 0;                      transform: rotateX(90deg) translateZ(calc(var(--half) + 0.5px)) scale(1.12); }
.cube-hat-bottom   { background-position: calc(var(--face) * -6) 0;                      transform: rotateX(-90deg) translateZ(calc(var(--half) + 0.5px)) scale(1.12); }

.sprite-missing {
  position: absolute; inset: 14%;
  background:
    linear-gradient(45deg, #000 25%, transparent 25%, transparent 75%, #000 75%),
    linear-gradient(45deg, #000 25%, transparent 25%, transparent 75%, #000 75%),
    #f0f;
  background-size: 50% 50%;
  background-position: 0 0, 50% 50%;
  z-index: 1;
}

.slot-count {
  position: absolute; right: 2px; bottom: 0;
  font-size: .62rem; line-height: 1; font-weight: 700; color: #fff;
  text-shadow: 1px 1px 0 #000, -1px 0 0 #000, 0 -1px 0 #000;
  pointer-events: none; z-index: 2;
}

/* Search dims rather than hides: removing tiles collapses the grid and destroys
   the shape that makes it readable as a chest. */
.slot-dim { opacity: .12; }

#sorted { margin-bottom: 1.6rem; }
#sorted h2 { font-size: .95rem; font-weight: 700; }
.container-meta {
  color: var(--text-2); margin-bottom: .5rem;
  font-size: .74rem; letter-spacing: .03em; text-transform: uppercase;
}

.empty { color: var(--text-2); position: relative; z-index: 1; }
[hidden] { display: none !important; }

/* --- The item card -------------------------------------------------------- */

.card                              { --r: #b8b8b8; }
.card[data-rarity="common"]        { --r: #b8b8b8; }
.card[data-rarity="uncommon"]      { --r: #55ff55; }
.card[data-rarity="rare"]          { --r: #5555ff; }
.card[data-rarity="epic"]          { --r: #aa00aa; }
.card[data-rarity="legendary"]     { --r: #ffaa00; }
.card[data-rarity="mythic"]        { --r: #ff55ff; }
.card[data-rarity="divine"]        { --r: #55ffff; }
.card[data-rarity="special"]       { --r: #ff5555; }
.card[data-rarity="very-special"]  { --r: #ff5555; }
.card[data-rarity="supreme"]       { --r: #aa0000; }

.card {
  border: none; padding: 0;
  max-width: 23rem; width: calc(100% - 2rem);
  background: #101219; color: var(--text);
  border-radius: var(--radius);
  overflow: hidden;
  /* A drill's lore runs to thirty lines. Without a ceiling the card grows past
     the viewport and the footer - the item's value - ends up unreachable. */
  display: flex; flex-direction: column; max-height: 85vh;
  /* `display: flex` on a <dialog> overrides the UA's
     `dialog:not([open]) { display: none }`, so the card was rendered even
     while CLOSED — parked at its static position at the end of the document,
     still showing the last item looked at. Normally below the fold and
     unnoticed; search for something with no matches, every container hides,
     the page gets short, and an empty grey card appears at the bottom left.
     That is the box Gulubero kept seeing, in both reports. */
  box-shadow: 0 24px 60px rgba(0, 0, 0, .6);
}
.card::backdrop { background: rgba(4, 5, 8, .7); }

/* Restores what `display: flex` above took away. A closed dialog must not be
   rendered at all — this is the one rule standing between the page and a card
   permanently parked at the bottom of it. */
.card:not([open]) { display: none; }

/* The backstop for the card that used to sit at the bottom of the page.
   A dialog that is open while being neither pinned (modal) nor a hover tooltip
   has no position of its own, so it renders at its static place near the end
   of the document — bottom-left, ignoring the cursor. listing.js closes that
   state as soon as it notices; this makes sure it is never drawn even for the
   frame before that happens. Never hides a card that is legitimately open. */
.card[open]:not(.card-hover):not(:modal) { display: none; }

/* The pinned card, centred.
   The `* { margin: 0 }` reset at the top of this file also hits dialog:modal,
   whose UA `margin: auto` is the ONLY thing that centres a modal dialog. With
   it reset away the clicked card opened at the viewport's top-left corner
   while its backdrop still covered the page - so hovering did nothing, the
   next click only dismissed the backdrop, and the page read as frozen.
   Written as :not(.card-hover) rather than :modal so it cannot fight the
   tooltip rule below, which sets margin: 0 on purpose. */
.card[open]:not(.card-hover) { margin: auto; }

.card.card-hover {
  position: fixed; margin: 0;
  pointer-events: none;
  max-width: 21rem; max-height: 70vh;
  /* Above the tiles: a dialog opened with show() is not in the browser's top
     layer, and the tiles carry z-index 1 and 2. Without this they paint over
     the card and it reads as translucent. */
  z-index: 100;
}
.card.card-hover::backdrop { background: transparent; }
/* Touch has no hover, and a tooltip that appears on tap then follows the
   finger is worse than none. Tap opens the pinned card instead. */
@media (hover: none) { .card.card-hover { display: none; } }

.card-head, .card-foot { flex: 0 0 auto; }
.card-lore { flex: 1 1 auto; overflow-y: auto; min-height: 0; }

.card-head {
  display: flex; align-items: center; gap: .6rem;
  padding: .7rem .9rem;
  background: var(--r); color: #fff;
  font-weight: 800; letter-spacing: .02em; text-transform: uppercase;
  font-size: .9rem;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .45);
}
/* The name is forced white. An item's name is coloured BY its rarity and the
   header behind it is that same colour, so honouring the code paints the text
   in its own background - "§9Miner Boots" was dark blue on dark blue. */
.card-head span { color: #fff !important; }

.card-icon { position: relative; flex: 0 0 auto; width: 1.9rem; height: 1.9rem; overflow: hidden; }
.card-icon img { image-rendering: pixelated; }
.card-icon img.flat { position: absolute; inset: 0; width: 100%; height: 100%; }
.card-icon img.head { position: absolute; width: 800%; height: 800%; left: -100%; top: -100%; }
/* A cloned pet cube sizes itself from --slot-size, which would otherwise be the
   grid's. The card's icon box is its own size, so say so - and the cube gets a
   bigger share of it than in the grid, because the icon here sits beside 1rem
   text rather than inside a tile: at the grid's fraction it reads as a chip of
   something rather than the item the card is about.
   .card-icon CLIPS, and the hat layer is 1.12x the base, so the tall axis is
   the binding one: 0.55 * 1.549 * 1.12 = 0.95 of the box. */
.card-icon .sprite-cube {
  --slot-size: 1.9rem;
  --face: calc(var(--slot-size) * 0.55);
}
/* A PRE-RENDERED cube in the card. Same job as the rule above and the same
   answer, arrived at differently: the picture is baked against a slot box, so
   the way to give it the card's larger share is to scale the whole image by the
   ratio between the two --face fractions, 0.55/0.48. That lands the silhouette
   at 0.94 of the box, which is where the cloned CSS cube sits, so a card looks
   the same whichever renderer drew the item.
   `auto` for the same reason .sprite-iso says it: this PNG is antialiased, and
   .card-icon img sets `pixelated` for the 16x16 icons it was written for. */
.card-icon img.iso {
  image-rendering: auto;
  transform: scale(calc(0.55 / 0.48));
}

.card-lore { padding: .85rem .95rem 1.1rem; background: #0b0c11; font-size: .87rem; line-height: 1.45; }
.card-lore .lore-line { white-space: pre-wrap; word-break: break-word; min-height: .9em; }

/* §0 and §8 sit at roughly 2.2:1 on this background - §8 alone appears 233
   times in real recordings. Lifted to a legible floor here and only here. */
.card-lore span[style*="#000000"] { color: #6b6b6b !important; }
.card-lore span[style*="#555555"] { color: #a0a0a0 !important; }

.card-foot {
  padding: .6rem .95rem .75rem;
  background: #080910;
  border-top: 1px solid var(--border);
  font-size: .8rem;
}
.foot-row { display: flex; justify-content: space-between; gap: 1rem; }
.foot-row + .foot-row { margin-top: .2rem; }
.foot-label { color: var(--text-2); }
.foot-value { color: var(--gold); font-weight: 650; text-align: right; }
.foot-muted .foot-value { color: #c9cedb; font-weight: 500; }
/* Breakdown rows sit under the Item Value they explain. */
.foot-row.foot-muted { padding-left: 0.75rem; }

/* --- Profiles index ------------------------------------------------------- */

.index-head { position: relative; z-index: 1; margin-bottom: 1.75rem; }
.index-head h1 { font-size: 1.75rem; font-weight: 800; letter-spacing: -.02em; }
.index-head p { color: var(--text-2); font-size: .9rem; margin-top: .25rem; }

.index-section { position: relative; z-index: 1; font-size: .8rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-2); margin: 1.5rem 0 .75rem; }
.index-section:first-of-type { margin-top: 0; }

.profile-grid {
  position: relative; z-index: 1;
  display: grid; gap: 1rem;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}

.profile-card {
  display: block;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.1rem 1.15rem;
  transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.profile-card:hover {
  border-color: var(--border-hover);
  background: var(--surface-hover);
  transform: translateY(-2px);
}
.profile-card-top { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; }
.profile-card h2 { font-size: 1.05rem; font-weight: 700; letter-spacing: -.01em; }
/* The price sits where the networth used to, because it is the number a buyer
   scans a grid of cards for.
   It carries NO colour and no chip. Two attempts proved the point: gold made it
   compete with the networth, which is also gold and is a different question;
   an emerald pill was worse, because a tinted badge is the most templated
   component there is and reads as decoration rather than as the price. Size and
   weight alone say "this is the number" - it is the largest text on the card,
   set against a grey title and grey stats, and nothing else on the card is
   white. Tabular figures so a grid of prices lines up column-wise. */
.profile-card-price {
  flex-shrink: 0;
  color: var(--text);
  font-size: 1.2rem;
  font-weight: 750;
  font-variant-numeric: tabular-nums;
  letter-spacing: -.02em;
  white-space: nowrap;
}
.profile-card-meta { color: var(--text-2); font-size: .78rem; margin-top: .1rem; }
.profile-card-stats {
  display: flex; flex-wrap: wrap; gap: .3rem .9rem;
  margin-top: .8rem; padding-top: .8rem; border-top: 1px solid var(--border);
  font-size: .76rem; color: var(--text-2);
}
.profile-card-stats b { color: var(--text); font-weight: 650; }
/* Networth joins the other measures of the account, but keeps the gold so it
   still reads as money at a glance. First in the row, so it never wraps onto
   a line of its own on a narrow card. */
.profile-card-stats .stat-networth { flex: 0 0 100%; }
.profile-card-stats .stat-networth b { color: var(--gold); font-weight: 750; }
.profile-card-foot {
  display: flex; align-items: center; justify-content: flex-end;
  margin-top: .8rem;
}

/* A licence condition - see textureCredit() in lib/listing-html.js. */
.texture-credit {
  position: relative; z-index: 1;
  max-width: 1040px; margin: 0 auto; padding: 0 1.25rem 2.5rem;
  color: var(--text-2); font-size: .78rem;
}
.texture-credit a { color: var(--text-2); text-decoration: underline; }
.texture-credit a:hover { color: var(--text); }

@media (max-width: 40rem) {
  /* Still has to clear the pill, which is fixed at every width. */
  .listing { padding: 5.25rem .85rem 3rem; }
  .listing-header { padding: 1.15rem 1.05rem 1.05rem; }
  .listing-header h1 { font-size: 1.2rem; }
  .price { font-size: 1.7rem; }
  .stats { grid-template-columns: repeat(auto-fit, minmax(7rem, max-content)); gap: .45rem 1.4rem; }
  .stat-hero .stat-value { font-size: 1.2rem; }
  .grid { --slot-size: 1.95rem; }
  .site-header-inner { padding: 0 1rem; gap: 12px; }
}

/* Back to top. Bottom right, clear of the card dialog, and hidden until the
   page has actually been scrolled - a control that does nothing is noise.
   scroll-behavior lives on the button's handler rather than on html{} so it
   cannot slow down every in-page jump on the site. */
#to-top {
  position: fixed; right: 1.25rem; bottom: 1.25rem; z-index: 90;
  width: 2.75rem; height: 2.75rem;
  display: grid; place-items: center;
  font: 700 1.1rem/1 var(--font);
  color: var(--text);
  background: rgba(16, 18, 27, .72);
  border: 1px solid rgba(255, 255, 255, .10);
  border-radius: 999px;
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
  box-shadow: 0 4px 18px rgba(0, 0, 0, .28);
  cursor: pointer;
  transition: opacity .18s ease, transform .18s ease, background .18s ease;
}
#to-top[hidden] { display: none; }
#to-top:hover { background: rgba(24, 27, 38, .86); transform: translateY(-2px); }
#to-top:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Someone who asked for less motion still gets the button, just not the ride. */
@media (prefers-reduced-motion: reduce) {
  #to-top { transition: none; }
  #to-top:hover { transform: none; }
}

/* --- The enchantment glint ------------------------------------------------
   Minecraft's own texture, straight from the client jar (see
   scripts/extract_textures.py), drawn the way the game draws it: TWO copies of
   the same tileable sheet scrolling at different rates and angles, added over
   the item rather than covering it.

   MASKED to the item's own pixels. Painted across the square tile it would be
   a purple box; mask-image clips it to the sprite's alpha, so it shimmers on
   the blade and nowhere else. The sprite therefore has to be a background
   rather than an <img> - an <img> can carry neither a ::after nor a mask - and
   the url arrives as --sprite so one value serves both.

   screen, not normal: the glint ADDS light in game. On a dark item it reads as
   a sheen; on a bright one it barely touches it, which is exactly right. */
.sprite-glint {
  position: absolute; inset: 14%;
  width: 72%; height: 72%;
  background-image: var(--sprite);
  /* --sprite-h is 100% for a normal icon and N x 100% for an animation strip,
     which is set by .sprite-anim rather than overridden here. Two rules fighting
     over background-size would be settled by their order in this file, and that
     is not a thing anyone should have to know to move a rule. */
  background-size: 100% var(--sprite-h, 100%);
  background-repeat: no-repeat;
  image-rendering: pixelated;
  z-index: 1;
}
.sprite-glint::after {
  content: '';
  position: absolute; inset: 0;
  background-image: var(--glint), var(--glint);
  background-size: 210% 210%, 150% 150%;
  background-position: 0 0, 100% 0;
  mix-blend-mode: screen;
  /* Tuned against the real thing rather than guessed. A ladder of .18 / .28 /
     .40 / .55 / .85 over the Flaming Flay: at .85 a deep orange whip renders
     pale pink. .28 keeps the most colour; Gulubero looked at the ladder and chose
     .55, which is a good deal more obvious and still leaves a deep orange
     reading as orange. His site, his call - and it is one number to change. */
  opacity: .55;
  /* The mask is the item itself. Both prefixes: Safari still wants -webkit-. */
  -webkit-mask-image: var(--sprite);
  mask-image: var(--sprite);
  -webkit-mask-size: 100% var(--sprite-h, 100%);
  mask-size: 100% var(--sprite-h, 100%);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  /* steps(30), not linear, and this is a PERFORMANCE decision with a visual
     cost - see the note below before changing it back. */
  animation: glint-scroll 5s steps(30) infinite;
  pointer-events: none;
}
:root { --glint: url('/textures/misc/glint.png'); }

/* Two layers, two speeds, opposite drifts - a single sheet sliding one way
   reads as a moving wallpaper rather than as a shimmer. */
@keyframes glint-scroll {
  to { background-position: 210% 210%, -150% 150%; }
}

/* WHY steps(30) AND NOT linear - measured, not guessed
   ----------------------------------------------------
   background-position animates on the PAINT path, so a linear glint repaints
   every one of these elements on every frame. A geared profile carries 112 of
   them, and that - not the blend and not the mask - is what made the page
   stutter while sitting still. Measured on the deployed page: pausing this one
   animation took idle from 65fps to 220.

   steps(30) redraws six times a second instead of sixty. Paired A/B runs,
   interleaved so machine drift hits both arms: median +30% idle and +10%
   scrolling, better in 6 of 8 pairs.

   Things that were tried FIRST and are worse - do not re-propose them:
     - animating `transform` instead: 38fps against a 65fps baseline. It
       promotes 112 blended elements to 112 compositor layers, which costs more
       than the repaint did.
     - pausing offscreen glints with an IntersectionObserver: no change at all.
       Offscreen elements were never painting; the cost is the ~30 on screen.
     - turning the blend or the mask off: no change either. Neither is the cost.

   The visual price is that the shimmer advances in steps rather than gliding.
   Gulubero picked 30 off a ladder of smooth / 60 / 30 / 15. To change it, edit the
   number here and nothing else; lower is faster and steppier. */

/* A page full of shimmering tiles is a page nobody can read. Someone who has
   asked for less motion gets the sheen, held still. */
@media (prefers-reduced-motion: reduce) {
  .sprite-glint::after { animation: none; }
}

/* --- Animated items -------------------------------------------------------
   FurfSky ships 35 items the game animates, as N frames of 16x16 stacked into
   one tall PNG with a .png.mcmeta giving the frametime. The strip is committed
   whole and walked here.

   The image is the element's BACKGROUND, N times as tall as the box, and one
   steps() animation slides it from the top frame to the bottom one. That is the
   whole mechanism - no script, no atlas, no second request.

   Why percentages work out: at background-size 100% (N x 100%) the image is N
   box-heights tall, and a background-position of P% aligns P% of the image with
   P% of the box, so P = k/(N-1) lands exactly on frame k. jump-none is what
   makes steps() emit N values INCLUDING both ends - plain steps(N) would emit
   0/N .. (N-1)/N and never show the last frame. Each frame therefore holds for
   --anim / N.

   --frames and --anim are per item and come from the pack's own mcmeta, because
   a single site-wide speed would be wrong by 150x: MUSIC_PANTS cycles in 200ms
   and PRISMARINE_BOW in 30000ms. */
.sprite-anim {
  position: absolute; inset: 14%;
  width: 72%; height: 72%;
  background-image: var(--sprite);
  --sprite-h: calc(var(--frames) * 100%);
  background-size: 100% var(--sprite-h);
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  z-index: 1;
  animation: sprite-walk var(--anim) steps(var(--frames), jump-none) infinite;
}

/* An enchanted animated item takes .sprite-glint as well, and the glint is
   masked to the item's own pixels - which for a strip means the pixels of the
   frame it is CURRENTLY showing, not of the whole strip. So the mask is put on
   this element (a mask clips the ::after with it) and moved by the SAME
   keyframes as the background. Two separate animations of equal length would
   look synchronised without any guarantee of remaining so. */
.sprite-anim.sprite-glint {
  -webkit-mask-image: var(--sprite);
  mask-image: var(--sprite);
  -webkit-mask-size: 100% var(--sprite-h);
  mask-size: 100% var(--sprite-h);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
/* ...and the ::after drops its own mask, which would otherwise clip the sheen
   to the whole strip's alpha squashed into the box. The parent's mask already
   does the job, correctly. */
.sprite-anim.sprite-glint::after {
  -webkit-mask-image: none;
  mask-image: none;
}

@keyframes sprite-walk {
  to {
    background-position: 0 100%;
    -webkit-mask-position: 0 100%;
    mask-position: 0 100%;
  }
}

/* Reduced motion holds frame 0 - the same static icon the page showed before
   any of this, so nothing is lost by asking for stillness. */
@media (prefers-reduced-motion: reduce) {
  .sprite-anim { animation: none; }
}

/* The lore card clones the tile's animated span rather than rebuilding it from
   data-sprite, exactly as it clones a cube: an <img> of a strip is the smear
   again. The card's icon box is smaller and has no 14% inset of its own. */
.card-icon .sprite-anim { inset: 0; width: 100%; height: 100%; }
