/* ══════════════════════════════════════════════════════════════════════════
   fm-bubbles.css — CAREER BUBBLES
   Opt-in ambience layer for a hero carrying [data-fm-bubbles].
   Pairs with fm-bubbles.js. Namespace: bub-  ·  no framework, no build step.

   MOTION CONTRACT
   Every bubble is a CSS keyframe animation. There is no rAF position ticker.
   Only `transform` and `opacity` animate — never top/left/width/height/margin,
   never an animated filter. Renders identically at 60Hz and 120Hz.

   Three nested elements per bubble, one motion each, so the transforms never
   fight inside a single matrix:
     .bub        outer  → vertical rise + current drift + opacity envelope
     .bub-sway   inner  → lateral sine sway + gentle roll (alternate)
     .bub-shell  skin   → surface-break swell 0.62 → 1.06

   Per-instance custom properties are written by JS:
     --bx --by --bsize --bdur --bdelay --btravel --bsway --bswaydur --bphase
     --bdrift --brot --bopacity --bhue --bfs --bfs2
   ══════════════════════════════════════════════════════════════════════════ */

/* The host owns the containing block and the stacking context. This is a STYLESHEET
   rule, not a JS style mutation: a React / DC host rewrites its inline style attribute
   on every re-render, which would silently drop both and let the field escape the hero. */
[data-fm-bubbles] {
  position: relative;
  isolation: isolate;
}

