/* ==========================================================================
   blissolic.com — noir
   Palette sampled from the new pair: assets/pfp.gif (Batman on a rooftop) and
   assets/bg.jpg (a ringed planet over mountains).

   Both are essentially colourless and that is the whole design. The background
   has literally ZERO chromatic pixels — a pure greyscale ramp from #070707 to
   #F6F6F6. The avatar is 93% achromatic too, and every chromatic pixel it does
   have sits in one 10-degree bin, H190-199: the cold halo around the moon.

   So the page is built from greys, with exactly two accents, both lifted from
   the avatar: bone-cream moonlight and that cold teal. Saturation anywhere else
   would be invented, and against a greyscale photograph it would look it.
   ========================================================================== */

:root {
  /* % is that bucket's share of the sampled circle */
  --void-950: #070707; /* 47.48% of the background — the floor */
  --ink-900:  #131313; /* 74.22% of the avatar — its black */
  --ink-800:  #161616; /* 26.40% of the background */
  --ash-700:  #262626; /* 11.91% */
  --ash-600:  #363636; /* 5.52% */
  --ash-500:  #565656; /* 1.91% */
  --steel-400:#767676; /* 0.75% */
  --steel-300:#979797; /* 0.34% */

  /* the moon's cold halo — the avatar's only chroma, all of it in H190-199 */
  --teal-800: #112732; /* 1.98% — S49 L13 */
  --teal-700: #143641; /* 1.43% — S53 L17 */
  --teal-600: #1C404A; /* 0.60% */
  --teal-500: #2E4D56; /* 0.57% — the lightest, so it carries the halo */

  /* moonlight itself */
  --bone-300: #8F8475; /* 4.95% */
  --bone-200: #A08F7A; /* 3.76% */
  --bone-100: #A7977F; /* 1.35% — the brightest moon, the primary accent */
  --moon-100: #E6E6E6; /* the highlight end of the greyscale ramp */
  --white:    #F6F6F6;

  --text:       var(--white);
  --text-dim:   rgba(246, 246, 246, 0.58);
  --text-faint: rgba(246, 246, 246, 0.34);
  /* Handles (@blissolic, discord.gg/bliss, the email) sat on --text-faint and
     were the least readable thing on the page: over the tile substrate that
     lands at ~2.9:1, well under the 4.5:1 small-text minimum. 0.47 alpha is
     where it crosses 4.5; 0.72 gives ~8.6:1 with room for the photograph
     showing through the tile. --text-faint is now only used for glyphs. */
  --text-soft:  rgba(246, 246, 246, 0.72);

  /* lifted from 0.035 / 0.08 once the photograph went in behind them — tiles
     need more separation over an image than over a flat ambient */
  --tile-bg:        rgba(255, 255, 255, 0.05);
  --tile-bg-hover:  rgba(255, 255, 255, 0.085);
  --tile-border:    rgba(255, 255, 255, 0.10);

  /* per-tile brand accent, overridden by [data-brand] */
  --brand: var(--bone-300);

  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
}

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

html { -webkit-text-size-adjust: 100%; }

body {
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem, 5vw, 3rem) 1.25rem;
  background: var(--void-950);
  color: var(--text);
  font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

::selection { background: var(--bone-300); color: var(--void-950); }

:focus-visible {
  outline: 2px solid var(--moon-100);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ==========================================================================
   Background — layered dusk, drifting aurora, VHS grain + scanlines
   ========================================================================== */

.bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  background: var(--void-950);
}

/* The moon over water — and it is a two-value image, not a photograph:
   59.45% pure #000000 and 37.02% near-white #FAFAFA, with everything else
   under 1%. There is no midtone to work with at all.

   That makes it the most aggressive background of any site in this family. At
   full strength the moon is 37% of the frame at L98 and white text on it is
   simply unreadable, so brightness comes right down to bring the moon to a mid
   grey — which incidentally turns it into a halo behind the avatar, which is
   the best thing this image does. No saturate(): there is nothing to
   desaturate, and it would only tint the JPEG's own noise.

   The brightness and the blur are BAKED INTO the file rather than applied as a
   CSS filter. `filter: brightness(0.42) blur(2px)` on a full-screen layer has
   to re-rasterise whenever anything above it recomposites, and this stack
   recomposites constantly; `assets/bg-baked.jpg` is the same image with
   `gblur=sigma=1.6` (2 CSS px at the desktop cover scale) and a 0.42 multiply
   already applied. Measured mean luma 18.489 baked vs 18.484 filtered — the
   same picture, for no per-frame cost. `assets/bg.jpg` stays as the source to
   re-derive from; nothing loads it. */
.photo {
  position: absolute;
  inset: -2%;
  background: url("assets/bg-baked.jpg") center 42% / cover no-repeat;
  /* Baking the filter out cost this element the composited layer the filter was
     forcing for free, and without it the JPEG got rescaled into the .bg stack
     every time the grain shifted. Pinning the layer back measured as the single
     best change on the MangoPlayz build (0.844 -> 0.764 renderer CPU-seconds per
     second), so both sites carry it. */
  will-change: transform;
}

