/* ═══════════════════════════════════════════════════════════════════════════
   The Velcro stage.

   The illusion rests on four things, in order of how much they matter:

   1. The shadow is a separate element, and it does NOT move in lockstep with
      the badge. On release it collapses faster than the badge rotates (140ms vs
      380ms) — that mismatch is what the eye reads as *contact*. Animating a
      box-shadow instead would be both slower and less convincing.
   2. The hinge differs per badge. A field where every badge peels from the same
      edge at the same rate reads as a CSS demo instantly.
   3. A fuzz strip along the hinge stretches and fades as the badge lifts. It is
      a gradient on a 6px element and it does more work than anything else here.
   4. The bag itself twitches 2px on re-attach. Almost subliminal; it is the
      difference between "element animated" and "thing touched thing".

   Only transform / opacity / filter are animated. will-change is added on
   pointerdown and removed on settle, never parked on.
   ═══════════════════════════════════════════════════════════════════════════ */

.stage {
  --bag-w: 100%;
  --peel: 62deg;          /* how far a badge lifts */
  --lift: 26px;           /* how far it comes toward the viewer */
  /* Springy return for the drag-home, distinct from the peel's own easing. */
  --ease-spring: linear(0, .38 11%, .72 22%, .94 32%, 1.045 43%, 1.02 55%, .992 68%, 1.003 84%, 1);
  position: relative;
  width: 100%;
  max-width: 560px;
  margin-inline: auto;
  aspect-ratio: 1;
  perspective: 900px;
  perspective-origin: 50% 42%;
  /* NOT touch-action:none here. The stage is most of the screen on a phone, and
     owning every touch in it means the page cannot be scrolled past the hero —
     a finger anywhere on the bag just does nothing. Only the badges take the
     gesture; the fabric around them scrolls like any other picture. */
  user-select: none;
}

.stage__bag {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  transition: opacity 320ms var(--ease-out);
}
/* The colour swap crossfades: two stacked images, the incoming one fades up. */
.stage__bag--out { opacity: 0; }

/* Bag reacts to a badge landing on it. Tiny on purpose. */
.stage--thud .stage__bag { animation: bag-thud 200ms cubic-bezier(.3, .8, .35, 1); }
@keyframes bag-thud {
  0%   { transform: translateY(0) }
  35%  { transform: translateY(2px) }
  100% { transform: translateY(0) }
}

/* ── the loop-fabric field, revealed where a badge has been lifted ───────── */

.stage__hooks {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.hook-patch {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: var(--size);
  height: var(--size);
  translate: -50% -50%;
  rotate: var(--rot);
  opacity: 0;
  transition: opacity 220ms var(--ease-out);
  border-radius: 50%;
  /* Real loop tape, photographed: assets/img/tex/velcro-loop.webp, generated
     from velcro.jpg by tools/prep_assets.py.

     This was drawn in CSS twice before and neither worked. A
     repeating-conic-gradient of spokes converged on the centre and read as a
     brushed-metal sunburst. feTurbulence had no direction, which was right, but
     at the size a badge renders it is just grain — the thing that makes velcro
     legible is the loops, and noise has no loops.

     Dark rather than tinted to the bag. The texture is what makes it readable
     as velcro, and tinting it to the shell colour washes the loops out to the
     point where it is grain again. Change `background-color` here if it should
     match the bag instead. */
  background-color: #26241f;
  box-shadow:
    inset 0 0 0 1.5px rgba(0, 0, 0, .45),
    inset 0 2px 7px rgba(0, 0, 0, .55);
}
.hook-patch::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  opacity: .92;
  /* Percentage, not pixels: the stage is fluid, and a fixed tile size would
     make the pile coarse on a phone and fine on a desktop. ~2 repeats across
     the patch at any width. */
  background: url('../img/tex/velcro-loop.webp') 0 0 / 52% 52% repeat;
}
.hook-patch--shown { opacity: .92; }
.hook-patch--flash { animation: hook-flash 160ms var(--ease-out); }
@keyframes hook-flash {
  0%   { opacity: .92 }
  60%  { opacity: .45 }
  100% { opacity: 0 }
}

/* ── a badge ─────────────────────────────────────────────────────────────── */

