/* ============================================================
   yourcustomprofile.app — catalogue card and frame motion
   Two lanes from the rank-gated catalogue (content/catalog.mjs):
   `cardMotions` (8) and `frameMotions` (7).

   Contract. A card motion is set as `data-card-motion="<id>"` on the card by
   profile.js and only reacts while `.is-pointer-in` is present — the engine
   already tracks the pointer and only adds that class on a fine pointer, so a
   touch screen never sees these. A frame motion is a class
   `.frame-motion-<id>` on `.avatar`.

   Nothing here changes layout: every rule moves a decoration layer or a
   transform, never the box the content sits in. Motion is always cancelled by
   `html[data-motion="low"]` and by `.is-slow-device`, and each one still
   reads as something without it.
   ============================================================ */

/* ═══════════════════════════════════════════════════════ card motion ══ */

/* ------------------------------------------------------------ ink follow
   A wet trail follows the pointer: a soft blob offset opposite to the cursor
   position, so it drags behind the movement like ink on a brush. */
.profile-card[data-card-motion="inkFollow"] {
  /* `--mx-c` / `--my-c` are the engine's own normalised pointer values
     (-1…1). The earlier `--cm-x` pair was never written by any script, so the
     blob sat dead centre on every pointer move. */
}
.profile-card[data-card-motion="inkFollow"]::after {
  content: "";
  position: absolute;
  inset: -6%;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    circle at calc(50% - var(--mx-c, 0) * 14%) calc(50% - var(--my-c, 0) * 14%),
    color-mix(in srgb, var(--accent) 22%, transparent),
    transparent 52%
  );
  opacity: 0;
  transition: opacity 0.35s ease;
  filter: blur(10px);
}
.profile-card[data-card-motion="inkFollow"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------ paper lift
   A corner lifts toward you: the whole card tilts very slightly around the
   bottom-right, which is the corner a real sheet lifts from. */
.profile-card[data-card-motion="paperLift"] {
  transform-origin: 100% 100%;
  transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.5s ease;
}
.profile-card[data-card-motion="paperLift"].is-pointer-in {
  transform: rotate(-0.55deg) translateY(-3px);
  box-shadow: -10px 18px 40px -22px rgba(0, 0, 0, 0.8);
}

/* -------------------------------------------------------------- lantern
   A warm patch of light, hand-held: the lit area lags behind the pointer and
   never quite catches up. Implemented as a highlight layer with a slow
   transition, not a tracking calculation. */
.profile-card[data-card-motion="lantern"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    circle at calc(30% + var(--mx-c, 0) * 16%) calc(24% + var(--my-c, 0) * 16%),
    color-mix(in srgb, #fbbf24 16%, transparent),
    transparent 46%
  );
  opacity: 0;
  transition: opacity 0.6s ease;
}
.profile-card[data-card-motion="lantern"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------ scan pick
   A line reads the card: a single horizontal bar crosses the card once per
   hover, then rests. One shot, not a loop — a scanner that never finishes
   reads as broken. */
.profile-card[data-card-motion="scanPick"] {
  --cm-scan: 0%;
}
.profile-card[data-card-motion="scanPick"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: var(--cm-scan);
  height: 2px;
  z-index: 3;
  pointer-events: none;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 80%, transparent), transparent);
  opacity: 0;
  transition: opacity 0.2s ease;
}
.profile-card[data-card-motion="scanPick"].is-pointer-in::after {
  opacity: 1;
  animation: cm-scan-pick 1.5s ease-in-out infinite;
}

/* ----------------------------------------------------------- tilt print
   A print leaning in its frame: the card leans toward the pointer but is
   clamped, so it can never look like it flew off the wall. */
.profile-card[data-card-motion="tiltPrint"] {
  transition: transform 0.45s cubic-bezier(0.2, 0.8, 0.3, 1);
  transform-style: preserve-3d;
}
.profile-card[data-card-motion="tiltPrint"].is-pointer-in {
  transform: perspective(900px) rotateX(1.4deg) rotateY(-1.6deg);
}

/* --------------------------------------------------------- caustic wash
   Water light follows you: two soft bands, both lagging, both low contrast.
   This is a light, not a spotlight. */