/* Vertical, and sized in rem rather than %. The card is 27.5rem at every
   viewport, so a %-based ellipse that covers it on desktop shrinks off it on a
   phone and the tiles end up sitting on bare photograph. The image is left
   readable at the top behind the hero, where the type is large, and the page
   darkens into the tile stack. */
.scrim {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(ellipse 30rem 26rem at 50% 76%,
      rgba(4, 4, 4, 0.82) 0%,
      rgba(4, 4, 4, 0.5) 60%,
      rgba(4, 4, 4, 0) 100%),
    linear-gradient(to bottom,
      rgba(4, 4, 4, 0.24) 0%,
      rgba(4, 4, 4, 0.36) 26%,
      rgba(4, 4, 4, 0.72) 62%,
      rgba(4, 4, 4, 0.86) 100%);
}

.aurora {
  position: absolute;
  border-radius: 50%;
  filter: blur(90px);
  will-change: transform;
}

/* The photograph is the ambient; these only keep some drift alive behind a
   still image. Both are the avatar's moon-halo teal, and both are weak — on a
   greyscale page the eye finds any colour instantly, so a little goes further
   here than it did on the colour sites. */
.aurora--1 {
  width: 58rem; height: 58rem;
  top: -24rem; left: -16rem;
  background: radial-gradient(circle, rgba(28, 64, 74, 0.32) 0%, transparent 68%);
  animation: drift-1 26s var(--ease-soft) infinite alternate;
}

/* the cold halo, kept as the one spot of colour on the whole page */
.aurora--3 {
  width: 38rem; height: 38rem;
  top: 30%; right: -16rem;
  background: radial-gradient(circle, rgba(46, 77, 86, 0.26) 0%, transparent 66%);
  animation: drift-3 38s var(--ease-soft) infinite alternate;
}

/* Translate only — NO scale. Scaling a 90px-blurred element forces the blur to
   be recomputed at the new size on every frame, which is the whole cost of the
   effect; translating a layer that is already rasterised is free. The distance
   went up a little to keep the same sense of movement without it.
   (drift-2 went with aurora--2 — see Decisions.) */
@keyframes drift-1 {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(7rem, 5rem, 0); }
}
@keyframes drift-3 {
  from { transform: translate3d(0, 0, 0); opacity: 0.75; }
  to   { transform: translate3d(-4rem, 5rem, 0); opacity: 1; }
}

/* inset was -50%, i.e. an element 2x the viewport in each direction — four
   times the pixels, every one of them going through `mix-blend-mode: overlay`.
   The shift animation only moves it by 2%, so -4% is all the overhang it ever
   needed, and the blended area drops by ~3.7x for an identical picture. */
.grain {
  position: absolute;
  inset: -4%;
  opacity: 0.045;
  mix-blend-mode: overlay;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain-shift 0.7s steps(1) infinite;
}

@keyframes grain-shift {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(-2%, 1%); }
  50%  { transform: translate(1%, -2%); }
  75%  { transform: translate(-1%, -1%); }
  100% { transform: translate(2%, 1%); }
}

/* Scanlines and vignette were two elements; they are two background layers on
   one element now. Both are static, so the merge costs nothing visually and
   takes a full-viewport layer out of a stack that recomposites together. The
   vignette is listed first because the first background layer paints on top,
   which is the order the two separate elements had. The scanline alpha is
   halved (0.045 -> 0.0225) to replace the `opacity: 0.5` that used to scale it
   — that opacity would otherwise have faded the vignette too. */
.scanlines {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(125% 95% at 50% 50%, transparent 42%, rgba(4, 4, 4, 0.6) 100%),
    repeating-linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0) 0px,
      rgba(0, 0, 0, 0) 2px,
      rgba(0, 0, 0, 0.0225) 3px,
      rgba(0, 0, 0, 0.0225) 4px
    );
}

/* ==========================================================================
   Card
   ========================================================================== */

.card {
  width: 100%;
  max-width: 27.5rem;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* staggered entrance */
[data-reveal] {
  opacity: 0;
  transform: translateY(14px);
  filter: blur(6px);
  animation: reveal 0.9s var(--ease) forwards;
  animation-delay: calc(var(--i) * 70ms + 120ms);
}

@keyframes reveal {
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}

/* ==========================================================================
   Hero
   ========================================================================== */

.hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  margin-bottom: 2.25rem;
}

.avatar {
  position: relative;
  width: 8.25rem;
  height: 8.25rem;
  display: grid;
  place-items: center;
  margin-bottom: 1.35rem;
}

/* mauve-400 rather than the plums: this one has to read as a halo against the
   ambient right behind it, so it needs the lift in lightness */
.avatar__glow {
  position: absolute;
  inset: -18%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(46, 77, 86, 0.5) 0%, transparent 68%);
  filter: blur(20px);
  animation: breathe 5.5s var(--ease-soft) infinite alternate;
}

@keyframes breathe {
  from { opacity: 0.55; transform: scale(0.94); }
  to   { opacity: 1;    transform: scale(1.06); }
}

