/* ============================================================================
   The invite arrival screen.

   An invited player's first frame should be the OBJECT, not a form. The ticket
   is centre stage, lit, and physically tiltable; the words are secondary and the
   only action is to accept. Everything here is presentation — the referral was
   recorded server-side before this screen existed.
   ========================================================================== */

.invite-ticket-view {
    position: fixed;
    inset: 0;
    z-index: 10050;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: clamp(14px, 3vmin, 32px);
    padding:
        calc(var(--mf-safe-top, 0px) + clamp(16px, 4vmin, 40px))
        calc(var(--mf-safe-right, 0px) + clamp(16px, 4vmin, 40px))
        calc(var(--mf-safe-bottom, 0px) + clamp(16px, 4vmin, 40px))
        calc(var(--mf-safe-left, 0px) + clamp(16px, 4vmin, 40px));
    box-sizing: border-box;
    background:
        radial-gradient(ellipse at 50% 38%, rgba(120, 84, 168, 0.42) 0%, rgba(8, 4, 16, 0.94) 62%),
        rgba(6, 3, 12, 0.97);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    animation: invite-ticket-in 420ms cubic-bezier(0.2, 0.9, 0.3, 1.02) both;
}

.invite-ticket-view--leaving {
    animation: invite-ticket-out 260ms ease forwards;
}

/* The stage exists so the ticket can overshoot and rotate without the layout
   reflowing around it. */
/* The brandmark. Sits inside the safe area — on a notched phone in landscape
   the left 59pt is under the Dynamic Island, and a logo tucked under it is a
   logo nobody sees.

   NO blend mode. The asset's CORNERS are near-black, which is why `screen`
   looked like the right call, but its face is a pale lavender plaque — screen
   lifted that into a glowing white box. Drawn normally the dark corners sit flat
   against the veil and the plaque reads as an emblem, which is what it is. */
.invite-ticket-brand {
    position: absolute;
    top: calc(var(--mf-safe-top, 0px) + clamp(10px, 2.2vmin, 22px));
    left: calc(var(--mf-safe-left, 0px) + clamp(12px, 2.6vmin, 26px));
    width: clamp(64px, 10.5vmin, 116px);
    height: auto;
    opacity: 0.9;
    pointer-events: none;
    -webkit-user-select: none;
    user-select: none;
}

/* A landscape phone page box is ~300px tall and the ticket already fills it, so
   a corner mark would sit on top of the artwork. It steps down rather than
   disappearing, because the brand is the point of the screen. */
@media (orientation: landscape) and (max-height: 560px) {
    .invite-ticket-brand {
        width: clamp(52px, 16svh, 84px);
    }
}

.invite-ticket-stage {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    perspective: 1100px;
    min-height: 0;
    flex: 0 1 auto;
}

.invite-ticket {
    position: relative;
    display: block;
    /* Sized off the SHORT axis so a landscape phone gets a ticket that fits the
       page box rather than one cropped by it. */
    height: min(58svh, 520px);
    aspect-ratio: 533 / 1100;
    /* THE FACE IS SIZED FROM THE TICKET, NOT THE VIEWPORT.
       The stamps used to be `clamp(7px, 1.05vmin, 10px)`, which is a viewport
       measurement printed onto an object whose size does not track the viewport
       the same way. At a landscape phone page box the ticket shrinks but the
       floor holds the type at 7px, so every stamp wrapped — "ADMIT / ONE",
       "ONE / SEAT / HELD" — and the documentary layout fell apart. A container
       makes `cqw` mean "percent of this ticket", so the printing keeps its
       proportions at every size. */
    container-type: size;
    transform-style: preserve-3d;
    will-change: transform;
    /* NO GOLD BLOOM — proven by A/B, not assumed.
       `drop-shadow` blurs the ALPHA SILHOUETTE, so a light-coloured shadow
       traces the whole outline and pools in every narrow cut. On this artwork
       that meant a pale halo down both edges, filling the side notches at the
       tear line and banding below the stub — the ticket's two segments read as
       one lit slab. Four states rendered and measured at the stub, veil
       brightness immediately beside it:
           both shadows            33.9   halo, notches filled
           dark only               25.6   clean edge, notches dark  <-- this
           gold bloom only         34.6   halo
           no filter at all        27.9   clean but flat, no depth
       The dark shadow gives the depth; the gold one only emitted light. */
    filter: drop-shadow(0 calc(2 * var(--story-ui-unit, 1px)) 18px rgba(0, 0, 0, 0.7));
    animation: invite-ticket-arrive 720ms cubic-bezier(0.16, 1, 0.3, 1) both 120ms;
}