.profile-card[data-card-motion="causticWash"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background:
    radial-gradient(circle at calc(34% + var(--mx-c, 0) * 18%) calc(30% + var(--my-c, 0) * 18%), color-mix(in srgb, #7dd3fc 14%, transparent), transparent 40%),
    radial-gradient(circle at calc(70% - var(--mx-c, 0) * 18%) calc(68% - var(--my-c, 0) * 18%), color-mix(in srgb, #38bdf8 10%, transparent), transparent 44%);
  opacity: 0;
  transition: opacity 0.7s ease;
}
.profile-card[data-card-motion="causticWash"].is-pointer-in::after { opacity: 1; }

/* -------------------------------------------------------------- reticle
   Brackets snap to the pointer: the four reticles are fixed to the card,
   but the gap between them closes as the pointer enters. */
.profile-card[data-card-motion="reticle"]::after {
  content: "";
  position: absolute;
  inset: 6px;
  z-index: 3;
  pointer-events: none;
  background:
    linear-gradient(var(--accent) 0 0) left top / 14px 1.5px no-repeat,
    linear-gradient(var(--accent) 0 0) left top / 1.5px 14px no-repeat,
    linear-gradient(var(--accent) 0 0) right top / 14px 1.5px no-repeat,
    linear-gradient(var(--accent) 0 0) right top / 1.5px 14px no-repeat,
    linear-gradient(var(--accent) 0 0) left bottom / 14px 1.5px no-repeat,
    linear-gradient(var(--accent) 0 0) left bottom / 1.5px 14px no-repeat,
    linear-gradient(var(--accent) 0 0) right bottom / 14px 1.5px no-repeat,
    linear-gradient(var(--accent) 0 0) right bottom / 1.5px 14px no-repeat;
  opacity: 0;
  transition: opacity 0.3s ease, inset 0.3s ease;
}
.profile-card[data-card-motion="reticle"].is-pointer-in::after {
  opacity: 0.9;
  inset: 12px;
}

/* ---------------------------------------------------------- onyx reflect
   A black surface, one reflection: a single soft sheen that slides across as
   the pointer crosses, and nothing else. */
.profile-card[data-card-motion="onyxReflect"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(
    100deg,
    transparent 20%,
    rgba(255, 255, 255, 0.055) 46%,
    transparent 62%
  );
  background-size: 220% 100%;
  background-position: 130% 0;
  transition: background-position 0.9s ease;
}
.profile-card[data-card-motion="onyxReflect"].is-pointer-in::after {
  background-position: -30% 0;
}

/* ══════════════════════════════════════════════════════ frame motion ══ */

/* ----------------------------------------------------------- breathe slow
   Six seconds, barely there. The amplitude is under 2% so it never reads as
   pulsing — it reads as something alive at rest. */
.frame-motion-breatheSlow .avatar__fx-shell,
.frame-motion-breatheSlow .avatar__fx-arc {
  animation: fm-breathe-slow 6s ease-in-out infinite;
}

/* --------------------------------------------------------------- sway
   A hanging print, settling: a single damped rotation, not a loop. */
.frame-motion-sway .avatar__fx {
  transform-origin: 50% -8%;
  animation: fm-sway 7s ease-in-out infinite;
}

/* ---------------------------------------------------------------- tick
   A clock hand, one step a second: steps() so it never eases. */
.frame-motion-tick .avatar__fx-runes {
  animation: fm-tick 1s steps(1, end) infinite;
}

/* --------------------------------------------------------------- drift
   A slow horizontal travel: the whole ornament layer shifts a few pixels and
   returns. Used where the frame is a material that should feel suspended. */
.frame-motion-drift .avatar__fx {
  animation: fm-drift 14s ease-in-out infinite alternate;
}

/* ------------------------------------------------------- caustic breathe
   Light swelling and failing: opacity only, no scale, so a textured frame
   does not appear to zoom. */
.frame-motion-causticBreathe .avatar__fx-arc,
.frame-motion-causticBreathe .avatar__fx-aura {
  animation: fm-caustic 8s ease-in-out infinite;
}

/* ------------------------------------------------------------ scan sweep
   One pass every eight seconds: a bar crosses the frame and then waits, which
   is what a scanner does. */
.frame-motion-scanSweep .avatar__fx-beam {
  animation: fm-scan-sweep 8s linear infinite;
}

/* ------------------------------------------------------------ gilt turn
   The light rotates slowly: a conic gradient's angle advances. Only frames
   that already use a conic gradient benefit; on the rest this is a no-op. */
.frame-motion-giltTurn .avatar__fx-shell,
.frame-motion-giltTurn .avatar__fx-arc {
  animation: fm-gilt-turn 22s linear infinite;
}

/* ---------------------------------------------------------- keyframes */

@keyframes cm-scan-pick {
  0% { top: 4%; opacity: 0; }
  10% { opacity: 1; }
  90% { opacity: 1; }
  100% { top: 94%; opacity: 0; }
}
@keyframes fm-breathe-slow {
  0%, 100% { scale: 1; opacity: 0.92; }
  50% { scale: 1.018; opacity: 1; }
}
@keyframes fm-sway {
  0%, 100% { rotate: -0.8deg; }
  30% { rotate: 0.9deg; }
  62% { rotate: -0.4deg; }
  84% { rotate: 0.2deg; }
}
@keyframes fm-tick {
  0% { rotate: 0deg; }
  100% { rotate: 30deg; }
}
@keyframes fm-drift {
  0% { translate: -3px 0; }
  100% { translate: 3px 0; }
}
@keyframes fm-caustic {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 1; }
}
@keyframes fm-scan-sweep {
  0% { opacity: 0; translate: 0 -120%; }
  8% { opacity: 0.9; }
  42% { opacity: 0.9; translate: 0 120%; }
  46%, 100% { opacity: 0; translate: 0 120%; }
}
@keyframes fm-gilt-turn {
  to { rotate: 360deg; }
}

/* -------------------------------------------------------- reduced motion
   Every one of these is decoration, so all of them stop. Nothing needs a
   fallback position because none of them is load-bearing. */
html[data-motion="low"] .profile-card[data-card-motion]::after,
html[data-motion="low"] .avatar[class*="frame-motion-"] .avatar__fx,
html[data-motion="low"] .avatar[class*="frame-motion-"] .avatar__fx > *,
html.is-slow-device .profile-card[data-card-motion]::after,
html.is-slow-device .avatar[class*="frame-motion-"] .avatar__fx,
html.is-slow-device .avatar[class*="frame-motion-"] .avatar__fx > * {
  animation: none !important;
}
/* The two card motions that are pure transforms must also release their
   transform, or a card could freeze mid-lean.
   They used to be singled out by name while the other 28 transform-based
   motions were left to the generic `[data-card-motion]` rule at the end of the
   file. Two effects stopping by a different mechanism from their siblings is a
   trap: adding a third pure-transform motion meant remembering this list. One
   selector covers every motion, so the set can grow without editing it. */
html[data-motion="low"] .profile-card[data-card-motion],
html.is-slow-device .profile-card[data-card-motion] {
  transform: none !important;
}

/* Coarse pointers get no pointer-follow layers at all: there is no pointer.
   The per-motion rules after this block carry equal specificity and live
   later, so a bare `transform: none` loses the cascade and 19 of the 35
   motions kept following a pointer that does not exist on touch. The guard
   must out-rank the wave, exactly like the reduced-motion block above. */
@media (hover: none) {
  .profile-card[data-card-motion]::after { display: none !important; }
  .profile-card[data-card-motion].is-pointer-in { transform: none !important; }
}

/* ══════════════════════════ second wave: free-tier motions. Same contract —
   transforms and decoration layers only, and none of them is load-bearing. ══ */

/* ------------------------------------------------------------ edge light
   One edge of the card catches the pointer and nothing else does. */
.profile-card[data-card-motion="edgeLight"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 26%, transparent), transparent 18%);
  opacity: 0;
  transition: opacity 0.4s ease;
}
.profile-card[data-card-motion="edgeLight"].is-pointer-in::after { opacity: 1; }

/* --------------------------------------------------------- settle weight
   The card takes the weight of the pointer and stops: a small downward give,
   no rotation, no scale. */
.profile-card[data-card-motion="settleWeight"] {
  transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.profile-card[data-card-motion="settleWeight"].is-pointer-in { transform: translateY(2px); }

/* ------------------------------------------------------------- grid snap
   A faint lattice appears under the card, as if it were being placed on a
   drawing board. */
.profile-card[data-card-motion="gridSnap"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background:
    repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 12%, transparent) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(0deg, color-mix(in srgb, var(--accent) 12%, transparent) 0 1px, transparent 1px 22px);
  opacity: 0;
  transition: opacity 0.45s ease;
}
.profile-card[data-card-motion="gridSnap"].is-pointer-in::after { opacity: 1; }

/* --------------------------------------------------------- paper corner
   One corner keeps trying to lift and never quite makes it. A single fold
   shadow in the bottom-right, no movement of the card itself. */
.profile-card[data-card-motion="paperCorner"]::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 0;
  width: 46px;
  height: 46px;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(315deg, rgba(0, 0, 0, 0.42), rgba(255, 255, 255, 0.1) 46%, transparent 52%);
  opacity: 0;
  transition: opacity 0.4s ease;
  border-bottom-right-radius: inherit;
}
.profile-card[data-card-motion="paperCorner"].is-pointer-in::after { opacity: 1; }