/* thin conic ring, masked hollow — the lilac/rose glow from the avatar */
.avatar__ring {
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  padding: 2px;
  background: conic-gradient(
    from 0deg,
    transparent 0deg,
    var(--bone-300) 70deg,
    var(--bone-200) 120deg,
    var(--moon-100) 190deg,
    transparent 250deg,
    transparent 360deg
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  opacity: 0.9;
  animation: spin 9s linear infinite;
}

@keyframes spin { to { transform: rotate(1turn); } }

.avatar__img {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  border: 2px solid rgba(7, 7, 7, 0.9);
  box-shadow: 0 12px 40px -10px rgba(0, 0, 0, 0.85);
  transition: transform 0.5s var(--ease);
  user-select: none;
}

.avatar:hover .avatar__img { transform: scale(1.04); }

.name {
  font-size: clamp(2.1rem, 8vw, 2.6rem);
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 1.05;
  background: linear-gradient(175deg, var(--white) 30%, #D5C6EF 100%);
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
}

.kana {
  font-family: "Noto Sans JP", "Outfit", sans-serif;
  font-size: 0.8rem;
  font-weight: 400;
  letter-spacing: 0.42em;
  text-indent: 0.42em;
  margin-top: 0.5rem;
  color: var(--bone-300);
  opacity: 0.85;
}

.bio {
  margin-top: 0.85rem;
  font-size: 0.95rem;
  font-weight: 300;
  color: var(--text-dim);
}

/* --- live subscriber pill --- */

/* the sub pill and the view count sit as a matched pair.
   width:100% is load-bearing — on the sibling site shrink-to-fit sized this box
   one pixel under its contents and the view pill wrapped to a second line. */
.stats {
  width: 100%;
  margin-top: 1.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0.5rem;
}

/* Deliberately quieter than the sub pill beside it: no border, no dot, no
   backdrop blur. Two pills of equal weight compete, and the subscriber count is
   the one that matters — this is a footnote that happens to live up here. */
.views {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.85rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.05);
  color: var(--text-dim);
  transition: background 0.4s var(--ease), color 0.4s var(--ease);
}

.views:hover {
  background: rgba(167, 151, 127, 0.16);
  color: var(--bone-100);
}

.views__icon {
  width: 0.95rem;
  height: 0.95rem;
  flex: none;
  display: block;
}

.views__n {
  font-size: 0.85rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  transition: opacity 0.4s var(--ease);
}

.views[data-state="loading"] .views__n { opacity: 0.4; }

/* both counter services down — take the whole pill away rather than leaving an
   eye staring at an em dash */
.views[data-state="dead"] { display: none; }

.subs {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  /* symmetric now the dot is gone — the shorter left padding existed to balance
     a round shape against a text edge, and with text on both sides it just
     looked off-centre */
  padding: 0.5rem 1.05rem;
  border-radius: 999px;
  text-decoration: none;
  color: var(--text);
  background: rgba(160, 143, 122, 0.1);
  border: 1px solid rgba(160, 143, 122, 0.26);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  transition: background 0.4s var(--ease), border-color 0.4s var(--ease),
              transform 0.4s var(--ease), box-shadow 0.4s var(--ease);
}

.subs:hover {
  background: rgba(160, 143, 122, 0.18);
  border-color: rgba(167, 151, 127, 0.5);
  transform: translateY(-2px);
  box-shadow: 0 10px 30px -12px rgba(160, 143, 122, 0.7);
}

/* The pulsing "live" dot that sat left of the count is gone at the user's
   request (2026-07-29). Its @keyframes went with it — nothing else used them. */

.subs__count {
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--bone-200);
  transition: opacity 0.3s var(--ease);
  /* the count-up passes through wider strings than it lands on
     ("1.23K" -> "25.7K"); reserve the widest so the pill can't resize */
  min-width: 5ch;
  text-align: center;
}

.subs__count[data-state="loading"] { opacity: 0.4; }

/* brief flash when the live count ticks */
.subs__count.is-bump { animation: bump 0.65s var(--ease); }

@keyframes bump {
  0%   { transform: translateY(0); text-shadow: none; }
  35%  { transform: translateY(-3px); text-shadow: 0 0 14px rgba(167, 151, 127, 0.85); }
  100% { transform: translateY(0); text-shadow: none; }
}

.subs__label {
  font-size: 0.85rem;
  font-weight: 300;
  color: var(--text-dim);
}

/* ==========================================================================
   Link tiles
   ========================================================================== */