.invite-ticket-art {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
    pointer-events: none;
    -webkit-user-drag: none;
    -webkit-user-select: none;
    user-select: none;
}

/* Specular sweep, driven by the same custom properties the card tilt engine
   writes, so the ticket catches light exactly as a card does. */
/* Specular sweep, driven by the same custom properties the card tilt engine
   writes, so the ticket catches light exactly as a card does.

   CLIPPED TO THE ARTWORK. `mix-blend-mode: overlay` lightens whatever is
   beneath it, and beneath the TRANSPARENT parts of this rectangle is the dark
   veil — so an unclipped sweep paints light straight into the ticket's own
   cut-outs. Rendered at rest and again with the pointer on the seam, the side
   notches and the bottom notch are dark cuts at rest and pale grey on hover:
   the segmentation survives until you touch it, which is exactly the report,
   "hovering it lights up its background, filling its gaps and breaking the
   illusion of segmentation". The mask follows the art's own alpha, so light can
   only land where the ticket has material, and it stays correct if the art
   changes shape.

   Two other things were changed here and are worth knowing were tried: the
   variable names read `--gx`/`--gy`, which NOTHING sets, so the highlight sat
   frozen at dead centre instead of following the pointer (`card-tilt.js`
   publishes `--card-tilt-x`/`--card-tilt-y`). And `color-dodge`, multiplying the
   sweep through the artwork before `screen`, and splitting the highlight at the
   tear line were each built and measured — all three were worse or inert. */
.invite-ticket-glare {
    position: absolute;
    inset: 0;
    pointer-events: none;
    border-radius: 3%;
    mix-blend-mode: overlay;
    opacity: 0.55;
    background: radial-gradient(
        circle at calc(var(--card-tilt-x, 0.5) * 100%) calc(var(--card-tilt-y, 0.5) * 100%),
        rgba(255, 246, 214, 0.55) 0%,
        rgba(255, 246, 214, 0.12) 34%,
        transparent 62%
    );
    -webkit-mask-image: url('../assets/images/invite-ticket.webp');
    mask-image: url('../assets/images/invite-ticket.webp');
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
}

/* NO INSET ON THIS ELEMENT. An earlier attempt shrank the sweep to `inset:
   2.5%` to keep light off the outline, which quietly BROKE the mask above: the
   mask is sized `100% 100%` of the element, so shrinking the element rescales
   the mask and it stops lining up with the artwork it is supposed to trace.
   A misaligned mask leaks exactly where the shape is thinnest — the tear-line
   notches — which is the opposite of the intent.

   `mask-mode: luminance` was also tried and rejected on evidence: keying the
   sweep on brightness suppressed it almost everywhere, and a glare-on versus
   glare-off heatmap came back essentially black. That removes the defect by
   removing the specular, which is not a fix. */

/* THE TILT ENGINE BRINGS ITS OWN GLARE, AND IT IS THE ONE THAT LIT THE CUTS.
   `card-tilt.js` INJECTS a `.card-glare` child on activation — `mix-blend-mode:
   overlay` at opacity 1, covering the element's whole rectangle. On a card that
   is a solid rounded box, so it is invisible. This ticket is a die-cut shape:
   the tear-line notches and the bottom notch are holes, and an unmasked overlay
   layer paints straight through them, lifting the dark veil behind to a flat
   grey. Measured at the bottom notch: rgb(17,11,25) at rest, rgb(128,126,137)
   the moment the pointer arrives — in Chromium AND Firefox.

   That is the whole of "hovering it lights up its background, filling its gaps
   and breaking the illusion of segmentation", and it is why every earlier fix
   failed: they were all aimed at OUR glare, at the gold bloom, or at the veil,
   while a second glare nobody had looked for was doing the damage. It only
   exists while `card-tilt-active` is on, which is why the defect appears on
   hover and vanishes at rest.

   Masked to the artwork, scoped to this ticket, so no other card surface is
   touched. */
