/* ===== Envelope =====
   Rebuilt with clip-path instead of CSS border triangles. Border triangles
   can only ever be one flat colour — that was the single reason the old
   envelope looked cheap. clip-path shapes accept gradients, inset shadows
   and paper texture, which is where the "premium" feel comes from. */

/* ---------- Per-occasion palettes ----------
   Palettes are attached to .card-theme, which is deliberately separate from
   the .card-page full-screen layout below. That lets the same envelope
   component be themed inside a small panel (the landing-page demo, the phone
   frame on preview.html) without inheriting full-viewport layout rules. */

.card-theme {
    --paper: #fbf7f2;
    --ink: #2c2420;
    --env-1: #f0c5d1;
    --env-2: #e19cae;
    --env-3: #d4869c;
    --seal: #b4384f;
    --seal-2: #8d2439;
    --accent: #b4384f;
    --bg-1: #f7e5e9;
    --bg-2: #efd3da;
}

.card-theme.theme-sorry {
    --env-1: #c4d3e8; --env-2: #9db3d1; --env-3: #8298bb;
    --seal: #3d5a80; --seal-2: #2b4260; --accent: #3d5a80;
    --bg-1: #e2e9f2; --bg-2: #cfdae8;
}

.card-theme.theme-miss {
    --env-1: #cdc3e3; --env-2: #a99cc9; --env-3: #8f81b3;
    --seal: #4a3b6b; --seal-2: #342a4d; --accent: #4a3b6b;
    --bg-1: #e6e1f0; --bg-2: #d5cde5;
}

.card-theme.theme-thanks {
    --env-1: #c3ddc9; --env-2: #9cbfa5; --env-3: #82a68c;
    --seal: #35624a; --seal-2: #244534; --accent: #35624a;
    --bg-1: #e1eee5; --bg-2: #cee0d4;
}

.card-theme.theme-anniversary {
    --env-1: #eed7b4; --env-2: #ddbd8b; --env-3: #c9a36c;
    --seal: #8a5a2b; --seal-2: #6b4420; --accent: #8a5a2b;
    --bg-1: #f5e8d3; --bg-2: #ecd9bd;
}

.card-theme.theme-birthday {
    --env-1: #f6d2b8; --env-2: #e8ad8b; --env-3: #d89470;
    --seal: #b5502a; --seal-2: #8f3c1e; --accent: #b5502a;
    --bg-1: #fae5d6; --bg-2: #f4d4bf;
}

/* Milestones. Kept in the same muted, papery register as the originals — a
   congratulations card that shouts in saturated primaries would look like a
   different product bolted on. Every --accent here is dark enough to carry
   white button text at AA. */

.card-theme.theme-congrats {
    --env-1: #f4d9a8; --env-2: #e6c078; --env-3: #d0a555;
    --seal: #96661a; --seal-2: #744d10; --accent: #96661a;
    --bg-1: #f9ecd0; --bg-2: #f2dcb0;
}

.card-theme.theme-goodluck {
    --env-1: #bcd9d5; --env-2: #92bcb6; --env-3: #74a29b;
    --seal: #1f6058; --seal-2: #14453f; --accent: #1f6058;
    --bg-1: #dceae8; --bg-2: #c7dcd8;
}

.card-theme.theme-getwell {
    --env-1: #d6e3c8; --env-2: #b3c8a0; --env-3: #97ae83;
    --seal: #4a6636; --seal-2: #354b26; --accent: #4a6636;
    --bg-1: #e8f0de; --bg-2: #d8e5ca;
}

.card-theme.theme-newbaby {
    --env-1: #f3e0d2; --env-2: #e2c4ae; --env-3: #cda98f;
    --seal: #8a6248; --seal-2: #684832; --accent: #8a6248;
    --bg-1: #f8ece2; --bg-2: #f0dccd;
}

.card-theme.theme-farewell {
    --env-1: #cfd6e0; --env-2: #a8b3c2; --env-3: #8b97a8;
    --seal: #45536b; --seal-2: #2f3a4d; --accent: #45536b;
    --bg-1: #e4e9ef; --bg-2: #d2dae3;
}

/* Festivals. These carry more colour than the everyday set, because a festival
   card that looks apologetic misses the point — but they stay in the same
   paper-and-wax family so the whole site still reads as one thing. */

.card-theme.theme-rakhi {
    --env-1: #f7d9c2; --env-2: #eeba97; --env-3: #e09d75;
    --seal: #a83f3f; --seal-2: #822c2c; --accent: #a83f3f;
    --bg-1: #fbe8da; --bg-2: #f6d8c4;
}

.card-theme.theme-diwali {
    --env-1: #f6d9a0; --env-2: #e7bb6b; --env-3: #d29f46;
    --seal: #8c3d84; --seal-2: #6a2b63; --accent: #8c3d84;
    --bg-1: #fbecc9; --bg-2: #f5dca6;
}

.card-theme.theme-eid {
    --env-1: #bfdcd2; --env-2: #93bfb0; --env-3: #74a493;
    --seal: #175c4c; --seal-2: #0e4234; --accent: #175c4c;
    --bg-1: #dcebe4; --bg-2: #c8ded4;
}

.card-theme.theme-christmas {
    --env-1: #d8dfd2; --env-2: #b0bda6; --env-3: #93a487;
    --seal: #8d2b2b; --seal-2: #6b1c1c; --accent: #8d2b2b;
    --bg-1: #e9efe3; --bg-2: #d9e2d1;
}

.card-theme.theme-newyear {
    --env-1: #d3d5e6; --env-2: #aaadca; --env-3: #8d91b3;
    --seal: #3a3d70; --seal-2: #272a52; --accent: #3a3d70;
    --bg-1: #e5e6f1; --bg-2: #d3d5e8;
}

.card-theme.theme-friendship {
    --env-1: #f8d7c0; --env-2: #eeb693; --env-3: #de9a73;
    --seal: #b5613a; --seal-2: #8d4726; --accent: #b5613a;
    --bg-1: #fce7d8; --bg-2: #f7d6c1;
}

/* ---------- Full-screen stage (recipient view only) ---------- */

.card-page {
    background:
        radial-gradient(ellipse 80% 55% at 50% 0%, var(--bg-1), transparent 70%),
        linear-gradient(170deg, var(--bg-1), var(--bg-2));
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    overflow: hidden;
}

.stage {
    position: relative;
    z-index: 5;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 24px 20px 90px;
    width: 100%;
}

.stage-intro {
    text-align: center;
    margin-bottom: 26px;
    transition: opacity .45s ease, transform .45s ease;
}

.stage.revealed .stage-intro {
    opacity: 0;
    transform: translateY(-14px);
    pointer-events: none;
}

.stage-intro .eyebrow {
    font-size: .72rem;
    letter-spacing: .18em;
    text-transform: uppercase;
    color: var(--accent);
    opacity: .75;
    margin-bottom: 8px;
}

.stage-intro h1 {
    font-family: var(--font-heading);
    font-size: clamp(1.5rem, 5.5vw, 2.1rem);
    color: var(--ink);
    font-weight: 500;
}

/* ---------- Envelope ---------- */

.envelope {
    position: relative;
    width: 100%;
    aspect-ratio: 3 / 2;
    cursor: pointer;
    transition: transform .5s cubic-bezier(.22,1,.36,1);
    -webkit-tap-highlight-color: transparent;

    /* Makes cqh/cqw inside resolve against THIS box, so the card can be sized as
       a fraction of its envelope — which is the only meaningful reference for a
       card that has to fit inside one. The same component then works at 210px on
       the write page and 370px on the recipient's card with no per-context cap.
       Must be `size` (not inline-size) for cqh to resolve; safe here because the
       height comes from aspect-ratio rather than from the contents. */
    container-type: size;
}

.envelope:hover { transform: translateY(-5px); }
.envelope.opened { cursor: default; transform: none; }

/* Body: gradient + fine paper grain */
.env-body {
    position: absolute;
    inset: 0;
    border-radius: 6px;
    background:
        linear-gradient(160deg, var(--env-1) 0%, var(--env-2) 58%, var(--env-3) 100%);
    box-shadow:
        0 1px 0 rgba(255,255,255,.5) inset,
        0 22px 45px -12px rgba(60,30,40,.34),
        0 6px 14px -6px rgba(60,30,40,.2);
    z-index: 1;
    overflow: hidden;
}

/* Paper grain — pure CSS, no image request */
.env-body::after {
    content: '';
    position: absolute;
    inset: 0;
    opacity: .32;
    background-image:
        repeating-linear-gradient(90deg, rgba(255,255,255,.16) 0 1px, transparent 1px 3px),
        repeating-linear-gradient(0deg, rgba(120,70,85,.07) 0 1px, transparent 1px 4px);
}

/* ---------- The inside of the envelope ----------
   Holds the enclosures and CLIPS them, so a card taller than the envelope is
   simply in it rather than hanging out of the bottom. This is what makes the two
   items read as enclosed: paper you cannot see through.

   The clip extends a long way UP (-300%) and out to both SIDES (-120%), because
   those are the directions the items travel on their way out, and a clip there would
   slice them mid-flight. The BOTTOM is pinned WHILE THE ITEMS ARE STILL INSIDE: that
   edge is real paper, and nothing travels down through it until the items are out in
   front of the envelope altogether. The photo is square, and therefore taller than
   the 3:2 envelope holding it, so without this it hangs out underneath.

   It is released on the same frame as the lift (see @keyframes inside-out), which is
   the frame the enclosures stop being enclosures. It has to be: the settled stack
   lands low, over the envelope, and its bottom edge crosses the envelope's — so a
   bottom clip that stayed pinned would cut the card off along the fold. Both halves
   of "these are in front now" happen at once, and for the same reason.

   The bottom is pinned 2px INSIDE the envelope rather than exactly on its edge.
   At exactly 0 the clipped edge of the enclosures and the antialiased bottom
   corner of .env-bottom's own polygon fall on the same subpixel line, and the
   white paper bled through as a hairline below the fold — visible until any
   repaint of the card or photo, which is why it came and went. Nothing is lost:
   the bottom fold covers the lower 62% of the envelope, so 2px of an item that is
   already hidden behind paper is 2px nobody could see.

   z-index 1.5 is deliberate: above .env-body (1), below .env-left/right (2). It
   works because these are all siblings in the same stacking context.

   No overflow:hidden — that would clip in all four directions. clip-path is the
   only way to open one edge and keep another. */
.env-inside {
    position: absolute;
    inset: 0;
    z-index: 1;
    /* Sits above .env-body, which is also z-index 1, by DOM order. */
    pointer-events: none;
    clip-path: inset(-300% -120% 2px -120%);

    /* The viewing distance for the flip. The stack turns over in 3D (see .env-deck),
       and without a perspective here a rotateY is an orthographic squash: the card
       narrows to nothing and widens again with no sense of a near edge swinging
       towards you. 1200px is far enough back that the paper does not visibly stretch
       — a card is ~340px tall, so the perspective is 3.5x its own size.

       It lives on this element rather than on .env-deck because `perspective` applies
       to an element's CHILDREN. Putting it on the deck itself would give each face its
       own vanishing point instead of one shared one, and the card and the photo would
       turn as two objects rather than as a stack. */
    perspective: 1200px;

    /* ---- The reveal fades THE STACK, not the two items separately ----
     *
     * This opacity used to live on .letter and .env-photo, one each, and both
     * animated 0 -> 1 on the same beat. That produced a real and reported bug: for
     * the ~400ms of the fade the CARD — which is the item in FRONT — was
     * semi-transparent, so the photo behind it showed straight THROUGH it and then
     * disappeared as the card became opaque. Watched at speed that reads exactly as
     * "the photo is in front of the card, then it goes behind", and it was measured
     * as such (/tmp/lnshots/flick2.js pins the composition at fixed instants: at
     * 330ms the card is at opacity .31 with the photo's colour filling its whole
     * body; by 560ms the card is solid and the photo is only the strip above it).
     *
     * Worth being precise about what was NOT wrong, because the symptom points
     * straight at it: the stacking order never changed. .letter is z-index 3 and
     * .env-photo is 2 throughout, and a hit test at a point inside both boxes
     * returned the card on every one of 169 sampled frames. Reordering the two, or
     * retiming the lift, would have fixed nothing.
     *
     * Fading the group is also the truer model of what is happening: the flap comes
     * off ONE stack of paper, not off two items that each independently become
     * visible. And it makes the bug unreachable rather than merely tuned away —
     * while the parent fades, the card is fully opaque paper within it, so it
     * occludes the photo at every value of this opacity. */
    opacity: 0;
    /* For CLOSING. The reveal is an animation (see .envelope.opened .env-inside);
       this only catches the way back, when that animation stops applying and the
       computed value returns here. Without it the contents blink out. */
    transition: opacity .3s ease;
}