/* ----------------------------------------------------------- ink breath
   The surface darkens and recovers, once, slowly. Opacity only, so it never
   looks like the card is breathing in size. */
.profile-card[data-card-motion="inkBreath"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(circle at 50% 50%, color-mix(in srgb, #000 28%, transparent), transparent 70%);
  opacity: 0;
  transition: opacity 0.9s ease;
}
.profile-card[data-card-motion="inkBreath"].is-pointer-in::after { opacity: 1; }

/* ---------------------------------------------------------------- drift
   A slow travel while the pointer rests, keyed to where the pointer is. The
   layer moves a few pixels; the card does not. */
.profile-card[data-card-motion="drift"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    circle at calc(50% + var(--mx-c, 0) * 30%),
    color-mix(in srgb, var(--accent) 10%, transparent),
    transparent 60%
  );
  opacity: 0;
  transition: opacity 0.7s ease;
}
.profile-card[data-card-motion="drift"].is-pointer-in::after { opacity: 1; }

/* ---------------------------------------------------------------- etch
   A rule is cut where the pointer passes: one vertical hairline that sits at
   the pointer's x position and fades behind it. */
.profile-card[data-card-motion="etch"]::after {
  content: "";
  position: absolute;
  top: 6%;
  bottom: 6%;
  left: calc(50% + var(--mx-c, 0) * 42%);
  width: 1px;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--accent) 55%, transparent) 20%, color-mix(in srgb, var(--accent) 55%, transparent) 80%, transparent);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.profile-card[data-card-motion="etch"].is-pointer-in::after { opacity: 1; }