.links {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.tile[data-brand="youtube"] { --brand: #FF0033; }
.tile[data-brand="discord"] { --brand: #5865F2; }
/* TikTok's cyan rather than its red: the red collides with YouTube's two tiles
   up, and with the rose already in the palette */
.tile[data-brand="tiktok"]  { --brand: #25F4EE; }
.tile[data-brand="x"]       { --brand: #FFFFFF; }
.tile[data-brand="mail"]    { --brand: var(--bone-200); }

.tile {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 0.95rem;
  padding: 0.95rem 1.1rem;
  border-radius: 1rem;
  text-align: left;
  text-decoration: none;
  color: var(--text);
  font-family: inherit;
  cursor: pointer;
  background: var(--tile-bg);
  border: 1px solid var(--tile-border);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  transition:
    transform 0.45s var(--ease),
    background 0.45s var(--ease),
    border-color 0.45s var(--ease),
    box-shadow 0.45s var(--ease);
}

.tile:hover {
  transform: translateY(-3px);
  background: var(--tile-bg-hover);
  border-color: color-mix(in srgb, var(--brand) 45%, transparent);
  box-shadow:
    0 14px 34px -14px color-mix(in srgb, var(--brand) 60%, transparent),
    0 0 0 1px color-mix(in srgb, var(--brand) 14%, transparent) inset;
}

.tile:active { transform: translateY(-1px) scale(0.99); }

/* diagonal sheen sweeping across on hover */
.tile__sheen {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    105deg,
    transparent 30%,
    color-mix(in srgb, var(--brand) 16%, transparent) 48%,
    transparent 66%
  );
  transform: translateX(-120%);
  transition: opacity 0.3s var(--ease);
}

.tile:hover .tile__sheen {
  opacity: 1;
  animation: sweep 0.95s var(--ease) forwards;
}

@keyframes sweep {
  from { transform: translateX(-120%); }
  to   { transform: translateX(120%); }
}

.tile__icon {
  flex: none;
  width: 2.4rem;
  height: 2.4rem;
  display: grid;
  place-items: center;
  border-radius: 0.7rem;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.06);
  transition: background 0.45s var(--ease), border-color 0.45s var(--ease),
              transform 0.45s var(--ease);
}

.tile__icon svg {
  width: 1.15rem;
  height: 1.15rem;
  fill: var(--text-dim);
  transition: fill 0.45s var(--ease), stroke 0.45s var(--ease),
              filter 0.45s var(--ease);
}

/* the envelope is a stroked glyph, not a filled brand mark */
.tile[data-brand="mail"] .tile__icon svg { fill: none; stroke: var(--text-dim); }

.tile:hover .tile__icon {
  background: color-mix(in srgb, var(--brand) 16%, transparent);
  border-color: color-mix(in srgb, var(--brand) 34%, transparent);
  transform: scale(1.06);
}

.tile:hover .tile__icon svg {
  fill: var(--brand);
  filter: drop-shadow(0 0 8px color-mix(in srgb, var(--brand) 55%, transparent));
}

.tile[data-brand="mail"]:hover .tile__icon svg { fill: none; stroke: var(--brand); }

.tile__text {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
  flex: 1;
}

.tile__title {
  font-size: 1.02rem;
  font-weight: 600;
  letter-spacing: -0.008em;
}

/* The handle line. Three things were fighting it at once — 0.34 alpha, weight
   300, and 12.5px — so all three moved: weight 300 is spindly at this size in
   Outfit, and a hair of positive tracking is what small type wants (the title
   above still tracks negative, which is right at 16px). */
.tile__sub {
  font-size: 0.83rem;
  font-weight: 400;
  letter-spacing: 0.012em;
  color: var(--text-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 0.45s var(--ease);
}

/* hover goes to full white — --text-dim would now be a step DOWN from the base */
.tile:hover .tile__sub { color: var(--text); }

/* live follower/member count, right-aligned before the arrow */
.tile__count {
  flex: none;
  margin-right: 0.15rem;
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
  transition: color 0.45s var(--ease), opacity 0.4s var(--ease);
}

.tile:hover .tile__count { color: var(--bone-100); }

/* placeholder before the first fetch lands — dim, so it never looks broken */
.tile__count[data-state="loading"] { opacity: 0.35; }

/* a source that dies just leaves the tile clean rather than showing a lie */
.tile__count[data-state="dead"] { display: none; }

.tile__count.is-bump { animation: count-bump 0.6s var(--ease); }

@keyframes count-bump {
  0%   { transform: translateY(0); text-shadow: none; }
  35%  { transform: translateY(-2px); text-shadow: 0 0 12px rgba(167, 151, 127, 0.8); }
  100% { transform: translateY(0); text-shadow: none; }
}

.tile__arrow {
  flex: none;
  display: grid;
  place-items: center;
  /* lifted off --text-faint with the text: at 0.34 it read as a smudge next to
     a brighter handle line, and it's an affordance, not decoration (4.3:1) */
  color: rgba(246, 246, 246, 0.46);
  transition: color 0.45s var(--ease), transform 0.45s var(--ease);
}

.tile__arrow svg { width: 1rem; height: 1rem; display: block; }

.tile:hover .tile__arrow {
  color: var(--brand);
  transform: translateX(3px);
}

/* copy button swaps its glyph in place */
.tile__arrow--copy { position: relative; }
.tile__arrow--copy svg { transition: opacity 0.28s var(--ease), transform 0.28s var(--ease); }
.tile__arrow--copy .ico-check {
  position: absolute;
  inset: 0;
  margin: auto;
  opacity: 0;
  transform: scale(0.5);
  color: var(--bone-200);
}

.tile[data-brand="mail"]:hover .tile__arrow { transform: none; }

.tile.is-copied .ico-copy  { opacity: 0; transform: scale(0.5); }
.tile.is-copied .ico-check { opacity: 1; transform: scale(1); }
.tile.is-copied .tile__arrow { color: var(--bone-200); }

/* ==========================================================================
   Cursor trail — a lagging, speed-stretched glow behind the real pointer.
   The native cursor stays visible on purpose; see cursor.js.
   ========================================================================== */

/* Two things were making this the most expensive 26px on the page, and both are
   gone:

   `mix-blend-mode: screen` forced everything under the glow to recomposite on
   every single pointermove — and it bought nothing here. Over black, screen and
   normal compositing give the identical result (screen against 0 is the source
   colour, then alpha applies either way); the two only differ over lit pixels,
   which on this page is the moon and little else.

   `filter: blur(6px)` had to re-rasterise every frame, because the glow is
   scaled per frame by the speed stretch and a scaled filter cannot be reused.
   A radial gradient with soft stops IS a blur, so the spread moved into the
   gradient and the box grew to cover what the blur used to add. */
.cursor-glow {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  pointer-events: none;   /* must never eat a click */
  opacity: 0;
  will-change: transform, opacity;
  background: radial-gradient(
    circle,
    rgba(167, 151, 127, 0.5) 0%,
    rgba(163, 146, 124, 0.3) 26%,
    rgba(160, 143, 122, 0.13) 48%,
    rgba(230, 230, 230, 0) 72%
  );
}

/* touch devices and reduced-motion never get the element at all, but belt and
   braces in case the script runs before the query settles */
@media (pointer: coarse), (prefers-reduced-motion: reduce) {
  .cursor-glow { display: none; }
}

/* ==========================================================================
   Now playing — drives a hidden YouTube IFrame player
   ========================================================================== */

/* the real player: offscreen at full size rather than display:none or 0x0,
   which some browsers treat as a reason not to start playback */
.yt-host {
  position: fixed;
  left: -10000px;
  top: 0;
  width: 320px;
  height: 180px;
  opacity: 0;
  pointer-events: none;
}

.player {
  width: 100%;
  margin-top: 0.9rem;
  padding: 0.85rem 1rem 0.75rem;
  border-radius: 1rem;
  background: var(--tile-bg);
  border: 1px solid var(--tile-border);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  transition: border-color 0.45s var(--ease), background 0.45s var(--ease);
}

.player:hover { border-color: rgba(167, 151, 127, 0.28); }

/* set by player.js when the API is blocked or the video isn't embeddable */
.player[hidden] { display: none; }

.player__top {
  display: flex;
  align-items: center;
  gap: 0.8rem;
}

.player__art {
  flex: none;
  width: 3.25rem;
  height: 3.25rem;
  border-radius: 0.7rem;
  overflow: hidden;
  position: relative;
  border: 1px solid rgba(255, 255, 255, 0.09);
  box-shadow: 0 6px 18px -8px rgba(0, 0, 0, 0.9);
}

.player__art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  user-select: none;
  transition: transform 0.6s var(--ease), filter 0.6s var(--ease);
  filter: saturate(0.9) brightness(0.92);
}

/* the art only comes fully alive while it's actually playing */
.player[data-playing="true"] .player__art img {
  transform: scale(1.06);
  filter: saturate(1) brightness(1);
}

.player__meta {
  display: flex;
  flex-direction: column;
  gap: 0.14rem;
  min-width: 0;
  flex: 1;
}

.player__label {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.6rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.player__title {
  font-size: 0.92rem;
  font-weight: 500;
  letter-spacing: -0.012em;
  color: var(--text);
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 0.4s var(--ease);
}

.player__title:hover { color: var(--bone-100); }

/* three bars that only move while the track is running */
.player__eq {
  display: inline-flex;
  align-items: flex-end;
  gap: 2px;
  height: 0.55rem;
}

.player__eq i {
  width: 2px;
  height: 30%;
  border-radius: 1px;
  background: var(--bone-100);
  animation: eq 0.9s var(--ease-soft) infinite alternate;
  opacity: 0.35;
}

.player__eq i:nth-child(2) { animation-delay: 0.28s; }
.player__eq i:nth-child(3) { animation-delay: 0.55s; }

.player[data-playing="true"] .player__eq i { opacity: 1; }
.player[data-playing="false"] .player__eq i { animation-play-state: paused; }


@keyframes eq {
  from { height: 25%; }
  to   { height: 100%; }
}

.player__btn {
  flex: none;
  width: 2.3rem;
  height: 2.3rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  cursor: pointer;
  color: var(--bone-100);
  background: rgba(167, 151, 127, 0.13);
  border: 1px solid rgba(167, 151, 127, 0.3);
  transition: background 0.4s var(--ease), border-color 0.4s var(--ease),
              transform 0.4s var(--ease), box-shadow 0.4s var(--ease);
}

.player__btn:hover {
  background: rgba(167, 151, 127, 0.22);
  border-color: rgba(230, 230, 230, 0.55);
  transform: scale(1.06);
  box-shadow: 0 8px 22px -10px rgba(167, 151, 127, 0.8);
}

.player__btn:active { transform: scale(0.97); }

.player__btn svg { width: 0.95rem; height: 0.95rem; fill: currentColor; display: block; }

.player[data-playing="false"] .ico-pause,
.player[data-playing="true"]  .ico-play { display: none; }

/* --- seek --- */

/* padded wrapper so the 4px bar has a comfortable grab area */
.player__seekwrap {
  margin-top: 0.7rem;
  padding: 0.45rem 0;
  cursor: pointer;
  touch-action: none;
}

.player__seekwrap:focus-visible { outline-offset: 6px; }

.player__track {
  position: relative;
  height: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.13);
  overflow: visible;
}

.player__fill {
  width: 0%;
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--teal-500), var(--bone-100));
  box-shadow: 0 0 12px -2px rgba(167, 151, 127, 0.65);
}

.player__knob {
  position: absolute;
  top: 50%;
  left: 0;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--bone-100);
  box-shadow: 0 0 10px rgba(167, 151, 127, 0.75);
  transform: translate(-50%, -50%);
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}