/* There is deliberately no `.envelope.opened .env-inside { clip-path: ... }` rule.
   Opening the flap does not release the bottom — the items are still inside at that
   point, and releasing it there (which this used to do) let the square photo hang out
   below a closed-bottomed envelope. What releases it is the LIFT, a second and a half
   later, and only because by then the items are in front of the envelope rather than
   in it. See @keyframes inside-out.

   ---------- The deck ----------
   One box holding both enclosures, so the pair can be turned over as a single object:
   the card on the front face, the photo on the back. That is the whole mechanism
   behind "See photo" — nothing moves from behind the card to in front of it, the
   stack simply turns around, and what was behind is now facing you.

   preserve-3d is what makes it a deck rather than a picture of one. Without it the
   children are flattened into this element's plane before the rotation, so they rotate
   as one printed sheet and the item behind stays behind; with it, each child keeps its
   own position in the deck's 3D space, and the rotation genuinely carries the back one
   round to the front.

   Two consequences of preserve-3d worth knowing before editing anything inside it:

     - z-index stops ordering these children. Depth comes from translateZ, and only
       from translateZ. .letter's z-index: 3 and .env-photo's 2 are still in the file
       and still do the job while the deck is unrotated, but the +2px/-2px on the two
       items is what actually decides who is in front — see those rules.

     - a 180deg rotation NEGATES its children's x and z. That is not a nuisance to work
       around, it is the effect: the photo sitting 2px behind the card at rest ends up
       2px in front of it, without either item's own transform changing depth. The card
       does not move back; the deck turns over and takes it with it.

   No transition on the deck itself here — it is set on the .settled state, so the flip
   cannot start until the opening sequence has handed over. */
.env-deck {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
}

/* .env-inside spans the whole envelope and would otherwise swallow the clicks
   meant for it, so it takes no pointer events. The card opts back in once it is
   open, because the voice player lives on it. */
.envelope.opened .env-inside .letter { pointer-events: auto; }

/* Side panels — clip-path triangles, the left and right folds of the envelope.

   These are real, OPAQUE paper. They used to be near-transparent white sheens
   (rgba(255,255,255,.30) and .22) painted over the envelope body purely as
   highlights, which was fine while nothing was ever behind them — but now the
   enclosures are, and the card and photo were plainly visible straight through
   both folds. The bottom fold looked right for exactly one reason: it already had
   an opaque gradient of its own.

   Built as the envelope colour first, with the old highlight layered on top via
   a second gradient, so the fold still catches the light as it did — it is just
   no longer see-through. */
.env-left, .env-right {
    position: absolute;
    top: 0;
    height: 100%;
    width: 52%;
    z-index: 2;
}

.env-left {
    left: 0;
    clip-path: polygon(0 0, 100% 50%, 0 100%);
    background:
        linear-gradient(100deg, rgba(255,255,255,.30), rgba(255,255,255,.04)),
        linear-gradient(120deg, var(--env-1), var(--env-2) 70%, var(--env-3));
}

.env-right {
    right: 0;
    clip-path: polygon(100% 0, 0 50%, 100% 100%);
    background:
        linear-gradient(-100deg, rgba(255,255,255,.22), rgba(255,255,255,.02)),
        linear-gradient(-120deg, var(--env-1), var(--env-2) 70%, var(--env-3));
}

/* Bottom panel — the crease that reads as folded paper */
.env-bottom {
    position: absolute;
    left: 0;
    bottom: 0;
    width: 100%;
    height: 62%;
    clip-path: polygon(0 100%, 50% 0, 100% 100%);
    background: linear-gradient(180deg, var(--env-2), var(--env-3));
    box-shadow: 0 -1px 0 rgba(255,255,255,.28) inset;
    z-index: 4;
}

/* ---------- Opening choreography, in one place ----------
   Four moves in three beats, and the whole sequence is described by the variables
   below.

     1. FLAP        the lid swings back.
     2. REVEAL      the two items appear, still sitting INSIDE the envelope —
                    the card in front, the photo behind it and a little higher so
                    only its top edge shows. Nothing moves.
     3. EXIT        after a deliberate pause, both come out — and coming out of an
                    envelope is two moves, because that is how paper leaves paper:
        3a. EMERGE  the stack slides straight UP, still behind the envelope's front
                    paper, until both items' bottom edges have passed its top edge.
                    At that instant they are out of the envelope, nothing overlaps
                    it, and the interior flips in front of the front panels — an
                    invisible change, made at the only moment it can be one.
        3b. SETTLE  now in front, they come back DOWN the screen and land over the
                    envelope as ONE STACK: the card nearly the envelope's width,
                    the photo behind it and tilted so a corner shows.

   Then nothing, until the recipient asks: FLIP turns the stack over to look at the
   photo, and back. That is not part of the opening — it is theirs to trigger, on the
   button below the card. See the flip section further down.

   Beat 2 holds for --exit-at minus the end of the reveal (1.5s - .7s = .8s of
   stillness). That pause is the point of the sequence: it is the beat where the
   envelope reads as containing two things. Without it the items appear and leave
   in one motion, and you never see them stacked.

   The settled stack replaced a settled ROW — card left, photo right, side by side.
   Two items sharing one envelope width gave each of them barely two thirds of it,
   which is the wrong trade for the two things the recipient came to look at: a
   message too narrow to read comfortably, next to a thumbnail. One at a time, each
   at nearly full width, is what the flip buys.

   Set on .envelope, not :root, so a context can retime the whole sequence. */