.invite-ticket .card-glare {
    -webkit-mask-image: url('../assets/images/invite-ticket.webp');
    mask-image: url('../assets/images/invite-ticket.webp');
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
}

.invite-ticket-copy {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: clamp(6px, 1.4vmin, 14px);
    text-align: center;
    max-width: min(92vw, 560px);
}

.invite-ticket-title {
    margin: 0;
    font-family: "Cinzel", Georgia, serif;
    font-size: clamp(20px, 4.4vmin, 40px);
    line-height: 1.1;
    letter-spacing: 0.01em;
    color: #fff1be;
    text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8), 0 0 22px rgba(234, 199, 97, 0.3);
}

.invite-ticket-body {
    margin: 0;
    font-size: clamp(12px, 1.9vmin, 17px);
    line-height: 1.35;
    color: rgba(238, 219, 241, 0.86);
}

.invite-ticket-accept {
    margin-top: clamp(2px, 1vmin, 10px);
    min-height: 44px;
    padding-inline: clamp(18px, 3.4vmin, 34px);
}

@keyframes invite-ticket-in {
    from { opacity: 0; }
    to { opacity: 1; }
}

@keyframes invite-ticket-out {
    to { opacity: 0; }
}

/* Anticipation, overshoot, settle — the ticket ARRIVES rather than appearing. */
@keyframes invite-ticket-arrive {
    0% { opacity: 0; transform: translateY(9%) scale(0.86) rotate(-3.2deg); }
    62% { opacity: 1; transform: translateY(-1.6%) scale(1.035) rotate(0.8deg); }
    100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}

/* A landscape phone has almost no vertical room; the ticket and its words sit
   side by side instead of stacked. */
@media (orientation: landscape) and (max-height: 560px) {
    .invite-ticket-view {
        flex-direction: row;
        gap: clamp(14px, 4vw, 40px);
    }

    .invite-ticket {
        height: min(78svh, 300px);
    }

    .invite-ticket-copy {
        max-width: min(52vw, 420px);
        align-items: flex-start;
        text-align: left;
    }
}

@media (prefers-reduced-motion: reduce) {
    .invite-ticket-view,
    .invite-ticket-view--leaving,
    .invite-ticket {
        animation: none;
    }

    /* The arrival still has to READ as an arrival without motion. */
    .invite-ticket {
        filter: drop-shadow(0 4px 18px rgba(0, 0, 0, 0.78));
    }
}

/* ===========================================================================
   THE DOCUMENTARY FACE

   Studied from cloudflare.pay, whose claim card prints five pieces of 9px
   letterspaced monospace into its corners — IDENTITY, ACCOUNT & AGENT, ONE OF
   ONE, the handle, and AUGUST / 2026. Nobody reads them, which is the point:
   they are the visual grammar of passports, boarding passes and share
   certificates, and they say "an instrument was issued, with a serial and a
   date" faster than a sentence can.

   Every label Moonfall prints here is TRUE — the ticket admits one player, the
   code is its serial, the date is when it was opened. No "one of one", because
   an invite code is not unique to a person.
   =========================================================================== */
.invite-ticket-face {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: clamp(10px, 2.2vmin, 20px);
    pointer-events: none;
    /* Above the art, below the glare. */
    z-index: 2;
}

.invite-ticket-face-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 7cqw;
}

/* THE HEAD ROW WRAPS. The wordmark and the issue month share one line, and a
   long month beside "MOONFALL" is wider than the face of a ~150px dialog ticket
   — a `nowrap` flex item never shrinks, so "SEPTEMBER / 2026" ran 7px off the
   right edge into the frame, "SEPTIEMBRE / 2026" 12px, on every ticket size
   down to the landscape phone ("badly centered text, it overshoots, at least
   for month september"). When the pair does not fit, the date takes the next
   line and keeps its right corner; when it fits, nothing changes. */
.invite-ticket-face-row--head {
    flex-wrap: wrap;
    row-gap: 2px;
}

.invite-ticket-stamp--date {
    margin-left: auto;
}