.player:hover .player__knob,
.player__seekwrap:focus-visible .player__knob { opacity: 1; }

/* --- foot: time + volume --- */

.player__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.8rem;
}

.player__time {
  font-size: 0.7rem;
  font-weight: 300;
  font-variant-numeric: tabular-nums;
  color: var(--text-faint);
}

.player__sep { margin: 0 0.3rem; opacity: 0.55; }

.player__vol {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.player__mute {
  display: grid;
  place-items: center;
  padding: 0.15rem;
  background: none;
  border: 0;
  cursor: pointer;
  color: var(--text-faint);
  transition: color 0.4s var(--ease);
}

.player__mute:hover { color: var(--bone-100); }
.player__mute svg { width: 0.95rem; height: 0.95rem; display: block; }

.player[data-muted="false"] .ico-muted,
.player[data-muted="true"]  .ico-vol { display: none; }
.player[data-muted="true"] .player__mute { color: var(--mauve-400); }

.player__slider {
  -webkit-appearance: none;
  appearance: none;
  width: 5.5rem;
  height: 3px;
  border-radius: 999px;
  cursor: pointer;
  /* --vol is kept in sync by player.js so the filled part tracks the thumb */
  background: linear-gradient(
    to right,
    var(--bone-100) calc(var(--vol, 28) * 1%),
    rgba(255, 255, 255, 0.14) calc(var(--vol, 28) * 1%)
  );
}

.player__slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--bone-100);
  box-shadow: 0 0 0 3px rgba(167, 151, 127, 0.16);
  transition: box-shadow 0.3s var(--ease);
}