.envelope {
    --flap-dur: .62s;

    /* Beat 2. The card and photo become visible together, as the flap clears —
       they are one stack being uncovered, not two things arriving separately. */
    --reveal-at: .30s;
    --reveal-dur: .40s;

    /* Beat 3. --exit-at is measured from the click, not from the end of the
       reveal, so it is the single number that says "how long do the two items sit
       in the envelope before coming out". */
    --exit-at: 1.5s;

    /* ---- Beat 3 is TWO moves, and the split is the point ----
       An envelope only lets go of what is in it one way: the thing slides up out
       of the mouth until it is clear of the paper, and only then is it in front of
       the envelope rather than inside it. So:

         EMERGE  the stack rises STRAIGHT UP, still behind the front paper, until
                 both items' BOTTOM edges have passed the envelope's top edge —
                 i.e. until they are genuinely out of the envelope.
         SETTLE  now in front of it, they come back DOWN and out to the settled
                 row, which is where the recipient reads them.

       The interior's z-index flips between the two (see --inside-lift), and that
       is what the two-move split buys: at the moment of the flip nothing overlaps
       the envelope at all, so the change of stacking order is invisible. Every
       previous single-move version had to flip while the items were still across
       the envelope's front, which is what read as the card popping out in front of
       the paper it was supposedly still inside.

       Roughly equal durations for unequal distances — the rise is the longer trip
       (a full card height, ~290px on a phone) and the descent about half of it —
       so the descent is the slower, calmer half. That is the right way round: the
       rise is the envelope giving the card up, the descent is it being laid down
       to be read. */
    --emerge-dur: .66s;
    --settle-dur: .74s;

    /* Each half decelerates INTO the peak and accelerates out of it, because the
       stack reverses direction there and a reversal at speed reads as a bounce.
       Both curves therefore have a flat end (emerge) and a flat start (settle) —
       zero velocity at the turn, from both sides.

       Deliberately not the cubic-bezier(.22,1,.36,1) used everywhere else in this
       file: that curve puts ~90% of the distance in the first third of the time,
       which over a 290px rise is a yank rather than a card being drawn out. */
    --emerge-ease: cubic-bezier(.32,.12,.28,1);
    --settle-ease: cubic-bezier(.42,0,.3,1);

    /* ---- Where each item ends up: the settled stack ----
       All values are % of the envelope, which is what `left`/`right`/`top` on an
       absolutely positioned child already mean, so they need no conversion.

       EACH ITEM HAS ONE FIXED WIDTH AND ONE FIXED HORIZONTAL POSITION, held from
       the moment it is revealed to the moment it settles and through the flip. Both
       are centred on the envelope, so `left` and `right` never move at all: the
       settle is purely vertical.

       Width is fixed because changing it is what made the card visibly rebuild
       itself on the way out: narrowing the paper re-wraps the message into a
       different number of lines every frame, so you watch the text re-flow rather
       than watching the card leave the envelope. The only thing that changes size
       is the card's HEIGHT, via --card-maxh — the one axis that re-wraps nothing.

       Because the two no longer sit side by side, each can have most of the
       envelope's width instead of two thirds of it, and nothing overhangs the
       envelope's edges any more — which is what used to make the row need ~108% of
       the envelope's width on a phone and 146% on a desktop, and what made every
       enclosing frame (the preview's phone screen, the write page's card) a fitting
       problem. A stack that is narrower than the envelope it came out of cannot
       overflow anything the envelope already fits in.

       Nothing is scaled. transform: scale() was the first attempt and it shrank the
       type along with the box, so the card arrived as a small copy of itself. The
       only rotation is the few degrees of tilt on whichever item is BEHIND (see
       --peek-tilt): on a stack that reads as casual and hand-laid, which is what a
       stack is. The item in front is always square to the screen. */

    /* ---- The two inputs ----
       The card's width, and the photo's. Both are a fraction of the envelope and
       both are honest widths now that neither has to leave room for the other: the
       card takes most of the envelope, and the photo is wide enough to be a photo
       rather than a stamp — 70% against the 34% it was given on a phone when the two
       had to share a row. Everything else is derived.

       They are also deliberately CLOSE to each other, which is a constraint the stack
       introduced and the row did not have. Whichever item is behind has to be visible
       past the one in front, and the only ways for that to happen are to stick out or
       to be nudged out. The card is bigger, so it sticks out on its own. The photo has
       to be nudged (see --photo-peek-x), and the nudge has to cover half the width
       difference before it buys a single visible pixel — so a much smaller photo would
       have to be shoved most of the way off the card to peek at all. 84/70 keeps that
       nudge small enough to read as "laid down slightly askew".

       There is a ceiling on --photo-w from the other end of the sequence, too: the
       photo's box is 1.053x its width, so past some width it becomes TALLER than the
       card's enclosed cap and the shared rise stops being enough for it — at which
       point --photo-rise takes over and the two stop travelling in lockstep. 70% is
       ~271px against a 292px rise on a full-size envelope, which leaves that intact
       with room to spare. See --rise. */
    --card-w: 84%;
    --photo-w: 70%;

    /* Centred across the envelope, each at its own width. ONE position per item,
       used by the base rules, by the settle's keyframes at both ends, and by the
       reduced-motion fallback — so there is nothing to keep in sync. */
    --card-in: calc((100% - var(--card-w)) / 2);
    --photo-in: calc((100% - var(--photo-w)) / 2);

    /* Where each item's TOP edge sits while enclosed. Named because the emerge has
       to know them — how far an item must rise to be out of the envelope is its own
       height plus however far below the envelope's top edge it starts.

       In cqh rather than %, and they are the same thing here: both resolve against
       the envelope's height (the enclosures' containing block is .env-inside, which
       is inset: 0 of the envelope). cqh is a LENGTH, though, so unlike a bare % it
       can be added to a height inside calc() — which is exactly what --rise does.

       The photo starts a hair ABOVE the envelope's mouth, which is what makes its
       top edge show behind the card while both are still inside. */
    --card-in-top: 6cqh;
    --photo-in-top: -1cqh;

    /* ---- Where the stack lands ----
       Low, and over the envelope rather than floating above it: the card covers most
       of the paper it came out of, which is what a card laid down on its envelope
       looks like. It used to settle at -55%, more than half its own height above the
       envelope's top edge, with the envelope sitting underneath as a separate object
       nobody was looking at any more.

       -22% is measured, not chosen: the card is ~265px of content on a phone against
       a 228px envelope, so a top edge 22% (50px) above the envelope's puts its bottom
       edge ~13px short of the envelope's bottom. Majority covered, both edges of the
       envelope still visible, and the whole composition is one object.

       Minus --voice-extra, because a card carrying a voice player is EXACTLY that much
       taller (the player is 34px plus 16px of margin, and it is the only thing added).
       Subtracting it here holds the card's BOTTOM edge still between the two cases
       instead of hanging it 50px lower, which is what decides whether the after-reveal
       footer below has room — see .after-reveal. The top edge moves instead, into the
       space above the envelope, where there is nothing to collide with. */
    --card-top: calc(-22% - var(--voice-extra));

    /* The photo's landing place: a little higher than the card's, so that when it is
       the one in front it sits centred on the same area the card occupies rather than
       low in it. The photo is square (--photo-w wide, so --photo-w * 1.05 of the
       envelope's WIDTH tall) and does not change with a voice note, so unlike the
       card's this is one flat number. */
    --photo-top: -16%;

    /* ---- The tilt and offset of whichever item is BEHIND ----
       The back item is turned a few degrees and, if it needs to be, nudged out from
       under the front one, so that it is visibly a second thing rather than something
       hidden under the first. Percentages of the ITEM'S OWN box, which is what a
       percentage inside `translate` means, so they scale with the envelope.

       TWO sets of numbers, not one, because the two items are not the same size and
       what each needs from being behind is therefore different:

         the photo, behind the card, is the SMALLER box. Centred, it is entirely
         covered — 84% against 70% leaves 7% of the envelope's width of card on each
         side of it — so it has to be nudged up and to the right far enough to clear
         that overlap before any of it shows. Half the width difference is ~10% of the
         photo's own width, so 18% buys the visible ~20px corner and nothing more.

         the card, behind the photo, is the LARGER box: 84% against 70% means it shows on
         all four sides without being moved at all. So its offset is small and does one
         job only — it puts the card to the LEFT of the photo rather than evenly around
         it, so the pair reads as two sheets set down slightly apart instead of a mat
         printed around a picture. 5% of the card is ~14px, against the ~24px it already
         shows on each side.

       Both are stated as POSITIVE numbers, and the CARD's come out mirrored — offset to
       the left, leaning the other way — because the state they apply in is the one where
       the deck has turned over, and a turned-over deck negates its children's x and
       their sense of rotation. See .env-deck. That is not a bug to correct: it is why a
       positive 5% here is the left side of the photo, and the mirroring is also what
       makes the pair lean apart after the flip instead of both leaning the same way,
       which is what two loose sheets do when you turn them over together. */
    --photo-peek-x: 18%;
    --photo-peek-y: -13%;
    --photo-peek-tilt: 5deg;
    --card-peek-x: 5%;
    --card-peek-tilt: 4deg;

    /* ---- The flip ----
       Slow enough to read as one object turning over rather than as two pictures being
       swapped, which is the whole reason it is a rotation and not a cross-fade. .85s is
       about the duration of the exit's two moves together, and it is a deliberate echo:
       the recipient has just watched the stack come out at that pace.

       The easing is symmetric on purpose. The deck is exactly edge-on halfway through
       its ROTATION, and an asymmetric curve puts that instant somewhere other than
       halfway through the DURATION — which is fine today, but the moment anything has
       to be timed against the edge-on frame (a JS class change, a label swap), the two
       need to coincide. Cheaper to keep than to rediscover. */
    --flip-dur: .85s;
    --flip-ease: cubic-bezier(.6,.02,.4,1);

    /* ---- How tall the card is allowed to be ----
       Taller than the 88cqh the card is capped at while enclosed: out of the envelope
       there is nothing to fit inside any more, and at --card-w the message needs the
       extra lines. Still a cap, so a very long message scrolls inside the paper
       rather than carrying its own top edge off the screen.

       max(), not a bare 132cqh, and this is the whole fix for the preview page. A cap
       measured in cqh is a fraction of the ENVELOPE, but everything stacked inside the
       card — the mark, the greeting, the voice player, the rule, the signature — is
       sized in ABSOLUTE PIXELS and does not shrink with it. On the preview's 165px-tall
       envelope, 132cqh came to 218px for content that needed 270px, and since nothing
       clips .letter the 52px of signature simply painted onto the envelope behind it.
       That is the "text on the background" — not a z-index or a clipping bug, just a
       cap smaller than the thing it was capping.

       340px is the floor: the chrome plus two lines of message measures ~270px with a
       voice note, and the slack above that is what lets the signature sit fully on the
       paper instead of being clipped by its bottom edge. On the recipient's 370px
       envelope 132cqh is 326px, so this floor is what applies there too — the card
       gains a little height and loses nothing.

       --card-enclosed-maxh is the same cap while the card is still INSIDE the envelope:
       a smaller fraction, and a smaller floor, because there it is meant to look tucked
       in behind the front paper. Named rather than written twice: .letter and @keyframes
       card-exit's 0% must agree to the pixel or the card jumps when the exit takes over.

       The two floors must stay DIFFERENT, and that is not cosmetic. The card growing as
       it comes out is the effect asked for — "width should stay the same, only height
       should change" — and height is the only dimension that can change without
       re-wrapping the message. When both caps hit the same floor (which is what a single
       shared floor did on a small envelope) the exit became a pure slide with no growth
       at all, and the message kept the two lines it had while tucked away. */
    --card-maxh: max(132cqh, var(--card-floor));

    /* The floor, as its own dial so the voice case can raise it. A voice player is 34px
       plus 16px of margin — 50px that comes straight out of the message, because the
       player cannot shrink and the message is the only child that can. At the 340px
       floor that cost the message two lines and hid 74px of the note behind a scrollbar
       on a phone, which is the sender's words going unread on the card they paid extra
       for. So a card carrying a player is 50px taller and the message keeps every line
       it had without one. Set once here; both caps derive from it.

       Only the BASE is scaled by .8 for the enclosed cap, so the gap between the two
       floors — the growth — survives whatever the base is set to. 0.8, so a 340px card
       is 272px while tucked in: enough for its own contents (~270px), which is the
       constraint that created these floors in the first place.

       --voice-extra is added to BOTH floors unscaled, because the player's cost is
       absolute, not a fraction: 34px of control plus 16px of margin is 50px whatever
       size the card is. Scaling it gave the enclosed card only 40px for a 50px player
       and the message quietly lost the difference. */
    --card-floor-base: 340px;
    --voice-extra: 0px;
    --card-floor: calc(var(--card-floor-base) + var(--voice-extra));
    --card-enclosed-maxh: max(88cqh,
        calc(var(--card-floor-base) * .8 + var(--voice-extra)));

    /* ---- How far the stack rises to be OUT of the envelope ----
       The emerge ends when the items' bottom edges have passed the envelope's top
       edge, so the distance is: how far the item's bottom sits below that edge,
       plus a few pixels so it visibly clears rather than grazing it.

       Measured against the card's HEIGHT CAP, not its actual height, and that is
       deliberate: this ONE distance is used by both items, so the stack rises as a
       stack — the photo keeps peeking out from behind the card the whole way up,
       exactly as it does inside the envelope. Per-item distances would have the
       shorter photo travelling more slowly than the card over the same duration
       and sliding down behind it, and at the peak it would be hidden by the card
       altogether. The cost of using the cap is a little overshoot when the sender
       wrote too little to fill the paper, which is invisible: the card is above the
       envelope with nothing near it either way.

       The cap is the right side of safe for the PHOTO too, because the photo is the
       shorter item — but only just, and that is the constraint on --photo-w. Its box
       is 1.053 x its width (a square image plus the polaroid border), so at 70% it is
       .737 of the envelope's width, and the envelope is 3:2, so 1.106 x its HEIGHT:
       271px on a full-size 370x247 envelope, against a card cap of 272px. One pixel of
       margin at the top of the range, ~13px once the shared +6px of clearance is
       counted in, and it shrinks as --photo-w grows.

       The photo's own keyframes take the larger of the two distances anyway (see
       --photo-rise), so a --photo-w that crossed over would still come fully out of the
       envelope — it would just travel further than the card over the same duration and
       therefore drift upwards out of the stack on the way. That is the thing to watch
       for if these widths are ever retuned, and it is why --photo-w has a ceiling that
       has nothing to do with how it looks. */
    --rise: calc(var(--card-enclosed-maxh) + var(--card-in-top) + 6px);

    /* The photo's own version: normally the shared --rise, but never less than what
       the photo itself needs to clear the mouth. -100% is the photo's OWN height (a
       percentage in `translate` resolves against the element), and --photo-in-top is
       negative, so subtracting it is what accounts for the photo starting slightly
       above the envelope's top edge. min() of two negative lengths takes the larger
       rise. */
    --photo-rise: min(calc(-1 * var(--rise)),
                      calc(-100% - var(--photo-in-top) - 6px));

    /* The polaroid border, as a fraction of the PHOTO rather than a fixed figure.
       A % padding resolves against the containing block's width — the envelope —
       not the element's own, so a literal 1.6% would be the same slab of white
       whatever --photo-w is. Scaling it by --photo-w keeps the border the same
       thickness relative to the picture at every envelope size. Constant through the
       exit and the flip, because the width it is derived from is. */
    --photo-pad: calc(var(--photo-w) * .047)
                 calc(var(--photo-w) * .047)
                 calc(var(--photo-w) * .1);

    /* When .env-inside lifts in front of the envelope's front panels: at the TURN,
       the instant the emerge ends and the settle begins.

       That instant is chosen by the geometry, not by taste. The emerge is defined as
       "rise until the items' bottom edges are past the envelope's top edge", so at
       the moment it ends there is no overlap between the enclosures and the envelope
       anywhere on screen — which is precisely the one moment when reordering the two
       changes nothing visible. Before it the items are inside the envelope and must
       be drawn behind its front paper; after it they are in front of it and coming
       down over it. The flip is a fact about which of those is true, and it happens
       where the two states agree.

       Every earlier version flipped MID-TRAVEL, while the items still lay across the
       envelope's front, and each was a different flavour of the same wrong thing: at
       --exit-at exactly, the card was in front on the first frame and so appeared on
       top of the envelope fully formed before moving at all; at .08 of the duration,
       a handful of pixels above the top edge with two-thirds of the travel left, the
       card's own lower half was still over the paper it was meant to be inside; at
       .55, it surfaced only once it had already arrived. There is no good number for
       a flip that happens while the card overlaps the envelope — which is why the
       exit is two moves now, so that there is a moment when it does not. */
    --inside-lift: calc(var(--exit-at) + var(--emerge-dur));

    /* When the back panel starts to recede — as the items leave, not before. */
    --letter-at: var(--exit-at);
}

/* ---------- No photo ----------
   Most cards have no photo, and that case now needs NO geometry of its own: the card
   is centred at --card-w whether or not there is a second item behind it, so a lone
   card simply rises out of the envelope and lands in the same place. The stack that
   replaced the settled row is what removed this special case — the old row had to be
   re-composed around the missing item (its --photo-w and --gap zeroed) or the card
   sat parked at the left edge holding a gap open for something that was never sent.

   What DOES key off the missing photo is the flip button, which has nothing to offer
   here: see .env-flip. */

/* ---------- A card carrying a voice note is taller ----------
   The player is 34px of fixed chrome plus 16px of margin, and it is flex-shrink: 0, so
   those 50px come out of the message — the only child that can give them up. Measured on
   a phone that was two lines and 74px of the note hidden behind a scrollbar: the sender
   paid ₹99 partly for the voice, and it cost them their words being read.

   So the card grows by exactly what the player takes, and the message keeps every line
   it would have had. Read off the same .hidden class showVoice() toggles — the single
   source of truth for "is there a voice note", exactly as .env-photo.hidden is for the
   photo. One declaration, because both caps derive from --card-floor.

   Only the floor moves, not 132cqh: on a large envelope the cqh term already wins and
   there is nothing to fix. */
.envelope:has(.letter-voice:not(.hidden)) {
    --voice-extra: 50px;
}