.invite-ticket-stamp {
    display: flex;
    flex-direction: column;
    gap: 2px;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: clamp(7px, 1.05vmin, 10px);
    font-weight: 650;
    letter-spacing: 0.14em;
    /* NO blanket `nowrap` here. It was tempting — a stamp reads as documentary
       furniture only on one line — but these labels are TRANSLATED, and German's
       "EINLASS FÜR EINE PERSON" is two and a half times the English "ADMIT ONE".
       Forced onto one line it runs straight off the ticket, which is worse than
       wrapping. Only the short, format-controlled stamps below get `nowrap`. */
    line-height: 1.25;
    text-transform: uppercase;
    color: rgba(255, 241, 190, 0.72);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
}

.invite-ticket-stamp--date,
.invite-ticket-stamp--serial {
    text-align: right;
    align-items: flex-end;
    /* Safe to keep on one line in every language: the date is a formatted
       "MONTH / YYYY" and the serial label is "NO." and its six-character value.
       Neither grows with translation the way a sentence does. */
    white-space: nowrap;
}

.invite-ticket-stamp--brand {
    font-weight: 800;
    letter-spacing: 0.2em;
    color: rgba(255, 246, 214, 0.92);
}

.invite-ticket-stamp-value {
    color: rgba(255, 246, 214, 0.95);
    letter-spacing: 0.1em;
}

/* Who sent it. Empty until the lookup answers, and it must occupy no space
   while empty or the face jumps when the name lands. */
.invite-ticket-issuer {
    /* IN FLOW, not absolutely positioned. The old `bottom: clamp(30px,…)` was a
       hand-tuned offset into a different coordinate system from the admit row,
       so the two printed on top of each other as soon as that row gained
       padding. `min-height` keeps the line box while the lookup is outstanding,
       so the name landing moves nothing. */
    text-align: center;
    min-height: 1.25em;
    /* ABOVE THE ORNAMENT, not on it. Measured off the artwork: the bottom
       scrollwork band starts at 73.6% of the ticket's height and the stub
       separates at 80%, so the issuer line was printing straight over the
       filigree. This margin lifts its baseline clear of 73.6% — expressed in
       `cqh` because it is a fraction of the ticket's HEIGHT, so it holds at
       every ticket size. */
    margin-bottom: 18cqh;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: clamp(7px, 1.05vmin, 10px);
    font-weight: 650;
    letter-spacing: 0.16em;
    color: rgba(255, 241, 190, 0.9);
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
    opacity: 0;
}

.invite-ticket-issuer--filled {
    /* PRINTED, not stamped on. This line used to scale in from 1.35 through
       0.96 to 1 over 320ms — "a name being pressed into the ticket" — and it
       was the one thing on the face that visibly changed size and place after
       first paint: measured, the box slid 5px and grew 8px wide across the
       first four frames. "issued by user text moves on load. it should start
       at its final position otherwise it breaks the impression of a physical
       object, not morph around." A printed object does not morph: the name is
       at its final size and place from the first frame, exactly like the date,
       the serial and the admit stamp. A name that lands late (the arrival
       screen's lookup) simply appears where it was always going to be. */
    opacity: 1;
}

/* THE FACE IS PRINTED WHEN THE TICKET LANDS.
   The stamps used to start at `opacity: 0` and fade in only once JS added
   `invite-ticket-view--resolved` on a 260ms timer, with up to 180ms of stagger
   after that — a deliberate "resolution" beat, staged so the serial and date
   appeared to stamp themselves onto the face.

   It was wrong for this object. Reported: "when loading it initially lacks text
   and it appears after a second, breaking illusion". A ticket is a PRINTED
   thing; it cannot arrive blank and acquire its own printing while you watch,
   and the moment it does the physical illusion the whole screen depends on is
   gone. The entrance animation still gives the arrival its weight — the ticket
   flies in, overshoots and settles — it simply arrives already printed.

   The `--resolved` class is still applied (it also cues the arrival sound) but
   it no longer gates any text. */

/* INTEGRATION — the fourth beat. Accepting sends the ticket INTO the game
   rather than fading it into nothing: a reward that does not visibly become
   yours is half a reward. */
