/* holo-card.css — vanilla holographic foil for any `[data-holo="rare|epic|legendary"]` element.
 * Port of src/styles/holo-card.css (React HoloCard) with no build step and no framework.
 * Numbers carried over from there (originally holo-card-studio's fragment shader, MIT — see
 * docs/research/HOLO_CARD_STUDIO_EVAL_2026-09-07.md): 58deg stripe axis; spectrum #ff529e ->
 * #ffd952 -> #389eff -> white; 113deg narrow specular band (pow(sin,12) => 46/50/54% stops);
 * star tint #a8dbff. Everything keys off VIEW ANGLE (--hx/--hy), not the clock, bar the twinkle.
 * Only transform / background-position / opacity animate; measured at 0 CLS.
 * Dark-stock change from the React file: the foil blends `overlay`, not `soft-light` — these
 * cards are dark plum under a light portrait, so it stays quiet on the plum and vivid on the art. */

/* --hx/--hy drive the light (foil, sweep, nebula, stars) and move all the time, pointer or idle.
   --tx/--ty drive the card's ROTATION and move only under the pointer. See note 3 below. */
@property --hx { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --hy { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --tx { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --ty { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --holo-offset { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --holo-lift { syntax: '<length>'; inherits: false; initial-value: 0px; }
/* Motion multiplier, set from the `data-holo-intensity` attribute by holo-card.js. 1 = the tuned
   look; .5 and 1.5 are the owner's A/B. Scales rotation, Z separation, pop and pointer travel. */
@property --holo-int { syntax: '<number>'; inherits: true; initial-value: 1; }
@property --holo-pop { syntax: '<number>'; inherits: true; initial-value: 1; }

/* Two things here are load-bearing and were both found by measuring CLS, not by reading specs.
 * 1. The doubled attribute selector is (0,2,0) on purpose, so the tilt beats a host page's own
 *    class-level transform on the same element (here `.js .rv`, the scroll reveal). Movement the
 *    host still owns goes into the --holo-offset slot instead of a competing transform.
 * 2. EVERYTHING moves via `transform`. Chrome excludes a changed `transform` DECLARATION from
 *    layout-shift scoring, but not the independent `translate`/`rotate`/`scale` properties, not
 *    `will-change: transform`, and not movement produced by animating a custom property that
 *    `transform` merely reads. Each of those measured 0.027 CLS on the 390px landing page. So the
 *    lift and the host offset are registered <length> custom properties read by one static
 *    transform declaration, and --holo-offset is a POSITION, not something to animate: a host that
 *    transitions it will score CLS. Reveal a holo card by fading it, not by sliding it.
 *    The upside of the same arrangement: `transform` is never transitioned, so the per-frame
 *    writes are never damped by an easing curve.
 * 3. Same reason, one step further: the idle sway must NOT rotate the card. A shift within ~500ms
 *    of a real pointer event is exempt (hadRecentInput), so tilting under the pointer is free —
 *    but an unattended card rotating on a timer scores every time, and it also made the three-up
 *    row look misaligned. So the idle breathing moves only the LIGHT (--hx/--hy: gradients, mask
 *    and background positions, none of which are layout), while the rotation reads --tx/--ty,
 *    which holo-card.js writes only while a pointer is on the card.
 * transition-delay is deliberately not set, so a host's stagger (.rv.d1/.d2/.d3) survives. */
[data-holo][data-holo] {
  --holo-tilt: 0deg; --holo-foil: 0; --holo-glare: 0; --holo-stars: 0; --holo-neb: 0;
  --holo-neb-1: #3050c8; --holo-neb-2: #7a4dff; --holo-neb-3: #22c8ff; --holo-neb-4: #ff6ab0;
  --holo-stock: #14101a;
  --holo-lift: 0px;
  --holo-persp: 900px;
  position: relative;
  /* 4. preserve-3d turns the injected layers into a real stack: the nebula sits behind the card
   *    plane and the portrait in front of it, so a rotation slides them past each other by
   *    perspective division instead of by a faked background offset. It is ignored on an element
   *    whose overflow is not visible, so the host's `overflow:hidden` is dropped here and every
   *    layer clips itself with `border-radius: inherit` instead (the portrait carries a radial
   *    mask, so it needs no clip at all). Neither change moves anything: 0 CLS is unaffected.
   *    The FX layers stay at z = 0 on purpose — a transform on a blending element's ancestor makes
   *    a stacking context, and a stacking context isolates the blend, which is the bug in note 2's
   *    family. The foil buys its forward read with a wider pointer travel instead. */
  overflow: visible;
  transform-style: preserve-3d;
  transform: perspective(var(--holo-persp)) translateY(calc(var(--holo-offset) + var(--holo-lift)))
    rotateY(calc(var(--tx, 0) * var(--holo-tilt) * var(--holo-int)))
    rotateX(calc(var(--ty, 0) * var(--holo-tilt) * var(--holo-int) * -1))
    scale(var(--holo-pop));
  transition-property: --holo-offset, --holo-lift, opacity, box-shadow;
  transition-duration: .7s, .25s, .7s, .25s;
  transition-timing-function: ease;
}
[data-holo][data-holo]:hover { --holo-lift: -8px; }
/* Pop and rotation happen ONLY under a real pointer (note 3): `is-holo-live` is set from a pointer
   event, so both land inside Chrome's hadRecentInput window and score nothing. */
[data-holo].is-holo-live { --holo-pop: calc(1 + .03 * var(--holo-int)); }

/* Content raised above the foil so names and copy stay legible. holo-card.js tags statically
   positioned children .holo-above and already-positioned ones .holo-raise, so nothing's own
   positioning is clobbered. Media is deliberately not raised: the foil lies across the art. */
.holo-above { position: relative; z-index: 3; }
.holo-raise { z-index: 3; }

/* No wrapper and no z-index on the fx layers. A positioned element with a z-index makes a stacking
   context, which isolates the blend modes to the wrapper instead of mixing them with the art
   beneath (the React file hit this: a flat rainbow decal). Last in the DOM is enough. */
.holo-foil, .holo-glare, .holo-stars {
  position: absolute; inset: 0; display: block; border-radius: inherit;
  pointer-events: none; background-repeat: repeat;
}

/* ---- NEBULA FACE (2026-09-07, second pass) --------------------------------------------------
   First pass painted a faint tint over the plum card; the owner's reference is a holo trading
   card, where the nebula IS the face. So the card stock goes dark, `.holo-bg` paints the field
   across the whole face behind the portrait, `.holo-plate` carries the caption band, and the
   portrait's own edges are masked so the field is visible AROUND the face rather than stopping at
   a hard square. `--holo-neb` survives as the field-strength knob: the stock is on the card, so
   dialling it down thins the clouds instead of revealing the old plum. */
/* Two things here that cost a capture each to find.
   1. `perspective` as a PROPERTY, not only the `perspective()` function in the transform list. The
      function applies to the element's OWN transform; only the property foreshortens its children.
      Without it a child at translateZ(-40) is not pushed back at all, it is just 4.5% larger — the
      compensation scale with nothing to compensate for.
   2. The card's own background has to go. In a preserve-3d parent the parent's background paints at
      its own plane, so an opaque stock occludes every child behind it: the nebula survived only as
      the 4.5% ring sticking out past the card edge (two captures looked like "the field is too
      dim"; it was fully painted and fully hidden). `.holo-bg` carries the stock instead. */
[data-holo][data-holo] {
  background: transparent;
  perspective: var(--holo-persp);
}

/* The portraits are 600x600 RGBA cutouts (`webpinfo … Alpha: 1`) cut from the game's own transparent
   neutral sprites, so the silhouette is real and the field shows through around it. The previous
   crops were opaque studio squares whose light backgrounds glowed inside the nebula, and the
   ellipse mask here existed only to fake a silhouette out of them: it is gone. What is left is a
   soft BOTTOM fade, so the bust dies into the caption band instead of ending on the cut edge.
   `background:#fff` is still cleared — it would flash white through the transparent pixels. */
[data-holo][data-holo] img,
[data-holo][data-holo]:hover img {
  background: transparent;
  /* Inset so the field is visible AROUND the portrait, not just as a rim. At the host's `width:100%`
     the crop covers the whole card and the nebula reduced to a 2px fringe (first capture). 86% plus
     a top margin leaves a real margin of sky on three sides and hands the fourth to the caption. */
  width: 86%; margin: 16px auto 0; display: block;
  /* Bottom fade only. The cutout's own alpha does the silhouette; a side/top vignette would just
     eat the hair and the shoulders it no longer needs to hide. */
  -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 62%, rgba(0,0,0,.62) 84%, transparent 100%);
  mask-image: linear-gradient(180deg, #000 0%, #000 62%, rgba(0,0,0,.62) 84%, transparent 100%);
  /* In FRONT of the card plane; .holo-bg is 40px behind it. The scale is perspective compensation
     (900/916 = .9825), so the portrait's footprint at rest is unchanged and only the rotation
     shows the separation. It replaces the host's 1.04 hover zoom, which overflow:visible would
     otherwise let spill past the card edge. */
  transform: translateZ(calc(16px * var(--holo-int))) scale(calc(1 - .0175 * var(--holo-int)));
}

/* Nebula face: procedural gradients in the tier tint + fractal noise, parallaxed against the tilt.
   z-index:-1 puts it over the card's own background and under its content. */
.holo-bg {
  position: absolute; inset: 0; z-index: -1; display: block; border-radius: inherit;
  overflow: hidden; pointer-events: none;
  /* The card stock lives HERE, not on the card: see the note on [data-holo] above. It is on the
     wrapper rather than on the field below so `--holo-neb` can thin the clouds without ever making
     the card itself translucent. */
  background-color: var(--holo-stock);
  /* 40px BEHIND the card plane (900/940 = .9574, hence the 1.045 compensation). */
  transform: translateZ(calc(-40px * var(--holo-int))) scale(calc(1 + .045 * var(--holo-int)));
}

/* The field. Four OPAQUE tier hues screened into the four corners, a coarse fractal for cloud
   structure, a fine one for dust, and a multiply vignette that keeps the middle third quiet under
   the portrait. Strength belongs on this layer (`--holo-neb`), never on the blob colours: a
   `screen` layer at alpha .9 lands at a fraction of its brightness, and the field came out as a
   near-black card with a coloured rim. Same recipe and the same hues as the React
   `.holo-card__facefield`, so the two treatments match. */
.holo-bg::before {
  content: ""; position: absolute; inset: 0; display: block;
  opacity: var(--holo-neb);
  background-color: var(--holo-stock);
  background-image:
    radial-gradient(66% 56% at 50% 42%, rgba(0,0,0,.86) 0%, rgba(0,0,0,.4) 46%, transparent 84%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='c'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.014' numOctaves='5' seed='7' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23c)' opacity='.5'/%3E%3C/svg%3E"),
    radial-gradient(46% 34% at calc(14% + var(--hx,0) * -9%) calc(20% + var(--hy,0) * -7%),
      var(--holo-neb-1) 0%, transparent 64%),
    radial-gradient(48% 36% at calc(88% + var(--hx,0) * -8%) calc(16% + var(--hy,0) * -6%),
      var(--holo-neb-3) 0%, transparent 62%),
    radial-gradient(52% 36% at calc(86% + var(--hx,0) * -6%) calc(74% + var(--hy,0) * -5%),
      var(--holo-neb-2) 0%, transparent 66%),
    radial-gradient(44% 30% at calc(10% + var(--hx,0) * -5%) calc(78% + var(--hy,0) * -4%),
      var(--holo-neb-4) 0%, transparent 62%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='.28'/%3E%3C/svg%3E");
  background-size: 132% 132%, 300px 300px, auto, auto, auto, auto, 140px 140px;
  background-repeat: no-repeat, repeat, no-repeat, no-repeat, no-repeat, no-repeat, repeat;
  background-position:
    calc(50% - var(--hx,0) * 8px) calc(50% - var(--hy,0) * 6px),
    calc(50% - var(--hx,0) * 22px) calc(50% - var(--hy,0) * 17px),
    0 0, 0 0, 0 0, 0 0,
    calc(var(--hx,0) * 14px) calc(var(--hy,0) * 14px);
  background-blend-mode: multiply, overlay, screen, screen, screen, screen, overlay;
  filter: saturate(1.12);
}

/* Caption band. Injected as its own layer rather than a pseudo-element on .holo-bg, which carries
   the field opacity and would fade the band with it. Positioned with z-index auto: that paints it
   ABOVE the non-positioned portrait and below the .holo-above copy, with no z-index of its own to
   start a stacking context the foil would then be isolated from. */
.holo-plate {
  position: absolute; left: 0; right: 0; bottom: 0; height: 42%; display: block;
  pointer-events: none; border-radius: inherit;
  background: linear-gradient(180deg, rgba(9,6,14,0) 0%, rgba(9,6,14,.38) 24%,
    rgba(9,6,14,.8) 56%, rgba(9,6,14,.93) 100%);
}
/* ---- HOVER QUOTE --------------------------------------------------------------------------
   The host page anchors the bubble to the card FOOT (`.quip{bottom:74px}`), which was measured
   against the flat plum card whose copy sat lower. On the nebula card it lands straight across the
   instructor's name and the flavour line. It is re-anchored to the CAPTION BAND instead: the plate
   is 42% of the card height, so `42% + 16px` puts the bubble's tail a clear 16px above the band at
   every card width — 1440 three-up and 390 single-column both — and the bubble itself sits over the
   bust, above the chest line and inside the face. Percentage, not pixels, because the card's height
   is driven by the square portrait and therefore by the column width. Only the bubble moves.
   The white bubble also had to go: over a bright cloud bank it read as a torn-out sticker. Dark
   translucent backing + blur keeps the nebula visible through it and the text legible on top.
   translateZ puts it in FRONT of the portrait (which is at +16px) in the card's 3D stack, so the
   tilt slides it across the face rather than under it; the entrance still animates as the host
   wrote it, translateY(8px) -> 0, composed into the same transform. */
[data-holo][data-holo] .quip {
  bottom: calc(42% + 16px);
  background: rgba(9,5,14,.76);
  -webkit-backdrop-filter: blur(7px) saturate(1.25);
  backdrop-filter: blur(7px) saturate(1.25);
  color: #fff3f8;
  border: 1px solid rgba(255,255,255,.18);
  text-shadow: 0 1px 6px rgba(0,0,0,.85);
  box-shadow: 0 12px 30px rgba(0,0,0,.55);
  transform: translateZ(calc(26px * var(--holo-int))) translateY(8px);
}
[data-holo][data-holo] .quip::after { color: rgba(9,5,14,.86); }
[data-holo][data-holo]:hover .quip,
[data-holo][data-holo]:focus-within .quip {
  transform: translateZ(calc(26px * var(--holo-int)));
}

/* The caption sat on flat plum before; it now sits on a painted field, so it gets a shadow and the
   body copy is lifted off `--dim` (a mid grey that disappears over a bright cloud bank). */
[data-holo][data-holo] h3 { text-shadow: 0 1px 10px rgba(0,0,0,.85); }
[data-holo][data-holo] p { color: rgba(255,236,226,.88); text-shadow: 0 1px 8px rgba(0,0,0,.9); }

/* Laser rainbow: the 58deg spectrum band plus a finer cross-hatch standing in for the shader's
   noise() warp, masked to the same moving light band as the sweep, so the rainbow appears where
   the light is rather than uniformly across the card. */
.holo-foil {
  opacity: var(--holo-foil);
  background-image:
    repeating-linear-gradient(58deg, rgba(255,82,158,.9) 0px, rgba(255,217,82,.9) 26px,
      rgba(56,158,255,.9) 52px, rgba(255,255,255,.9) 74px, rgba(255,82,158,.9) 100px),
    repeating-linear-gradient(-14deg, rgba(255,255,255,.09) 0 5px, rgba(0,0,0,.09) 5px 11px);
  background-position: calc(var(--hx,0) * 120px * var(--holo-int)) calc(var(--hy,0) * 120px * var(--holo-int)),
    calc(var(--hx,0) * 40px * var(--holo-int)) calc(var(--hy,0) * 30px * var(--holo-int));
  background-blend-mode: overlay;
  mix-blend-mode: overlay;
  /* Two mask layers intersected: the moving 113deg light band, and a vertical fade that stops the
     foil before the copy at the foot of the card. Masking rather than z-index means the rainbow
     dies out over the text instead of ending on a hard line. */
  /* Three mask layers intersected: the moving 113deg light band, a vertical fade that thins the
     foil before the caption, and — new with the nebula face — an ellipse over the portrait that
     holds the foil at ~45% there, so the rainbow crosses the whole face without washing the face
     of the instructor out. */
  -webkit-mask-image: linear-gradient(113deg, transparent 12%, #000 42%, #000 58%, transparent 88%),
    linear-gradient(180deg, #000 0 var(--holo-foil-stop, 100%), transparent 100%),
    radial-gradient(ellipse 52% 34% at 50% 26%, rgba(0,0,0,.45) 0%, rgba(0,0,0,.66) 60%, #000 92%);
  mask-image: linear-gradient(113deg, transparent 12%, #000 42%, #000 58%, transparent 88%),
    linear-gradient(180deg, #000 0 var(--holo-foil-stop, 100%), transparent 100%),
    radial-gradient(ellipse 52% 34% at 50% 26%, rgba(0,0,0,.45) 0%, rgba(0,0,0,.66) 60%, #000 92%);
  -webkit-mask-size: 170% 170%, 100% 100%, 100% 100%; mask-size: 170% 170%, 100% 100%, 100% 100%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: calc(50% + var(--hx,0) * 58% * var(--holo-int))
    calc(50% + var(--hy,0) * 34% * var(--holo-int)), 0 0, 0 0;
  mask-position: calc(50% + var(--hx,0) * 58% * var(--holo-int))
    calc(50% + var(--hy,0) * 34% * var(--holo-int)), 0 0, 0 0;
  -webkit-mask-composite: source-in; mask-composite: intersect;
}

/* Specular sweep. The shader's pow(sin,12) is a hairline; at card size the eye lost it the moment
   the card started moving properly, so it is widened to 40/50/60 with a brighter core. */
.holo-glare {
  opacity: var(--holo-glare);
  background-image: linear-gradient(113deg, transparent 24%, rgba(255,255,255,.12) 40%,
    rgba(255,255,255,.78) 50%, rgba(255,255,255,.12) 60%, transparent 76%);
  background-size: 260% 260%; background-repeat: no-repeat;
  background-position: calc(50% + var(--hx,0) * 58% * var(--holo-int))
    calc(50% + var(--hy,0) * 34% * var(--holo-int));
  mix-blend-mode: screen;
}

/* Starfield: three coprime tile sizes (97/131/173) so the lattice only re-aligns off-card and
   reads as scatter, not polka dots. Legendary only, and the one thing here on the clock. */
.holo-stars {
  opacity: var(--holo-stars);
  background-image:
    radial-gradient(circle at 21% 34%, rgba(120,216,255,.85) 0 1.1px, transparent 2px),
    radial-gradient(circle at 68% 12%, rgba(255,146,212,.8) 0 1px, transparent 1.8px),
    radial-gradient(circle at 44% 79%, rgba(255,236,170,.8) 0 1px, transparent 1.8px);
  background-size: 97px 97px, 131px 131px, 173px 173px;
  background-position: calc(var(--hx,0) * 26px) calc(var(--hy,0) * 26px),
    calc(19px + var(--hx,0) * -18px) calc(41px + var(--hy,0) * -18px),
    calc(53px + var(--hx,0) * 12px) calc(7px + var(--hy,0) * 14px);
  animation: holo-twinkle 3.4s ease-in-out infinite;
}
@keyframes holo-twinkle {
  0%, 100% { opacity: calc(var(--holo-stars) * .4); }
  50% { opacity: var(--holo-stars); }
}

/* ---- tiers ----
   The React file's opacities assume white card stock and a deliberate pull-to-reveal moment. These
   cards are dark, always on screen, and breathe on their own, so the same numbers read as a rainbow
   decal (they did — first capture). Roughly a third of each, and --holo-foil-stop keeps the foil off
   the copy below the art. Tilt is likewise halved: the card sits in a three-up grid where a wide
   rotation makes the row look misaligned rather than alive. */
[data-holo="rare"][data-holo] {
  --holo-tilt: 11deg; --holo-glare: .4; --holo-foil: .18; --holo-neb: .7; --holo-foil-stop: 88%;
  --holo-stock: #0e1326;
  --holo-neb-1: #2b5cf0; --holo-neb-2: #7b4dff; --holo-neb-3: #1fb8ff; --holo-neb-4: #4d7cff;
}
[data-holo="epic"][data-holo] {
  --holo-tilt: 13deg; --holo-glare: .5; --holo-foil: .3; --holo-neb: .78; --holo-foil-stop: 88%;
  --holo-stock: #170a24;
  --holo-neb-1: #ff3fb0; --holo-neb-2: #7b2bff; --holo-neb-3: #12c9c0; --holo-neb-4: #ff77d0;
}
[data-holo="legendary"][data-holo] {
  --holo-tilt: 15deg; --holo-glare: .58; --holo-foil: .42; --holo-stars: .7; --holo-neb: .86;
  --holo-foil-stop: 88%; --holo-stock: #1c0d18;
  --holo-neb-1: #ffc247; --holo-neb-2: #a24bff; --holo-neb-3: #ff3d5e; --holo-neb-4: #ff8a3d;
  /* the gold rim the shader paints on the card edge: mix(vec3(.55,.34,.1), spectrum, .65) */
  box-shadow: inset 0 0 0 1px rgba(255,206,79,.4);
}
/* the sweep brightens as you move across it, the way it does under a real light */
[data-holo].is-holo-live .holo-glare { opacity: calc(var(--holo-glare) * 1.3); }

/* Reduced motion: a fixed, off-centre sheen. holo-card.js never starts its loop and never writes
   --hx/--hy, so these authored values stand. No tilt, no lift, no twinkle, no breathing. */
@media (prefers-reduced-motion: reduce) {
  [data-holo][data-holo] {
    --hx: .28; --hy: -.16;
    transform: none;
    transition-property: opacity, box-shadow;
    transition-duration: .7s, .25s;
  }
  [data-holo][data-holo]:hover { --holo-lift: 0px; }
  /* With the card's transform off there is no 3D context, so the layers' translateZ would collapse
     into their bare compensation scales and leave a hairline of stock around the portrait. */
  [data-holo][data-holo] .holo-bg,
  [data-holo][data-holo] img,
  [data-holo][data-holo]:hover img { transform: none; }
  /* Same collapse for the quote bubble, and its entrance stays a pure fade. */
  [data-holo][data-holo] .quip,
  [data-holo][data-holo]:hover .quip,
  [data-holo][data-holo]:focus-within .quip { transform: none; }
  .holo-stars { animation: none; opacity: calc(var(--holo-stars) * .55); }
  [data-holo].is-holo-live .holo-glare { opacity: var(--holo-glare); }
}