/* ---------- No viewport breakpoint for the widths ----------
   There used to be one, and it is worth saying why there is not any more.

   The settled row made the two items 146% of the envelope wide on a screen with room
   for it and 108% on a phone, so --photo-w had to be picked from a `min-width` media
   query — and a media query asks how wide the BROWSER is, which is the wrong question
   for the two envelopes that live inside a PHONE MOCKUP (the preview's screen, the
   write page's reference card). Those are sized by their frame, so on a desktop they
   were handed a 540px-wide row to lay out inside a 310px screen. The fix at the time
   was an .env-mini class opting them out.

   The stack needs none of that. Both items are a fraction of the envelope, both are
   narrower than it, and the composition is therefore the same shape at every size:
   what fits the envelope fits the stack. So the widths above are the only widths,
   phone and desktop and mockup alike, and .env-mini has nothing left to say. */

/* Top flap — rotates open in 3D. Beat one, so no delay. */
.env-flap {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 52%;
    transform-origin: top center;
    transform-style: preserve-3d;
    transition: transform var(--flap-dur) cubic-bezier(.6,-0.18,.5,1);
    z-index: 6;
}

.env-flap-face {
    position: absolute;
    inset: 0;
    clip-path: polygon(0 0, 100% 0, 50% 100%);
    background: linear-gradient(175deg, var(--env-1), var(--env-2) 85%);
    box-shadow: 0 1px 0 rgba(255,255,255,.4) inset;
}

.envelope.opened .env-flap {
    transform: rotateX(-172deg);
    /* 0, so the flipped-back flap is BEHIND the enclosures (.env-inside is 1) as
       well as behind the envelope's own front panels.

       It used to be 2, which is above .env-inside's 1 — and the flap rotates
       -172deg about its TOP edge, so once open it lies flat across the area just
       above the envelope's mouth, which is precisely where the card and photo
       travel on the way out. The card therefore rose UNDERNEATH the open lid and
       was hidden by it until --inside-lift flipped the interior to 20, at which
       point both items appeared abruptly on top. Delaying that lift (so the card
       climbs out from behind the FRONT paper, which is the effect we want) made
       this worse, because it lengthened the window in which the flap was the thing
       covering them.

       Behind .env-body (also 1, winning on DOM order) is correct and not just
       expedient: the flap is the far side of the paper once it is thrown open, so
       it belongs at the very back of the envelope. Nothing needs to draw on top of
       it there — the seal is a child of the flap and travels with it. */
    z-index: 0;
}

/* Wax seal — layered gradients, not an emoji */
.seal {
    position: absolute;
    left: 50%;
    bottom: 0;
    width: 58px;
    height: 58px;
    margin-left: -29px;
    margin-bottom: -29px;
    border-radius: 50%;
    background:
        radial-gradient(circle at 34% 28%, rgba(255,255,255,.42), transparent 45%),
        radial-gradient(circle at 68% 76%, var(--seal-2), transparent 55%),
        var(--seal);
    box-shadow:
        0 3px 10px rgba(70,20,30,.42),
        0 1px 0 rgba(255,255,255,.25) inset;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 8;
    transition: transform .35s ease, opacity .3s ease;
}

/* Scalloped wax edge */
.seal::before {
    content: '';
    position: absolute;
    inset: -3px;
    border-radius: 50%;
    background: var(--seal);
    opacity: .34;
    filter: blur(2.5px);
    z-index: -1;
}

.seal svg { width: 25px; height: 25px; fill: rgba(255,255,255,.9); }

/* The 'initial' stamp presses a letter into the wax instead of a glyph. It is
   set in the display face at a weight that survives being small and white on
   a mid-tone wax. */
.seal.seal-initial {
    font-family: var(--font-heading);
    font-size: 1.5rem;
    font-weight: 600;
    color: rgba(255,255,255,.92);
    line-height: 1;
    /* Optical centring: the cap-height box sits low in the line box, so the
       glyph looks a shade high inside a circle without this nudge. */
    padding-top: 2px;
}

/* ---------- Paper flourishes ----------
   Each border is drawn on .letter::after so it composes with ::before, which
   already carries the hairline edge. They are intentionally subtle: at this
   size a decorative border competes with the handwriting and wins, which is
   the wrong outcome for a letter. */

.letter::after {
    content: '';
    position: absolute;
    pointer-events: none;
    border-radius: 3px;
}

/* Each rule declares a plain colour first and a color-mix() second. On an older
   Android WebView (color-mix landed in Chrome 111) the second declaration is
   discarded as invalid and the border still draws — just without picking up the
   wax colour. Losing the tint is fine; losing the border is not. */

.border-deckle .letter::after {
    inset: 5px;
    border: 1px dashed rgba(140,100,110,.42);
    border-color: color-mix(in srgb, var(--accent) 42%, transparent);
}

.border-double .letter::after {
    inset: 5px;
    border: 1px solid rgba(140,100,110,.45);
    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
    box-shadow: 0 0 0 3px var(--paper), 0 0 0 4px rgba(140,100,110,.22);
    box-shadow: 0 0 0 3px var(--paper),
                0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent);
}

/* Ruled paper: faint horizontal lines behind the text, like a writing pad.
   Sized in em so the rules keep pace with a larger handwriting choice. */
.border-ruled .letter::after {
    inset: 0;
    border-radius: 4px;
    background-image: repeating-linear-gradient(
        180deg,
        transparent 0 calc(1.75em - 1px),
        rgba(140,100,110,.14) calc(1.75em - 1px) 1.75em
    );
    background-image: repeating-linear-gradient(
        180deg,
        transparent 0 calc(1.75em - 1px),
        color-mix(in srgb, var(--accent) 14%, transparent) calc(1.75em - 1px) 1.75em
    );
    /* Keep the rules off the greeting and the signature, where they read as a
       rendering artefact rather than stationery. */
    top: 58px;
    bottom: 54px;
}

.envelope:hover .seal { transform: scale(1.07) rotate(-4deg); }

/* The wax has just been broken, so it goes. Correct for a card someone opens —
   and wrong for the write page, where the envelope is PERMANENTLY open and this
   rule made the wax the sender was actively choosing invisible. That page opts
   out below via .env-static. */
.envelope.opened .seal {
    opacity: 0;
    transform: scale(.4) rotate(22deg);
    transition: opacity .25s ease, transform .35s ease;
}

/* ---------- A permanently-open envelope ----------
   The write page shows the card already open, because there is no suspense to
   preserve for the person writing it: they need to see their words. But "opened"
   also means "the flap has swung back", and that is not true here — nothing was
   ever shut, so the flap stays flat and the card simply stands in front of it.

   The wax is deliberately NOT reinstated here. It was, for a while, so that the
   Wax controls changed something visible; but a seal pressed on the front of an
   open envelope sat right below the occasion icon at the top of the card and read
   as a second, competing emblem. The wax belongs on a SHUT envelope, which is the
   only state the recipient ever sees it in. The swatch beside the Wax control
   previews its colour instead — see .wax-preview in styles.css. */
.env-static .envelope.opened .env-flap,
.env-static.envelope.opened .env-flap {
    transform: none;
    /* 0, matching .envelope.opened .env-flap above, so the two states cannot
       disagree about where the flap sits in the stack.

       The old value was 2 and the old comment said "behind the emerging letter" —
       which was not true of either state: .env-inside is z-index 1, so 2 was IN
       FRONT of the card, not behind it. It went unnoticed here because this flap
       keeps `transform: none` and so stays flat at the top of the envelope,
       nowhere near the card, whereas the rotated one lands right across the card's
       exit path. A wrong value that is merely out of the way is still worth
       correcting: it is the kind of thing that is copied to a context where it
       does matter. */
    z-index: 0;
}

/* ---------- The card inside ---------- */

/* The letter slides up by a percentage of ITS OWN height (see slide-out), so an
   unbounded letter travels further the taller it gets — a 400-character message
   used to lift its own top edge clean off the top of the viewport, where no
   amount of scrolling could reach it. Nothing on this page scrolls, so a letter
   above the fold is simply unreadable.

   Capping the height is what makes the travel bounded. The message then scrolls
   inside the paper instead of growing it, which is also the more honest model: a
   real card has a fixed size and you fit your words to it.

   The cap is a fraction of the VIEWPORT, not a fixed pixel count, because the
   travel is a fraction of the letter and the space above the envelope is itself
   only ~0.30 x vh. A fixed 430px cap therefore fits a tall window and overshoots
   a short one by 128px — the two have to scale together or short viewports break.
   The 430px ceiling stops the paper looking like a poster on a large screen.
   Measured worst case across 360x640 -> 1440x780: top edge +10px. */
.letter {
    position: absolute;
    /* --card-w wide, and it stays that wide for the whole sequence: card-exit slides
       this box to the settled row without resizing it, so the message is laid out
       once and never re-wraps mid-flight. Centred in the envelope while enclosed. */
    left: var(--card-in);
    right: var(--card-in);
    top: var(--card-in-top);
    z-index: 3;
    background: var(--paper);
    border-radius: 4px;
    padding: 26px 24px 22px;
    box-shadow: 0 3px 12px rgba(60,30,40,.16);
    /* No opacity here, and deliberately not: the card is always FULLY OPAQUE paper.
       The reveal fades .env-inside, the parent, so the card and the photo come up as
       one stack — and while that happens the card still occludes the photo behind
       it. It used to fade itself from 0, which made it briefly translucent and let
       the photo show through, reported as "the photo is in front, then it goes
       behind". See the note on .env-inside's opacity. */
    pointer-events: none;

    /* Column layout so the message can be the one part that scrolls, keeping
       the greeting and the signature always visible. */
    display: flex;
    flex-direction: column;

    /* The card has to FIT in the envelope, because for the first beat and a half it
       IS in the envelope. The envelope is 3:2, so its height is 66.6% of its width;
       88% of that leaves the card sitting inside with the mouth visible above it.

       Sized against the envelope (cqh) rather than the viewport. The old cap was
       min(50vh, 430px) — tuned for a card standing out in the open on a full
       screen, which is 430px of paper inside a 247px envelope.

       This cap applies while the card is INSIDE the envelope. card-exit animates it
       up to --card-maxh on the way out, where there is nothing to fit inside any more.
       It stays a cap in both places, so a very long message scrolls inside the paper
       rather than growing it and carrying its own top edge off the screen — which is
       the more honest model anyway: a real card is a fixed size and you fit your words
       to it.

       max(...) for the same reason as --card-maxh: the cap scales with the envelope
       but the card's chrome does not, so on a small envelope 88cqh was 146px around
       270px of content and the signature painted onto the envelope. It can no longer
       be smaller than the card's own contents.

       The floor is --card-enclosed-maxh, not a literal, because @keyframes card-exit's
       0% has to match this value EXACTLY or the card jumps the instant the exit takes
       over — and the floor now moves (a voice note raises it; see --card-floor). Two
       hand-typed copies of a number that changes is the drift this file keeps being
       bitten by, so there is one name for it and both places read it. */
    max-height: var(--card-enclosed-maxh);

    /* ---- The card is a two-faced object ----
       It has to be, because the stack turns over: after the flip you are looking at the
       BACK of the card, and the back of a card is blank paper, not the message printed
       in reverse.

       preserve-3d so .letter-back — which is rotated to face the other way — stays a
       real second face in 3D rather than being flattened into this one and drawn on top
       of the message. backface-visibility: hidden so this face (its paper, its rounded
       corners, its shadow) disappears the moment it turns away from the viewer.

       Safe to declare unconditionally: while the deck is unrotated this is a card facing
       forward with a blank sheet behind it that nobody can see, which is what it is.

       +2px of Z, and this is what actually puts the card in FRONT of the photo. Inside a
       preserve-3d deck z-index no longer orders siblings — depth does — so the z-index: 3
       above is doing nothing here. It is kept because it says the same thing, and because
       it still orders the two for a browser that ignores preserve-3d. 2px rather than
       something larger because the perspective is 1200px: the two have to read as
       touching, and visible parallax between a card and the photo behind it looks like a
       gap. The value only has to be non-zero, and non-zero in the right direction.

       Every keyframe below restates this translateZ, because an animation's transform
       replaces this one outright. Losing it there does not merely flatten the stack: a
       coplanar deck keeps its paint order when it rotates, so the photo would still come
       out behind the card and the flip would appear to do nothing at all. */
    transform-style: preserve-3d;
    backface-visibility: hidden;
    transform: translateZ(2px);
}