.player__slider:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgba(167, 151, 127, 0.22); }

.player__slider::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 0;
  border-radius: 50%;
  background: var(--bone-100);
  box-shadow: 0 0 0 3px rgba(167, 151, 127, 0.16);
}

/* On anything wide enough, the player leaves the column and parks in the
   top-left corner of the viewport. Two reasons: it stops pushing the tiles down
   past the fold, and a persistent transport control belongs at a screen edge
   rather than buried under the links. Below 64rem it drops back into the flow,
   because a fixed panel would cover the hero on a phone.

   This block has to sit AFTER the base `.player` rule, not before it: same
   specificity means source order decides, and in front of it the base
   `width: 100%` won and the panel spanned the whole viewport. */
@media (min-width: 64rem) {
  .player {
    position: fixed;
    top: 1.15rem;
    left: 1.15rem;
    z-index: 20;
    width: 19.5rem;
    margin-top: 0;
  }
}

/* ==========================================================================
   Intro gate — the one click browsers insist on, turned into the entrance.

   Every visitor gets it, because that click is the only thing that guarantees
   audible playback: after a real gesture no browser can refuse. It also holds
   the staggered reveal below, so the page plays its entrance AS the overlay
   dissolves rather than having quietly finished behind it.
   ========================================================================== */

/* the reveal is paused, not delayed — pausing stops the delay clock too, so
   removing .gated replays the whole stagger from the top */
html.gated [data-reveal] { animation-play-state: paused; }
html.gated body { overflow: hidden; }

.gate {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  cursor: pointer;
  background:
    radial-gradient(70% 55% at 50% 45%, rgba(28, 64, 74, 0.22) 0%, transparent 70%),
    rgba(4, 4, 4, 0.82);
  backdrop-filter: blur(14px) saturate(0.9);
  -webkit-backdrop-filter: blur(14px) saturate(0.9);
  animation: gate-in 0.55s var(--ease) both;
}

.gate[hidden] { display: none; }