.bub-field {
  /* ---- locally-scoped component properties (the only new props defined) ---- */
  --bub-water: var(--hzg, #5FD8F0);     /* the page's own horizon-glow colour; falls back to the
                                           canvas engine's water tie-back where --hzg is absent */
  --bub-origin: 68%;                    /* fallback surface line when __fmHorizon is absent */
  --bub-nav-clear: 28px;                /* clearance added under var(--nav-h) — nothing rises into it */
  --bub-exit: 700ms;                    /* must match EXIT_FADE_MS in fm-bubbles.js */
  --bub-ease-rise: cubic-bezier(.30,.10,.42,1);
  --bub-ease-sway: cubic-bezier(.37,0,.63,1);
  --bub-ease-depth: cubic-bezier(.37,0,.63,1);
  --bub-rim: color-mix(in srgb, var(--bub-water) 46%, transparent);
  --bub-rim-soft: color-mix(in srgb, var(--bub-water) 22%, transparent);
  --bub-label: var(--bub-label-host, color-mix(in srgb, #FFFFFF 78%, var(--bub-water)));

  position: absolute;
  inset: 0;
  z-index: -1;                          /* above the host background / canvas, below all hero copy.
                                           JS sets isolation:isolate on the host so this can't escape. */
  pointer-events: none;
  overflow: hidden;
  contain: layout paint;
  opacity: 1;
  transition: opacity var(--bub-exit) cubic-bezier(.4,0,.6,1);
}

/* Hero scrolled away / document hidden → the whole layer composites out in one op. */
.bub-field[data-state="out"] { opacity: 0; }

/* ══ VIEWPORT ANCHORING ════════════════════════════════════════════════════
   The engine's horizon is position:fixed — it stays at the same VIEWPORT y while
   the hero scrolls under it, so a field parented in document flow drifts away
   from the water by exactly scrollTop. This cancels that, 1:1, with no scroll
   listener: a scroll-progress timeline over the document's own scroll range, so
   translateY = progress × maxScroll = scrollTop. Compositor-driven, correct at
   60Hz and 120Hz alike. The FIELD keeps clipping to the hero; only the LAYER
   inside it moves, so nothing can ever paint outside the hero box.
   No @supports match = no animation = anchored at arm, exactly as the page
   already treats its parallax. data-track is set only when a horizon is actually
   published; the --bub-origin fallback is hero-relative and must not track. */
.bub-layer { position: absolute; inset: 0; }

@supports (animation-timeline: scroll(root block)) {
  .bub-field[data-track="1"] .bub-layer {
    animation: bub-track linear both;
    animation-timeline: scroll(root block);   /* the DOCUMENT scroller — the field's own
                                                 overflow:hidden would otherwise be picked as
                                                 the nearest scroll container and never advance */
  }
}
@keyframes bub-track {
  /* Relative to the ARM scroll, not absolute: --bub-track-base is the scrollTop
     recorded when the field armed, so translate = scrollTop(t) - armScrollTop and
     a population spawned mid-page (or on re-entry) sits exactly where it was
     measured. At scroll 0 base is 0 and behaviour is unchanged. */
  from { transform: translate3d(0, calc(var(--bub-track-base, 0px) * -1), 0); }
  to   { transform: translate3d(0, calc(var(--bub-scroll-max, 0px) - var(--bub-track-base, 0px)), 0); }
}

/* ── outer: the rise ─────────────────────────────────────────────────────── */
.bub {
  position: absolute;
  left: var(--bx);
  top: var(--by);
  z-index: var(--bz, 2);          /* depth tier — far bubbles pass behind near ones */
  width: var(--bsize);
  height: var(--bsize);
  margin-left: calc(var(--bsize) / -2);
  margin-top: calc(var(--bsize) / -2);
  opacity: 0;
  animation:
    bub-rise var(--bdur) var(--bub-ease-rise) var(--bdelay) both,
    var(--bub-fade-name, bub-fade) var(--bdur) linear var(--bdelay) both;
}

@keyframes bub-rise {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(var(--bdrift), calc(var(--btravel) * -1), 0); }
}

/* Static fallback envelope. fm-bubbles.js injects bub-fade-dyn from FADE_START /
   FADE_END and points --bub-fade-name at it, so the tunables really drive the fade. */
@keyframes bub-fade {
  0%        { opacity: 0; }
  6%        { opacity: var(--bopacity); }
  70%       { opacity: var(--bopacity); }
  88%, 100% { opacity: 0; }
}

/* ── inner: the sway ─────────────────────────────────────────────────────── */
.bub-sway {
  position: absolute;
  inset: 0;
  animation: bub-sway var(--bswaydur) var(--bub-ease-sway) var(--bphase) infinite alternate;
}

@keyframes bub-sway {
  from { transform: translate3d(calc(var(--bsway) * -1), 0, 0) rotate(calc(var(--brot) * -1)); }
  to   { transform: translate3d(var(--bsway), 0, 0) rotate(var(--brot)); }
}

/* ── depth: travel toward and away from the viewer (v7) ──────────────────────
   A third nested element so the scale arc never fights the rise or the sway.
   The old 1.0 → 1.06 swell is absorbed here; the 0% stop keeps the surface-break
   pop. One arc per rise (d1 → d2 → d1); the two-arc ambient variant alternates
   the same two stops. Depth duration is 0.72–0.9 of the rise so it stays
   incommensurate with the sway and the field never locks into a rhythm. */
.bub-depth {
  position: absolute;
  inset: 0;
  animation: bub-depth var(--bddur) var(--bub-ease-depth) var(--bdelay) both;
}
.bub[data-arcs="2"] .bub-depth { animation-name: bub-depth2; }
@keyframes bub-depth {
  0%   { transform: scale(calc(var(--bd1) * 0.62)); }
  7%   { transform: scale(var(--bd1)); }
  52%  { transform: scale(var(--bd2)); }
  100% { transform: scale(var(--bd1)); }
}
@keyframes bub-depth2 {
  0%   { transform: scale(calc(var(--bd1) * 0.62)); }
  7%   { transform: scale(var(--bd1)); }
  30%  { transform: scale(var(--bd2)); }
  55%  { transform: scale(var(--bd1)); }
  80%  { transform: scale(var(--bd2)); }
  100% { transform: scale(var(--bd1)); }
}

/* ── focus: the third depth channel (v7.1). NO blur radius ever animates: the
   soft twin is rasterised once with a static blur, and the depth animation
   crossfades the two layers' OPACITIES in counterphase. Same duration, delay,
   easing and stop structure as the scale arc, so they cannot drift. Far = soft
   layer up; near = sharp layer up. A face is sharpest exactly when largest. */
.bub-shell {
  position: absolute;
  inset: 0;
  border-radius: var(--r-circle, 50%);
  overflow: hidden;
  animation: bub-focus var(--bddur) var(--bub-ease-depth) var(--bdelay) both;
}
.bub[data-arcs="2"] .bub-shell { animation-name: bub-focus2; }
.bub-soft {
  filter: blur(var(--bblur, 1.5px));
  animation: bub-soft var(--bddur) var(--bub-ease-depth) var(--bdelay) both;
}
.bub[data-arcs="2"] .bub-soft { animation-name: bub-soft2; }
@keyframes bub-focus  { 0%, 7% { opacity: var(--bo1); } 52% { opacity: var(--bo2); } 100% { opacity: var(--bo1); } }
@keyframes bub-focus2 { 0%, 7% { opacity: var(--bo1); } 30% { opacity: var(--bo2); } 55% { opacity: var(--bo1); } 80% { opacity: var(--bo2); } 100% { opacity: var(--bo1); } }
@keyframes bub-soft   { 0%, 7% { opacity: var(--bs1); } 52% { opacity: var(--bs2); } 100% { opacity: var(--bs1); } }
@keyframes bub-soft2  { 0%, 7% { opacity: var(--bs1); } 30% { opacity: var(--bs2); } 55% { opacity: var(--bs1); } 80% { opacity: var(--bs2); } 100% { opacity: var(--bs1); } }

/* ── PORTRAIT — the subject. 3–4 concurrent, carries the career photos. ──── */
.bub-portrait { will-change: transform, opacity; }
.bub-portrait .bub-sway,
.bub-portrait .bub-depth { will-change: transform; }

.bub-portrait .bub-shell {
  background: color-mix(in srgb, var(--bub-water) 7%, transparent);
  -webkit-backdrop-filter: blur(2px) saturate(1.15);
  backdrop-filter: blur(2px) saturate(1.15);
}

.bub-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 32%;             /* face sits in the upper third of a 3:4 portrait crop */
  border-radius: var(--r-circle, 50%);
  display: block;
}

/* Labelled placeholder — visible slot number + stage slug, so anyone can see
   at a glance which bubble is which and what shot is still missing. */
.bub-ph {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 13%;
  border-radius: var(--r-circle, 50%);
  text-align: center;
  font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace);
  color: var(--bub-label);
  background:
    repeating-linear-gradient(135deg,
      color-mix(in srgb, var(--bub-water) 7%, transparent) 0 5px,
      transparent 5px 11px),
    radial-gradient(closest-side,
      color-mix(in srgb, var(--bhue) 15%, transparent),
      color-mix(in srgb, var(--bub-water) 6%, transparent));
}
.bub-ph-n {
  font-size: var(--bfs, 15px);
  font-weight: 600;
  letter-spacing: .16em;
  line-height: 1;
  opacity: .96;
}
.bub-ph-s {
  font-size: var(--bfs2, 9px);
  font-weight: 400;
  letter-spacing: .05em;
  line-height: 1.25;
  overflow-wrap: anywhere;
  opacity: .72;
}