/* The blank reverse of the card. A real element rather than a pseudo-element because
   both of .letter's pseudos are taken — ::before draws the hairline edge, ::after the
   craft border — and it is aria-hidden in the markup, since it is the back of a piece
   of paper.

   rotateY(180deg) mounts it facing backwards; its own backface-visibility is what keeps
   it invisible until the deck brings it round. translateZ(0) keeps it exactly coplanar
   with the front face, because paper has no thickness — 1px of separation here shows as
   a bright edge along the card's side at the shallow angles mid-flip.

   Nothing is printed on it. A message on the back would be a second thing to read at the
   moment the recipient is looking at a photo, and the back of a card really is blank. */
.letter-back {
    position: absolute;
    inset: 0;
    border-radius: 4px;
    background: var(--paper);
    box-shadow: 0 3px 12px rgba(60,30,40,.16);
    transform: rotateY(180deg) translateZ(0);
    backface-visibility: hidden;
    pointer-events: none;
}

/* Everything printed ON the front face has to hide with it. backface-visibility is
   per-element, and inside a preserve-3d parent every child is oriented in the deck's 3D
   space on its own — so without this the paper turned away and the greeting, the message,
   the signature and the craft border stayed behind it, mirrored, floating where the card
   had been. The most visible failure this flip can have, and one rule closes it.

   :not(.letter-back) so the one child that is MEANT to face the other way keeps facing
   it. The children are otherwise flat — nothing sets transform-style on them — so each
   takes its whole subtree with it, the voice player included. */
.letter > *:not(.letter-back),
.letter::before,
.letter::after {
    backface-visibility: hidden;
}

/* Deckle-edge paper + a faint ruled line, like real stationery */
.letter::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 4px;
    box-shadow: 0 0 0 1px rgba(140,100,110,.13) inset;
    pointer-events: none;
}

/* Beats two and three for the card: revealed inside the envelope as the flap
   clears, then drawn out to the settled row.

   No z-index here. It used to be 20, applied the instant the envelope was
   clicked, which lifted the card in front of the whole envelope before it had
   moved a pixel — the single biggest reason nothing looked enclosed. The card is
   inside .env-inside now, and that layer's position in the stack is what decides
   whether the card is in the envelope or out of it. */
.envelope.opened .letter {
    /* Three animations, one per beat, and the pauses and the turn between them are
       real gaps with nothing running rather than flat sections inside one long
       keyframe set that would be far harder to retime:

         slide-out    beat 2, uncovered in place, inside the envelope
         card-emerge  beat 3a, straight up until the card is out of the envelope
         card-settle  beat 3b, down and out to the settled row

       `forwards` on all three, so each holds its end state for the next to take over
       from, and the card holds the settled position afterwards.

       card-emerge and card-settle both animate `transform`, which is legal and not
       an accident: they never apply at once (the settle's delay is exactly the
       emerge's duration), and where a fill would overlap, the later name in this list
       wins — which is the settle, the one that should. */
    animation:
        slide-out var(--reveal-dur) cubic-bezier(.22,1,.36,1) var(--reveal-at) forwards,
        card-emerge var(--emerge-dur) var(--emerge-ease) var(--exit-at) forwards,
        card-settle var(--settle-dur) var(--settle-ease)
            calc(var(--exit-at) + var(--emerge-dur)) forwards;
    pointer-events: auto;
}

/* Beat 3a: straight up out of the mouth, and far enough that the card's BOTTOM edge
   passes the envelope's top edge — see --rise. Nothing else moves: not the box, not
   the width, not the height. The card is still behind the envelope's front paper for
   all of it, so what you watch is a card being drawn up out of an envelope.

   A transform rather than the box, unlike the settle below, because this is pure
   travel: there is nothing to interpolate but position, and a transform is the cheap
   way to say so. It also keeps the rise independent of the card's own height — the
   box positions are percentages of the ENVELOPE, whereas --rise is measured in the
   card's own terms.

   ---- Why every transform on the card is written out in full ----
   translate(x, y) rotate(z) translateZ(2px), in that order, in every keyframe and in
   both settled states, with zeros written where nothing is happening. Three reasons,
   and they are all about the flip rather than about the exit:

     - the +2px is the card's DEPTH in the deck. An animation's transform replaces the
       base rule's outright, so an emerge that said only translateY would flatten the
       stack for the whole exit and un-flip the flip. See .letter.

     - a transform list interpolates component-wise ONLY between lists of the same
       functions in the same order. The settled state and the flipped state differ in
       every component, so they have to agree on the shape, and the cheapest way to
       guarantee that is for the shape never to vary anywhere.

     - order matters once a rotation is involved. rotate() before translateZ() means the
       lean is applied in the viewer's plane and the depth along the deck's normal; the
       other order tilts the card's z-axis and gives it a lean that changes with depth.
       Fixing the order everywhere means it cannot be got wrong in one place only. */
@keyframes card-emerge {
    from { transform: translate(0px, 0px) rotate(0deg) translateZ(2px); }
    to   { transform: translate(0px, calc(-1 * var(--rise))) rotate(0deg) translateZ(2px); }
}

/* Beat 3b: down out of the air and onto the envelope, low, as the front of the stack.
   Animates the card's own BOX rather than scaling it, because a transform would scale
   the type down with it. `left` and `right` do not move at all now that the settled
   composition is a stack rather than a row — the card is centred in the envelope from
   the moment it is revealed to the end of the flip — so the only box property that
   changes is max-height, and height is the one dimension that re-wraps nothing. The
   message is laid out once and simply travels.

   They are still WRITTEN at both ends, unchanged, rather than left out. An animation
   with `forwards` fill holds every property it mentions; mentioning left and right is
   what documents that this beat deliberately leaves them alone, and it costs nothing
   to interpolate a value into itself.

   The translateY the emerge left behind is unwound over the same interval, so the
   card's actual path is the SUM of the two: it comes down by the rise and up by the
   difference between the enclosed and settled tops. That sum is a net descent, which
   is the point — the card ends lower than it was at the peak and in front of the
   envelope rather than in it.

   Both ends come from the same variables as the base rule and as the emerge, so
   there is nothing to keep in sync by hand and no jump at either handover. */
@keyframes card-settle {
    0% {
        left: var(--card-in);
        right: var(--card-in);
        top: var(--card-in-top);
        /* The SAME variable .letter's max-height reads, not a copy of its value. These
           two have to agree to the pixel or the card jumps the instant this animation
           takes over, and the floor inside it moves now (a voice note raises it). */
        max-height: var(--card-enclosed-maxh);
        transform: translate(0px, calc(-1 * var(--rise))) rotate(0deg) translateZ(2px);
    }
    100% {
        left: var(--card-in);
        right: var(--card-in);
        top: var(--card-top);
        max-height: var(--card-maxh);
        /* Square to the screen and in front: the card is the item being read, and the
           lean belongs to whichever item is behind. Written in the same four-function
           shape as everything else so .settled can take over from it without moving a
           pixel — see the note on card-emerge. */
        transform: translate(0px, 0px) rotate(0deg) translateZ(2px);
    }
}

/* Once the card is genuinely on its way out, the whole interior lifts in front
   of the envelope's front panels — because by then it IS in front of them. Until
   this fires the card sits behind the front paper and only its top shows above
   the mouth, which is exactly the step-one picture. */
.envelope.opened .env-inside {
    /* Two animations on this one layer, and they are independent:
         inside-reveal  fades the whole stack up on beat 2, in place
         inside-out     flips it in front of the front panels on beat 3
       The fade is here rather than on the two children because fading them
       separately let the photo show through the translucent card — see the long
       note on .env-inside's `opacity` for the measurement. */
    animation:
        inside-reveal var(--reveal-dur) cubic-bezier(.22,1,.36,1) var(--reveal-at) forwards,
        inside-out 0s linear var(--inside-lift) forwards;
}

/* Beat 2, the group half: the enclosures are uncovered as the flap clears. Paired
   with the children's own scale keyframes, which are all that is left of them. */
@keyframes inside-reveal { from { opacity: 0; } to { opacity: 1; } }

/* Beat 3a's last frame, as a step: the enclosures stop being enclosures.
   Two properties, both discrete, both saying the same thing:

     z-index    in front of the envelope's front panels instead of between them
     clip-path  the bottom edge released, so the stack can land ACROSS the envelope's
                bottom fold instead of being sliced along it. It was pinned 2px inside
                the envelope for the whole time the items were in there (see
                .env-inside); the settled card hangs lower than that, and the flipped
                photo lower still.

   0s duration, so this is a jump rather than a transition, and the jump is invisible
   because of WHERE it is: --inside-lift is the instant the rise ends, the one frame in
   the sequence when nothing overlaps the envelope at all. Nothing is being reordered on
   screen, only in the layer list. */
@keyframes inside-out {
    to {
        z-index: 20;
        clip-path: inset(-300% -120% -300% -120%);
    }
}

/* Beat 2 for the card: it is uncovered where it lies, and does NOT move. All the
   travel belongs to card-exit above, which is what lets the two beats be timed
   independently. The only motion here is a hair of scale, so being uncovered reads
   as the flap coming off rather than as a light switch.

   Deliberately not translating by --slide (the per-context -58%/-60%/-46% in
   styles.css): that would fling the card out of a barely-open envelope, which is
   exactly what beat 3 exists to do properly. --slide is now used only by the
   reduced-motion fallback. */
@keyframes slide-out {
    0%   { transform: translate(0px, 0px) rotate(0deg) translateZ(2px) scale(.98); }
    100% { transform: translate(0px, 0px) rotate(0deg) translateZ(2px) scale(1); }
}

/* The envelope recedes a little once its contents are the subject — but ONLY the
   back panel, and only slightly. The front panels (env-left/right/bottom) keep
   full opacity: they are the paper the card is tucked behind, and fading them to
   .5 made the whole envelope translucent, so both enclosures were fully visible
   through the front the instant it was clicked. Nothing looked enclosed. */
.envelope.opened .env-body {
    opacity: .85;
    transition: opacity .6s ease var(--letter-at);
}

/* ---------- The photo, as the second thing in the envelope ----------
   Two items travel in one envelope: the letter, and a photo tucked behind it.
   Shut, you see one stack; opened, the flap lifts, both are briefly visible
   together, then the letter draws out and the photo comes with it — and it stays
   behind it, peeking, until the recipient asks to see it.

   It was previously a postage stamp on the OUTSIDE of the envelope. That put it
   on show before opening, which had its own charm, but it made the photo
   decoration rather than a gift: something the sender pinned to the wrapper
   instead of something enclosed. It also meant the photo was fully visible
   without ever opening the card, which spends the moment before it arrives.

   Sizing is in %, like everything else in the envelope, so the whole component
   scales from a 210px write-page preview to a full-screen card unchanged. */
