/* CharacterStage layer mechanics — the companion stylesheet to web/stage.js,
   shared by the chat app and the stream demo. The HOST page sizes and skins the
   stage box (background, border-radius, aspect); this file owns how the layers
   inside it stack and swap. Apply class `char-stage` to the host element.

   The stage is full-frame <img> layers toggled by opacity: mouths underneath,
   emotion overlays above them, blink on top. */

.char-stage { position: relative; overflow: hidden; }

.char-stage img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0;
  /* Discrete swaps ARE the animation — easing here would smear the mouth shapes. */
  transition: none;
}

.char-stage img.on { opacity: 1; }

/* The blink overlay is the ONE layer that eases. A real blink is motion-blurred,
   so a hard cut reads as a glitch — but that same easing on a viseme swap would
   smear the mouth shapes, which is why the rule above stays transition:none.
   This selector's specificity (0,2,1) has to beat that one's (0,1,1), or the
   crossfade silently never happens and the blink pops. */
.char-stage img.blink { transition: opacity 45ms linear; }

/* Emotion overlays ease longer than the blink: an expression change is a face
   moving, not a lip position — 140ms reads as intent where a hard cut reads as
   a glitch. */
.char-stage img.emotion { transition: opacity 140ms ease; }

/* ── Breath ───────────────────────────────────────────────────────────────────
   The character's chest moves. web/breath.js writes the custom properties; the
   shape of the transform lives here.

   ONE transformed element, and it is masked. Everything else on the stage is
   pixel-static, which is the whole point: these frames are a character AND a room,
   and a transform on the stack — or a mask that is a full-width band — moves the
   doorframe with her. Defaults are the identity and a fully transparent mask, so a
   character with no geometry, an unbaked build or prefers-reduced-motion renders
   exactly as it did before this file grew. */

.char-stage .layers {
  position: absolute;
  inset: 0;
}

/* The torso copy: one more full-frame layer, always visible (never toggled by
   `on`), masked to a shoulder-sized blob and scaled about a pivot BELOW the frame —
   far enough away that ~0.5% of scale reads as a lift rather than a stretch. */
.char-stage img.chest {
  opacity: 1;
  transform: scale(var(--breath-sx, 1), var(--breath-sy, 1));
  transform-origin: 50% var(--breath-pivot, 100%);
  /* The transform is the only thing that changes here, every frame. */
  will-change: transform;
  /* Two gradients, intersected: a vertical band crossed with a horizontal one is a
     blob. Either alone spans the frame on the other axis and takes the scenery with
     it. No mask at all means no torso was derived, and the layer must then be
     invisible rather than a second opaque copy of the character on top of the
     first — hence the transparent default. */
  -webkit-mask-image: var(--breath-mask, linear-gradient(#0000, #0000)), var(--breath-mask-x, linear-gradient(#0000, #0000));
  mask-image: var(--breath-mask, linear-gradient(#0000, #0000)), var(--breath-mask-x, linear-gradient(#0000, #0000));
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* A still character is the correct rendering of "no motion", not a degraded one —
   breath.js also never starts its loop, so this is belt and braces for a
   preference that flips mid-session. */
@media (prefers-reduced-motion: reduce) {
  .char-stage img.chest { transform: none; }
}

/* The picker's card crops the art to fill a fixed box instead of fitting it. The
   layer mechanics are identical; only the fit differs, and breath.js is told which
   one so the mask lands on the body either way. */
.char-stage.fit-cover img {
  object-fit: cover;
  object-position: top;
}