/* Rim + specular. Sits above the photo so the bubble reads as glass, not a hole. */
.bub-glass {
  position: absolute;
  inset: 0;
  border-radius: var(--r-circle, 50%);
  background:
    radial-gradient(52% 42% at 31% 21%, rgba(255,255,255,.40), rgba(255,255,255,0) 62%),
    radial-gradient(120% 120% at 76% 86%, color-mix(in srgb, var(--bhue) 24%, transparent), transparent 58%);
  box-shadow:
    inset 0 0 0 1px var(--bub-rim),
    inset 0 10px 24px -12px rgba(255,255,255,.42),
    inset 0 -12px 26px -14px color-mix(in srgb, var(--bhue) 46%, transparent),
    0 0 26px -6px color-mix(in srgb, var(--bhue) 30%, transparent);
}

/* ── AMBIENT — ambience only. 8–12 concurrent, no photo, no backdrop-filter. ── */
.bub-ambient .bub-shell {
  background:
    radial-gradient(54% 44% at 32% 24%, rgba(255,255,255,.62), rgba(255,255,255,0) 64%),
    radial-gradient(closest-side,
      color-mix(in srgb, var(--bub-water) 14%, transparent),
      color-mix(in srgb, var(--bub-water) 4%, transparent));
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--bub-water) 46%, transparent),
    0 0 12px -4px color-mix(in srgb, var(--bhue) 40%, transparent);
}

/* ══ REDUCED MOTION ════════════════════════════════════════════════════════
   No rising motion at all. JS renders the portrait bubbles only, in a calm
   arrangement near the horizon; they arrive on a plain opacity fade. The photos
   carry content, so we stop the movement — we do not render nothing.          */
.bub-field[data-rm="1"] .bub {
  animation: bub-settle 900ms ease-out var(--bdelay) both;
}
.bub-field[data-rm="1"] .bub-sway,
.bub-field[data-rm="1"] .bub-depth,
.bub-field[data-rm="1"] .bub-shell,
.bub-field[data-rm="1"] .bub-soft {
  animation: none;
  transform: none;
}
.bub-field[data-rm="1"] .bub-shell { opacity: 1; }
.bub-field[data-rm="1"] .bub-soft { opacity: var(--bs1, 0.3); }
@keyframes bub-settle {
  from { opacity: 0; }
  to   { opacity: var(--bopacity); }
}