.env-photo {
    position: absolute;
    /* 2: BEHIND the card, which is z-index 3, and in front of the envelope's back
       panel. Shut, the two read as one stack of paper rather than two separate objects.

       It is not what orders the two any more, though. Both are children of a preserve-3d
       deck, and inside one of those depth comes from translateZ and z-index is ignored
       for positioned siblings — otherwise the flip could not work, since turning the deck
       over must swap which item is nearer without touching either z-index. Kept because
       it agrees with the depths and still applies in a browser that flattens the deck. */
    z-index: 2;

    /* --photo-w wide, and it stays that wide throughout — and centred in the envelope
       for the whole sequence, so `left` and `right` never animate at all. What shows
       above the card's top edge is therefore a wide strip of a narrower item, which
       reads as a second, different thing tucked behind the letter. */
    left: var(--photo-in);
    right: var(--photo-in);

    /* The one number that produces the step-one picture: the photo's top edge
       sits this far above the card's (the card is at --card-in-top), so ~7% of the
       envelope's height of photo shows behind it. Enough to see clearly that
       something else is in there, not enough to compete with the card. */
    top: var(--photo-in-top);

    /* A polaroid border, with a deeper lip along the bottom. Derived from --photo-w
       (see --photo-pad) rather than written as a literal, because a % padding
       resolves against the envelope's width, not the photo's — so a literal would be
       the same slab of white around a 34% photo and a 70% one. Constant through the
       exit and the flip, because the width it scales with is. */
    padding: var(--photo-pad);
    background: #fffdf9;
    border-radius: 3px;
    box-shadow:
        0 0 0 1px rgba(140,100,110,.2),
        0 6px 16px -6px rgba(60,30,40,.34);
    /* No opacity here either — the parent .env-inside fades for both of them. See
       the note on its opacity: this element fading itself was half of the
       show-through bug, since the card in front was fading at the same time. */
    pointer-events: none;

    /* ---- Mounted face-DOWN in the deck ----
       The rotateY is not motion and never animates: it is how the photo is fixed into
       the stack. The deck turns 180deg to bring the photo forward (see .env-deck), and
       something mounted square-on would arrive mirrored — a photograph of the sender
       printed back-to-front, which is the one thing in this whole sequence a recipient
       would certainly notice. Mounted backwards, the deck's rotation cancels it exactly:
       360deg, i.e. facing the viewer, un-mirrored, the right way round.

       The price is paid at REST, where the photo is showing its back face and the corner
       peeking out from behind the card is therefore mirrored. That is the trade, and it
       is the right way round: the peek is a ~20px corner of a box whose outer 5% is
       white border, so what is mirrored is a scrap of image nobody can compare against
       anything. The photo they actually look at is exact.

       No backface-visibility on this element, deliberately, for the same reason: it has
       to stay visible while facing away, because facing away is its resting state.

       -2px of Z with the mount, which comes out as +2px in front of the card once the
       deck has turned over — a 180deg rotation negates its children's z. So at rest the
       photo is 2px behind the card, and after the flip 2px in front of it, without
       either item's own depth ever changing. That negation IS the mechanism; nothing
       here re-orders anything.

       Zeros for the offset and the lean, because this is the ENCLOSED photo: square in
       the envelope, peeking above the card's top edge rather than out to one side of it.
       The lean arrives with the landing (see photo-settle), which is the right place for
       it — paper is straight while it is in an envelope and askew once it is put down.
       Written out in full all the same, so every state of this element has the same four
       functions in the same order and can interpolate against the others. */
    transform: translate(0px, 0px)
               rotate(0deg)
               translateZ(-2px)
               rotateY(180deg);
}

.env-photo img {
    display: block;
    width: 100%;
    /* ---- A square HOLE, and the whole photo inside it ----
       The box is square so that a portrait and a landscape photo produce the same
       shape. That is not cosmetic: this element has no height of its own, so the
       mount's height — and with it --photo-rise, the peek above the card's top edge,
       .after-reveal's margin and the mock-up panels' heights — all derive from this
       one ratio. A photo-shaped hole that changed with whatever the sender picked
       would move every number in the flight.

       contain, not cover. cover filled that square by cropping to it, which cost a
       3:4 portrait a quarter of its height and a 9:16 phone photo nearly half —
       taken off the top and bottom, which is where the faces are, and there is no
       full-size view anywhere in the product to recover them from. The sender chose
       this picture; the recipient should get all of it.

       The letterboxing is invisible rather than tastefully grey, because the ground
       is the MOUNT's own paper colour (#fffdf9, same as .env-photo). A portrait
       simply reads as a print with wider margins at its sides. Grey bands would read
       as a bug — a photo that failed to fill its frame.

       Centred — the default, and left implicit rather than written out, because it is
       the mount's own padding that does the work here: 4.7% above the print and a 10%
       lip below (see --photo-pad), so a centred print inherits that deeper-bottom-lip
       silhouette at every ratio. Aligning to the top instead was tried, on the theory
       that a polaroid's print sits high: at 4:3 it looks the same, but a 16:9 photo
       then leaves 44% of the mount as one blank slab underneath, which reads as a
       photo stuck at the top of a card rather than a print mounted on it. */
    aspect-ratio: 1;
    object-fit: contain;
    border-radius: 2px;
    background: #fffdf9;
}

/* Opened: the photo is revealed, in place, at the same moment as the card. They
   are one stack being uncovered by the flap, so they must not arrive separately.

   Then, on the same beat as the card, it draws out and settles to the RIGHT of it.
   Both leave together rather than one after the other: they were stacked, and a
   stack lifted out of an envelope comes out as a stack and separates in the air.

   It stays at z-index 2, BEHIND the card's 3, throughout — which matters during the
   exit, while the two boxes still overlap. Being behind is only half of what makes
   the photo LOOK behind, though: the card also has to be opaque while it is there,
   which is why the reveal fades the parent rather than these two. */
.envelope.opened .env-photo {
    animation:
        photo-out var(--reveal-dur) cubic-bezier(.22,1,.36,1) var(--reveal-at) forwards,
        photo-emerge var(--emerge-dur) var(--emerge-ease) var(--exit-at) forwards,
        photo-settle var(--settle-dur) var(--settle-ease)
            calc(var(--exit-at) + var(--emerge-dur)) forwards;
}

/* Beat 2: uncovered in place. No travel, no lean, and no opacity — the fade belongs to
   the parent now (see inside-reveal), so this is the scale alone. Everything else is the
   base rule's mounting, restated because an animation replaces the whole transform: lose
   the rotateY here and the photo faces the wrong way for the rest of its life. */
@keyframes photo-out {
    0% {
        transform: translate(0px, 0px) rotate(0deg) translateZ(-2px) rotateY(180deg)
                   scale(.98);
    }
    100% {
        transform: translate(0px, 0px) rotate(0deg) translateZ(-2px) rotateY(180deg)
                   scale(1);
    }
}

/* Beat 3a: up and out, the same distance and on the same curve as the card, so the
   two travel as the one stack they were — the photo's top edge stays exactly as far
   above the card's the whole way up. --photo-rise is --rise unless the photo would
   need more to clear the mouth, which no current layout does. */
@keyframes photo-emerge {
    from {
        transform: translate(0px, 0px) rotate(0deg) translateZ(-2px) rotateY(180deg);
    }
    to {
        transform: translate(0px, var(--photo-rise)) rotate(0deg)
                   translateZ(-2px) rotateY(180deg);
    }
}

/* Beat 3b: down out of the air and into its place BEHIND the settled card, where the
   stack stops being a straight stack: the photo comes down a little short of the card's
   landing, slides out to the right and leans, so its corner is showing when everything
   stops. The card's own settle is square-on and centred, so the whole of the separation
   between the two is in these three numbers.

   Which is also where the lean comes from, and it is worth doing here rather than as a
   separate flourish afterwards: the photo acquires it over the same 740ms as the descent,
   so it lands askew instead of landing straight and then twitching.

   The photo is the simpler of the two boxes — its image is aspect-ratio 1, so a constant
   width is a constant height, and nothing but the transform changes over the interval.
   `left`/`right` are written at both ends unchanged for the same reason the card's are:
   a filling animation holds what it mentions, and mentioning them says they are meant to
   stay put. */
@keyframes photo-settle {
    0% {
        left: var(--photo-in);
        right: var(--photo-in);
        top: var(--photo-in-top);
        transform: translate(0px, var(--photo-rise)) rotate(0deg)
                   translateZ(-2px) rotateY(180deg);
    }
    100% {
        left: var(--photo-in);
        right: var(--photo-in);
        top: var(--photo-top);
        transform: translate(var(--photo-peek-x), var(--photo-peek-y))
                   rotate(var(--photo-peek-tilt))
                   translateZ(-2px) rotateY(180deg);
    }
}

/* ===== The handover: from animation to state =====
 *
 * .settled goes on the envelope when the opening sequence has finished (js/envelope.js
 * adds it at ENVELOPE_TIMING.settled). Nothing about the picture changes on that frame.
 * What changes is who owns it.
 *
 * It has to happen, and this is the reason: the three animations above all have
 * `forwards` fill, and a filling animation beats any declarative rule for the properties
 * it mentions — including a rule triggered by a class added later. So `.show-photo .letter
 * { transform: ... }` would simply be ignored, forever, with card-settle still holding the
 * transform it finished on. The flip would not start.
 *
 * `animation: none` drops the fill. Everything the fill was holding then has to be stated
 * here instead, to the pixel, or the stack jumps at the handover — which is why each of
 * these values is the SAME var() the keyframe's 100% used rather than a copy of it.
 *
 * The transition arrives with the same class, so it also cannot fire during the opening:
 * until .settled there is no transition on these elements at all, and the exit is
 * animation-driven from start to finish. */
.envelope.opened.settled .letter,
.envelope.opened.settled .env-photo {
    animation: none;
    transition: transform var(--flip-dur) var(--flip-ease);
}

.envelope.opened.settled .letter {
    left: var(--card-in);
    right: var(--card-in);
    top: var(--card-top);
    max-height: var(--card-maxh);
    transform: translate(0px, 0px) rotate(0deg) translateZ(2px);
}

.envelope.opened.settled .env-photo {
    left: var(--photo-in);
    right: var(--photo-in);
    top: var(--photo-top);
    transform: translate(var(--photo-peek-x), var(--photo-peek-y))
               rotate(var(--photo-peek-tilt))
               translateZ(-2px) rotateY(180deg);
}

/* The deck's own transition, likewise only from .settled onwards. Stated as an explicit
   rotateY(0deg) rather than left to the base rule's absence of a transform, because a
   transition between `none` and a rotation is a transition between two matrices rather
   than between two angles — and the interpolation of the matrix decomposition is not the
   same rotation for the middle 90% of it. Angles interpolate as angles only if both ends
   are written as angles. */
.envelope.opened.settled .env-deck {
    transform: rotateY(0deg);
    transition: transform var(--flip-dur) var(--flip-ease);
}

/* ===== Flipped: the photo is the one being looked at =====
 *
 * One class on the envelope, three transforms, and no reordering anywhere. The deck turns
 * over — which by itself swaps which of the two items is nearer the viewer, since a
 * 180deg rotation negates its children's z — and the two items adjust what being in front
 * and being behind means for each of them:
 *
 *   the photo drops its offset and its lean and comes square to the screen, centred,
 *   because it is the thing being looked at now.
 *
 *   the card takes a lean and a small offset: it is the larger box, so it already shows
 *   on all four sides of the photo, and the offset only decides that it shows mostly on
 *   ONE side. Both are rendered mirrored by the deck — to the left, leaning the other
 *   way — which is exactly what turning two loose sheets over together does.
 *
 * The card's front face and its contents disappear on the way round and its blank back
 * appears, all by backface-visibility — see .letter and .letter-back. Nothing here has to
 * say anything about that. */
.envelope.opened.settled.show-photo .env-deck {
    transform: rotateY(180deg);
}

.envelope.opened.settled.show-photo .letter {
    transform: translate(var(--card-peek-x), 0px) rotate(var(--card-peek-tilt))
               translateZ(2px);
}

.envelope.opened.settled.show-photo .env-photo {
    transform: translate(0px, 0px) rotate(0deg) translateZ(-2px) rotateY(180deg);
}

/* ===== The button that asks for it =====
 *
 * Under the stack, centred, and it appears only once everything has landed — a control
 * offered mid-animation is a control offered before the recipient has finished looking at
 * what just happened.
 *
 * Absolutely positioned against .envelope-shell, below its bottom edge. The shell is the
 * envelope's own box (the envelope is aspect-ratio 3/2 at 100% width), so `top: 100%` is
 * the envelope's bottom edge and the button hangs below it — clear of the settled stack,
 * whose lowest edge is inside the envelope in both states.
 *
 * 50px below that edge, by way of 20px and 35px: the card settles LOW over the envelope,
 * its bottom edge landing ~13px above the bottom fold at the 370px cap (see --card-top),
 * so at 20px there were only 34px of air between the card's edge and the button — close
 * enough that the two read as one block. 50px measures 64px from the card's edge there.
 *
 * The narrow end is what this last 15px really buys. The offset is absolute px while the
 * envelope's height is not (min(370px, 88vw) / 1.5), so as the viewport narrows the
 * envelope shrinks out from under a card whose height is set by absolute type: at 320px
 * wide with a voice note the card runs ~343px inside a 187px envelope and hangs ~65px past
 * its bottom fold, and at 35px the button sat ON the card's lower 30px. It still overlaps
 * at 320px, by ~15px — fixing that properly means anchoring the button to the card's own
 * bottom edge, which cannot be done in CSS without the card's real height (--card-maxh is
 * a cap, and using it would strand the button far below a short card).
 *
 * This value governs the RECIPIENT'S page alone, though it is written as the base:
 * .hero-demo has no button to place (the landing demo emits none — it turns the stack over
 * on a timer, see ENVELOPE_IDS in js/envelope.js), the create-page preview never reaches
 * .settled so its button never appears, and .mini-stage overrides `top` outright.
 * That override deliberately did NOT follow this +15px — preview.html's Close it button is
 * only a few px below it at 320px wide, and the panel height that buys that clearance is
 * tuned per 15px moved (see styles.css). The one thing that did have to follow is the
 * margin on .after-reveal below, because the button hangs lower than the card and is what
 * that margin actually has to clear.
 *
 * Out of flow deliberately: the pages below this component lay out the CTA and the hint
 * against the envelope's box, and a button in flow would push all of that down by its own
 * height on three pages at once, including the two where it never appears. */