.badge {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: var(--size);
  height: var(--size);
  translate: -50% -50%;
  padding: 0;
  border: 0;
  background: none;
  cursor: grab;
  touch-action: none;     /* the badge owns its drag; the stage around it does not */
  transform-style: preserve-3d;
  /* --dx/--dy are written by the drag loop; the transition covers the
     spring-home when the pointer is released away from a slot. */
  transform: translate3d(var(--dx, 0px), var(--dy, 0px), 0) rotate(var(--tilt, 0deg));
  transition: transform 520ms var(--ease-spring);
  z-index: 1;
}
.badge:active { cursor: grabbing; }
.badge--dragging {
  transition: none;
  z-index: 30;
  will-change: transform;
}
.badge--lifted { z-index: 20; }
.badge--gone { opacity: 0; pointer-events: none; }
/* One frame with the transition off, so a swapped badge can be snapped back to
   where it came from before it is allowed to slide to its new slot. */
.badge--nofx { transition: none; }
/* The badge about to be displaced backs off a little — the drop reads as "these
   two trade places", not as "this one is landing on top of that one". */
.badge--swap .badge__peel { transform: scale(.86) rotate(-4deg); filter: brightness(.94); }
.badge--swap .badge__shadow { opacity: .34; }

.badge__peel {
  position: absolute;
  inset: 0;
  transform-origin: var(--hinge, bottom center);
  transform: rotate3d(var(--ax, 1), var(--ay, 0), 0, 0deg) translateZ(0);
  transition:
    transform 520ms cubic-bezier(.16, .84, .28, 1),
    filter 520ms var(--ease-out);
  will-change: auto;
}

.badge--lifted .badge__peel,
.badge--dragging .badge__peel {
  transform:
    rotate3d(var(--ax, 1), var(--ay, 0), 0, calc(var(--peel) * var(--pd, 1)))
    translateZ(var(--lift))
    scale(1.045);
  filter: brightness(1.06);
}

/* Re-attach: overshoot past flat, then settle. A damped spring approximated
   with linear() — cheaper and smoother than chaining keyframes. */
.badge--landing .badge__peel {
  transition: transform 380ms linear(
    0, .42 12%, .78 24%, .98 34%, 1.06 44%, 1.03 56%, .985 68%, 1.004 82%, 1);
}

.badge__face {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: saturate(1.06) contrast(1.04)
          drop-shadow(0 1px 1px rgba(20, 20, 20, .35));
  backface-visibility: hidden;
  pointer-events: none;
}

/* Woven thread texture at scale — keeps the badge from looking like flat clipart
   once it is enlarged and lit by the lift. */