/* JS-less / pre-arm guard: honour the OS setting even before the attribute lands. */
@media (prefers-reduced-motion: reduce) {
  .bub-field:not([data-rm="0"]) .bub {
    animation: bub-settle 900ms ease-out var(--bdelay) both;
  }
  .bub-field:not([data-rm="0"]) .bub-sway,
  .bub-field:not([data-rm="0"]) .bub-depth,
  .bub-field:not([data-rm="0"]) .bub-shell,
  .bub-field:not([data-rm="0"]) .bub-soft {
    animation: none;
    transform: none;
  }
  .bub-field { transition: none; }
}

/* ══ SMALL SCREENS ═════════════════════════════════════════════════════════
   344px portrait (Z Flip 7) is the acceptance width. Counts drop in JS; here the
   faces get relatively larger so they stay legible, and the label tightens.
   [data-sm] is set from the MEASURED host width, so it is correct inside a
   narrow stage as well as at a narrow viewport. */
.bub-field[data-sm="1"] .bub-ph { padding: 11%; gap: 2px; }
.bub-field[data-sm="1"] .bub-ph-s { letter-spacing: .02em; }
.bub-field[data-sm="1"] .bub-portrait .bub-shell {
  -webkit-backdrop-filter: blur(1.5px) saturate(1.1);
  backdrop-filter: blur(1.5px) saturate(1.1);
}

@media (max-width: 640px) {
  .bub-field { --bub-nav-clear: 20px; }
  .bub-field .bub-ph { padding: 11%; gap: 2px; }
}

/* ══ LIGHT MATERIAL ═══════════════════════════════════════════════════════
   Opt-in per host: data-fm-bubbles-tone="light". Much lower fill alpha, no
   hatch, separation by a fine water rim and the site's light shadow recipe. */
.bub-field[data-tone="light"] .bub-ph {
  background: radial-gradient(closest-side,
    color-mix(in srgb, var(--bhue) 9%, transparent),
    color-mix(in srgb, var(--bub-water) 5%, transparent));
}
.bub-field[data-tone="light"] .bub-glass {
  background:
    radial-gradient(52% 42% at 31% 21%, rgba(255,255,255,.55), rgba(255,255,255,0) 62%),
    radial-gradient(120% 120% at 76% 86%, color-mix(in srgb, var(--bhue) 12%, transparent), transparent 58%);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--bub-water) 52%, transparent),
    inset 0 10px 24px -12px rgba(255,255,255,.6),
    0 14px 30px -10px rgba(16,24,48,0.16);
}
.bub-field[data-tone="light"] .bub-ambient .bub-shell {
  background:
    radial-gradient(54% 44% at 32% 24%, rgba(255,255,255,.7), rgba(255,255,255,0) 64%),
    radial-gradient(closest-side,
      color-mix(in srgb, var(--bub-water) 9%, transparent),
      color-mix(in srgb, var(--bub-water) 2%, transparent));
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--bub-water) 44%, transparent),
    0 8px 18px -8px rgba(16,24,48,0.14);
}

/* ══ THE TURN (v8.1) — how a BUBBLE changes identity ══════════════════════
   v8 rotated one disc to 90° and swapped at the edge. Wrong physics: at 90° a
   sphere becomes a 1px line, so the bubble read as a paper card being flipped —
   the exact artefact in review.

   A bubble has no edge. So the turn is a VOLUME turn, and both faces exist at
   once through the whole of it:
     · the outgoing face swings back and away  (0° → -50°, pushed back in z)
     · the incoming face swings in from the far side (+50° → 0°, with a small
       settle overshoot) and crossfades UP while the outgoing one fades DOWN,
       their opacity windows overlapping in the middle — so the disc is never
       thinner than ~cos(25°) and the silhouette stays a full circle
     · the water WOBBLES: surface tension squash-and-stretch on the inner disc
       while the rim (.bub-glass) stays perfectly round — the skin holds, the
       contents slosh. This is what sells "bubble" over "card"
     · a specular GLINT sweeps once at the crossover, the way light catches a
       real bubble as it turns

   Transform and opacity only, per the file's motion contract — no animated
   filter, no animated blur; softness stays the job of the pre-blurred twin. */