/* Set just before removal, so it dissolves instead of vanishing.

   It deliberately KEEPS pointer-events. It used to set `pointer-events: none`
   here, and on a phone that made tapping "click anywhere to enter" open a
   random link: a tap fires touchstart, the gate dismisses, and then the browser
   synthesises the mouse/click from that same tap — by which point the gate no
   longer hit-tested, so the click fell through to whichever tile was under the
   finger. Staying hit-testable for the 0.6s fade means that phantom click lands
   on the gate, which does nothing. The gate is `hidden` immediately after. */
.gate.is-leaving {
  animation: gate-out 0.6s var(--ease) forwards;
}

@keyframes gate-in {
  from { opacity: 0; backdrop-filter: blur(0); }
  to   { opacity: 1; }
}

@keyframes gate-out {
  to { opacity: 0; transform: scale(1.02); }
}

.gate__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 0 1.5rem;
  animation: gate-rise 0.7s var(--ease) 0.08s both;
}

@keyframes gate-rise {
  from { opacity: 0; transform: translateY(12px); filter: blur(5px); }
  to   { opacity: 1; transform: none; filter: blur(0); }
}

.gate__art {
  position: relative;
  width: 5.4rem;
  height: 5.4rem;
  display: grid;
  place-items: center;
}

.gate__art img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  border: 2px solid rgba(7, 7, 7, 0.9);
  box-shadow: 0 14px 40px -12px rgba(0, 0, 0, 0.9);
  user-select: none;
}

/* one slow pulse ring — reads as "waiting for you" without being a button */
.gate__ring {
  position: absolute;
  inset: -6%;
  border-radius: 50%;
  border: 1px solid rgba(167, 151, 127, 0.5);
  animation: gate-pulse 2.6s var(--ease-soft) infinite;
}

@keyframes gate-pulse {
  0%   { transform: scale(0.96); opacity: 0.9; }
  70%  { transform: scale(1.22); opacity: 0; }
  100% { transform: scale(1.22); opacity: 0; }
}

.gate__eq {
  display: inline-flex;
  align-items: flex-end;
  gap: 3px;
  height: 0.85rem;
  margin-top: 1.15rem;
}

.gate__eq i {
  width: 2px;
  height: 30%;
  border-radius: 1px;
  background: var(--bone-100);
  animation: eq 0.85s var(--ease-soft) infinite alternate;
}

.gate__eq i:nth-child(2) { animation-delay: 0.16s; }
.gate__eq i:nth-child(3) { animation-delay: 0.33s; }
.gate__eq i:nth-child(4) { animation-delay: 0.5s; }
.gate__eq i:nth-child(5) { animation-delay: 0.67s; }

.gate__title {
  margin-top: 0.8rem;
  font-size: 1.35rem;
  font-weight: 600;
  letter-spacing: -0.03em;
  background: linear-gradient(175deg, var(--white) 30%, var(--bone-100) 100%);
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
}

.gate__hint {
  margin-top: 0.4rem;
  font-size: 0.78rem;
  font-weight: 300;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-faint);
  animation: gate-breathe 2.8s var(--ease-soft) infinite alternate;
}

@keyframes gate-breathe {
  from { opacity: 0.45; }
  to   { opacity: 0.95; }
}

/* ==========================================================================
   Bats — the swarm that crosses as the gate clears, and the few that keep
   drifting down afterwards. See bats.js.

   The swarm sits above the gate (z 70) so it stays crisp while the overlay is
   still fading. The drift sits at z 1, BELOW the card's z 2, so nothing ever
   crosses the text and the tiles' backdrop-filter blurs a bat as it passes
   behind the glass.
   ========================================================================== */

/* No `will-change` here on purpose: a running transform animation already gets
   its own compositor layer, so declaring it only pinned 20+ extra layers. */
.bat,
.bat-fall {
  position: fixed;
  pointer-events: none;
}

.bat {
  top: var(--top);
  left: -16vw;
  z-index: 70;
  animation: bat-cross var(--dur) linear var(--delay) both;
}

.bat-fall {
  top: -14vh;
  left: var(--left);
  z-index: 1;
  animation: bat-fall var(--dur) linear var(--delay) both;
}

/* the bob is a separate element so it composes with the travel instead of
   fighting it for the transform channel */
.bat i,
.bat-fall i {
  display: block;
  animation: bat-bob var(--bobDur) ease-in-out infinite alternate;
}

/* The bat itself: one cell of assets/bats.webp, 6 flap phases across and 3
   variants down. The rim light and the shadow are already in the pixels — see
   the header of bats.js for why they are not CSS filters any more.

   The cell is 1.5x the bat it holds (the glow needs somewhere to live), so the
   box is scaled up to match and pulled back by the difference to keep the bat
   itself the size --size asks for.

   Row 2 is the drift, and it is black on purpose — real bats over a night sky.
   That brings the opposite problem: over the 59% of the frame that is pure
   black, a black bat is invisible. So it carries a moonlight RIM rather than a
   shadow: the silhouette stays dark and only its edge catches the light, which
   is how a bat actually looks against a moonlit sky. Two rims, a tight bright
   one to draw the shape and a wide faint one so it registers over deep black —
   both now baked into the atlas rather than computed every frame. */