.invite-ticket--claimed {
    animation: invite-ticket-claim 460ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

@keyframes invite-ticket-claim {
    0% { transform: translateY(0) scale(1) rotate(0deg); opacity: 1; }
    28% { transform: translateY(3%) scale(0.965) rotate(-1.4deg); opacity: 1; }
    100% { transform: translateY(-46%) scale(0.42) rotate(6deg); opacity: 0; }
}

/* The stamp-on keyframes that lived here are gone with the animation above:
   they once carried a `translateX(-50%)` from the line's absolute-centred days
   and printed the name half a box off the ticket's left edge, then a scale
   that morphed it on load. A keyframe that carries a positioning trick is a
   landmine for the next layout change; a line that is printed needs none. */

@media (prefers-reduced-motion: reduce) {
    /* The face is printed from the first frame for everyone, so there is no
       staged reveal left to undo here. */
    .invite-ticket--claimed {
        animation: none;
        opacity: 0;
    }
}

/* The entrance animation runs with `fill-mode: both`, so it keeps owning
   `transform` after it ends — and an animation beats the inline transform the
   tilt engine writes, which is why the ticket could not be rotated with the
   mouse. Once the arrival has settled the animation is cleared; the 100%
   keyframe is the identity transform, so the pose does not change. */
.invite-ticket--settled {
    animation: none;
    /* CONSUME THE TILT VARIABLES.
       CardTilt does not write a transform — it writes `--card-tilt-rotate-x/y`
       and expects a rule to apply them, which `.card` has and this ticket did
       not. Registering therefore "worked" while the ticket sat perfectly still:
       "not 3d rotatable based on mouse". The stage already supplies the
       perspective, so the rotation is applied here directly. */
    transform:
        rotateX(var(--card-tilt-rotate-x, 0deg))
        rotateY(var(--card-tilt-rotate-y, 0deg));
    transition: transform 90ms ease-out;
}

/* READABILITY OF THE FACE.
   Reported: "white text on top unreadable, perhaps move lower". The stamps sat
   hard against the ticket's top edge, over the brightest part of the art, with
   only a text-shadow between them and it. They now sit lower, and each row
   carries its own scrim so the labels are legible over any artwork rather than
   only over a dark one. */
.invite-ticket-face {
    /* MOVED DOWN off the frame. The stamps used to sit hard against the top
       edge, printing over the gold ornament where white-on-gold is unreadable —
       "white text on top unreadable, perhaps move lower". They now sit inside
       the purple field, where the scrim below has something to darken. */
    /* The head row sits BELOW THE MOON, not above it. Up at the top edge the
       white type crossed the gold ornament and the crescent's own highlight,
       where white on gold has almost no contrast — "text on top is not readable
       ... fix it by mving it underneath the moon". The crescent is measured at
       11%-28.7% of the ticket's height, so the face's content starts at 33%,
       on the plain star-field where the type has something to sit on. */
    padding: 33cqh 11.9cqw 10cqw;
}

/* The bottom block is one column so its two lines can never overlap. */
.invite-ticket-face-foot {
    display: flex;
    flex-direction: column;
}

.invite-ticket-face-row {
    position: relative;
    padding: clamp(4px, 0.9vmin, 8px) clamp(6px, 1.2vmin, 10px);
    border-radius: 6px;
}

/* NO SCRIM BEHIND THE TEXT. Two versions were tried — a flat rounded rectangle
   and then a soft ellipse — and both were rejected for the same reason: any
   darkened patch behind the type reads as a UI chip pasted onto the artwork.
   "the black background u added to texxt makes it look cheap, remove it".

   Legibility comes from the type itself instead: a tight dark halo carried in
   `text-shadow`, which is how ink actually sits on a printed ticket. It costs no
   box, no edge, and no rectangle. */

/* THE INK IS v1's, EXACTLY. With the scrim gone this block used to brighten the
   type to `rgba(255, 246, 214, 0.96)` with a wider halo to compensate. That is a
   change to the type, and the standing instruction is that the type matches the
   first version — "in case u changed text font/color on ticket freom first
   version in any wya pls revert" — so the override is gone and the base rule's
   v1 values stand: `rgba(255, 241, 190, 0.72)` with
   `text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)`.

   Legibility now comes from PLACEMENT rather than brighter ink: the head row
   sits below the moon and the issuer above the bottom ornament, both on plain
   star-field instead of across gold, which is where white type had no contrast
   in the first place. */

.invite-ticket-issuer {
    position: relative;
    /* NEVER `nowrap`. A username is user-controlled and arbitrarily long; a
       nowrap line ran straight off the ticket's left edge and printed
       "BY VELVETVALE97" with the "ISSUED" cut away. It wraps and shrinks
       instead, so any name stays inside the card. */
    white-space: normal;
    overflow-wrap: anywhere;
    max-width: 100%;
}
/* ===========================================================================
   THE ONLY PLACE THE FACE TYPE LEAVES v1's FORMULA.

   v1 sizes the stamps `clamp(7px, 1.05vmin, 10px)` — a VIEWPORT measurement
   printed on an object whose size does not track the viewport the same way. On
   a landscape phone the ticket shrinks to ~113px wide (~85px at an iPhone SE)
   while that 7px floor holds, so the face disintegrates: "/ 2026" clipped off
   the date, "ADMIT / ONE / ONE / SEAT / HELD" over five lines spilling across
   the ornament. Here, and only here, the type is sized off the TICKET.

   Measured against v1 at every standard viewport — 1400x900, 1400x640,
   1920x1080, 2560x1440, 3840x2160, 1366x768, 1024x768, 768x1024 and portrait
   393x659 — the computed size is IDENTICAL. It diverges inside this query
   alone, which is the case v1 could not render.

   This block lives at the END of the file on purpose: the base
   `.invite-ticket-stamp` rule is declared later than the earlier landscape
   media query, so an override placed there loses on source order and silently
   does nothing.
   =========================================================================== */
@media (orientation: landscape) and (max-height: 560px) {
    .invite-ticket-stamp,
    .invite-ticket-issuer {
        font-size: 3.75cqw;
    }
}

/* THE REWARD TERMS. Quieter than the body copy — it is a condition, not the
   promise — but present, because a reward that arrives three matches later
   reads as a bug when nobody said so. */
.invite-ticket-terms {
    margin: 0;
    font-size: clamp(11px, 1.6vmin, 15px);
    line-height: 1.35;
    color: rgba(238, 219, 241, 0.72);
}

/* THE TICKET STANDS BESIDE ITS COPY, not on top of it.
   This is the arrangement the share card uses -- the artefact on the left, what
   it says on the right -- and it is the arrangement the owner picked. It is
   applied on any screen that is WIDE AND NOT TALL, which is desktop and a
   landscape phone alike: a landscape phone hands the page about 300px of
   height, where a stacked ticket plus copy plus button does not fit at all.
   Portrait keeps the stack, because there the column is the whole screen.

   The one difference from the card: the right-hand column carries the ACCEPT
   button. The card has nothing to press. */
@media (min-aspect-ratio: 1 / 1) and (min-width: 700px) {
    .invite-ticket-view {
        flex-direction: row;
        align-items: center;
        justify-content: center;
        gap: clamp(28px, 5vw, 76px);
    }

    /* ONE COLUMN, ONE AXIS. The heading was left-aligned while the button was
       centred, so the block had two different centres and read as two pieces
       of layout rather than one. Everything shares the column's centre line. */
    .invite-ticket-copy {
        align-items: center;
        text-align: center;
        max-width: min(46vw, 460px);
    }

    /* These carry their own alignment, so the container alone does not move
       them -- and this block has to sit AFTER those rules to win on equal
       specificity, which is why it lives at the end of the file. */
    .invite-ticket-title,
    .invite-ticket-body,
    .invite-ticket-terms {
        text-align: center;
    }

    .invite-ticket-accept {
        align-self: center;
        margin-top: clamp(6px, 1.4vmin, 16px);
    }
}

/* ============================================================================
   THE HOLD.

   A physical object does not assemble itself in front of you. The ticket used
   to mount and paint immediately, so its heading and button faded up first, its
   corner stamps printed into the empty space where the ticket was going to be,
   and the 326KB artwork arrived underneath them up to three seconds later --
   with the inviter's real name replacing "a friend" after that. Four visible
   states for one object.

   `visibility` rather than `display`: the element keeps its box, so the tilt
   engine can measure it and nothing reflows when it appears. `animation: none`
   parks the entrance at its first frame; removing this class starts it, so the
   finished object gets the whole 420ms arrival rather than snapping in
   half-played.

   The class is removed by a HARD-CAPPED promise in `invite-ticket.js` -- a gate
   that can fail to open would have deleted the screen it was warming up.
   ========================================================================== */
.invite-ticket-view--holding {
    visibility: hidden;
    animation: none !important;
}