.bub-flip {
  position: absolute;
  inset: 0;
  border-radius: var(--r-circle, 50%);
  overflow: hidden;
  transform-origin: 50% 50%;
}
.bub-face {
  position: absolute;
  inset: 0;
  border-radius: var(--r-circle, 50%);
  overflow: hidden;
  backface-visibility: hidden;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

/* The two halves of one turn. --bturn is the duration, written by JS. */
/* Rotation is kept to ±42° and paired with a lateral slide: the face travels AROUND
   the sphere and off it, instead of narrowing in place. The overlap window is short
   and asymmetric — each face is clearly dominant while it is on, so the crossover
   never reads as a double exposure. */
@keyframes bub-face-out {
  0%   { transform: perspective(760px) rotateY(0deg) translate3d(0, 0, 0) scale(1); opacity: 1; }
  26%  { opacity: 0.74; }
  48%  { opacity: 0; }
  100% { transform: perspective(760px) rotateY(-42deg) translate3d(-9%, 0, -30px) scale(0.95); opacity: 0; }
}
@keyframes bub-face-in {
  0%   { transform: perspective(760px) rotateY(44deg) translate3d(10%, 0, -30px) scale(0.95); opacity: 0; }
  20%  { opacity: 0; }
  46%  { opacity: 0.6; }
  68%  { opacity: 1; }
  87%  { transform: perspective(760px) rotateY(-2.6deg) translate3d(-0.6%, 0, 0) scale(1.003); opacity: 1; }
  100% { transform: perspective(760px) rotateY(0deg) translate3d(0, 0, 0) scale(1); opacity: 1; }
}

/* Surface tension. Peak squash early, one soft counter-swing, settled by the end.
   Deliberately NOT on .bub-shell: the rim must stay a circle throughout. */
@keyframes bub-turn-wobble {
  0%   { transform: scale(1, 1); }
  20%  { transform: scale(1.05, 0.952); }
  48%  { transform: scale(0.968, 1.032); }
  74%  { transform: scale(1.012, 0.99); }
  100% { transform: scale(1, 1); }
}

/* One pass of light across the sphere, brightest as the faces cross. */
.bub-glint {
  position: absolute;
  inset: 0;
  border-radius: var(--r-circle, 50%);
  pointer-events: none;
  opacity: 0;
  mix-blend-mode: screen;
  background: linear-gradient(108deg,
    rgba(255,255,255,0) 30%,
    rgba(255,255,255,0.26) 46%,
    rgba(255,255,255,0.05) 55%,
    rgba(255,255,255,0) 64%);
}
@keyframes bub-glint {
  0%   { opacity: 0; transform: translate3d(-30%, 5%, 0); }
  46%  { opacity: 0.42; }
  100% { opacity: 0; transform: translate3d(30%, -5%, 0); }
}

/* Reduced motion: the identity still changes, but nothing turns — a plain
   crossfade of the two faces, no wobble, no glint. */
.bub-field[data-rm="1"] .bub-flip { animation: none !important; transform: none; }
.bub-field[data-rm="1"] .bub-glint { animation: none !important; opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .bub-field:not([data-rm="0"]) .bub-flip { animation: none !important; transform: none; }
  .bub-field:not([data-rm="0"]) .bub-glint { animation: none !important; opacity: 0; }
}

/* A repelled face is being pushed off the copy column — hold it further back so the
   shove reads as depth as well as sideways travel. */
.bub-portrait[data-repelled="1"] .bub-glass {
  box-shadow:
    inset 0 0 0 1px var(--bub-rim-soft),
    inset 0 10px 24px -12px rgba(255,255,255,.34),
    0 0 22px -8px color-mix(in srgb, var(--bhue) 24%, transparent);
}

/* ══ THE HOLE ══════════════════════════════════════════════════════════════
   A host that declares data-fm-bubbles-avoid gets a soft exclusion in the field
   itself: bubbles fade out as they drift behind the copy and re-form as they
   leave it. fm-bubbles.js writes --bub-hole from the measured box (see measure).
   This replaces the backdrop plate that was tried first — masking the field costs
   one composited mask, blurs nothing, and leaves no visible edge anywhere. */
.bub-field[data-hole="1"] {
  -webkit-mask-image: var(--bub-hole);
  mask-image: var(--bub-hole);
}
