/* ======================================================================
   Contact bubble — the ask in the corner.

   A sigil token on a vintage ticket. Resting, it is just the token; the
   pointer opens the ticket, which opens the contact modal. It can be put
   away for the rest of the day.

   Chosen in /elfil/lab/bubble/ (DEBUG only), which still carries the
   alternatives this was picked from — six stocks, four far edges, four
   reveals, five resting animations, six pointer effects. What ships is
   the Box office stock with a scalloped far end, so the variables below
   are set once rather than being an axis.

   Two things rotate per SESSION, the way Khora's ornaments do: the
   resting animation (halo / pulse / beacon) and the pointer effect
   (hedge / lamp). Three and two are coprime, so the pair takes six
   sessions to come round rather than two or three.
   ====================================================================== */

/* DWELL — 1 while the pointer is on the bubble, 0 once it leaves, taking
   exactly the grace period to get there. Every pointer effect multiplies
   itself by it, so none of them switch off: they run down, and what you
   watch draining IS the countdown to the ticket closing. One number, so
   the picture and the timer cannot disagree. */
@property --eb-dwell { syntax: '<number>'; inherits: true; initial-value: 0; }

/* The host owns no transform. .eb-move does, composed from these three.
   The ring, the dismiss button and the shadow are siblings of the pill,
   so a transform on the pill alone lifted it out of its own outline. And
   the transform must not sit on the host, because the host also holds
   .eb-shield, which is what the pointer is tested against: a moving hit
   area is a feedback loop — hover lifts the bubble, the lift carries it
   out from under the cursor, the hover is lost, it drops back, and it
   oscillates. The shield stays still; only the picture moves. */