.badge__face-wrap { position: absolute; inset: 0; }
.badge__face-wrap::after {
  content: '';
  position: absolute;
  inset: 0;
  opacity: .12;
  mix-blend-mode: overlay;
  pointer-events: none;
  border-radius: 50%;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='64' height='64' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Fibres letting go along the hinge.
   Deliberately NOT an attempt to draw individual hooks: at the size a badge
   actually renders (~70px) a hook is sub-pixel, and trying produced a moiré
   that read as scrambled text. A fine vertical grain that fades at the tips,
   stretched as the badge lifts, is what the eye accepts as velcro. */
.badge__fuzz {
  position: absolute;
  left: 14%;
  right: 14%;
  height: 4px;
  bottom: 0;
  transform-origin: center top;
  opacity: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(90deg,
      rgba(255, 255, 255, .22) 0 .5px, rgba(255, 255, 255, 0) .5px 2.5px),
    linear-gradient(to bottom, rgba(255, 255, 255, .30), rgba(255, 255, 255, 0));
  /* Taper both ways: a hard-ended bar reads as a drawn object sitting under the
     badge instead of as fibres still caught between badge and fabric. */
  -webkit-mask-image:
    linear-gradient(to bottom, #000 10%, transparent 100%),
    linear-gradient(to right, transparent, #000 22%, #000 78%, transparent);
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(to bottom, #000 10%, transparent 100%),
    linear-gradient(to right, transparent, #000 22%, #000 78%, transparent);
  mask-composite: intersect;
  filter: blur(.7px);
  transition: transform 520ms var(--ease-out), opacity 520ms var(--ease-out);
}
.badge--lifted .badge__fuzz,
.badge--dragging .badge__fuzz {
  opacity: 0;
  transform: scaleY(2.6);
}
/* Visible only during the transition itself: full at the start of the lift,
   gone by the time the badge is clear of the fabric. */
.badge--peeling .badge__fuzz { opacity: .55; transform: scaleY(1); }

/* At rest it hides *behind* the badge: a patch pressed flat to fabric casts no
   visible shadow. It only appears as the badge lifts away. That is also why it
   is a separate element — see the note at the top of this file.

   The blur lives on the wrapper and the round mask on the child, in that order.
   This used to be a clip-path and both could not go on one element, because
   clip-path applies to the *filtered* result and sliced the blur back to a hard
   edge, so the shadow read as a flat grey plate. border-radius does not have
   that problem, but the split is kept: it costs nothing and it is what makes
   the blur spread beyond the disc instead of being cut off at it. */
.badge__shadow {
  position: absolute;
  inset: 0;
  z-index: -1;
  filter: blur(1.5px);
  opacity: .5;
  transform: translate(0, 1px) scale(.9);
  transition:
    filter 520ms var(--ease-out),
    opacity 520ms var(--ease-out),
    transform 520ms var(--ease-out);
}
.badge__shadow::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: rgba(16, 16, 18, .62);
}
.badge--lifted .badge__shadow,
.badge--dragging .badge__shadow {
  filter: blur(13px);
  opacity: .26;
  transform: translate(var(--sx, 6px), var(--sy, 14px)) scale(1.02);
}
/* Contact: the shadow slams back well before the badge finishes rotating. */
.badge--landing .badge__shadow {
  transition:
    filter 140ms var(--ease-out),
    opacity 140ms var(--ease-out),
    transform 140ms var(--ease-out);
}

/* ── empty slot targets ──────────────────────────────────────────────────── */

.slot {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: var(--size);
  height: var(--size);
  translate: -50% -50%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 200ms var(--ease-out), scale 200ms var(--ease-out);
  border-radius: 50%;
  background: rgba(255, 255, 255, .14);
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
}
.stage--dragging .slot--open { opacity: .55; }
.stage--dragging .slot--near { opacity: 1; scale: 1.06; }

/* ── controls ────────────────────────────────────────────────────────────── */

.stage-controls {
  display: flex; flex-wrap: wrap; gap: var(--sp-3);
  align-items: center; justify-content: center;
  margin-top: var(--sp-5);
}

.theme-pills { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.theme-pill {
  padding: .45em 1em; border-radius: var(--r-pill);
  border: 1.5px solid var(--line); background: var(--paper);
  font-size: var(--t-xs); font-weight: 600; cursor: pointer;
  transition: background-color var(--dur) var(--ease-out),
              border-color var(--dur) var(--ease-out),
              color var(--dur) var(--ease-out);
}
.theme-pill:hover { border-color: var(--sea); }
.theme-pill[aria-pressed='true'] { background: var(--ink); border-color: var(--ink); color: var(--cream); }

.sound-toggle {
  display: inline-flex; align-items: center; gap: 6px;
  border: 0; background: none; cursor: pointer;
  font-size: var(--t-xs); color: var(--ink-mute);
}
.sound-toggle[aria-pressed='true'] { color: var(--ink); font-weight: 600; }

.stage-hint {
  text-align: center; font-size: var(--t-xs); color: var(--ink-mute);
  margin-top: var(--sp-3);
}

/* ── idle attract: a single badge breathes so the hero is alive ───────────── */

.badge--attract .badge__peel { animation: attract 1600ms var(--ease-in-out); }
@keyframes attract {
  0%, 100% { transform: rotate3d(var(--ax, 1), var(--ay, 0), 0, 0deg) }
  45%      { transform: rotate3d(var(--ax, 1), var(--ay, 0), 0,
                          calc(var(--peel) * var(--pd, 1) * .18))
                        translateZ(5px) }
}
.badge--attract .badge__shadow { animation: attract-shadow 1600ms var(--ease-in-out); }
@keyframes attract-shadow {
  0%, 100% { filter: blur(2px); opacity: .35 }
  45%      { filter: blur(5px); opacity: .28 }
}

/* ── scroll showcase (¾ hero) ────────────────────────────────────────────── */

.showcase {
  position: relative;
  max-width: 620px;
  margin-inline: auto;
  overflow: hidden;
}
.showcase__bag {
  width: 100%;
  height: auto;
  opacity: 0;
  transform: translateY(20px) scale(.985);
  transition: opacity 700ms var(--ease-out), transform 900ms cubic-bezier(.2, .9, .25, 1);
}
.showcase--in .showcase__bag {
  opacity: 1;
  transform: none;
}

/* ── reduced motion: keep every state change, drop every animation ───────── */

@media (prefers-reduced-motion: reduce) {
  .stage { --peel: 0deg; --lift: 0px; }
  .badge, .badge__peel, .badge__shadow, .badge__fuzz,
  .stage__bag, .hook-patch, .slot, .showcase__bag {
    transition-duration: 1ms !important;
    animation: none !important;
  }
  .badge--lifted .badge__peel, .badge--dragging .badge__peel { transform: none; }
  .badge--lifted, .badge--dragging { opacity: .55; }
  .badge__fuzz { display: none; }
  .showcase__bag { opacity: 1; transform: none; }
}
