/* CornerStone holdable variants — verbatim port of the @orbit/ui
   component-cornerstone.html preview (base + liquid + stripes + flames + triple).
   Used by the Envoy launch CTA (v-triple v-liquid v-stripes v-flames). */

  .cornerstone.holdable {
    position: relative;
    overflow: hidden;
    min-width: 320px;
    justify-content: center;
    user-select: none;
    background: var(--inv-100);
    border-radius: 64px;
  }
  .cornerstone.holdable .text,
  .cornerstone.holdable .icon-trail {
    position: relative; z-index: 2;
    color: #fff;
    mix-blend-mode: difference;
  }
  .cornerstone.holdable:hover .text { color: #fff; }
  /* `.swap` was previously gated behind a `swap-in` keyframe that
     drove opacity 0 → 1.  Real-world browsers (and the html-to-image
     capture pipeline) sometimes stalled the animation at time 0 for
     elements composited under `mix-blend-mode: difference`, which
     left the headline CTA reading as an empty pill.  Static default
     is opacity 1 so the rest state is always legible; a tiny CSS
     transition fires when the JS swaps the innerHTML during the
     hold (the new node enters with `key`-driven `swap-enter` class
     bumped on for one frame). */
  /* Text swap is now a true crossfade — the OUTGOING span fades
     + blurs out while the NEW span fades in.  Both spans live
     in the SAME grid cell so they perfectly stack without any
     position:absolute trickery, and the container has a min-width
     so the button stops resizing as beat text changes width. */
  .cornerstone.holdable .text {
    position: relative;
    display: inline-grid;
    grid-template-areas: "stack";
    align-items: center;
    justify-items: center;
    min-width: 220px;
    min-height: 1.2em;
  }
  .cornerstone.holdable.small .text { min-width: 140px; }
  .cornerstone.holdable .text .swap {
    grid-area: stack;
    display: inline-block;
    white-space: nowrap;
    opacity: 1;
    filter: blur(0);
    transform: translateY(0);
    /* Default (= enter) transition has a delay so the old span
       finishes fading OUT before the new one starts fading IN.
       Sequential rather than concurrent crossfade. */
    transition: opacity .22s var(--ease-out) .20s,
                filter   .22s var(--ease-out) .20s,
                transform .22s var(--ease-out) .20s;
  }
  /* Entering span starts blurred + offset down; one frame later
     the classes drop and the transition runs it into rest. */
  .cornerstone.holdable .text .swap.swap-enter {
    opacity: 0;
    filter: blur(6px);
    transform: translateY(3px);
  }
  /* Leaving span exits IMMEDIATELY (no delay) so it's gone by the
     time the entering span starts its delayed transition. */
  .cornerstone.holdable .text .swap.swap-leave {
    opacity: 0;
    filter: blur(6px);
    transform: translateY(-3px);
    pointer-events: none;
    transition: opacity .18s var(--ease-out),
                filter   .18s var(--ease-out),
                transform .18s var(--ease-out);
  }
  .cornerstone.holdable.launched { box-shadow: 0 0 32px var(--40), 0 0 48px var(--40), 0 0 8px var(--40); }

  /* ── LIQUID — true fuel fill, two-layer meniscus.
     A growing body (`.body`) anchored at the bottom whose height
     IS the fill level — driven by `--level` (0 % → 100 %) from
     the JS hold loop.  Two sine-wave SVG strips ride on top of
     that body as front + back layers panning in opposite
     directions, so the meniscus shears like real liquid.  Each
     wave SVG path fills DOWNWARD from the sine crest to the
     strip's bottom edge so it overlaps the body cleanly with no
     visible seam.  Bubbles rise from the floor of the body and
     pop at the meniscus; a sheen highlight tracks the crest. */
  .v-liquid .fill {
    position: absolute; inset: 0; z-index: 1; overflow: hidden;
    pointer-events: none;
    --level-frac: 0;
  }
  /* The fluid body — sized in PIXELS by JS via `body.style.height`
     (and inset:0 with bottom anchoring).  CSS variable + percentage
     resolution against an `inset: 0` containing block was failing
     silently in this sandbox; px-driven height is bulletproof. */
  .v-liquid .fill .fluid-body {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 0;
    background: linear-gradient(180deg,
      rgba(255,255,255,0.78) 0%,
      rgba(255,255,255,0.92) 35%,
      rgba(255,255,255,1)    100%);
  }
  /* Wave strips ride at the meniscus — bottom edge sits at
     calc(var(--level-frac) * 100%), top edge floats above the
     body.  The SVG fill extends from the sine crest DOWN to the
     strip's bottom edge so it merges seamlessly with the body. */
  .v-liquid .fill .wave {
    position: absolute;
    left: 0; right: 0;
    bottom: var(--level-px, 0px);
    pointer-events: none;
    transition: bottom .04s linear;
    z-index: 2;
  }
  .v-liquid .fill .wave.back  { height: 16px; opacity: 0.6; margin-bottom: -1px; }
  .v-liquid .fill .wave.front { height: 12px; margin-bottom: -1px; }
  .v-liquid .fill .wave svg {
    position: absolute;
    left: 0; top: 0;
    width: 300%;
    height: 100%;
    display: block;
  }
  .v-liquid .fill .wave.back  svg { animation: liquid-pan-back  5.4s linear infinite; }
  .v-liquid .fill .wave.front svg { animation: liquid-pan-front 3.2s linear infinite; }
  @keyframes liquid-pan-back  { from { transform: translateX(-66.667%); } to { transform: translateX(0); } }
  @keyframes liquid-pan-front { from { transform: translateX(0); } to { transform: translateX(-66.667%); } }
  /* Sheen highlight — bright hairline tracking the meniscus. */
  .v-liquid .fill .sheen {
    position: absolute;
    left: 4%; right: 4%;
    bottom: calc(var(--level-px, 0px) + 9px);
    height: 1px;
    background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 35%, rgba(255,255,255,0.85) 65%, rgba(255,255,255,0) 100%);
    mix-blend-mode: screen;
    transition: bottom .04s linear, opacity .12s linear;
    opacity: var(--sheen-opacity, 0);
    z-index: 3;
  }
  /* Bubble container — sized in PIXELS by JS to match the body
     so bubbles only render INSIDE the fluid.  overflow: hidden
     clips bubbles whose translateY animation carries them past
     the meniscus — they "pop" at the surface naturally. */
  .v-liquid .fill .bubbles {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 0;
    pointer-events: none;
    overflow: hidden;
    z-index: 1;
  }
  /* Bubbles rise from the floor of the body toward the meniscus. */
  .v-liquid .fill .bubble {
    position: absolute;
    bottom: 4px;
    left: var(--bx, 50%);
    width: var(--bs, 5px);
    height: var(--bs, 5px);
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, rgba(255,255,255,1) 0%, rgba(255,255,255,0.55) 60%, rgba(255,255,255,0) 100%);
    filter: drop-shadow(0 0 3px rgba(255,255,255,0.65));
    animation: liquid-bubble var(--bd, 2s) ease-in infinite;
    animation-delay: var(--bdl, 0s);
    animation-play-state: paused;
    opacity: 0;
  }
  .v-liquid .fill.has-volume .bubble { animation-play-state: running; }
  @keyframes liquid-bubble {
    0%   { transform: translateY(0)     scale(0.55); opacity: 0; }
    15%  { opacity: 1; }
    85%  { transform: translateY(calc(-1 * var(--rise, 40px))) scale(1); opacity: 0.9; }
    95%  { transform: translateY(calc(-1 * var(--rise, 40px) - 2px)) scale(1.3); opacity: 0; }
    100% { transform: translateY(calc(-1 * var(--rise, 40px) - 2px)) scale(1.3); opacity: 0; }
  }
  /* Terminal full-white wash that locks the button at launched. */
  .v-liquid .fill::after {
    content: "";
    position: absolute; inset: 0;
    background: #fff;
    opacity: var(--end-opacity, 0);
    transition: opacity .04s linear;
    pointer-events: none;
    z-index: 4;
  }

  /* ── SMALL DENSITY — port of the cornerstone shrunk for inline
     contexts (toolbar slots, list-row actions, sidecar callouts).
     32-px tall, 200-px min-width, smaller text + icon.  Every fill
     variant keeps working because the fills are anchored against
     `.fill`'s bounds, not absolute pixel sizes. */
  .cornerstone.holdable.small {
    height: 32px;
    min-width: 200px;
    padding: 0 16px;
    border-radius: 16px;
    font-size: 12px;
  }
  .cornerstone.holdable.small .text { font-size: 12px; }
  .cornerstone.holdable.small .icon-trail [data-orbit] { width: 12px; height: 12px; }
  .small-row { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }

  /* ── SwitchButton — verbatim port of
     orbit-ui-main/orbit-ui/src/components/buttons/SwitchButton.svelte.
     Geometry copied 1:1 (container 194×70, disc 72×72, range 0..152,
     border-radius 82px on the capsule, triple soft-glow box-shadow on
     the disc).  The disc shifts to `var(--inv-60)` background +
     `var(--40)` border + backdrop-blur on .active, NOT a brand
     accent; matches the Svelte file's `:global(.main-switch.active)`
     rule.  Icon morphs idle→drag→done; the drag glyph rotates 180°.
     Text fades opacity = `Max(0, 1 - (pos + 24) / 128)`, NOT linear
     pos/max.  Disc glyph sits in a 54-px svg inside a 72-px slot.
     The text loops a typewriter effect while not dragging — we
     approximate with a CSS `caret` animation since this card has
     no Svelte runtime. */
  .switch-container {
    position: relative;
    display: flex;
    width: 236px;
    height: 70px;
    border: 1px solid var(--40);
    border-radius: 82px;
    padding: 6px 6px 6px 6px;
    background: var(--inv-40);
    align-items: center;
    gap: 12px;
    user-select: none;
    overflow: visible;
  }
  .switch-container .main-switch {
    position: relative;
    width: 72px;
    height: 72px;
    margin-top: -2px;
    background: var(--100);
    border-radius: 50%;
    border: 1px solid transparent;
    color: var(--inv-100);
    box-shadow:
      0 0 10px var(--40),
      0 0 10px var(--40),
      0 0 10px var(--40);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: grab;
    touch-action: none;
    will-change: transform;
    transition:
      box-shadow 0.3s ease,
      background 0.3s ease,
      border 0.3s ease;
  }
  .switch-container .main-switch:hover { color: var(--inv-100); }
  .switch-container .main-switch.active {
    color: var(--100);
    background: var(--inv-60);
    border: 1px solid var(--40);
    box-shadow: none;
    backdrop-filter: blur(6px);
    cursor: grabbing;
  }
  .switch-container .main-switch [data-orbit] {
    width: 54px;
    height: 54px;
    color: inherit;
    transition: transform 0.25s ease, color 0.3s ease;
  }
  /* During drag we keep the icon at its native orientation (the
     orbit `arrow` glyph already points right toward the target)
     but layer on a continuous "nudge right" pulse so the icon
     feels alive and reinforces the direction the user should
     drag.  The source rotated 180° because its glyph stack
     pointed the other way; that would flip our arrow to LEFT
     mid-drag.  This pulse loop replaces that rotation with
     directionally-correct motion. */
  .switch-container .main-switch.dragging [data-orbit] {
    transform: rotate(0deg) translateX(0);
    animation: switch-arrow-nudge 0.9s ease-in-out infinite;
  }
  @keyframes switch-arrow-nudge {
    0%, 100% { transform: translateX(0) scale(1); }
    45%      { transform: translateX(5px) scale(1.06); }
    55%      { transform: translateX(5px) scale(1.06); }
  }
  /* On commit, the icon settles to centre with a soft scale-in
     so the final "done" glyph lands rather than appears. */
  .switch-container .main-switch.committed [data-orbit] {
    transform: rotate(0deg) translateX(0);
    animation: switch-icon-land 0.32s var(--ease-back) both;
  }
  @keyframes switch-icon-land {
    0%   { transform: scale(0.4); opacity: 0; }
    60%  { opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
  }
  /* Soft glow ring around the disc that the source carries via
     the `glow` prop on Icon during drag. */
  .switch-container .main-switch.dragging { filter: drop-shadow(0 0 4px var(--100)); }
  .switch-container .switch-text {
    margin: 0;
    flex: 1;
    white-space: nowrap;
    text-align: center;
    color: var(--60);
    font-family: var(--f-sans);
    font-weight: 180;
    font-size: 14px;
    letter-spacing: 0.16px;
    text-shadow: none;
    pointer-events: none;
    transition: color 0.3s ease, opacity 0.3s ease;
  }
  .switch-container:hover .switch-text { color: var(--100); }
  /* The caret blinks for the Typewriter feel even without a Svelte
     runtime — it sits at the END of the text so the label reads as
     a live typed prompt. */
  .switch-container .switch-text::after {
    content: "_";
    margin-left: 1px;
    animation: switch-caret 1.1s steps(2) infinite;
    color: inherit;
  }
  @keyframes switch-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
  .switch-row { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; }
  /* Inverted SwitchButton — dark capsule + dark disc on the light
     side, switches to a black disc on a white capsule for the dark
     side.  The source's `inverted` prop is wired but the source CSS
     has no rule — we add one here.  Capsule swaps `--inv-40` →
     `--40`, disc swaps `--100` → `--inv-100`, and the active state
     mirrors back. */
  .switch-container.is-inverted {
    background: var(--40);
    border-color: var(--inv-40);
  }
  .switch-container.is-inverted .main-switch {
    background: var(--inv-100);
    color: var(--100);
    box-shadow:
      0 0 10px var(--inv-40),
      0 0 10px var(--inv-40),
      0 0 10px var(--inv-40);
  }
  .switch-container.is-inverted .main-switch.active {
    background: var(--40);
    color: var(--inv-100);
    border: 1px solid var(--inv-40);
    box-shadow: none;
  }
  .switch-container.is-inverted .switch-text { color: var(--inv-60); }
  .switch-container.is-inverted:hover .switch-text { color: var(--inv-100); }

  /* All variants share the canonical cornerstone chrome.  Every
     button now uses .cornerstone.holdable (large or small);
     .mini-btn is dead.  The shared rule below sizes the fill slot
     properly across every variant so the SVG/CSS animations are
     anchored against the button's bounds. */
  .cornerstone.holdable .fill {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    overflow: hidden;
    border-radius: inherit;
  }
  /* Small density */
  .cornerstone.holdable.small {
    min-width: 160px;
    height: 36px;
    border-radius: 24px;
  }

  /* Shared easing tokens (referenced by JS through computed style
     where useful; mostly here so authors reading the file can see
     the curve choices in one place). */
  :root {
    --ease-out:    cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-q:  cubic-bezier(0.22, 1, 0.36, 1);
    --ease-back:   cubic-bezier(0.34, 1.56, 0.64, 1);
    --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  }

  /* 1. HYPERSPACE — stars hidden at rest; reveal on hover as a
     gentle scintillation, then stretch into FTL trails during the
     hold.  Each star carries its own twinkle phase + drift so the
     field never looks like a static rendering, and the central
     burst at p≈1 floods the surface white.

     v3: every transition switched from `.04s linear` to
     `.16s var(--ease-out)` so when the release ramp eases p back
     to 0 the trails retract smoothly instead of clicking off. */
  .v-sparks .fill {
    background: transparent;
    overflow: hidden;
  }
  .v-sparks .fill .star-pt {
    transition: opacity .25s var(--ease-out), transform .25s var(--ease-out);
  }
  .v-sparks .fill .star-trail {
    transition: opacity .14s var(--ease-out);
  }
  .v-sparks .fill .star-burst {
    transition: transform .16s var(--ease-out), opacity .16s var(--ease-out);
  }
  /* Idle parallax drift — every starfield slowly slides across,
     giving the rest state the same sense of life the liquid waves
     have.  Suspended during a hold so the JS-driven trails read
     cleanly. */
  .v-sparks:not(.holding) .fill .starfield {
    animation: spark-drift 18s linear infinite;
  }
  @keyframes spark-drift {
    0%   { transform: translateX(0); }
    100% { transform: translateX(-8%); }
  }
  .v-sparks .fill svg.starfield {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    display: block; overflow: visible;
  }
  /* At rest stars are invisible.  Hover reveals the scintillation. */
  .v-sparks .fill .star-pt {
    fill: #fff;
    opacity: 0;
    transform-origin: center;
    transform-box: fill-box;
    transition: opacity .35s var(--ease-out);
  }
  .v-sparks:hover .fill .star-pt {
    opacity: 1;
    animation: starpt-twinkle var(--twk, 1.4s) ease-in-out infinite;
    animation-delay: var(--sdl, 0s);
    filter: drop-shadow(0 0 2px rgba(255,255,255,0.6));
  }
  /* CLICK-PLAY — driven by JS (station-surfaces.js): on click the warp runs
     to completion in ~1s via .holding + the trail/burst paint, then the
     surface transitions. Hold behaviour (if ever wired) is unaffected. */
  .v-sparks .fill .star-trail {
    stroke: #fff;
    stroke-width: 1.2;
    stroke-linecap: round;
    filter: drop-shadow(0 0 4px #fff);
    opacity: 0;
    transition: opacity .04s linear;
  }
  .v-sparks.holding .fill .star-trail { opacity: 1; }
  .v-sparks.holding .fill .star-pt { opacity: 0; }
  /* Gentler twinkle so the rest field doesn't look like fireflies. */
  @keyframes starpt-twinkle {
    0%, 100% { transform: scale(0.65); opacity: 0.35; }
    50%      { transform: scale(1.1);  opacity: 1; }
  }
  /* Center sun — dim point at hover, blasts wide at p≈1.  Hidden
     completely until the hold begins so the burst reads as a real
     ignition event. */
  .v-sparks .fill .star-burst {
    fill: #fff;
    filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 3px #fff);
    transform-origin: center;
    transform-box: fill-box;
    transition: transform .04s linear, opacity .04s linear;
    transform: scale(0);
    opacity: 0;
  }
  /* Terminal full white wash for hyperspace — drives to opacity 1. */
  .v-sparks .fill .solid {
    position: absolute; inset: 0;
    background: #fff;
    opacity: 0;
    transition: opacity .04s linear;
    pointer-events: none;
    z-index: 2;
  }

  /* 2. APERTURE — iris reveal that respects the label.
     v3 (operator-flag: v2 covered the button with a conic-gradient
     at p=0 so the text was unreadable at rest).  Redesign:
       • At p=0 the fill is EMPTY (transparent) — the dark button +
         label read as the rest state, no overlay.
       • As p grows, a white iris OPENS from the centre via an
         8-sided polygon clip-path on a solid white slab.  The
         polygon points scale outward proportional to `--ap-frac`.
       • A faint blade hairline SVG rotates 60° over the hold to
         carry the mechanical feel of a camera shutter.
       • Final 15% of the hold the slab snaps to a circle so the
         button reads as fully open.
     The text uses `mix-blend-mode: difference` already, so the
     label remains readable through every frame: dark on the dark
     button, then black on the rising white iris. */
  .v-aperture .fill {
    background: transparent;
    overflow: hidden;
  }
  .v-aperture .iris-slab {
    position: absolute;
    inset: 0;
    background: #fff;
    /* Octagon clip; `--ap-r` is the OUTER radius in %.  Setting it
       to 0 collapses the polygon to a point at the centre. */
    --ap-r: 0;
    clip-path: polygon(
      50% calc(50% - var(--ap-r) * 1%),
      calc(50% + var(--ap-r) * 0.707%) calc(50% - var(--ap-r) * 0.707%),
      calc(50% + var(--ap-r) * 1%) 50%,
      calc(50% + var(--ap-r) * 0.707%) calc(50% + var(--ap-r) * 0.707%),
      50% calc(50% + var(--ap-r) * 1%),
      calc(50% - var(--ap-r) * 0.707%) calc(50% + var(--ap-r) * 0.707%),
      calc(50% - var(--ap-r) * 1%) 50%,
      calc(50% - var(--ap-r) * 0.707%) calc(50% - var(--ap-r) * 0.707%)
    );
    transition: clip-path .18s var(--ease-out);
  }
  /* Blade hairlines — 8 radial lines from centre out to the
     perimeter, drawn as thin SVG strokes so they stay 0.6 px at
     any scale.  Rotates with the hold to imply blade motion. */
  .v-aperture .iris-blades {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  .v-aperture .iris-blades svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    transform-origin: 50% 50%;
    transform: rotate(var(--ap-rot, 0deg));
    transition: transform .22s var(--ease-out);
  }
  /* Ambient idle rotation — slow drift so the rest state has the
     same low-key life that the liquid bubbles give the canonical. */
  .v-aperture:not(.holding) .iris-blades svg {
    animation: aperture-drift 18s linear infinite;
  }
  @keyframes aperture-drift {
    0%   { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
  }
  .v-aperture:not(.holding) .iris-blades line { opacity: 0.10; }
  .v-aperture .iris-blades line {
    stroke: rgba(255, 255, 255, 0.45);
    stroke-width: 0.6;
    stroke-linecap: round;
    vector-effect: non-scaling-stroke;
    opacity: var(--ap-blade-opacity, 0);
    transition: opacity .18s var(--ease-out);
  }
  /* Central glint at the end of the hold — small, focused pop. */
  .v-aperture .iris-glint {
    position: absolute;
    left: 50%; top: 50%;
    width: 6px; height: 6px;
    background: #fff;
    border-radius: 50%;
    transform: translate(-50%, -50%) scale(var(--ap-glint, 0));
    box-shadow: 0 0 10px #fff, 0 0 4px #fff;
    transition: transform .22s var(--ease-back);
    pointer-events: none;
  }

  /* ── APERTURE v2 — SHUTTER DOORS.  Legacy iris-slab/blades/glint
     are removed from the DOM by augmentFill; these rules define
     the new mechanical-door reveal that replaces them.
     - iris-base: full-white slab, clipped to a thin band at rest
       (the door crack), opening as the band widens.
     - seam: bright hairline at the crack, visible only at rest
       so the button reads as a "closed door waiting to open".
     - edge top/bottom: bright glow strips that ride at the door
       edges as they retract — sells the mechanical motion.
     - iris-burst: centre flash that pops at the climax. */
  .v-aperture .fill .iris-base {
    position: absolute; inset: 0;
    background: #fff;
    /* `--ap-band` is the half-height of the open gap in PIXELS.
       At 0 the gap is closed (no white visible); at fill-height
       /2 it fills the whole button.  Driven by paint(). */
    --ap-band: 0px;
    clip-path: inset(calc(50% - var(--ap-band)) 0 calc(50% - var(--ap-band)) 0);
    transition: clip-path .22s var(--ease-out);
    z-index: 1;
  }
  .v-aperture .fill .seam {
    position: absolute;
    left: 5%; right: 5%;
    top: 50%; height: 1px;
    margin-top: -0.5px;
    background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.85) 50%, transparent 100%);
    mix-blend-mode: screen;
    opacity: 0.45;
    pointer-events: none;
    z-index: 2;
    animation: aperture-seam-flicker 1.8s ease-in-out infinite;
    transition: opacity .25s var(--ease-out);
  }
  @keyframes aperture-seam-flicker {
    0%, 100% { opacity: 0.3; }
    50%      { opacity: 0.7; }
  }
  .v-aperture.holding .fill .seam { opacity: 0; animation: none; }
  .v-aperture .fill .edge {
    position: absolute;
    left: 0; right: 0;
    height: 3px;
    background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 50%, rgba(255,255,255,0) 100%);
    filter: blur(1px);
    mix-blend-mode: screen;
    z-index: 3;
    opacity: var(--edge-op, 0);
    pointer-events: none;
    transition: top .22s var(--ease-out), bottom .22s var(--ease-out), opacity .22s var(--ease-out);
  }
  .v-aperture .fill .edge.top    { top: calc(50% - var(--ap-band, 0px) - 2px); }
  .v-aperture .fill .edge.bottom { bottom: calc(50% - var(--ap-band, 0px) - 2px); top: auto; }
  .v-aperture .fill .iris-burst {
    position: absolute;
    left: 50%; top: 50%;
    width: 8px; height: 8px;
    margin-left: -4px;
    margin-top: -4px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 0 12px #fff, 0 0 6px #fff;
    transform: scale(var(--burst-s, 0));
    opacity: var(--burst-op, 0);
    z-index: 4;
    pointer-events: none;
    transition: transform .22s var(--ease-back), opacity .22s var(--ease-out);
  }
  /* Terminal white wash for aperture — guarantees the variant
     reaches a fully white end state regardless of door geometry
     measurement quirks. */
  .v-aperture .fill .solid {
    position: absolute; inset: 0;
    background: #fff;
    opacity: 0;
    pointer-events: none;
    z-index: 5;
    transition: opacity .22s var(--ease-out);
  }

  /* 4. SWEEP — multi-pass scan.  The original single lead beam
     was a thin gradient; the rework keeps that lead but adds two
     trailing ghost beams (translateX-delayed copies) so the
     surface reads as a CRT-style retrace, plus a scanline overlay
     whose opacity ramps with p.  At p=1 a clean wake fills the
     full width.  Driven entirely from CSS vars set by JS. */
  .v-sweep .fill {
    background: linear-gradient(90deg,
      transparent 0%, rgba(255,255,255,0.4) 30%, #fff 50%, rgba(255,255,255,0.4) 70%, transparent 100%);
    width: 100%;
    transform: translateX(var(--sweep-x, -100%));
    transition: transform .16s var(--ease-out);
  }
  /* The wake — a single white slab that grows from the left,
     driven by scaleX from the lead beam's left side.  Source had
     a single `--p` driver; the addition is the two ghost beams
     below. */
  .v-sweep .fill::after {
    content: ""; position: absolute; inset: 0;
    background: #fff;
    transform: scaleX(var(--p, 0));
    transform-origin: left center;
    transition: transform .18s var(--ease-out);
  }
  /* Ghost beam #1 — softer, trails the lead by ~10% of width. */
  .v-sweep .fill::before {
    content: '';
    position: absolute; inset: 0;
    background: linear-gradient(90deg,
      transparent 35%, rgba(255,255,255,0.35) 50%, transparent 65%);
    transform: translateX(var(--sweep-ghost, -110%));
    transition: transform .18s var(--ease-out), opacity .22s var(--ease-out);
    opacity: var(--sweep-ghost-opacity, 0);
    z-index: 1;
  }

  /* 5. STRIPES — seamless diagonal flow via rotated inner.
     v3 (operator-flag: the previous `background-position` pan of
     -34px on a -45° gradient was an approximation of the true
     period 24·√2 ≈ 33.94, so a visible seam stuttered through
     every frame).  Rework: an inner element holds vertical
     stripes (0° gradient, period 24px) and is rotated -45° at
     the container level.  Animation pans the inner's background
     by exactly 24px in 1.6s, the rotation puts those stripes on
     the diagonal — seam-free.  Reveal is now driven by an
     inset clip-path on a sibling `.primary-clip` wrapper rather
     than on `.fill` itself, so the rotated inner doesn't clash
     with the reveal clip. */
  .v-stripes .fill {
    background: transparent;
    overflow: hidden;
  }
  .v-stripes .fill .primary-clip {
    position: absolute;
    inset: 0;
    overflow: hidden;
    clip-path: inset(0 calc(100% - var(--stripe-pri, 0%)) 0 0);
    transition: clip-path .22s var(--ease-out);
    pointer-events: none;
  }
  .v-stripes .fill .stripes-primary {
    position: absolute;
    /* Inset -150% → 400% size centred on the clip wrapper.  After
       -45° rotation the bounding box is ~566% — ample coverage
       even for very wide buttons. */
    inset: -150%;
    background: repeating-linear-gradient(0deg, #fff 0 12px, transparent 12px 24px);
    transform: rotate(-45deg);
    transform-origin: center;
    animation: stripe-flow-v 1.6s linear infinite;
  }
  @keyframes stripe-flow-v {
    from { background-position-y: 0; }
    to   { background-position-y: -24px; }
  }
  /* Secondary counter-direction layer at lower opacity for
     moire-style depth.  Same rotate-inner pattern, opposite
     rotation, reveal closing in from the right. */
  .v-stripes .fill .counter-clip {
    position: absolute;
    inset: 0;
    overflow: hidden;
    clip-path: inset(0 0 0 calc(100% - var(--stripe-cnt, 0%)));
    transition: clip-path .22s var(--ease-out);
    mix-blend-mode: screen;
    pointer-events: none;
  }
  .v-stripes .fill .counter {
    position: absolute;
    inset: -150%;
    background: repeating-linear-gradient(0deg, rgba(255,255,255,0.45) 0 8px, transparent 8px 22px);
    transform: rotate(45deg);
    transform-origin: center;
    animation: stripe-flow-v-rev 2.3s linear infinite;
  }
  @keyframes stripe-flow-v-rev {
    from { background-position-y: 0; }
    to   { background-position-y: 22px; }
  }
  /* Leading-edge highlight — hairline bright slab that rides at
     the primary-clip's right edge so the reveal reads as a wipe
     with a bright shutter. */
  .v-stripes .fill::after {
    content: '';
    position: absolute;
    top: 0; bottom: 0;
    left: calc(var(--stripe-edge, 0) * 1%);
    width: 14px;
    margin-left: -7px;
    background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.9) 50%, transparent 100%);
    filter: blur(2px);
    opacity: var(--stripe-edge-opacity, 0);
    transition: left .22s var(--ease-out), opacity .18s var(--ease-out);
    pointer-events: none;
    z-index: 3;
  }

  /* 6. RINGS — multi-ring cascade.  Builds a STACK of 4 concentric
     rings that emanate from centre with a stagger; each ring's
     scale = `Max(0, p*32 - i*8)` so as one ring expands and fades,
     the next emerges behind it.  Continuous emanation, not a
     single one-shot.  Hairline stays 0.6 px via non-scaling-stroke. */
  .v-rings .fill { background: transparent; }
  .v-rings .fill svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
  .v-rings .fill .ring-idle,
  .v-rings .fill .ring-held {
    fill: none;
    stroke: #fff;
    stroke-width: 0.6;
    vector-effect: non-scaling-stroke;
    filter: drop-shadow(0 0 4px rgba(255,255,255,0.6));
    transform-origin: center;
    transform-box: fill-box;
  }
  .v-rings .fill .ring-idle {
    animation: ring-idle-svg 2.2s ease-out infinite;
    transform: scale(0.4);
    opacity: 0;
  }
  .v-rings .fill .ring-held {
    opacity: 0;
    transform: scale(0);
    transition: transform .22s var(--ease-out), opacity .22s var(--ease-out);
  }
  .v-rings .fill.held .ring-idle { animation: none; opacity: 0; }
  /* Each held ring's opacity is set inline by JS so the stack
     fades as it expands.  Default rule below ensures held rings
     read at full opacity until JS takes over. */
  .v-rings .fill.held .ring-held { opacity: 1; }
  @keyframes ring-idle-svg {
    0%   { transform: scale(0.4); opacity: 0.85; }
    100% { transform: scale(30);  opacity: 0; }
  }

  /* 4. SWEEP — add a vertical scanline overlay that intensifies
     with the hold, so the lead beam + ghost beam combination
     reads as a CRT retrace passing OVER a scanned grid, not just
     a translating gradient. */
  .v-sweep .fill::after {
    /* Existing wake — kept. */
    content: ""; position: absolute; inset: 0;
    background: #fff;
    transform: scaleX(var(--p, 0));
    transform-origin: left center;
    transition: transform .04s linear;
  }
  .v-sweep::before {
    /* Scanlines that fade in as p grows. */
    content: '';
    position: absolute; inset: 0;
    pointer-events: none;
    background: repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,0.10) 2px 3px);
    opacity: var(--sweep-scan, 0);
    border-radius: inherit;
    z-index: 1;
  }

  /* 3. PIXEL RAIN — column streaks fall through the surface.  No
     rain at rest, no rain on hover.  The hold drives the visible
     column count via `--rain-pct` (0..1) so density grows in
     lockstep with progress.  At p=1 the column field becomes a
     solid wall of falling pixels.  No fade-to-white wash — the
     final surface fills via the column density itself. */
  .v-rain .fill {
    background: transparent;
    overflow: hidden;
    --rain-pct: 0;
  }
  .v-rain .fill .col {
    position: absolute;
    top: 0;
    width: 2px;
    height: 14px;
    background: linear-gradient(180deg, transparent 0%, #fff 100%);
    border-radius: 1px;
    box-shadow: 0 0 4px rgba(255,255,255,0.6);
    opacity: 0;
    animation: rain-drop var(--rd, 0.7s) linear infinite;
    animation-delay: var(--rdl, 0s);
    animation-play-state: paused;
  }
  /* Only during hold do columns animate.  Their visibility cuts
     in at staggered thresholds of `--rain-pct` so the field
     densifies as the hold progresses (operator-flagged: hyper
     should reach genuine downpour at p≈1). */
  .v-rain.holding .fill .col {
    animation-play-state: running;
  }
  /* 24 column slots — opacity flips on when the slot's index/24
     threshold is below the current rain-pct.  Indices 1..24 are
     interleaved (even / odd / dense pattern) so they fill in a
     visually random order rather than left-to-right. */
  .v-rain .fill .col { --t: 0; opacity: 0; transition: opacity .25s var(--ease-out); }
  .v-rain.holding .fill .col[data-t]:not([data-t="0"]) { opacity: 0; }
  /* Terminal white slab — appears once the rain is fully dense
     (last 12 % of the hold) so the surface reads as fully
     saturated.  Most of the white comes from the rain itself; the
     slab only fills the gaps. */
  .v-rain .fill .solid {
    position: absolute; inset: 0;
    background: #fff;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
    pointer-events: none;
    z-index: 2;
  }
  /* Splash row at the bottom — small bright pixels that flash on
     impact.  Grows with hold progress so by p=1 the entire base
     is a row of impacts. */
  .v-rain .fill .splash {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 5px;
    background:
      radial-gradient(circle at 4%  100%, rgba(255,255,255,0.95) 0 1px, transparent 2px),
      radial-gradient(circle at 13% 100%, rgba(255,255,255,0.7)  0 1.2px, transparent 2.2px),
      radial-gradient(circle at 22% 100%, rgba(255,255,255,0.95) 0 1px, transparent 2px),
      radial-gradient(circle at 31% 100%, rgba(255,255,255,0.65) 0 1.2px, transparent 2.2px),
      radial-gradient(circle at 40% 100%, rgba(255,255,255,0.9)  0 1px, transparent 2px),
      radial-gradient(circle at 49% 100%, rgba(255,255,255,0.7)  0 1.4px, transparent 2.4px),
      radial-gradient(circle at 58% 100%, rgba(255,255,255,0.95) 0 1px, transparent 2px),
      radial-gradient(circle at 67% 100%, rgba(255,255,255,0.7)  0 1.2px, transparent 2.2px),
      radial-gradient(circle at 76% 100%, rgba(255,255,255,0.95) 0 1px, transparent 2px),
      radial-gradient(circle at 85% 100%, rgba(255,255,255,0.7)  0 1.2px, transparent 2.2px),
      radial-gradient(circle at 94% 100%, rgba(255,255,255,0.95) 0 1px, transparent 2px);
    opacity: 0;
    filter: blur(0.5px);
    transition: opacity .22s var(--ease-out);
    z-index: 1;
  }
  @keyframes rain-drop {
    0%   { transform: translateY(-20px); opacity: 0; }
    10%  { opacity: 1; }
    100% { transform: translateY(72px); opacity: 0; }
  }


  /* 7. FLAMES — discrete flame tongues hang from the TOP edge of
     the button.  Each tongue is a teardrop anchored at y=0, with
     its own flicker animation and stagger.  As the hold builds,
     the tongues drip further down (height grows), and in the last
     20 % a white burn-through layer fades in so the button reads
     fully white at p=1. */
  .v-flames .fill {
    background: transparent;
    overflow: hidden;
  }
  .v-flames .fill svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: block; overflow: visible; }
  .v-flames .tongue {
    fill: #fff;
    transform-origin: 50% 0;
    transform-box: fill-box;
    will-change: transform, opacity;
  }
  /* Rocket-engine combustion glow — back layer is huge and soft,
     core is sharp.  Drop-shadow on the back tongues blooms past
     the button bounds so the ignition feels like it's exhaling
     light, not a candle wick. */
  .v-flames .tongue.back { opacity: 0.65; filter: drop-shadow(0 0 36px rgba(255,255,255,1)) drop-shadow(0 0 14px #fff) drop-shadow(0 0 4px #fff); }
  .v-flames .tongue.core { opacity: 1;    filter: drop-shadow(0 0 18px rgba(255,255,255,1)) drop-shadow(0 0 6px #fff); }
  /* Aggressive, fast jet-engine flicker. */
  .v-flames .tongue.t-a { animation: t-flicker-a .10s ease-in-out infinite alternate; }
  .v-flames .tongue.t-b { animation: t-flicker-b .14s ease-in-out infinite alternate; }
  .v-flames .tongue.t-c { animation: t-flicker-c .09s ease-in-out infinite alternate; }
  .v-flames .tongue.back { animation-duration: .22s; animation-delay: -.08s; }
  @keyframes t-flicker-a {
    0%   { transform: scaleY(0.85) scaleX(1.10); }
    100% { transform: scaleY(1.78) scaleX(0.58); }
  }
  @keyframes t-flicker-b {
    0%   { transform: scaleY(0.78) scaleX(1.28); }
    100% { transform: scaleY(1.62) scaleX(0.66); }
  }
  @keyframes t-flicker-c {
    0%   { transform: scaleY(1.36) scaleX(0.72); }
    100% { transform: scaleY(0.58) scaleX(1.42); }
  }

  /* Extra: a radial flare under the center tongue that brightens
     with the burn level. */
  .v-flames .fill .flare {
    position: absolute;
    left: 50%; top: 0;
    width: 60%; height: 100%;
    transform: translateX(-50%);
    background: radial-gradient(ellipse at 50% 0%,
      rgba(255,255,255,0.85) 0%,
      rgba(255,255,255,0.4) 30%,
      transparent 70%);
    opacity: 0;
    pointer-events: none;
    transition: opacity .18s var(--ease-out), filter .18s var(--ease-out);
  }
  /* The tongues bunch up under a vertical scale driven by JS.
     The wrapper group <g class="tongues"> takes the scale. */
  .v-flames .tongues {
    transform-origin: 0 0;
    transform-box: view-box;
    transform: scaleY(var(--burn, 0));
    transition: transform .22s var(--ease-out);
  }
  /* End-stage white wash. */
  .v-flames .fill .solid {
    position: absolute;
    inset: 0;
    background: #fff;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
  }
  /* Idle: a faint warmth at the top edge so the rest state hints
     at the burner.  Suspended during hold so the JS flare can
     dominate. */
  .v-flames:not(.holding) .fill::before {
    content: '';
    position: absolute;
    inset: 0 0 auto 0;
    height: 30%;
    background: radial-gradient(ellipse at 50% 0%,
      rgba(255,255,255,0.10) 0%,
      rgba(255,255,255,0.04) 50%,
      transparent 100%);
    animation: flame-pilot 2.6s ease-in-out infinite alternate;
    pointer-events: none;
  }
  @keyframes flame-pilot {
    0%   { opacity: 0.6; }
    100% { opacity: 1.0; }
  }

  /* 8. PLANETS — a real star system: bright sun, three concentric
     orbits, each with a planet riding its ellipse via SMIL
     animateMotion.  Orbit lines + planets render fully white from
     the start (no half-state ramp); the hold only drives the
     terminal zoom into the sun for the flood. */
  .v-planets .fill {
    background: transparent;
    overflow: hidden;
  }
  /* The whole svg group can be scaled up at the end of the hold to
     zoom into the sun and flood the button with white. */
  .v-planets .fill svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    /* Dim at rest so the button label reads cleanly.  paint()
       smoothly ramps opacity to 1 over the first ~30% of hold
       using an outQuart curve — quick enough to feel decisive,
       smooth enough to avoid the CSS-snap pop. */
    opacity: 0.35;
    transform-origin: 50% 50%;
    transition: transform .26s var(--ease-out);
  }
  /* Terminal white wash so the variant reaches a true white at
     the end of the hold — the zoom alone doesn't cover the
     corners cleanly. */
  .v-planets .fill .solid {
    position: absolute; inset: 0;
    background: #fff;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
    pointer-events: none;
    z-index: 4;
  }
  .v-planets .orbit-line {
    fill: none;
    stroke: #fff;
    stroke-width: 1.1;
    stroke-opacity: 1;
    vector-effect: non-scaling-stroke;
    filter: drop-shadow(0 0 2px rgba(255,255,255,0.7));
  }
  .v-planets .sun-c  {
    fill: #fff;
    filter: drop-shadow(0 0 10px rgba(255,255,255,1)) drop-shadow(0 0 4px #fff);
    animation: sun-pulse 2.4s ease-in-out infinite;
    transform-origin: center;
    transform-box: fill-box;
  }
  .v-planets .planet {
    fill: #fff;
    filter: drop-shadow(0 0 6px rgba(255,255,255,1)) drop-shadow(0 0 2px #fff);
  }
  @keyframes sun-pulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.18); }
  }

  /* 9. STARDUST — stars wake in sequence, then at the end they
     scale up dramatically so the surface reads as fully white.
     A short transform/filter transition prevents the bloom phase
     from snapping when it takes over from the twinkle keyframe. */
  .v-stardust .fill {
    background: transparent;
  }
  .v-stardust .fill svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
  .v-stardust .star {
    fill: #fff;
    transform-origin: center;
    transform-box: fill-box;
    opacity: 0;
    filter: drop-shadow(0 0 3px rgba(255,255,255,0.8));
    animation: star-twinkle 1.6s ease-in-out infinite;
    transition: opacity .24s var(--ease-out), transform .24s var(--ease-back), filter .24s var(--ease-out);
    will-change: transform, opacity;
  }
  @keyframes star-twinkle {
    0%, 100% { transform: scale(0.7); }
    50%      { transform: scale(1.2); }
  }

  .v-constellation .fill {
    background: transparent;
    overflow: hidden;
  }
  .v-constellation .fill svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    overflow: visible;
  }
  .v-constellation .star-c {
    fill: #fff;
    opacity: 0;
    transform-origin: center;
    transform-box: fill-box;
    transition: opacity .22s var(--ease-out), transform .22s var(--ease-back), filter .22s var(--ease-out);
    will-change: transform, opacity, filter;
  }
  .v-constellation .line-c {
    stroke: #fff;
    stroke-width: 0.6;
    fill: none;
    vector-effect: non-scaling-stroke;
    filter: drop-shadow(0 0 2px rgba(255,255,255,0.6));
    stroke-dasharray: 200;
    stroke-dashoffset: 200;
    transition: stroke-dashoffset .22s var(--ease-out);
  }

  /* ───────────────────────────────────────────────────────────
     SUPPLEMENTARY MOTION LAYERS — added in v2 to close the gap
     between liquid (5+ simultaneous layers + beat states) and
     the rest of the variants.  Each `.fill` gets one extra layer
     injected by JS at init time (`augmentFill()`); CSS below
     defines the rest state + transitions.  paint() then drives
     these layers through the same `p` value as the primary
     animation, so a press produces visible MULTI-LAYER motion
     instead of a single-effect ramp.
     ─────────────────────────────────────────────────────────── */

  /* SPARKS — additional starfield layer: 30 tiny "deep stars"
     that twinkle slowly at rest and stretch a tiny amount with
     the hold to give a sense of depth/parallax under the main
     trails.  These sit BEHIND the main starfield so the warp
     effect reads as a tunnel rather than a flat plane. */
  .v-sparks .fill .deep-stars { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
  .v-sparks .fill .deep-stars svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
  .v-sparks .fill .deep-stars .dp {
    fill: #fff;
    opacity: 0.30;
    transform-origin: center;
    transform-box: fill-box;
    filter: drop-shadow(0 0 1.2px rgba(255,255,255,0.4));
    animation: deep-twinkle var(--dpt, 2.6s) ease-in-out infinite;
    animation-delay: var(--dpd, 0s);
  }
  @keyframes deep-twinkle {
    0%, 100% { transform: scale(0.6); opacity: 0.18; }
    50%      { transform: scale(1.1); opacity: 0.55; }
  }
  /* Velocity rays — six radial light wedges that emanate from
     center during the hold, giving the warp tunnel a directional
     pull. */
  .v-sparks .fill .vel-rays {
    position: absolute; inset: 0; pointer-events: none; z-index: 1;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
  }
  .v-sparks .fill .vel-rays svg { position: absolute; inset: 0; width: 100%; height: 100%; }
  .v-sparks.holding .fill .vel-rays { opacity: 1; }

  /* APERTURE — chromatic aberration triple-ring that emanates
     outward in the last 35% of the hold, plus 6 light rays that
     beam from center at the climax.  Reads as a real lens-flare
     ignition rather than a flat slab opening. */
  .v-aperture .fill .iris-rays {
    position: absolute; inset: 0; pointer-events: none; z-index: 3;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
  }
  .v-aperture .fill .iris-rays svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
  .v-aperture .fill .iris-rays line {
    stroke: #fff;
    stroke-width: 1.4;
    stroke-linecap: round;
    filter: drop-shadow(0 0 6px #fff);
    vector-effect: non-scaling-stroke;
  }
  .v-aperture .fill .chroma-ring {
    position: absolute;
    left: 50%; top: 50%;
    width: var(--cr-size, 40px);
    height: var(--cr-size, 40px);
    margin-left: calc(var(--cr-size, 40px) / -2);
    margin-top: calc(var(--cr-size, 40px) / -2);
    border-radius: 50%;
    border: 1.2px solid #fff;
    box-shadow:
      0 0 8px rgba(255,255,255,0.8),
      0 0 2px rgba(255,255,255,1) inset;
    opacity: 0;
    pointer-events: none;
    z-index: 2;
    transition: width .22s var(--ease-out), height .22s var(--ease-out),
                margin-left .22s var(--ease-out), margin-top .22s var(--ease-out),
                opacity .22s var(--ease-out);
  }

  /* RAIN — accumulating puddle at the bottom that rises during
     the hold, plus impact ripples that flash where the splash
     dots already live. */
  .v-rain .fill .puddle {
    position: absolute;
    left: 0; right: 0;
    bottom: 0;
    height: 0;
    background: linear-gradient(180deg,
      rgba(255,255,255,0.40) 0%,
      rgba(255,255,255,0.85) 60%,
      rgba(255,255,255,1)    100%);
    transition: height .25s var(--ease-out);
    z-index: 1;
    pointer-events: none;
  }
  /* Puddle surface highlight — thin bright crest sitting on top of
     the rising puddle so the meniscus reads cleanly, same idea as
     the liquid sheen. */
  .v-rain .fill .puddle-crest {
    position: absolute;
    left: 6%; right: 6%;
    bottom: var(--puddle-h, 0px);
    height: 1px;
    background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.95) 50%, transparent 100%);
    mix-blend-mode: screen;
    opacity: var(--puddle-crest-opacity, 0);
    z-index: 2;
    pointer-events: none;
    transition: bottom .25s var(--ease-out), opacity .18s var(--ease-out);
  }
  /* Impact ripples — small expanding circles where drops land.
     Six of them across the floor, animation runs continuously
     when `holding` so impacts feel persistent rather than one
     time. */
  .v-rain .fill .ripple {
    position: absolute;
    bottom: 1px;
    left: var(--rx, 50%);
    width: 4px; height: 4px;
    margin-left: -2px;
    border: 1px solid rgba(255,255,255,0.7);
    border-radius: 50%;
    opacity: 0;
    pointer-events: none;
    z-index: 3;
    animation: rain-ripple var(--rpd, 0.9s) ease-out infinite;
    animation-delay: var(--rpdl, 0s);
    animation-play-state: paused;
  }
  .v-rain.holding .fill .ripple { animation-play-state: running; }
  @keyframes rain-ripple {
    0%   { transform: scale(0.3); opacity: 0.9; }
    100% { transform: scale(3.5); opacity: 0; }
  }

  /* SWEEP — additional trailing afterimage beams.  The primary
     `--sweep-x` drives the lead beam; these inherit the same
     translateX but lag behind by 8%, 16%, 24% with decreasing
     opacity for a phosphor-trail decay. */
  .v-sweep .fill .echo {
    position: absolute; inset: 0;
    background: linear-gradient(90deg,
      transparent 38%, rgba(255,255,255,0.5) 50%, transparent 62%);
    transform: translateX(var(--echo-x, -120%));
    transition: transform .18s var(--ease-out), opacity .18s var(--ease-out);
    pointer-events: none;
    mix-blend-mode: screen;
    z-index: 0;
  }
  .v-sweep .fill .echo.e2 { opacity: 0; background: linear-gradient(90deg, transparent 38%, rgba(255,255,255,0.30) 50%, transparent 62%); }
  .v-sweep .fill .echo.e3 { opacity: 0; background: linear-gradient(90deg, transparent 38%, rgba(255,255,255,0.18) 50%, transparent 62%); }

  /* STRIPES — secondary counter-direction stripe layer at lower
     opacity for moire-style depth.  Driven by a separate clip
     advancing from the right, so the two layers interleave as
     the hold builds. */
  /* RINGS — idle ambient pulse at rest (heartbeat) so the button
     hints at its emit/ping nature even before press.  A single
     thin ring expanding once per ~2.4s. */
  .v-rings .fill .idle-pulse {
    position: absolute;
    left: 50%; top: 50%;
    width: 6px; height: 6px;
    margin-left: -3px;
    margin-top: -3px;
    border: 1px solid rgba(255,255,255,0.55);
    border-radius: 50%;
    transform: scale(1);
    opacity: 0;
    pointer-events: none;
    z-index: 0;
    animation: idle-pulse-ring 2.6s ease-out infinite;
  }
  .v-rings.holding .fill .idle-pulse { animation-play-state: paused; opacity: 0; }
  @keyframes idle-pulse-ring {
    0%   { transform: scale(0.5); opacity: 0; }
    20%  { opacity: 0.8; }
    100% { transform: scale(10); opacity: 0; }
  }

  /* FLAMES — ember particles rising on hold (replaces the static
     visual void above the tongues).  Each ember is a small white
     dot drifting upward with horizontal sway. */
  .v-flames .fill .ember {
    position: absolute;
    bottom: var(--eb-bottom, 8%);
    left: var(--ex, 50%);
    width: var(--es, 2.5px);
    height: var(--es, 2.5px);
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 0 6px rgba(255,255,255,0.9), 0 0 2px #fff;
    opacity: 0;
    pointer-events: none;
    z-index: 2;
    animation: ember-rise var(--ed, 1.6s) ease-out infinite;
    animation-delay: var(--edl, 0s);
    animation-play-state: paused;
  }
  .v-flames.holding .fill .ember { animation-play-state: running; }
  @keyframes ember-rise {
    0%   { transform: translateY(0) translateX(0); opacity: 0; }
    15%  { opacity: 1; }
    50%  { transform: translateY(-30px) translateX(var(--esway, 6px)); }
    85%  { opacity: 0.7; }
    100% { transform: translateY(-65px) translateX(calc(var(--esway, 6px) * -0.4)); opacity: 0; }
  }

  /* PLANETS — ambient deep starfield (small dim points) drifting
     behind the orbit lines so the button reads as actual sky.
     Plus a sun corona ring that grows with the hold. */
  .v-planets .fill .bg-stars { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
  .v-planets .fill .bg-stars svg { position: absolute; inset: 0; width: 100%; height: 100%; }
  .v-planets .fill .bg-stars .bs {
    fill: #fff;
    opacity: 0.18;
    transform-origin: center;
    transform-box: fill-box;
    animation: bg-twinkle var(--bst, 3s) ease-in-out infinite;
    animation-delay: var(--bsd, 0s);
  }
  @keyframes bg-twinkle {
    0%, 100% { opacity: 0.08; transform: scale(0.7); }
    50%      { opacity: 0.30; transform: scale(1); }
  }
  .v-planets .fill .corona {
    position: absolute;
    left: 50%; top: 50%;
    width: 14px; height: 14px;
    margin-left: -7px;
    margin-top: -7px;
    border-radius: 50%;
    border: 1px solid rgba(255,255,255,0.8);
    box-shadow: 0 0 8px rgba(255,255,255,0.6) inset, 0 0 6px rgba(255,255,255,0.4);
    transform: scale(var(--corona-s, 1));
    opacity: var(--corona-op, 0);
    pointer-events: none;
    z-index: 1;
    transition: transform .22s var(--ease-out), opacity .22s var(--ease-out);
  }

  /* STARDUST — drifting dust motes between the stars.  Twelve
     tiny points moving on a slow curve so the surface reads as
     a real interstellar cloud rather than just twinkling points. */
  .v-stardust .fill .mote {
    position: absolute;
    top: var(--my, 50%);
    left: var(--mx, 50%);
    width: 1.4px; height: 1.4px;
    border-radius: 50%;
    background: rgba(255,255,255,0.9);
    box-shadow: 0 0 3px rgba(255,255,255,0.6);
    opacity: 0.55;
    pointer-events: none;
    animation: mote-drift var(--md, 6s) linear infinite;
    animation-delay: var(--mdd, 0s);
    z-index: 0;
  }
  @keyframes mote-drift {
    0%   { transform: translate(0, 0) scale(0.6); opacity: 0; }
    20%  { opacity: 0.6; }
    80%  { opacity: 0.5; }
    100% { transform: translate(var(--mdx, 18px), var(--mdy, -10px)) scale(1.1); opacity: 0; }
  }

  /* CONSTELLATION — tracer dot that rides along each line as it's
     drawn, plus a nebula radial bloom that emerges at completion.
     Each tracer is a small bright dot positioned by JS along its
     line's current progress. */
  .v-constellation .fill .tracer {
    position: absolute;
    width: 4px; height: 4px;
    margin-left: -2px;
    margin-top: -2px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 0 8px #fff, 0 0 3px #fff;
    opacity: 0;
    pointer-events: none;
    z-index: 2;
    transition: opacity .18s var(--ease-out);
  }
  .v-constellation .fill .nebula {
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 0;
    background: radial-gradient(ellipse at 50% 50%,
      rgba(255,255,255,0.35) 0%,
      rgba(255,255,255,0.15) 28%,
      transparent 65%);
    opacity: var(--nebula-op, 0);
    transform: scale(var(--nebula-s, 0.6));
    transform-origin: center;
    transition: opacity .25s var(--ease-out), transform .25s var(--ease-out);
  }
  /* ── TRIPLE cornerstone — three phases in sequence over 3s.
     Each .phase wraps the elements its variant CSS expects to
     find inside `.fill`; only one is visible at a time, driven by
     opacity transitions.  The button stacks v-liquid + v-stripes
     + v-flames classes so the existing CSS rules for each variant
     attach to the right children naturally. */
  .v-triple .fill .phase {
    position: absolute;
    inset: 0;
    pointer-events: none;
    opacity: 0;
    transition: opacity .22s var(--ease-out);
  }