@property --eb-y     { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --eb-lift  { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --eb-press { syntax: '<number>'; inherits: true; initial-value: 1; }

.eb-host {
    position: fixed; bottom: 22px; left: 22px;
    /* Under everything: topbar 50, the shop's .wish-fab 60, its drawer
       80/81, the contact modal 2000. The bubble must recede whenever any
       of those open. */
    z-index: 40;
    pointer-events: none;
    display: inline-flex; align-items: center; line-height: 0;

    --eb-size: 54px;
    --eb-dur: 420ms;          /* open  */
    --eb-out: 300ms;          /* close — always quicker than open */
    --eb-ease: cubic-bezier(.16, 1, .3, 1);
    --eb-x-size: 21px; --eb-x-ang: 45deg; --eb-x-off: 14px;
    --eb-x-wait: 140ms; --eb-x-dur: 320ms;

    /* Box office stock: velvet printed in gold, the house's own pairing.
       It is the one skin whose stock matches the site rather than
       contrasting with it, so the gold rule around the edge is what
       keeps it from dissolving into the page. */
    --tk-1: #5e1714; --tk-2: #46100d; --tk-3: #300a08;
    --tk-fleck: rgba(255, 190, 140, .07);
    --tk-grain: rgba(255, 200, 150, .035);
    --tk-edge: rgba(201, 168, 76, .55);
    --tk-edge-hi: rgba(233, 201, 106, .85);
    --tk-top: rgba(255, 220, 160, .18);
    --tk-bot: rgba(0, 0, 0, .35);
    --tk-rule: rgba(201, 168, 76, .55);
    --tk-cast: 0 9px 26px rgba(0, 0, 0, .62);
    --tk-bloom: 0 0 0 0 rgba(0, 0, 0, 0);
    --tk-glow: .34;
    --tk-r2: 3px;                      /* the scalloped far end */
    --tk-coin-1: #7e211b; --tk-coin-2: #4d100c; --tk-coin-3: #2c0706;
    --tk-coin-gap: rgba(60, 12, 9, .9);
    --eb-ink: #edd79b;
    --eb-metal: 201, 168, 76;

    transition: opacity .34s var(--eb-ease),
                --eb-lift .34s var(--eb-ease), --eb-press .18s var(--eb-ease),
                --eb-dwell var(--eb-dwell-t, 180ms) linear;
}

/* Arrival and dismissal run through the same three values, so the whole
   bubble has one transform and one set of transitions. */
.eb-host.eb-away { opacity: 0; --eb-lift: 14px; --eb-press: .92; pointer-events: none; }
.eb-host:hover:not(.eb-away) { --eb-lift: -3px; }
.eb-host:not(.eb-away):has(.eb:active) { --eb-press: .976; --eb-lift: -1px; }

.eb-move {
    position: relative;
    display: inline-flex; align-items: center; line-height: 0;
    transform: translateY(calc(var(--eb-y) + var(--eb-lift))) scale(var(--eb-press));
    transform-origin: left center;
    /* Deliberately NOT promoted to its own layer. Compositing was a
       mitigation for text crawling under the drift, and the drift no
       longer runs while there is any text to read — so the only thing
       promotion would still buy is grayscale antialiasing in place of
       subpixel, which makes the type worse, not better. The token moves
       while closed, and a drawing does not care. */
}

/* THE hover target, and the only thing in here that never moves. It
   tracks the visible SHAPE — a circle around the token while collapsed,
   the whole ticket once open — sized 12px proud of it, which is more
   than the 8px the picture travels. It cannot simply be the host's box
   inflated: that box is the full open width even while collapsed, so an
   invisible 300px strip would arm the bubble and swallow page clicks. */
.eb-shield {
    position: absolute; left: -12px; top: 50%;
    transform: translateY(-50%);
    width: calc(var(--eb-size) + 24px);
    height: calc(var(--eb-size) + 24px);
    border-radius: 999px;
    z-index: 0; pointer-events: auto;
    transition: width var(--eb-dur) var(--eb-ease);
}
.eb-host:has(.eb.eb-open) .eb-shield { width: calc(100% + 24px); }
/* pointer-events is inherited, but the shield sets its own, so the host
   going inert while away would not otherwise reach it. */
.eb-host.eb-away .eb-shield { pointer-events: none; }

/* ── The ticket ─────────────────────────────────────────────────────── */
/* Always laid out at full width; opening is a clip-path reveal, which
   also clips hit-testing, so the collapsed bubble cannot swallow clicks
   on the page beneath. Measuring the text and animating a width is what
   this replaces — that broke under different fonts and zoom levels. */
.eb {
    --eb-t: var(--eb-out);
    position: relative; display: inline-flex; align-items: center;
    height: var(--eb-size); z-index: 1;
    text-decoration: none; cursor: var(--cur-hand, pointer); pointer-events: auto;
    transform-origin: left center;
    border-radius: 999px var(--tk-r2) var(--tk-r2) 999px;
    clip-path: inset(0 calc(100% - var(--eb-size)) 0 0 round 999px);
    background:
        radial-gradient(ellipse 55% 120% at 14% 28%, var(--tk-fleck), transparent 62%),
        radial-gradient(ellipse 45% 100% at 68% 74%, var(--tk-fleck), transparent 64%),
        radial-gradient(ellipse 30% 80% at 92% 20%, var(--tk-fleck), transparent 66%),
        repeating-linear-gradient(93deg, var(--tk-grain) 0 1px, transparent 1px 3px),
        linear-gradient(168deg, var(--tk-1), var(--tk-2) 52%, var(--tk-3));
    border: 1px solid var(--tk-edge);
    /* Composed from variables and NOT restated on hover: box-shadow is a
       single property, so a hover rule that sets it replaces the whole
       stack, which silently took the inset highlights with it and
       flattened the stock the moment the pointer arrived. */
    box-shadow: var(--tk-cast), var(--tk-bloom),
                inset 0 1px 0 var(--tk-top),
                inset 0 -1px 0 var(--tk-bot);
    /* Semicircles bitten along the far edge. A mask, because clip-path
       is busy doing the reveal; radius and tile are tied so the bites
       nearly touch, or it reads as a few dents rather than a scallop. */
    -webkit-mask: radial-gradient(circle 6.5px at right center,
                  transparent 98%, #000 100%) right top / 100% 13.5px repeat-y;
            mask: radial-gradient(circle 6.5px at right center,
                  transparent 98%, #000 100%) right top / 100% 13.5px repeat-y;
    transition: border-color .28s ease, box-shadow .28s ease,
                clip-path var(--eb-t) var(--eb-ease);
}
.eb.eb-open {
    --eb-t: var(--eb-dur);
    clip-path: inset(0 0 0 0 round 999px var(--tk-r2) var(--tk-r2) 999px);
}
.eb:focus-visible { outline: 2px solid var(--gold-hi); outline-offset: 3px; }

/* Hover changes variables only, so the stock keeps its own surface. */
.eb-host:hover {
    --tk-cast: 0 14px 30px rgba(0, 0, 0, .58);
    --tk-bloom: 0 0 20px 1px rgba(var(--eb-metal), .18);
}
.eb-host:hover .eb { border-color: var(--tk-edge-hi); }
.eb-host:hover .eb-ring { border-color: var(--tk-edge-hi); }

/* ── The token ──────────────────────────────────────────────────────── */
/* Width is its own, height follows the ticket. Enamelled rather than
   struck, so it carries the stock's red instead of a neutral black. */
.eb-mark {
    flex: none; position: relative; z-index: 1;
    width: var(--eb-size); height: 100%;
    border-radius: 50%;
    display: grid; place-items: center;
    background: radial-gradient(circle at 34% 26%,
                var(--tk-coin-1), var(--tk-coin-2) 62%, var(--tk-coin-3) 100%);
    box-shadow:
        inset 0 0 0 1px rgba(var(--eb-metal), .9),
        inset 0 0 0 3px var(--tk-coin-gap),
        inset 0 0 0 4px rgba(var(--eb-metal), .4),
        inset 0 3px 10px rgba(0, 0, 0, .6),
        inset 0 -2px 6px rgba(255, 170, 130, .12),   /* enamel's gloss */
        0 2px 8px rgba(0, 0, 0, .55);
}
/* The sigil in its own gold — the drawing is never recoloured. */
.eb-mark .sg {
    position: absolute; left: 50%; top: 50%;
    width: 56%; height: 56%;
    transform: translate(-50%, -50%);
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .9));
}

/* Collapsed outline, drawn as its own full circle: clipping the ticket's
   border to the left 54px leaves only its left arc. */
.eb-ring {
    position: absolute; left: 0; top: 50%; transform: translateY(-50%);
    width: calc(var(--eb-size) + 2px); height: calc(var(--eb-size) + 2px);
    border-radius: 50%;
    border: 1px solid rgba(var(--eb-metal), .5);
    pointer-events: none; z-index: 2;
    transition: opacity .22s ease, border-color .28s ease;
}
.eb-host:has(.eb.eb-open) .eb-ring { opacity: 0; }

.eb-under {
    position: absolute; left: 0; top: 50%;
    width: calc(var(--eb-size) + 2px); height: calc(var(--eb-size) + 2px);
    border-radius: 50%; pointer-events: none; z-index: 0;
    background: radial-gradient(circle, rgba(0, 0, 0, .5), transparent 66%);
    transform: translateY(-50%) scale(1.3);
}

/* ── The perforation ────────────────────────────────────────────────── */
/* Where the stub tears off, with a punch notch bitten out of the top and
   bottom edge. The notches are painted in the page's dark rather than
   cut, because a real notch would have to come out of the clip path. The
   strip must be wider than the notches are across, or the circles are
   clipped by their own box and the bite reads as a square chip. */
.eb-seam {
    position: absolute; z-index: 2; opacity: 0;
    left: calc(var(--eb-size) + 1px); top: 0; bottom: 0; width: 13px;
    background:
        radial-gradient(circle 4.5px at 50% 0, rgba(18, 5, 4, .94) 97%, transparent 100%),
        radial-gradient(circle 4.5px at 50% 100%, rgba(18, 5, 4, .94) 97%, transparent 100%),
        repeating-linear-gradient(180deg,
            transparent 0 3px, var(--tk-rule) 3px 6px) 50% 0 / 1px 100% no-repeat;
    transition: opacity .3s ease;
}
.eb.eb-open .eb-seam { opacity: 1; }

/* ── The type ───────────────────────────────────────────────────────── */
.eb-text { overflow: hidden; white-space: nowrap; position: relative; z-index: 1; }
/* The padding lives on .eb-label INSIDE .eb-pad: an element's own padding
   is not clipped by its own overflow, so putting it on .eb-text would
   leak the full inline padding while collapsed. */
.eb-pad { display: block; }
.eb-label {
    display: block;
    padding-left: 22px; padding-right: 22px;
    font-family: var(--sans);
    line-height: 1.15;
    opacity: 0;
    /* The type RISES into the window the ticket has just opened, rather
       than drifting in sideways behind the wipe. .eb-text already clips,
       so nothing escapes above or below while it climbs. */
    transform: translateY(115%);
    transition: opacity calc(var(--eb-t) * .7) ease,
                transform var(--eb-t) var(--eb-ease);
}
/* Held back until the ticket is most of the way open: the two gestures
   read as one movement when they overlap, and as a sequence when the
   type waits. A sequence is the point — the ticket arrives, then what is
   printed on it does. */
.eb.eb-open .eb-label {
    opacity: 1; transform: none;
    transition-delay: calc(var(--eb-dur) * .45);
}
.eb-label b {
    display: block;
    font-family: var(--serif); font-size: .86rem; font-weight: 500;
    letter-spacing: .05em;
    color: var(--eb-ink);
    text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.eb-kicker {
    display: block; margin-top: 3px;
    font-size: .54rem; font-style: normal;
    letter-spacing: .2em; text-transform: uppercase;
    color: var(--eb-ink); opacity: .62;
    text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}

/* ======================================================================
   AT REST — emanations only. Something leaving the token and fading is
   the only family that points at the bubble; a glint, a breath or a rock
   all read as the object fidgeting instead. Three rings are available
   without extra markup: .eb-halo and its two pseudo-elements.
   ====================================================================== */
.eb-halo, .eb-halo::before, .eb-halo::after {
    position: absolute; left: 0; top: 50%;
    width: var(--eb-size); height: var(--eb-size);
    margin-top: calc(var(--eb-size) / -2);
    border-radius: 50%; pointer-events: none;
    opacity: 0;
}
.eb-halo { z-index: 0; border: 1px solid rgba(var(--eb-metal), .55); }
.eb-halo::before, .eb-halo::after {
    content: ''; left: 0; top: 0; margin-top: 0;
    border: 1px solid rgba(var(--eb-metal), .45);
}
/* Once open the attract has done its job — a ring still pulsing behind
   an open ticket reads as a glitch. */
.eb-host:has(.eb.eb-open) .eb-halo,
.eb-host:has(.eb.eb-open) .eb-halo::before,
.eb-host:has(.eb.eb-open) .eb-halo::after { animation: none !important; opacity: 0; }
.eb-host:hover .eb-halo { animation-play-state: paused; }

@keyframes ebRing {
    0%   { opacity: 0;   transform: scale(1); }
    16%  { opacity: .8; }
    100% { opacity: 0;   transform: scale(1.7); }
}
@keyframes ebRingWide {
    0%   { opacity: 0;   transform: scale(1); }
    14%  { opacity: .5; }
    100% { opacity: 0;   transform: scale(2.3); }
}
@keyframes ebPulse {
    0%, 100% { opacity: 0;   transform: scale(1.1); }
    45%      { opacity: .55; transform: scale(1.85); }
}

[data-rest="halo"] .eb-halo { animation: ebRing 4.2s ease-out infinite; }

[data-rest="ripple"] .eb-halo         { animation: ebRing 4.6s ease-out infinite; }
[data-rest="ripple"] .eb-halo::before { animation: ebRing 4.6s ease-out .45s infinite; }
[data-rest="ripple"] .eb-halo::after  { animation: ebRing 4.6s ease-out .9s infinite; }

[data-rest="pulse"] .eb-halo {
    border: 0;
    background: radial-gradient(circle,
                rgba(var(--eb-metal), .3) 0 42%, rgba(var(--eb-metal), .07) 62%, transparent 74%);
    animation: ebPulse 3.8s ease-in-out infinite;
}

[data-rest="beacon"] .eb-halo         { animation: ebRing 5s cubic-bezier(.2,.7,.3,1) infinite; }
[data-rest="beacon"] .eb-halo::before { animation: ebRingWide 5s ease-out .22s infinite; }

/* ======================================================================
   POINTER — both effects TRACK the cursor and both DRAIN with the dwell.
   An effect that only switches on says the pointer is somewhere over the
   bubble; one that follows says where, which is the difference between a
   state and a response.
   ====================================================================== */

/* Lamp — a raked band of warm light crossing the ticket. The rake has to
   be well past vertical to show at all: the ticket is ~300px wide and
   54px tall, so a few degrees off vertical shifts the band by single
   pixels over its height.

   The band is placed by moving the gradient's STOPS to the cursor, not
   by sliding an oversized background under the box. Sliding it ran
   backwards, and not by accident: a percentage background position
   aligns that point of the IMAGE with the same point of the BOX, so with
   an image wider than the box the offset is p x (W_box - W_img), which
   is negative — the band travelled left as the pointer went right. */
.eb-host[data-hover="lamp"] .eb::before {
    content: ''; position: absolute; inset: 0; border-radius: inherit;
    z-index: 0; pointer-events: none;
    opacity: var(--eb-dwell);
    background-image: linear-gradient(118deg,
        transparent calc(var(--eb-mx, 50%) - 30%),
        rgba(255, 236, 186, .58) calc(var(--eb-mx, 50%) - 4%),
        rgba(255, 216, 150, .22) calc(var(--eb-mx, 50%) + 7%),
        transparent calc(var(--eb-mx, 50%) + 30%));
}

/* ── Hedge ────────────────────────────────────────────────────────────
   The page's own branch drawing threaded around the ticket's whole
   OUTLINE — both rails, the round token cap and the scalloped far end —
   so the run curves wherever the edge curves.

   offset-path: border-box is what makes that possible without a single
   hand-placed coordinate: it is the element's own rounded border box as
   a path, so the sprigs inherit the silhouette. offset-rotate: auto
   turns each to the local tangent, which is what bends the run around
   the ends instead of letting it run off straight.

   Every sprig is its own element because they must differ — place,
   angle, length, flip and weight all come from bubble.js, drawn from the
   session's seed. A repeating background tiled one identical picture and
   read as exactly that.

   The reveal window lives on a BIGGER box than the ticket. Masking the
   ticket-sized layer and using mask-clip: no-clip does not work — that
   leaves everything outside the box UNMASKED rather than masking it, so
   the whole run shows at once; clipping to the border box instead hides
   every sprig, since they all stand outside by design. Its centre is
   given in PIXELS by JS, because a percentage here would be a share of
   this larger box, a different coordinate space from the one the pointer
   was measured in. */
.eb-hedge-win {
    position: absolute; inset: -80px; z-index: 0;
    pointer-events: none; opacity: 0;
    -webkit-mask: radial-gradient(ellipse 112px 132px
                  at calc(80px + var(--eb-mxpx, 140px)) 50%,
                  #000 4%, rgba(0, 0, 0, .5) 34%, transparent 66%) no-repeat;
            mask: radial-gradient(ellipse 112px 132px
                  at calc(80px + var(--eb-mxpx, 140px)) 50%,
                  #000 4%, rgba(0, 0, 0, .5) 34%, transparent 66%) no-repeat;
}
.eb-host[data-hover="hedge"]:has(.eb.eb-open) .eb-hedge-win {
    opacity: calc(var(--eb-dwell) * .92);
}
/* Back to the ticket's own box, so offset-path describes the ticket's
   outline and not the window's. */
.eb-hedge {
    position: absolute; inset: 80px;
    border-radius: 999px var(--tk-r2) var(--tk-r2) 999px;
}
.eb-hedge > i {
    position: absolute;
    display: block; width: 84px;
    aspect-ratio: 560 / 1233;
    background: url("../marks/spine-art.ada81567bc6d.webp") no-repeat center / 100% auto;
    opacity: var(--o, .9);
    offset-path: border-box;
    offset-distance: var(--d, 0%);
    /* The drawing stands on its base, so anchoring the base to the path
       leaves it growing outward along the local normal. */
    offset-anchor: 50% 100%;
    offset-rotate: auto calc(90deg + var(--r, 0deg));
    transform: scale(var(--s, .3)) scaleX(var(--f, 1));
    transform-origin: 50% 100%;
}

/* ── Dismiss ────────────────────────────────────────────────────────── */
/* Placed on the cap's curve by trig, then pushed clear of it. */
.eb-x {
    --eb-x-r: calc(var(--eb-size) / 2);
    --eb-x-dx: -7px;
    position: absolute; z-index: 4;
    width: var(--eb-x-size); height: var(--eb-x-size);
    top:   calc(var(--eb-x-r) - (var(--eb-x-r) + var(--eb-x-off)) * sin(var(--eb-x-ang)) - var(--eb-x-size) / 2);
    right: calc(var(--eb-x-r) - (var(--eb-x-r) + var(--eb-x-off)) * cos(var(--eb-x-ang)) - var(--eb-x-size) / 2);
    border-radius: 50%;
    border: 1px solid rgba(var(--eb-metal), .45);
    background: radial-gradient(circle at 32% 28%, #2a0b08, #150403 72%);
    color: var(--gold-hi);
    font: inherit; font-size: 0; line-height: 0; padding: 0;
    cursor: var(--cur-hand, pointer);
    display: flex; align-items: center; justify-content: center;
    box-shadow: 0 3px 10px rgba(0, 0, 0, .55);
    opacity: 0; transform: scale(.35) translate(var(--eb-x-dx), 7px);
    pointer-events: none;
    /* Hover ONLY. The entrance is an animation and deliberately not a
       transition: a transition-delay for the arrival has to sit on the
       element's steady state, where it also delays the hover-OUT — which
       held the button at full scale for 560ms after the pointer left. */
    transition: transform .13s ease, border-color .13s ease,
                background .13s ease, color .13s ease;
}
.eb-host:has(.eb.eb-open) .eb-x {
    opacity: 1; transform: none; pointer-events: auto;
    /* backwards fill holds the from-state through the delay, so it stays
       hidden until the ticket has finished opening. */
    animation: ebXIn var(--eb-x-dur) cubic-bezier(.34, 1.32, .64, 1)
               calc(var(--eb-dur) + var(--eb-x-wait)) backwards;
}
@keyframes ebXIn {
    from { opacity: 0; transform: scale(.35) translate(var(--eb-x-dx), 7px); }
}
/* Still needs the :has() prefix to outrank the arrival rule's
   `transform: none` on specificity. */
.eb-host:has(.eb.eb-open) .eb-x:hover,
.eb-host:has(.eb.eb-open) .eb-x:focus-visible {
    transform: scale(1.12);
    border-color: var(--gold-hi); color: #fff;
    background: radial-gradient(circle at 32% 28%, #45120d, #220705 72%);
    box-shadow: 0 3px 12px rgba(0, 0, 0, .6), 0 0 10px rgba(var(--eb-metal), .35);
}
.eb-host:has(.eb.eb-open) .eb-x:active { transform: scale(.93); transition-duration: .07s; }
.eb-x:focus-visible { outline: 2px solid var(--gold-hi); outline-offset: 2px; }
/* The cross is DRAWN from a mask, not typed: flexbox centres a line box,
   not the glyph's ink, so the x character sat low by a font-dependent
   amount. This is geometry instead. */
.eb-x::before {
    content: ''; position: absolute; left: 50%; top: 50%;
    width: 44%; height: 44%; transform: translate(-50%, -50%);
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
            mask: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
/* ~41px effective tap target without changing the 21px visual */
.eb-x::after { content: ''; position: absolute; inset: -10px; border-radius: 50%; }

/* ── Idle drift ─────────────────────────────────────────────────────── */
/* Animates --eb-y, NOT transform: an animation on transform would win
   over .eb-move's own transform outright, taking the hover lift and the
   press with it. */
.eb-host.eb-float:not(.eb-away) { animation: ebFloat 5.2s ease-in-out infinite; }
@keyframes ebFloat {
    0%, 100% { --eb-y: 0px; }
    50%      { --eb-y: -5px; }
}
/* Held under the pointer: the bubble should feel caught, not still
   bobbing under the cursor. Pausing is right here — the drift resumes
   from where it was when the pointer leaves. */
.eb-host.eb-float:hover { animation-play-state: paused; }

/* Open is different, and pausing is NOT enough for it.
   animation-play-state: paused freezes --eb-y wherever the cycle
   happened to be — -3.68px, say — so the type comes to rest on a
   fractional pixel and stays soft, shimmering on every repaint. Ending
   the animation and zeroing the offset puts it back on a whole pixel,
   which is the difference between stopped and merely not moving.

   This matters on touch above all, because touch has no hover: a phone
   opens the ticket by tap with the drift still running underneath it.
   Reading something is the one moment it has to hold still. */
.eb-host.eb-float:has(.eb.eb-open) {
    animation: none;
    --eb-y: 0px;
}

/* And no drift at all on a touch device. It is a desktop nicety that
   answers a pointer; with no pointer to answer it only costs legibility. */
@media (hover: none), (pointer: coarse) {
    .eb-host.eb-float { animation: none; --eb-y: 0px; }
}

/* ── Responsive ─────────────────────────────────────────────────────── */
@media (hover: none), (pointer: coarse) {
    .eb-x { --eb-x-size: 26px; }
    .eb-x::after { inset: -11px; }
}
@media (max-width: 600px) {
    .eb-host { bottom: 14px; left: 14px; }
    /* The ticket would otherwise run past the right edge on a phone. */
    .eb-label { padding-right: 16px; }
    .eb-label b { font-size: .8rem; }
}

/* The owner's Windows has animation effects switched off, so this branch
   is what a good many visitors actually see. With the transitions gone a
   lift or a press would arrive as an instant jump — which is motion,
   just worse — so the three inputs to the transform are zeroed and the
   rim light and shadow carry the hover on their own. */
@media (prefers-reduced-motion: reduce) {
    .eb-host, .eb-host *, .eb-host *::before, .eb-host *::after {
        transition: none !important;
        animation: none !important;
    }
    .eb-host { --eb-y: 0px !important; --eb-lift: 0px !important; --eb-press: 1 !important; }
}