.env-flip {
    position: absolute;
    top: calc(100% + 50px);
    left: 50%;
    transform: translateX(-50%);

    /* Quiet. It is an offer, not the point of the page: the card is. */
    padding: 9px 20px;
    border: 1px solid rgba(140,100,110,.3);
    border-radius: 999px;
    background: rgba(255,255,255,.72);
    color: var(--ink);
    font-family: inherit;
    font-size: .82rem;
    letter-spacing: .02em;
    cursor: pointer;
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
    box-shadow: 0 2px 10px -4px rgba(60,30,40,.3);

    /* Hidden until .settled, and not merely transparent: visibility and pointer-events
       both go, so it cannot be tabbed to or clicked while it is invisible. */
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity .4s ease, visibility .4s ease, background .2s ease;
}

.env-flip:hover { background: rgba(255,255,255,.92); }

.envelope.settled ~ .env-flip {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
}

/* No photo, no button. There is nothing on the back of the stack to turn to, and an
   offer to see a photo that was never sent is worse than no offer.

   Keyed off the same .env-photo.hidden class showPhoto() toggles, read from the sibling
   envelope — the single source of truth for "is there a photo", exactly as the voice
   note's cap is. display: none rather than opacity, so it is gone rather than invisible. */
.envelope:has(.env-photo.hidden) ~ .env-flip {
    display: none;
}

/* Push the whole envelope down so the emerging card stays on screen.
   Needs an explicit width: the envelope inside is sized in %, so a
   shrink-to-fit shell would collapse it to zero. */
/* The element js/envelope.js writes the envelope into. display:contents removes
   it from layout altogether, so .envelope-shell stays a direct flex child of the
   stage exactly as it was when the markup was hand-written here. Without this the
   host becomes a shrink-to-fit flex item and the percentage-sized envelope inside
   it collapses to nothing. */
.env-host { display: contents; }

.envelope-shell {
    width: min(370px, 88vw);
    max-width: 100%;
    /* Containing block for the flip button, which hangs below the envelope's bottom
       edge — see .env-flip. */
    position: relative;
    transition: transform .95s cubic-bezier(.22,1,.36,1);
}

/* The envelope drops as it opens, and this is the only thing that buys the rise any room
   at the top of the screen: the stage centres the envelope, so above it there is nothing
   but half the leftover height and the intro — and the stack rises a full card height
   (--rise, ~292px, and ~342px with a voice note) rather than the 55% of the envelope's
   height the old single-move exit needed.

   A CONSTANT, and it is worth being clear about why, because the obvious thing to write
   here is a viewport-relative expression and that is what used to be here:

       clamp(70px, 545px - 50dvh, 45dvh)

   derived from the measured fact that the envelope's top before the shift sits at
   ~0.5H - 197px, so a shorter window has proportionally less room above the envelope and
   needs proportionally more drop. The arithmetic was right and the constant it was built
   on was wrong — measured on a rig that was missing the after-reveal CTA, which is always
   in the layout (opacity 0, with a margin above it that is itself derived from this drop —
   see .after-reveal) and makes the stage ~97px taller, so the real envelope sits ~97px
   HIGHER than the derivation assumed. Every viewport with a voice note lost the top of
   the card.

   The lesson taken is not "re-measure the constant". It is that a drop which varies with
   the viewport makes every clearance in this component a function of viewport height, so
   each one has to be verified across the whole range and any of them can be wrong on a
   window nobody tried. A single number is checkable by inspection: the base position only
   ever ADDS headroom (a taller window centres the envelope lower), so whatever the
   shortest viewport does, every taller one does better.

   185px, and it was 250px — the drop that cleared the peak of the rise on every viewport
   from 360x600 up, voice note included. That number was bought at the settled card's
   expense: 250px of drop put the card the recipient actually READS low on the screen,
   its bottom edge ~593px down a 600px-tall window, which is the state they sit in for as
   long as they care to look. The peak is one frame of a 2.9s flight. So the trade is now
   made the other way round, and the cost is stated rather than hidden. Measured at 500px
   wide, the top of the card at the peak of the arc:

       no voice note    16px inside the top edge at 600px tall, and lower on any taller
                        window — clear everywhere, as it was at 250px
       voice note       34px ABOVE the top edge at 600px tall (the player adds 50px to
                        --rise), closing to nothing at ~940px tall, where the stage has
                        enough leftover height to start centring

   So a card with a voice note, on a short window, loses the top ~34px of itself for the
   few hundred ms around the top of the arc. .card-page is overflow: hidden, so it is
   clipped rather than scrollable. That is the whole price of this change, and it is paid
   in a transient frame to buy a settled position that is on screen indefinitely.

   Still ONE number for both cases, deliberately: a drop that grew with a voice note would
   make every clearance below a function of the card's contents, which is the same trap as
   making it a function of viewport height. */
.envelope-shell.opened {
    transform: translateY(var(--open-drop, 185px));
}

/* ---------- Letter typography ---------- */

.letter-mark {
    width: 26px;
    height: 26px;
    margin: 0 auto 12px;
    color: var(--accent);
    opacity: .85;
}

.letter-mark svg { width: 100%; height: 100%; fill: currentColor; }

/* The greeting is written in the sender's chosen hand, like the message. Only
   the message used to be, which made "Handwriting" look half-applied: you pick
   a script face and the words change while "Dear Meera," stays in the display
   serif. On a real card the same pen wrote both.
   Size stays fixed here — the hand's own --letter-size is tuned for a paragraph
   and would make the greeting shout. */
.letter-greeting {
    font-family: var(--letter-font, var(--font-heading));
    font-size: 1.16rem;
    font-weight: 500;
    color: var(--ink);
    margin-bottom: 12px;
    text-align: center;
}

/* The three --letter-* variables are written inline by applyCraft(). The
   fallbacks reproduce the pre-crafting look exactly, so a card with no chosen
   handwriting renders identically to before the feature existed — and the
   letter still reads correctly if the webfont never arrives. */
.letter-message {
    font-family: var(--letter-font, var(--font-heading));
    font-size: var(--letter-size, 1rem);
    line-height: var(--letter-lh, 1.75);
    color: #4a3d38;
    text-align: center;
    margin-bottom: 16px;
    white-space: pre-wrap;
    word-break: break-word;

    /* The one scrollable part of the letter, and the only child that can shrink —
       which is exactly why it needs a floor.

       min-height used to be 0, so that the flex default of `auto` (which refuses to
       shrink below the content) could not push the letter past its max-height. That
       worked, and went much too far: everything else in the card — the mark, the
       greeting, the rule, the signature, the voice player — is flex-shrink: 0 and
       sized in ABSOLUTE pixels, while the card's height cap is a fraction of the
       ENVELOPE. Shrink the envelope and the fixed chrome stops leaving room for
       anything, so the words are the one thing that gives. Measured on the preview's
       237px envelope: 44.7px of box showing 245px of message, and adding a voice note
       took it to 0px. A card with no visible words is not a card.

       So: two lines of the sender's message, guaranteed. Two rather than three because
       this floor competes directly with the signature for the same fixed 300px of
       card: at three lines the message won and "Forever and always, Arjun" was clipped
       mid-line by the bottom of the paper. The signature is the sender's NAME — the one
       part of a card that cannot be allowed to go missing — so the message yields
       first and scrolls, which it is built to do.

       Bounded by 34cqh as well, so on a small envelope the floor can never exceed what
       the card is able to show. A floor that overflows its own container is not a
       floor: .letter is capped by max-height, so a floor the card cannot honour does
       not grow the paper, it pushes the signature out onto the envelope behind it —
       which was the original bug. */
    overflow-y: auto;
    min-height: min(calc(var(--letter-lh, 1.75) * 2em), 34cqh);
    /* Nothing else on the page scrolls, so a visible scrollbar here reads as
       part of the paper rather than a browser artefact. */
    scrollbar-width: thin;
}

/* The greeting, the rule and the signature stay put while the words scroll —
   they are the frame of the letter, not part of its body. */
.letter-greeting,
.letter-rule,
.letter-sign,
.letter-voice { flex-shrink: 0; }

/* ---------- Voice note inside the letter ---------- */

/* No label above it any more. "A note in their voice" was explaining a play
   button, and a play button on a letter needs no explaining — the recipient
   presses it and hears the sender. */
.letter-voice { margin: 0 0 16px; }

/* ---- One button, 34px tall ----
   This was the browser's own <audio controls> panel, on the reasoning that native
   controls cannot be got wrong. They can: the recipient could not press play. A
   letter can spare 34px for a player, the panel's parts scale down with it until the
   play glyph is a few pixels across, and inside the preserve-3d deck WebKit hit-tests
   those shadow parts unreliably — the tap goes to the envelope behind the card. So
   the whole bubble is now one <button>: the entire 34px strip is the play target, it
   hit-tests like any other element, and it can be dressed as part of the letter.

   34px EXACTLY, unchanged from the panel it replaces, because --voice-extra (50px =
   34 + this block's 16px margin) is wired into the card's height and its resting top
   offset — see --card-top and --card-floor. A taller player is a taller card and a
   different landing place, which is a separate decision from fixing the button.

   The audio element itself is the engine and is never seen: same src, same formats,
   no controls. */
.letter-voice audio { display: none; }