/* --------------------------------------------------------- halo drift
   The ring travels around the portrait, slowly, on an ellipse. */
.frame-motion-haloDrift .avatar__fx-ring {
  animation: fm-halo-drift 18s linear infinite;
}

/* ------------------------------------------------------------ orb slow
   A single point of light on a long orbit. Only frames that paint a node or a
   spark show it; on the rest it is a no-op. */
.frame-motion-orbSlow .avatar__fx-node,
.frame-motion-orbSlow .avatar__fx-spark {
  animation: fm-orb-slow 22s linear infinite;
}

/* --------------------------------------------------------- gap breathe
   A gap in the rim opens and closes. Scale on one axis only, so the frame
   never appears to zoom. */
.frame-motion-gapBreathe .avatar__fx-arc {
  animation: fm-gap-breathe 7s ease-in-out infinite;
}

/* ----------------------------------------------------------- line march
   The rule advances in steps. steps() keeps it mechanical, which is what a
   printed rule does. */
.frame-motion-lineMarch .avatar__fx-glyph {
  animation: fm-line-march 4s steps(8, end) infinite;
}

/* ----------------------------------------------------------- soft pulse
   Weight without scale: shadow only. This is the one motion that is safe on a
   textured frame. */
.frame-motion-softPulse .avatar__fx-shell,
.frame-motion-softPulse .avatar__fx-arc {
  animation: fm-soft-pulse 5.5s ease-in-out infinite;
}

/* ------------------------------------------------------------ rim shift
   The highlight slides around the edge, as if the light source moved. */
.frame-motion-rimShift .avatar__fx-shell {
  animation: fm-rim-shift 16s linear infinite;
}

/* -------------------------------------------------------------- gather
   The fragments move inward and settle. One shot every 12 seconds, not a
   loop that keeps re-collapsing. */
.frame-motion-gather .avatar__fx-node,
.frame-motion-gather .avatar__fx-spark {
  animation: fm-gather 12s ease-in-out infinite;
}

/* ----------------------------------------------------------- etch turn
   The cut edge rotates one step every few seconds. */
.frame-motion-etchTurn .avatar__fx-runes {
  animation: fm-etch-turn 6s steps(6, end) infinite;
}

@keyframes fm-halo-drift {
  0% { translate: -50% 0; }
  50% { translate: -50% 6%; }
  100% { translate: -50% 0; }
}
@keyframes fm-orb-slow {
  0% { opacity: 0.5; }
  50% { opacity: 1; }
  100% { opacity: 0.5; }
}
@keyframes fm-gap-breathe {
  0%, 100% { scale: 1 1; }
  50% { scale: 1.03 0.97; }
}
@keyframes fm-line-march { to { rotate: 360deg; } }
@keyframes fm-soft-pulse {
  0%, 100% { filter: none; }
  50% { filter: brightness(1.25); }
}
@keyframes fm-rim-shift { to { rotate: 360deg; } }
@keyframes fm-gather {
  0%, 70%, 100% { translate: 0 0; opacity: 1; }
  35% { translate: 0 -6%; opacity: 0.6; }
}
@keyframes fm-etch-turn { to { rotate: 360deg; } }

/* ══════════════════════════ third wave: the ladder. Twenty card behaviours
   and twenty frame behaviours, four per rank. The contract is unchanged:
   transforms and decoration layers only, nothing load-bearing, and every one
   of them returns to rest when the pointer leaves or motion is reduced.

   Two pointer variables are available, both written by `armCardPointer()`:
   `--mx` / `--my` are 0…1 across the card, `--mx-c` / `--my-c` are -1…1
   around its centre. A rule that needs a position uses those; nothing here
   measures the pointer a second time. ══════════════════════════════════════ */

/* ---------------------------------------------------------- margin rule
   A rule lines up with the pointer's column, the way a compositor's guide
   follows the type. One line, no glow. */