.bat b,
.bat-fall b {
  display: block;
  width: calc(var(--size) * 1.5);
  height: calc(var(--size) * 0.75);
  margin: calc(var(--size) * -0.125) 0 0 calc(var(--size) * -0.25);
  background-image: url("assets/bats.webp");
  background-repeat: no-repeat;
  background-size: 600% 300%;
  background-position-y: var(--row);
  animation: bat-flap var(--flap) steps(6, jump-none) infinite alternate;
}

/* jump-none so the 0%-100% sweep lands on all six frames inclusive; `alternate`
   plays it back down again, which is the ping-pong the old scale flap had */
@keyframes bat-flap {
  from { background-position-x: 0%; }
  to   { background-position-x: 100%; }
}

@keyframes bat-bob {
  from { transform: translateY(calc(var(--bob) * -1)) rotate(-7deg); }
  to   { transform: translateY(var(--bob)) rotate(7deg); }
}

/* linear on purpose — an eased sweep reads as a swipe, and a bat crossing a
   courtyard does not slow down in the middle of it */
@keyframes bat-cross {
  from { transform: translate3d(0, 0, 0); opacity: 0; }
  9%   { opacity: var(--op); }
  84%  { opacity: var(--op); }
  to   { transform: translate3d(132vw, var(--drift), 0); opacity: 0; }
}

/* --y0 lets one begin part-way down the screen while still playing from 0%, so
   it fades in wherever it starts. A negative animation-delay would skip the
   fade entirely and the whole first batch would pop in at once. */
@keyframes bat-fall {
  from { transform: translate3d(0, var(--y0, 0vh), 0); opacity: 0; }
  14%  { opacity: var(--op); }
  86%  { opacity: var(--op); }
  to   { transform: translate3d(var(--drift), 126vh, 0); opacity: 0; }
}

/* the card has to own a stacking position for the drift to sit behind it */
.card {
  position: relative;
  z-index: 2;
}

@media (prefers-reduced-motion: reduce) {
  .bat,
  .bat-fall { display: none; }
}

/* ==========================================================================
   Footer + toast
   ========================================================================== */

.foot {
  margin-top: 2rem;
  font-size: 0.72rem;
  font-weight: 300;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.toast {
  position: fixed;
  left: 50%;
  bottom: 1.75rem;
  z-index: 10;
  padding: 0.6rem 1.1rem;
  border-radius: 999px;
  font-size: 0.84rem;
  font-weight: 400;
  color: var(--white);
  background: rgba(19, 19, 19, 0.9);
  border: 1px solid rgba(167, 151, 127, 0.35);
  box-shadow: 0 12px 34px -12px rgba(0, 0, 0, 0.9);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  opacity: 0;
  transform: translate(-50%, 1rem);
  pointer-events: none;
  transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
}

.toast.is-visible {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* ==========================================================================
   Small screens
   ========================================================================== */

@media (max-width: 26rem) {
  .avatar { width: 7.25rem; height: 7.25rem; }
  .tile { padding: 0.85rem 0.95rem; gap: 0.8rem; }
  /* still shrinks to fit the email on a narrow phone, but 0.74rem was 11.8px —
     under the ~12px floor where small type stops being comfortable */
  .tile__title { font-size: 0.98rem; }
  .tile__sub { font-size: 0.8rem; }
}

/* ==========================================================================
   Adaptive quality — see quality.js, which sets data-q on <html> from measured
   frame times. The page keeps its shape at every tier; what it sheds is the
   work that costs the most per frame and shows the least.
   ========================================================================== */

/* mid: the full-screen blends go, and the glass gets thinner. Everything still
   moves, so it still reads as the same page. */
[data-q="mid"] .grain { display: none; }

[data-q="mid"] .tile,
[data-q="mid"] .player,
[data-q="mid"] .subs {
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

/* low: nothing that costs per-frame work survives. The auroras hold their
   position instead of drifting, the backdrop blur goes (it re-blurs whenever
   anything moves behind it), and the cursor trail is gone — cursor.js parks its
   loop when it sees this tier, so the element is not merely hidden. */
/* the scanlines stay: they are static, and once the grain is gone and the
   auroras are still, the whole .bg stack composites once and is reused. Hiding
   them would also take the vignette with them, since the two are one element */
[data-q="low"] .grain,
[data-q="low"] .cursor-glow { display: none; }

[data-q="low"] .aurora { animation: none; }

[data-q="low"] .tile,
[data-q="low"] .player,
[data-q="low"] .subs,
[data-q="low"] .toast,
[data-q="low"] .gate {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* the glass carried some of the tile's separation from the photograph, so give
   it back as opacity rather than leaving the tiles floating */
[data-q="low"] {
  --tile-bg: rgba(255, 255, 255, 0.075);
  --tile-bg-hover: rgba(255, 255, 255, 0.12);
}

/* ==========================================================================
   Reduced motion — keep the design, drop the movement
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  [data-reveal] { opacity: 1; transform: none; filter: none; }
  .grain { animation: none; }
  .avatar__ring { animation: none; }
}