.voice-note {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    height: 34px;
    padding: 0 12px 0 10px;
    /* Not a browser pill: the paper's own tint, the card's accent, the card's radius.
       It should read as something printed on the letter. */
    background: color-mix(in srgb, var(--accent) 8%, #fff);
    border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
    border-radius: 100px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: background .2s ease;
}

.voice-note:hover { background: color-mix(in srgb, var(--accent) 14%, #fff); }
.voice-note:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* The play triangle, and the two bars it becomes while the note is running. Drawn in
   CSS rather than as an icon font or an SVG sprite: it is two shapes, and this way the
   playing state is one class rather than a swapped file. */
.voice-ico {
    flex-shrink: 0;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    background: var(--accent);
    position: relative;
}

.voice-ico::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    /* Optical centring: a triangle's visual centre sits behind its geometric one,
       so it is nudged right by a pixel. */
    transform: translate(-40%, -50%);
    border-style: solid;
    border-width: 4px 0 4px 6px;
    border-color: transparent transparent transparent #fff;
}

.voice-note.playing .voice-ico::before {
    /* Two bars from one box: the second is a box-shadow of the first, which keeps the
       pause glyph to a single pseudo-element. The extra -2px undoes the offset that
       shadow adds on the right, so the PAIR is centred rather than the left bar. */
    transform: translate(calc(-50% - 2px), -50%);
    width: 2px;
    height: 9px;
    border: 0;
    background: #fff;
    box-shadow: 4px 0 0 #fff;
}

/* Feedback, not a control — see wireVoice() in js/envelope.js on why there is no
   seeking. It fills as the note plays and resets when it ends. */
.voice-track {
    flex: 1;
    height: 3px;
    border-radius: 100px;
    background: color-mix(in srgb, var(--accent) 20%, transparent);
    overflow: hidden;
}

.voice-fill {
    display: block;
    width: 0%;
    height: 100%;
    border-radius: 100px;
    background: var(--accent);
    transition: width .2s linear;
}

/* Tabular figures: the seconds tick over without the row twitching sideways. */
.voice-time {
    flex-shrink: 0;
    font-family: var(--font-body);
    font-size: .72rem;
    font-variant-numeric: tabular-nums;
    color: color-mix(in srgb, var(--accent) 75%, #4a3f3c);
    min-width: 30px;
    text-align: right;
}

.letter-rule {
    width: 34px;
    height: 1px;
    background: var(--accent);
    opacity: .35;
    margin: 0 auto 14px;
}

/* Sign-off and name follow the hand too, for the same reason as the greeting:
   the signature is the MOST handwritten part of a letter, so leaving it in the
   UI sans-serif was the most obvious place the choice failed to land.
   letter-spacing is dropped to 0 for script faces, which are drawn joined and
   look broken when tracked out. */
.letter-sign {
    text-align: center;
    font-family: var(--letter-font, var(--font-heading));
    font-size: .82rem;
    color: #7d6b64;
    letter-spacing: 0;
}

.letter-sign strong {
    display: block;
    font-family: var(--letter-font, var(--font-heading));
    font-size: 1.06rem;
    color: var(--accent);
    font-weight: 500;
    margin-top: 3px;
    letter-spacing: 0;
}

/* ---------- Open prompt ---------- */

.open-hint {
    margin-top: 34px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    transition: opacity .35s ease;
}

.open-hint.gone { opacity: 0; pointer-events: none; }

.btn-open {
    background: var(--accent);
    color: #fff;
    border: none;
    padding: 14px 34px;
    border-radius: 100px;
    font-family: var(--font-body);
    font-size: .95rem;
    font-weight: 500;
    letter-spacing: .02em;
    cursor: pointer;
    box-shadow: 0 8px 22px -6px rgba(70,20,30,.45);
    transition: transform .2s ease, box-shadow .2s ease;
}

/* Scoped to buttons that are NOT centred by transform. The mini variant
   positions itself with translateX(-50%), and a bare `transform: translateY()`
   here would replace that centring, jumping the button sideways by half its
   own width on mouse-over. Its own hover rule below keeps both parts. */
.btn-open:not(.btn-open-mini):hover {
    transform: translateY(-2px);
    box-shadow: 0 12px 28px -6px rgba(70,20,30,.5);
}

.open-hint .tap-note {
    font-size: .75rem;
    color: #8a7a74;
    animation: breathe 2.6s ease-in-out infinite;
}

@keyframes breathe {
    0%, 100% { opacity: .5; }
    50% { opacity: 1; }
}

/* ---------- Sealed state ----------
   A sealed card is still a gift, so it keeps the full envelope. Only the
   affordance changes: no lift on hover, no pointer, and a countdown where the
   open button used to be. */

.envelope.locked { cursor: default; }
.envelope.locked:hover { transform: none; }
.envelope.locked .seal { animation: seal-pulse 3.4s ease-in-out infinite; }

@keyframes seal-pulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.045); }
}

.sealed-panel {
    margin-top: 30px;
    text-align: center;
}

.sealed-lead {
    font-size: .82rem;
    letter-spacing: .09em;
    text-transform: uppercase;
    color: #8a7a74;
    margin-bottom: 14px;
}

.countdown {
    display: flex;
    justify-content: center;
    gap: 8px;
}

.cd-part {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 58px;
    padding: 10px 6px 8px;
    background: rgba(255,255,255,.62);
    border: 1px solid rgba(255,255,255,.8);
    border-radius: var(--r-md, 14px);
    backdrop-filter: blur(6px);
}

.cd-part b {
    font-family: var(--font-heading);
    font-size: 1.7rem;
    font-weight: 600;
    line-height: 1;
    color: var(--ink);
    /* Tabular figures stop the box twitching as the seconds tick over. */
    font-variant-numeric: tabular-nums;
}

.cd-part i {
    font-style: normal;
    font-size: .66rem;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: #8a7a74;
    margin-top: 5px;
}

.sealed-when {
    margin-top: 14px;
    font-size: .84rem;
    color: #7d6b64;
}

@media (prefers-reduced-motion: reduce) {
    .envelope.locked .seal { animation: none; }
    /* Drop the lift, keep the centring: one is decoration, the other layout. */
    .btn-open:not(.btn-open-mini):hover { transform: translateY(0); }
    .btn-open-mini:hover { transform: translateX(-50%) translateY(0); }
}

/* ---------- After-reveal CTA ---------- */

.after-reveal {
    /* ---- Room for two things that are not in the flow ----
       The settled card hangs a long way below the envelope's BOX, and the flip button
       hangs below the card — and none of that overhang is in the layout: the shell's
       drop is a transform, the card is absolutely positioned, and so is the button. So
       this margin is what keeps the CTA out from under both, and the two want different
       amounts. Whichever is larger wins, which is why this is a max().

       THE CARD, worst case, from the top:

           drop                       185px   (--open-drop)
         - the card's top offset      -.22 x envelope height
         + the card's own height      up to max(132cqh, 340px) = 340px here, since a
                                      370px envelope is only 247px tall
         - the envelope's own height  the part of the above that IS in the flow
         - the open hint              ~122px, still in the flow after opening (it only
                                      fades — see .open-hint.gone)
         + a 30px gap

       Envelope height is min(370px, 88vw) / 1.5, and 1.22 x that collects the top
       offset and the height together, which leaves 434px - 1.22 x min(247px, 58.7vw)
       — 133px on anything wide enough to hit the 370px cap, and more as the viewport
       narrows, because a narrower card is a TALLER card (its type is absolute px, so
       the same message needs more lines). Measured at 360px wide the card runs 292px
       tall inside a 211px envelope and hangs 34px past its bottom fold. That is the
       calc() term, rounded up a couple of px: 435px - 72vw.

       THE FLIP BUTTON sets the floor, and unlike the card it does not vary with width
       at all — it is 50px below the envelope's bottom edge and 36px tall (see
       .env-flip), and both the offset and the hint below the envelope are absolute
       pixels, so the envelope's own height cancels out of the subtraction:

           drop 185px + 50px + 36px - the open hint's ~122px + a 15px gap = ~165px

       15px and not the card's 30px because the button is a ~106px pill on the centre
       line rather than a full-width sheet of paper, and because 15px is the clearance
       it had at the old 250px drop, which is the spacing this page was tuned with.
       This floor tracks .env-flip's offset px for px — it was 150px at 35px of offset
       — and the card's term beats it only below ~396px of viewport width.

       Neither can be measured and applied in JS at settle time, which would fit the
       actual card instead of the cap: .stage centres its content on a tall viewport,
       so changing this margin at 2.9s would shift the whole stage — and the card with
       it — a visible jump at exactly the moment the recipient is reading. A static
       worst-case margin costs some whitespace below a short card, in a place the
       recipient reaches by scrolling, which is the cheaper of the two.

       Measured at 500x813 after the drop came down to 185px and the button to 50px:
       settled card bottom 528, flip button 592..628, CTA top 643 — 15px under the
       button, 115px under the card, which is the button binding and the card with room
       to spare, as above. */
    margin-top: max(165px, calc(435px - 72vw));
    text-align: center;
    opacity: 0;
    transform: translateY(12px);
    transition: opacity .6s ease, transform .6s ease;
    pointer-events: none;
}

.after-reveal.show {
    opacity: 1;
    transform: none;
    pointer-events: auto;
}

.after-reveal p {
    font-size: .88rem;
    color: #7d6b64;
    margin-bottom: 12px;
}

.btn-ghost {
    display: inline-block;
    padding: 11px 26px;
    border-radius: 100px;
    border: 1px solid var(--accent);
    color: var(--accent);
    background: rgba(255,255,255,.55);
    text-decoration: none;
    font-size: .88rem;
    font-weight: 500;
    transition: background .25s ease, color .25s ease;
}

.btn-ghost:hover { background: var(--accent); color: #fff; }

/* ---------- Sender badge (free tier) ---------- */

.made-badge {
    position: fixed;
    bottom: 14px;
    left: 50%;
    transform: translateX(-50%);
    font-size: .72rem;
    color: #8a7a74;
    z-index: 30;
    text-decoration: none;
    background: rgba(255,255,255,.7);
    padding: 5px 14px;
    border-radius: 100px;
    backdrop-filter: blur(6px);
    white-space: nowrap;
}

.made-badge b { color: var(--accent); font-weight: 600; }

/* ---------- Ambient drift ---------- */

.drift {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 0;
    overflow: hidden;
}

.drift span {
    position: absolute;
    color: var(--accent);
    opacity: 0;
    animation: drift-up linear infinite;
    will-change: transform, opacity;
}

@keyframes drift-up {
    0%   { transform: translateY(102vh) rotate(0deg) scale(.7); opacity: 0; }
    12%  { opacity: .3; }
    88%  { opacity: .3; }
    100% { transform: translateY(-8vh) rotate(300deg) scale(1); opacity: 0; }
}

/* ---------- Error ---------- */

.error-box {
    background: var(--paper);
    padding: 40px 32px;
    border-radius: 12px;
    text-align: center;
    box-shadow: 0 18px 45px -12px rgba(60,30,40,.28);
    max-width: 340px;
}

.error-box h2 {
    font-family: var(--font-heading);
    font-weight: 500;
    margin-bottom: 10px;
    color: var(--ink);
}

.error-box p { color: #7d6b64; font-size: .9rem; margin-bottom: 20px; }

/* ---------- Accessibility ---------- */

@media (prefers-reduced-motion: reduce) {
    /* .env-inside is in this list because the reveal's fade lives on it now. Without
       it that animation survives here and the contents fade in after a 300ms delay —
       which is precisely the motion this preference asks us not to make. */
    .envelope, .env-flap, .letter, .envelope-shell, .env-inside, .env-deck,
    .seal, .env-photo, .env-flip, .drift span, .open-hint .tap-note {
        transition: none !important;
        animation: none !important;
    }
    /* The END state has to be written out explicitly for anything animated:
       killing the animation alone would leave these at opacity 0, i.e. an open
       envelope with nothing in it.

       The three-beat sequence collapses to nothing here, which is the correct
       reading of the preference: the flap, the card and the photo are all just
       already in their final places. Those places are the SAME settled stack the
       animation ends on — the same variables, not a second set of numbers that
       would quietly drift away from it. Someone who prefers reduced motion should
       get the same picture, only instantly.

       Including the transforms, which is new with the stack: the card's depth in the
       deck and the photo's mounting and lean are carried by `transform` now, not just
       by the box, so a rule that restated only left/right/top would leave the photo
       square behind the card with no corner showing and both items coplanar — which
       would also stop the flip working, since a flat deck does not reorder anything
       when it turns. The flip itself still works here; it is simply instant, which is
       what killing the deck's transition above does.

       The end state named is the UNFLIPPED one. .settled and .show-photo restate these
       same values with the same variables and win on specificity when they apply, so
       the flipped state is reachable from here unchanged. */
    .envelope.opened .letter {
        opacity: 1;
        left: var(--card-in);
        right: var(--card-in);
        top: var(--card-top);
        max-height: var(--card-maxh);
        transform: translate(0px, 0px) rotate(0deg) translateZ(2px);
    }
    /* No padding here: the photo's border is constant now, so the base rule already
       has it right. It used to need restating because the exit animated it. */
    .envelope.opened .env-photo {
        opacity: 1;
        left: var(--photo-in);
        right: var(--photo-in);
        top: var(--photo-top);
        transform: translate(var(--photo-peek-x), var(--photo-peek-y))
                   rotate(var(--photo-peek-tilt))
                   translateZ(-2px) rotateY(180deg);
    }
    /* The interior must still be lifted in front of the envelope's front panels, and
       its clip must still be opened at the bottom — both of which the animation would
       otherwise have done at --inside-lift. Without the first, the settled stack is
       drawn behind the envelope's own paper; without the second it is sliced along the
       envelope's bottom fold.

       And it must be VISIBLE: .env-inside is opacity 0 at rest, revealed by the
       inside-reveal animation that the rule above has just killed. Without this
       opacity the reduced-motion card is an open envelope with nothing in it — the
       exact failure the comment above warns about, which moved here along with the
       fade. Both children are opaque on their own now, so this one value is the whole
       of it. */
    .envelope.opened .env-inside {
        z-index: 20;
        opacity: 1;
        clip-path: inset(-300% -120% -300% -120%);
    }
    .drift { display: none; }
}

@media (max-width: 420px) {
    .letter { padding: 20px 18px 18px; }
    .letter-message { font-size: .94rem; line-height: 1.68; }
    .letter-greeting { font-size: 1.06rem; }
    .seal { width: 50px; height: 50px; margin-left: -25px; margin-bottom: -25px; }
    .seal svg { width: 21px; height: 21px; }
}