.profile-card[data-card-motion="marginRule"]::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--mx, 0.5) * 100%);
  width: 1px;
  z-index: 2;
  pointer-events: none;
  background: repeating-linear-gradient(
    180deg,
    color-mix(in srgb, var(--accent) 58%, transparent) 0 4px,
    transparent 4px 8px
  );
  /* A guide that runs the full height cuts across the avatar and reads as a
     seam. Fading it out over the identity block keeps it a margin guide. */
  -webkit-mask: linear-gradient(180deg, transparent 0 27%, #000 39% 88%, transparent 97%);
  mask: linear-gradient(180deg, transparent 0 27%, #000 39% 88%, transparent 97%);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.profile-card[data-card-motion="marginRule"].is-pointer-in::after { opacity: 0.85; }

/* ---------------------------------------------------------- sheet corner
   The nearest corner darkens, as if the sheet were lifting there. The
   gradient origin is the pointer, so the corner is always the right one. */
.profile-card[data-card-motion="sheetCorner"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    120% 90% at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    transparent 40%,
    rgba(0, 0, 0, 0.28) 100%
  );
  opacity: 0;
  transition: opacity 0.45s ease;
}
.profile-card[data-card-motion="sheetCorner"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------ ink spread
   A drop spreads where you rest: the radius is a slow transition, so holding
   still grows the mark and moving resets it. */
.profile-card[data-card-motion="inkSpread"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    circle 0 at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    color-mix(in srgb, var(--accent) 26%, transparent),
    transparent 70%
  );
  opacity: 0;
  transition: opacity 0.4s ease, background-size 1.4s ease;
}
.profile-card[data-card-motion="inkSpread"].is-pointer-in::after {
  opacity: 1;
  background: radial-gradient(
    circle 420px at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    color-mix(in srgb, var(--accent) 46%, transparent),
    transparent 72%
  );
}

/* -------------------------------------------------------- register shift
   The print slips a hair and corrects itself: a transform on the card, held
   at a fractional pixel so no text ever reflows. */
.profile-card[data-card-motion="registerShift"] {
  transition: transform 0.18s steps(2, end);
}
.profile-card[data-card-motion="registerShift"].is-pointer-in {
  transform: translate(calc(var(--mx-c, 0) * 2.2px), calc(var(--my-c, 0) * 2.2px));
}

/* --------------------------------------------------------------- case glow
   A case light follows, dim: a wide, low-contrast patch. Museum lighting is
   never bright at the edge of the beam. */
.profile-card[data-card-motion="caseGlow"]::after {
  content: "";
  position: absolute;
  inset: -8%;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    ellipse 46% 46% at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    rgba(255, 246, 226, 0.14),
    transparent 72%
  );
  opacity: 0;
  transition: opacity 0.6s ease;
}
.profile-card[data-card-motion="caseGlow"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------- label raise
   The label lifts level with the eye: the plate above the text rises one
   pixel and casts a shadow, and the text does not move. */
.profile-card[data-card-motion="labelRaise"] .profile-card__frame {
  transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.3s ease;
}
.profile-card[data-card-motion="labelRaise"].is-pointer-in .profile-card__frame {
  /* One pixel of lift is a rounding error at any device pixel ratio; three is
     the smallest amount that still reads as an object leaving the surface. */
  transform: translateY(-3px);
  box-shadow: 0 5px 12px -5px rgba(0, 0, 0, 0.6);
}

/* ------------------------------------------------------------ plinth slide
   The object slides a few millimetres: a single axis, damped, no rotation. */
.profile-card[data-card-motion="plinthSlide"] {
  transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.profile-card[data-card-motion="plinthSlide"].is-pointer-in {
  transform: translateX(calc(var(--mx-c, 0) * 7px));
}

/* -------------------------------------------------------------- glass tilt
   A pane tilts to catch the room: one axis only, which is what a framed
   picture behind glass actually does. */
.profile-card[data-card-motion="glassTilt"] {
  transition: transform 0.45s cubic-bezier(0.2, 0.8, 0.3, 1);
  transform-style: preserve-3d;
}
.profile-card[data-card-motion="glassTilt"].is-pointer-in {
  transform: perspective(1200px) rotateY(calc(var(--mx-c, 0) * 2.2deg));
}

/* ------------------------------------------------------------ beam follow
   A beam follows, hard-edged: a straight band with no falloff on its sides,
   because a beam is masked, not blurred. */
.profile-card[data-card-motion="beamFollow"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(
    90deg,
    transparent calc(var(--mx, 0.5) * 100% - 9%),
    rgba(255, 240, 208, 0.13) calc(var(--mx, 0.5) * 100% - 3%),
    rgba(255, 240, 208, 0.13) calc(var(--mx, 0.5) * 100% + 3%),
    transparent calc(var(--mx, 0.5) * 100% + 9%)
  );
  opacity: 0;
  transition: opacity 0.35s ease;
}
.profile-card[data-card-motion="beamFollow"].is-pointer-in::after { opacity: 1; }

/* ----------------------------------------------------------- caustic pool
   A pool of water light gathers: two overlapping highlights that drift out of
   phase, so the shape never repeats. */
.profile-card[data-card-motion="causticPool"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: inherit;
  background:
    radial-gradient(ellipse 22% 22% at calc(40% + var(--mx-c, 0) * 14%) calc(38% + var(--my-c, 0) * 14%),
      color-mix(in srgb, var(--accent) 20%, transparent), transparent 68%),
    radial-gradient(ellipse 18% 18% at calc(66% - var(--mx-c, 0) * 12%) calc(64% - var(--my-c, 0) * 12%),
      color-mix(in srgb, var(--accent2, var(--accent)) 16%, transparent), transparent 70%);
  opacity: 0;
  transition: opacity 0.55s ease;
}
.profile-card[data-card-motion="causticPool"].is-pointer-in::after { opacity: 1; }

/* -------------------------------------------------------------- afterglow
   Light fades behind the pointer: a warm trail that appears instantly and
   leaves slowly, which is the only difference from `causticPool`. */
.profile-card[data-card-motion="afterglow"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    ellipse 30% 30% at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    rgba(255, 232, 190, 0.2),
    transparent 74%
  );
  opacity: 0;
  transition: opacity 1.1s ease-out;
}
.profile-card[data-card-motion="afterglow"].is-pointer-in::after {
  opacity: 1;
  transition-duration: 0.12s;
}

/* ------------------------------------------------------------- prism turn
   Three fringes rotate with you: three thin bands whose offsets follow the
   pointer, one per primary. The card itself does not move. */
.profile-card[data-card-motion="prismTurn"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  mix-blend-mode: screen;
  background:
    linear-gradient(105deg, transparent 42%, rgba(255, 90, 150, 0.26) 50%, transparent 58%),
    linear-gradient(105deg, transparent 46%, rgba(90, 220, 190, 0.22) 54%, transparent 62%);
  translate: calc(var(--mx-c, 0) * 6px) 0;
  opacity: 0;
  transition: opacity 0.4s ease;
}
.profile-card[data-card-motion="prismTurn"].is-pointer-in::after { opacity: 1; }

/* ----------------------------------------------------------- bracket track
   Corner brackets track the pointer: four short marks that pull toward it
   without ever reaching it. */
.profile-card[data-card-motion="bracketTrack"]::after {
  content: "";
  position: absolute;
  inset: 6px;
  z-index: 3;
  pointer-events: none;
  background:
    linear-gradient(currentColor 0 0) left top / 18px 2px no-repeat,
    linear-gradient(currentColor 0 0) left top / 2px 18px no-repeat,
    linear-gradient(currentColor 0 0) right top / 18px 2px no-repeat,
    linear-gradient(currentColor 0 0) right top / 2px 18px no-repeat,
    linear-gradient(currentColor 0 0) left bottom / 18px 2px no-repeat,
    linear-gradient(currentColor 0 0) left bottom / 2px 18px no-repeat,
    linear-gradient(currentColor 0 0) right bottom / 18px 2px no-repeat,
    linear-gradient(currentColor 0 0) right bottom / 2px 18px no-repeat;
  color: color-mix(in srgb, var(--accent) 82%, #fff 18%);
  opacity: 0;
  translate: calc(var(--mx-c, 0) * 4px) calc(var(--my-c, 0) * 4px);
  transition: opacity 0.3s ease;
}
.profile-card[data-card-motion="bracketTrack"].is-pointer-in::after { opacity: 1; }
/* Two pixels is the minimum that survives a phone's device pixel ratio. */
.profile-card[data-card-motion="bracketTrack"]::after {
  filter: drop-shadow(0 0 1px color-mix(in srgb, var(--accent) 60%, transparent));
}

/* --------------------------------------------------------------- grid warp
   The lattice bends toward you: two grids at slightly different scales, so
   the interference reads as a bend rather than a zoom. */
.profile-card[data-card-motion="gridWarp"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background:
    repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 12%, transparent) 0 1px, transparent 1px 22px),
    repeating-linear-gradient(0deg, color-mix(in srgb, var(--accent) 10%, transparent) 0 1px, transparent 1px 22px);
  background-position:
    calc(var(--mx-c, 0) * 5px) 0,
    0 calc(var(--my-c, 0) * 5px);
  opacity: 0;
  transition: opacity 0.4s ease;
}
.profile-card[data-card-motion="gridWarp"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------- servo step
   The card steps in fixed increments: no easing at all, because a servo has
   none. The step is 2px, which is visible without being a jitter. */
.profile-card[data-card-motion="servoStep"] {
  transition: transform 0.12s steps(1, end);
}
.profile-card[data-card-motion="servoStep"].is-pointer-in {
  transform: translate(calc(round(var(--mx-c, 0) * 3) * 2px), calc(round(var(--my-c, 0) * 3) * 2px));
}

/* --------------------------------------------------------------- ping ring
   A ring leaves and fades: it is drawn on the frame layer, so it cannot sit
   above the text, and it only exists while the pointer is inside. */
.profile-card[data-card-motion="pingRing"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  inset: 14px;
  border-radius: 12px;
  border: 1px dashed color-mix(in srgb, var(--accent) 78%, #fff 22%);
  opacity: 0;
  scale: 0.94;
  transition: opacity 0.5s ease, scale 0.9s ease-out;
}
.profile-card[data-card-motion="pingRing"].is-pointer-in::after {
  opacity: 0.85;
  scale: 1;
}

/* ------------------------------------------------------------- gilt shine
   A sheen crosses, once per entry: a narrow diagonal band whose position
   follows the pointer, at gold's own low saturation. */
.profile-card[data-card-motion="giltShine"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: linear-gradient(
    104deg,
    transparent calc(var(--mx, 0.5) * 100% - 14%),
    rgba(240, 214, 150, 0.16) calc(var(--mx, 0.5) * 100% - 4%),
    rgba(255, 244, 214, 0.24) calc(var(--mx, 0.5) * 100%),
    rgba(240, 214, 150, 0.16) calc(var(--mx, 0.5) * 100% + 4%),
    transparent calc(var(--mx, 0.5) * 100% + 14%)
  );
  opacity: 0;
  transition: opacity 0.4s ease;
}
.profile-card[data-card-motion="giltShine"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------- onyx depth
   The surface darkens under you: light is absorbed at the point of contact,
   which is the inverse of every glow in this file. */
.profile-card[data-card-motion="onyxDepth"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(
    ellipse 42% 42% at calc(var(--mx, 0.5) * 100%) calc(var(--my, 0.5) * 100%),
    rgba(0, 0, 0, 0.42),
    transparent 76%
  );
  opacity: 0;
  transition: opacity 0.5s ease;
}
.profile-card[data-card-motion="onyxDepth"].is-pointer-in::after { opacity: 1; }

/* ------------------------------------------------------------ inlay track
   An inlaid line follows the edge: a thin band on the boundary itself, not a
   fill, so the card reads as having a second material inset. */
.profile-card[data-card-motion="inlayTrack"]::after {
  content: "";
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 9px;
  height: 2px;
  z-index: 3;
  pointer-events: none;
  border-radius: 2px;
  background: linear-gradient(
    90deg,
    transparent 0%,
    color-mix(in srgb, #e8ce94 70%, transparent) 18%,
    #e8ce94 50%,
    color-mix(in srgb, #e8ce94 70%, transparent) 82%,
    transparent 100%
  );
  translate: calc(var(--mx-c, 0) * 26px) 0;
  opacity: 0;
  transition: opacity 0.45s ease, translate 0.45s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.profile-card[data-card-motion="inlayTrack"].is-pointer-in::after { opacity: 0.9; }

/* ------------------------------------------------------------ seal weight
   The card settles under its own weight: down and very slightly back, once,
   with no follow-through while the pointer stays. */
.profile-card[data-card-motion="sealWeight"] {
  transition: transform 0.55s cubic-bezier(0.3, 0.9, 0.25, 1);
}
.profile-card[data-card-motion="sealWeight"].is-pointer-in {
  transform: translateY(5px);
  box-shadow: 0 1px 6px -4px rgba(0, 0, 0, 0.9);
}

/* ══════════════════════════════════════════════ third wave: frame motions.
   These ride on the ornament layer only (`.avatar__fx` and its children), so
   a frame can breathe without the portrait moving. ═══════════════════════ */

.frame-motion-gutterBreathe .avatar__fx-shell {
  animation: fm-gutter-breathe 5.5s ease-in-out infinite;
}
.frame-motion-stitchTick .avatar__fx-runes {
  animation: fm-stitch 2.4s steps(6, end) infinite;
}
.frame-motion-foldCrease .avatar__fx-arc {
  animation: fm-fold 7s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
.frame-motion-pressRoll .avatar__fx-beam {
  animation: fm-roll 6s linear infinite;
}
.frame-motion-caseSweep .avatar__fx-shell {
  animation: fm-case-sweep 9s ease-in-out infinite;
}
.frame-motion-plinthRise .avatar__fx {
  animation: fm-plinth 8s ease-in-out infinite;
}
.frame-motion-matSettle .avatar__fx-shell {
  animation: fm-mat-settle 0.9s cubic-bezier(0.2, 0.9, 0.25, 1) 1 both;
}
.frame-motion-pinRock .avatar__fx {
  transform-origin: 50% -12%;
  animation: fm-pin-rock 6.5s cubic-bezier(0.4, 0, 0.3, 1) infinite;
}
.frame-motion-flareDrift .avatar__fx-beam {
  animation: fm-flare-drift 11s ease-in-out infinite alternate;
}
.frame-motion-lensBreathe .avatar__fx-arc {
  animation: fm-lens 9s ease-in-out infinite;
}
.frame-motion-spectrumRun .avatar__fx-arc {
  animation: fm-spectrum 12s linear infinite;
}
.frame-motion-haloPulse .avatar__fx-aura {
  animation: fm-halo 7s ease-in-out infinite;
}
.frame-motion-ventCycle .avatar__fx-runes {
  animation: fm-vent 4.8s steps(4, end) infinite;
}
.frame-motion-signalRun .avatar__fx-beam {
  animation: fm-signal 3.6s linear infinite;
}
.frame-motion-meshBreathe .avatar__fx-runes {
  animation: fm-mesh 10s ease-in-out infinite;
}
.frame-motion-scanLock .avatar__fx-beam {
  animation: fm-lock 7s cubic-bezier(0.3, 0, 0.2, 1) infinite;
}
.frame-motion-gildShine .avatar__fx-shell {
  animation: fm-gild-shine 13s ease-in-out infinite;
}
.frame-motion-onyxSheen .avatar__fx-arc {
  animation: fm-onyx-sheen 16s ease-in-out infinite;
}
.frame-motion-inlayGlow .avatar__fx-arc {
  animation: fm-inlay-glow 8s ease-in-out infinite;
}
.frame-motion-crestLift .avatar__fx-shell {
  animation: fm-crest 9s ease-in-out infinite;
}

@keyframes fm-gutter-breathe {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 0.95; }
}
@keyframes fm-stitch {
  0% { opacity: 0.3; }
  100% { opacity: 1; }
}
@keyframes fm-fold {
  0%, 100% { transform: scaleX(1); }
  50% { transform: scaleX(1.015); }
}
@keyframes fm-roll {
  0% { transform: translateY(-110%); opacity: 0; }
  12% { opacity: 0.8; }
  88% { opacity: 0.8; }
  100% { transform: translateY(110%); opacity: 0; }
}
@keyframes fm-case-sweep {
  0%, 100% { filter: brightness(1); }
  46% { filter: brightness(1.16); }
  54% { filter: brightness(1.16); }
}
@keyframes fm-plinth {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -1.5px; }
}
@keyframes fm-mat-settle {
  0% { translate: 0 -4px; opacity: 0.4; }
  100% { translate: 0 0; opacity: 1; }
}
@keyframes fm-pin-rock {
  0%, 100% { rotate: 0deg; }
  20% { rotate: -1.6deg; }
  46% { rotate: 0.9deg; }
  70% { rotate: -0.4deg; }
}
@keyframes fm-flare-drift {
  0% { translate: -8px 0; opacity: 0.55; }
  100% { translate: 8px 0; opacity: 1; }
}
@keyframes fm-lens {
  0%, 100% { opacity: 0.5; filter: blur(0); }
  50% { opacity: 1; filter: blur(0.3px); }
}
@keyframes fm-spectrum {
  to { rotate: 360deg; }
}
@keyframes fm-halo {
  0%, 100% { scale: 1; opacity: 0.7; }
  50% { scale: 1.03; opacity: 1; }
}
@keyframes fm-vent {
  0% { clip-path: inset(0 0 72% 0); }
  100% { clip-path: inset(0 0 0 0); }
}
@keyframes fm-signal {
  0% { translate: 0 -120%; opacity: 0; }
  10% { opacity: 0.9; }
  90% { opacity: 0.9; }
  100% { translate: 0 120%; opacity: 0; }
}
@keyframes fm-mesh {
  0%, 100% { scale: 1; }
  50% { scale: 1.02; }
}
@keyframes fm-lock {
  0% { translate: 0 -100%; opacity: 0; }
  40% { translate: 0 0; opacity: 0.9; }
  72% { translate: 0 0; opacity: 0.9; }
  100% { translate: 0 0; opacity: 0.5; }
}
@keyframes fm-gild-shine {
  0%, 100% { filter: saturate(0.95) brightness(1); }
  48% { filter: saturate(1.15) brightness(1.14); }
  56% { filter: saturate(1.15) brightness(1.14); }
}
@keyframes fm-onyx-sheen {
  0%, 100% { opacity: 0.6; }
  50% { opacity: 1; }
}
@keyframes fm-inlay-glow {
  0%, 100% { filter: drop-shadow(0 0 0 rgba(232, 206, 148, 0)); }
  50% { filter: drop-shadow(0 0 4px rgba(232, 206, 148, 0.42)); }
}
@keyframes fm-crest {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -0.8px; }
}

/* Motion off and slow devices: every third-wave rule parks on its resting
   state, and the transform-based card motions release their transform so a
   card can never freeze mid-lean. */
@media (prefers-reduced-motion: reduce) {
  .profile-card[data-card-motion] {
    transform: none !important;
  }
}
html[data-motion="low"] .profile-card[data-card-motion]::after,
html[data-motion="low"] .profile-card[data-card-motion] .profile-card__frame,
html[data-motion="low"] .avatar[class*="frame-motion-"] .avatar__fx,
html[data-motion="low"] .avatar[class*="frame-motion-"] .avatar__fx > *,
html.is-slow-device .profile-card[data-card-motion]::after,
html.is-slow-device .profile-card[data-card-motion] .profile-card__frame,
html.is-slow-device .avatar[class*="frame-motion-"] .avatar__fx,
html.is-slow-device .avatar[class*="frame-motion-"] .avatar__fx > * {
  animation: none !important;
}
/* The `transform: none` half of this block now lives near the top with the
   other suppression rules, where it applies to every motion instead of only
   the two named pure-transform ones. */
