/* The couple's own wedding logo type, same two families the carousel uses
   (wedding-carousell/index.html) so the crest at the top of the journey is
   the SAME logo, not a lookalike. An @import has to come before every rule
   in the file, which is why it sits above the header comment's usual spot. */
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500&family=Great+Vibes&display=swap');

/* =====================================================================
   vn.css - the visual-novel stage (js/vn.js)

   THE ONE RULE: the layout is fixed. The owner's words after seeing the
   first pass -- "make sure the page layout is fixed and doesnt shift
   around. now the top part is always empty, text areas and buttons are
   not in fixed locations. use animations and fixed dialogue areas and
   button areas to make it look like a polished game cutscene."

   ROUND 4 RECOMPOSITION. The owner chose this layout himself, from his
   own screenshots: the two characters standing at the bottom-left and
   bottom-right of a big open middle, and the dialogue at the very bottom
   "just like the home screen" -- box and all.

   ROUND 5 folded the logo INTO that middle: "the canvas rectangle expands
   to the top of the screen and absorbs the logo". So .vn is a TWO-row
   grid and only the canvas is elastic:

     row 1  .vn-mid     minmax(0, 1fr)     THE CANVAS. Starts at the top
                                           of the screen and takes every
                                           leftover pixel. Every child
                                           inside is ABSOLUTELY POSITIONED,
                                           so mounting a card cannot move a
                                           single thing:
                                             .vn-sky      backdrop
                                             .vn-logo     the crest,
                                                          var(--vn-logo-h),
                                                          at the very top
                                             .vn-name     the guest's own
                                                          name, typeset
                                             .vn-content  cards / forms
                                             .vn-cast     the characters,
                                                          on the floor, the
                                                          guest's avatar
                                                          wearing .vn-gtag
                                                          over its head
                                             .vn-prop     overlay card
     row 2  .vn-dock    var(--vn-dock-h)   the bottom box. Carries the
                                           home-screen card fill and holds
                                             .vn-dlg      the sentence
                                             .vn-actions  var(--vn-act-h),
                                                          fixed, bottom of
                                                          the box -- where
                                                          the home screen
                                                          puts .dlg-choices

   The dock is a fixed height in every state and everything inside the
   canvas is out of flow, so the dialogue baseline and the primary button
   are pixel-identical from beat to beat and from scene to scene. No
   entrance animation may use layout-affecting properties anywhere.

   The ONE sanctioned exception is .vn--kbd: the soft keyboard genuinely
   collapsed the visual viewport, so the fixed heights step down.

   ZERO SCROLLBARS, ANYWHERE, EVER (owner, round 5, after catching the
   dialogue text and the character picker scrolling). There is not one
   `overflow: auto` left in this file: every zone CLIPS, and js/vn.js's
   auto-fit pass shrinks content until it fits -- --vn-tscale for text,
   `zoom` for a mounted panel or a button stack. A scrollbar in this
   composition is now a bug by definition, and it is assertable:
   scrollHeight <= clientHeight on every visible container, every beat.

   SOFT CHROME (owner, round 5: "remove the hard outline rectangles around
   the text and cards, the hard line is making it off. use solid colour
   fills with drop shadows instead"). So inside .vn nothing wears an ink
   border: the canvas, the dock, the cards, the name chips and the buttons
   are solid fills with a soft drop shadow (--vn-soft / --vn-soft-sm) and a
   gentler radius (--vn-r). The palette is untouched -- same theme.css
   paper, ink and accents, same per-mascot --vio / --mush name tag -- and
   this is scoped to .vn / .jy--vn, so the home screen's dialogue and the
   profile editor keep the PC-98 hard-edged chrome exactly as they are.

   ROUND 6 (owner): "reduce the text of the dialogue below and shift it
   down to give the top part more space. right now when introducing
   different parts of the site, all the characters get shrunked too much.
   also selecting the characters and previewing can be in their standalone
   mini segment ... one at a time is fine."

     * THE DOCK RESERVES TWO LINES, NOT THREE, and sits flush to the bottom
       (.jy--vn padding-bottom, a 13px footer, 9px dock padding, and its
       share of the budget down from .34 to .27). The canvas is the
       remainder, so every one of those pixels lands in the open middle.
     * THE CAST HAS A FLOOR. .vn--panel used to shrink the cast band to
       56-104px, which is under js/vn.js's smallest non-keyboard sprite
       step -- the mascots became icons on a tour beat. The band keeps a
       real height now (88-132px) and the CARD is what gives ground
       (compacter .vn-tcard, one clause of copy per card).
     * ONE THING AT A TIME in J2. The body picker (.vn-picks--solo, three
       roomy columns, art at 64px) and the preview (.vn-bprev, art at
       156px) are separate screens instead of one stacked panel. Round 7
       retired the third screen, the IGN field, with the MapleSEA import:
       .vn-msea and .vn-mcell are gone and the grid took the space.

   Loads AFTER css/journey.css so the .jy--vn frame overrides land.
   ===================================================================== */

/* ---------------- the page around the frame ----------------------
   The journey owns the viewport, so nothing outside .jy may scroll or
   animate its box. Two specific offenders:

   * portal.css reserves nav clearance with
     `#app-content { padding-bottom: calc(var(--nav-height) + 28px) }` AND
     puts a .2s transition on padding-bottom. journey.css zeroes the
     padding, but the TRANSITION still runs on the way in, so for 200ms
     #app-content is 96px taller than the viewport and the whole fixed
     composition can be scrolled. There is no nav on the start page to
     clear, so the padding is pinned and the transition switched off.
   * with that gone the document is exactly one viewport tall; overflow
     hidden makes sure a stray descendant can never re-introduce a scroll
     that would slide the fixed rows.                                  */
body[data-page="start"] { overflow: hidden; }
body[data-page="start"] #app-content {
    padding-bottom: 0;
    transition: none;
}

/* ---------------- the journey frame in VN mode -------------------- */
/* Exactly one viewport tall, and NOT scrollable: every zone that can
   overflow scrolls inside itself instead, so the frame cannot slide the
   fixed rows out from under the guest. */
.jy--vn {
    /* ONE height, driven by a variable, because js/vn.js re-pins the frame to
       visualViewport.height when the soft keyboard opens (iOS dvh does not
       shrink for the keyboard). journey.css's `.jy { min-height: 100dvh }`
       would out-rank an inline height, so min-height is released here and
       the variable is the single source of truth. */
    --vn-frame-h: 100vh;
    --vn-frame-h: 100dvh;
    height: var(--vn-frame-h);
    min-height: 0;
    max-height: var(--vn-frame-h);
    /* THE CANVAS REACHES THE TOP OF THE SCREEN (owner, round 5). Round 4
       reserved safe-area + 14px of air above a separate logo band; the logo
       lives INSIDE the canvas now and carries its own padding, so the frame
       only owes the notch / status bar. js/vn.js measures what is left, so
       every pixel not spent here goes to the canvas. */
    padding-top: max(6px, env(safe-area-inset-top));
    /* ROUND 6: FLUSH TO THE BOTTOM (owner: "shift it down to give the top
       part more space"). journey.css's .jy shorthand reserves 18px under the
       footer for a card on a page; the VN frame is not a card on a page, so it
       only owes the home indicator. ~16px straight back into the canvas. */
    padding-bottom: max(2px, env(safe-area-inset-bottom));
}
/* the art-deco corner ticks belong to a card on a page, not to a full-bleed
   canvas: inside the VN frame they land ON the canvas and read as the very
   hard outline round 5 removes */
.jy--vn .jy-corner { display: none; }
.jy--vn .jy-scroll {
    align-items: stretch;
    justify-content: flex-start;
    overflow: hidden;
    padding: 0;
    min-height: 0;
}
.jy--vn .jy-body {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
    /* the shared .rise entrance TRANSLATES the whole body, which would move
       the fixed rows for 450ms on every scene change -- exactly the shift
       the owner called out. Scene entrances are per-zone instead. */
    animation: none;
}
/* The footer is now JUST the progress dots: round 3 removed the full-width
   "Skip to site" link ("it eats bottom space"), and the escape hatch is the
   top-corner × in journey.css. ~24px instead of ~52px, and every reclaimed
   pixel goes back into the measured budget below -- the owner's "push down a
   little more". */
/* ROUND 6 trims it again: the dots are the whole footer, they do not need a
   pad above them AND a margin below them. */
.jy--vn .jy-foot {
    padding-top: 0;
    min-height: 13px;
}
.jy--vn .jy-dots { margin: 3px 0 0; }

/* the host the step drops the stage into has to pass the height down */
.vn-host {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
}

/* ---------------- the fixed composition --------------------------- */
.vn {
    /* ---- THE ZONE BUDGET (round 3) -------------------------------------
       The owner's phone was CLIPPING: an invisible cutoff, and he had to
       scroll to see the bottom of the action buttons. The cause was
       arithmetic-by-hope -- fixed px rows (108 + 104) plus dvh clamps, on a
       frame that was itself 100dvh while iOS only ever showed less than
       that. Any of those three could push the sum past the screen.

       So nothing is guessed any more. js/vn.js MEASURES the space the
       journey frame actually leaves (--vn-avail, the .vn-host client
       height, with the frame pinned to visualViewport.height), every zone
       is a FRACTION of that budget, and the stage row is the REMAINDER.
       The rows therefore sum to exactly the space available, on any device,
       with or without a keyboard: zero page scroll, nothing under a fold.

       ROUND 5: ONE fixed row. The dock is a fraction of the budget and THE
       CANVAS IS THE REMAINDER -- the logo is a zone inside it now, not a row
       of its own, so the canvas runs from the top of the screen to the top
       of the dock. Every fraction has a px floor and ceiling so a huge
       tablet does not get a comic dialogue box. On a viewport too short for
       the floors the canvas goes to 0 first (see the max(0px) below) -- the
       story is still readable and every button reachable.

       ROUND 6: A TWO-LINE DOCK. Owner: "reduce the text of the dialogue below
       and shift it down to give the top part more space." Every line in
       js/journey.js's SCRIPTS is one short sentence now, so the dock only has
       to reserve TWO lines of dialogue instead of three -- its share drops
       from .34 of the budget to .27, and every reclaimed pixel lands in the
       canvas (which is the remainder, so this is automatic).

       ROUND 7 -- BIG BUTTONS. The owner has called the button size a
       "glaring no-no" twice. The CSS was not the whole story: three stacked
       controls on the result card measured ~184px against a ~103px band, and
       js/vn.js's fitZoom then SCALED THE WHOLE STACK DOWN to about 0.7 to
       make it fit. So every declared size was being quietly undone at
       runtime. Two changes, together:

         * the result card dropped to TWO buttons (js/playstory.js
           resultActions -- the chapter list moved to the ☰ chip in the
           footer, which is on screen on every beat anyway), and nothing in
           the journey ever puts more than two in the band either;
         * --vn-btn-h is a real thumb target, and the band + dock shares grew
           to hold TWO of them with headroom, so fitZoom has nothing to do.

       THE ARITHMETIC, at 390x844 (--vn-avail ~= 790px):
         --vn-btn-h        58   comfortable thumb (owner asked 52-60)
         two + 8px gap    124   what the band must hold
         --vn-act-h  .17  134   = 790 * .17           -> 10px headroom
         dock, non-act    104   = 18 padding + 22 name chip + 4 gap
                                 + 52 two lines of 18.3px/1.42 + 8 margin
         --vn-dock-h .32  253   = 790 * .32 >= 104 + 134 -> 15px headroom
         --vn-mid-max     531   = 790 - 253 - 6, the canvas remainder
       ...and the FLOORS have to satisfy the same inequality, because on a
       ~700px-tall viewport it is the floors that bind and not the shares:
         act floor 124 >= 2 x 58 + 8            (the two buttons)
         dock floor 228 >= 124 + 104            (the band + the sentence)
       The canvas gives up ~40px against round 6 and keeps a 170px cast band,
       which is what pays for the buttons. Every short-viewport breakpoint
       below re-derives the same three numbers with a smaller --vn-btn-h. */
    --vn-avail: calc(100dvh - 120px);      /* fallback only; JS measures */
    /* ONE token for every control in the band, so "make the buttons bigger"
       is one number and the budget above can be checked against it */
    --vn-btn-h: 58px;
    /* the bottom box: dialogue + the fixed button band, as ONE card.
       THE FLOOR IS THE BAND FLOOR PLUS THE 104px ABOVE IT, not a round
       number: on a viewport short enough for the clamp floors to bind
       (~700px tall) a smaller floor would hand the band its 124px and leave
       the sentence 90px, and js/vn.js would start shrinking the type. */
    --vn-dock-h: clamp(228px, calc(var(--vn-avail) * .32), 288px);
    /* the button band INSIDE the dock: two --vn-btn-h controls and the 8px
       gap between them, with headroom. The floor is what stops the second
       one landing under an invisible fold. */
    --vn-act-h:  clamp(124px, calc(var(--vn-avail) * .17), 150px);
    /* round 6: 6px, not --space-sm -- the dock sits closer to the canvas */
    --vn-gaps: 6px;
    /* the canvas takes what is LEFT, never a pixel more */
    --vn-mid-max: max(0px, calc(var(--vn-avail) - var(--vn-dock-h) - var(--vn-gaps)));
    /* The logo zone, at the TOP OF THE CANVAS. Round 6 takes its SHARE down
       (.19 -> .16): the canvas grew, and the extra room is meant for the cast
       and the cards, not for a bigger crest on every scene. */
    --vn-logo-h: clamp(52px, calc(var(--vn-mid-max) * .16), 88px);
    /* the guest's own name, under the logo. 0 until there is one (js/vn.js
       adds .vn--named), so the canvas is not holding space for nothing. */
    --vn-name-h: 0px;
    /* the floor strip the cast stands on, at the bottom of the canvas. The
       card area is everything BETWEEN the name and it, so a mounted card and
       the characters never overlap. */
    --vn-cast-h: clamp(88px, calc(var(--vn-mid-max) * .34), 170px);

    position: relative;
    display: grid;
    grid-template-rows:
        minmax(0, var(--vn-mid-max))     /* THE CANVAS: what is left */
        var(--vn-dock-h);                /* the bottom box */
    align-content: end;        /* bottom-weighted, like every cutscene */
    row-gap: var(--vn-gaps);   /* round 6: the SAME number --vn-mid-max spends */
    /* SOFT CHROME (round 5): the two shadows every surface in here uses
       instead of an ink border. Same ink colour, spread into a shadow. */
    --vn-soft: 0 8px 22px -12px rgba(36,31,46,.42), 0 2px 6px -3px rgba(36,31,46,.22);
    --vn-soft-sm: 0 4px 12px -7px rgba(36,31,46,.38);
    --vn-r: 14px;
    --vn-r-sm: 10px;
    flex: 1 1 auto;
    min-height: 0;
    /* the last line of defence: if a future zone misbehaves it gets clipped
       INSIDE the composition instead of scrolling the page out from under
       the buttons. Every zone that can overflow scrolls internally. */
    overflow: hidden;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
/* with no logo handed in (a caller that does not pass ctx.logo) the zone
   gives its height back rather than leaving a gap */
.vn:not(.vn--logo) { --vn-logo-h: 0px; }
.vn:not(.vn--logo) .vn-logo { display: none; }

/* THE GUEST'S NAME takes a zone of its own the moment there is one, and
   gives it straight back when the character shows up to wear it as a
   nametag instead (js/vn.js paintName -> .vn--named / .vn--tagged) */
.vn--named { --vn-name-h: clamp(30px, calc(var(--vn-mid-max) * .11), 46px); }

/* A MOUNTED PANEL steps the cast back a little -- which moves nothing in the
   dock, so the dialogue baseline and the primary button are still at one y on
   every beat.

   ROUND 6: the step-back is much SMALLER. Owner: "right now when introducing
   different parts of the site, all the characters get shrunked too much."
   The old clamp bottomed out at 56-104px, which is below js/vn.js's smallest
   non-keyboard sprite step -- so a tour card turned the mascots into icons.
   The band keeps a real height now and the PANEL is what gives ground
   instead (compacter tour cards, shorter copy in js/journey.js, and
   js/vn.js's fitZoom as the safety net). */
.vn--panel { --vn-cast-h: clamp(88px, calc(var(--vn-mid-max) * .24), 132px); }

/* ---------------- the couple's logo, INSIDE the canvas ------------
   Ported from wedding-carousell/index.html's #logo-overlay (that file is
   untouched): Cormorant Garamond names, the Great Vibes copper ampersand,
   the two laurel SVGs, the "Salmon & Dill" nickname and the filigree
   divider -- compacted to fit a fixed zone on a phone.

   ROUND 5: it is a zone at the TOP OF THE CANVAS, not a band above it, and
   it is STATIC. Owner: "stop the logo fading in and out between scenes."
   So there is no animation on this block at all (there used to be an .8s
   vnFade, which fired on every scene because the whole stage re-mounted),
   and js/vn.js paints it exactly once per shell and then leaves it alone.

   The zone is height-locked like every other one, and the block scales
   itself to it with clamp()s tied to the same measured budget, so a short
   phone gets a smaller crest instead of a clipped one.

   (The two Google fonts are imported at the TOP of this file -- an @import
   is only valid before any rule, so it cannot live here next to the block
   that uses it.) */
.vn-logo {
    --logo-copper: #C98A56;
    --logo-shimmer: #F4D8A8;
    position: absolute;
    z-index: 1;
    left: 0; right: 0; top: 0;
    height: var(--vn-logo-h);
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    text-align: center;
    /* 44px sides: the escape-hatch × sits in the frame's top-right corner
       (journey.css .jy-x) and the crest must never run under it */
    padding: 6px 44px 0;
    box-sizing: border-box;
    pointer-events: none;
}
/* the one wrapper js/vn.js's fit pass scales (see _logoHtml) */
.vn-logo .vl-wrap {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 100%;
    max-width: 100%;
}
.vn-logo .vl-row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: clamp(6px, 2.4vw, 16px);
    max-width: 100%;
}
.vn-logo .vl-laurel {
    width: clamp(26px, calc(var(--vn-logo-h) * .42), 46px);
    height: auto;
    flex: 0 0 auto;
    opacity: .6;
    color: var(--logo-copper);
}
.vn-logo .vl-laurel svg { width: 100%; height: auto; display: block; }
.vn-logo .vl-laurel--r { transform: scaleX(-1); }
.vn-logo .vl-names {
    font-family: "Cormorant Garamond", var(--serif), Georgia, serif;
    font-weight: 300;
    font-size: clamp(20px, calc(var(--vn-logo-h) * .34), 34px);
    line-height: 1.1;
    letter-spacing: .02em;
    color: var(--ink);
    white-space: nowrap;
    margin: 0;
}
.vn-logo .vl-amp {
    font-family: "Great Vibes", "Cormorant Garamond", cursive;
    font-size: clamp(26px, calc(var(--vn-logo-h) * .46), 44px);
    line-height: .8;
    margin: 0 .1em;
    position: relative;
    top: .1em;
    color: var(--logo-copper);
    background: linear-gradient(135deg, var(--logo-shimmer) 0%, var(--logo-copper) 50%, var(--logo-shimmer) 100%);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}
.vn-logo .vl-nick {
    font-family: "Cormorant Garamond", var(--serif), Georgia, serif;
    font-style: italic;
    font-size: clamp(11px, calc(var(--vn-logo-h) * .15), 16px);
    letter-spacing: .12em;
    color: var(--ink-soft);
    opacity: .9;
    margin-top: .25em;
}
.vn-logo .vl-div {
    margin-top: .3em;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    opacity: .55;
}
.vn-logo .vl-div::before,
.vn-logo .vl-div::after {
    content: '';
    display: block;
    width: clamp(26px, 12vw, 52px);
    height: 1px;
    background: linear-gradient(90deg, transparent 0%, var(--logo-copper) 50%, transparent 100%);
}
.vn-logo .vl-dia {
    width: 5px; height: 5px;
    background: var(--logo-copper);
    transform: rotate(45deg);
    box-shadow: 0 0 8px var(--logo-shimmer);
}
/* THE DATE. Only ever shown on the ENTRANCE page (owner, round 5: "the
   first entrance page they will see our logo and today's date instead of
   that rectangle in the center"). It is part of the logo markup on every
   scene so the block never has to be repainted -- the hero class is what
   reveals it. */
.vn-logo .vl-date { display: none; }

/* ---- J0: THE CANVAS IS THE HERO ---------------------------------------
   The whole canvas is the logo on the entrance beat: crest large, the date
   under it, nothing else. Every later scene keeps the SAME block at the
   SAME place, just at the smaller --vn-logo-h -- one size change, at the
   J0 -> J1 boundary, and nothing fades. */
.vn--hero .vn-logo {
    height: auto;
    bottom: calc(var(--vn-cast-h) + 8px);
    padding: 10px 30px 0;
    justify-content: center;
}
.vn--hero .vn-logo .vl-names { font-size: clamp(30px, 9.4vw, 52px); }
.vn--hero .vn-logo .vl-amp { font-size: clamp(38px, 12vw, 66px); }
.vn--hero .vn-logo .vl-laurel { width: clamp(38px, 12vw, 68px); }
.vn--hero .vn-logo .vl-nick { font-size: clamp(13px, 3.8vw, 19px); margin-top: .5em; }
.vn--hero .vn-logo .vl-div { display: flex; margin-top: .6em; }
.vn--hero .vn-logo .vl-date {
    display: block;
    margin-top: .7em;
    font-family: var(--pixel);
    font-size: clamp(14px, 4.2vw, 20px);
    letter-spacing: .14em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

/* on the shortest logo zones the nickname and divider are the first to go --
   the names and laurels are the part that reads as "the couple's logo" */
@media (max-height: 720px) {
    .vn-logo .vl-div { display: none; }
    .vn--hero .vn-logo .vl-div { display: flex; }
}

/* ---------------- row 1: THE CANVAS -------------------------------
   The star of the composition: "a large open middle area, mostly empty,
   I'll decorate it later" (round 4), now reaching the top of the screen
   and carrying the logo (round 5). Every child is absolutely positioned
   inside it -- which is what makes mounting a card a zero-shift operation
   instead of a reflow.

   NO BORDER (round 5: "the hard line is making it off"): a solid paper
   fill and a soft drop shadow instead of the ink rectangle. */
.vn-mid {
    position: relative;
    min-height: 0;
    box-sizing: border-box;
    background:
        radial-gradient(120% 70% at 50% 100%, rgba(255,255,255,.5), transparent 72%),
        linear-gradient(180deg, var(--paper-2) 0%, var(--card) 58%, var(--card-warm) 100%);
    border: 0;
    border-radius: var(--vn-r);
    box-shadow: var(--vn-soft);
    overflow: hidden;
}
/* a soft floor line so the cast reads as standing, not floating */
.vn-mid::after {
    content: '';
    position: absolute;
    left: 14px; right: 14px; bottom: 8px;
    height: 2px;
    border-radius: 2px;
    background: var(--accent-soft);
    opacity: .38;
}
/* The backdrop. A soft arch of light behind where the cast stands plus a
   scatter of sparkles: enough to read as a lit little room, so the space
   above the sprites is scenery rather than blank paper. Purely decorative,
   absolutely positioned, cannot affect a single measurement. */
/* ---- PER-CHAPTER ATMOSPHERE (round 7) -----------------------------
   Owner: the five chapters should "feel like different places" even before
   any real backdrop art exists (full backdrops are a later round and are
   deliberately not attempted here).

   --ps-tint is set on the root by js/playstory.js from the chapter's own
   `tint` (sky / butter / mint / lavender / gold) and defaults to the site
   accent, so every OTHER consumer of this stage -- the journey, #/start --
   looks exactly as it did. The tint only ever rides DECORATIVE layers:
   the arch of light, the sparkle field, the valance and the ambient motes.
   Nothing tinted is ever load-bearing, so a bad value is a wrong colour
   and never a broken beat. */
.vn { --ps-tint: var(--accent-soft); }
.vn-sky {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background-image:
        /* the chapter's own colour, washed up from the floor UNDER the arch */
        radial-gradient(90% 62% at 50% 100%, var(--ps-tint), transparent 74%),
        /* the arch of light the cast stands in */
        radial-gradient(58% 74% at 50% 96%, var(--accent-soft), transparent 68%),
        /* sparkles */
        radial-gradient(2.5px 2.5px at 16% 24%, var(--accent-soft), transparent 62%),
        radial-gradient(2px 2px   at 78% 15%, var(--accent-soft), transparent 62%),
        radial-gradient(3px 3px   at 44% 34%, var(--accent-soft), transparent 62%),
        radial-gradient(2px 2px   at 88% 44%, var(--accent-soft), transparent 62%),
        radial-gradient(2.5px 2.5px at 26% 52%, var(--accent-soft), transparent 62%),
        radial-gradient(2px 2px   at 62% 26%, var(--accent-soft), transparent 62%),
        radial-gradient(2px 2px   at 8% 62%,  var(--accent-soft), transparent 62%);
    opacity: .42;
}
/* a scalloped valance along the top edge, so the upper half of the middle
   has something in it at every height */
.vn-mid::before {
    content: '';
    position: absolute;
    left: 0; right: 0; top: 0;
    height: 16px;
    background:
        radial-gradient(12px 14px at 12px 0, var(--ps-tint), transparent 70%) repeat-x;
    background-size: 24px 16px;
    opacity: .55;
    pointer-events: none;
}

/* ---- the ambient layer: a few motes in the chapter's colour ------
   The cheap half of "somewhere else". Scenery, so absolute and
   tap-transparent, exactly like .ps-extra -- it cannot move a measurement
   or eat a tap meant for a game. js/playstory.js keys it by chapter and
   skips the repaint when the key has not changed. */
.ps-amb {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    overflow: hidden;
}
.ps-amb i {
    position: absolute;
    width: 6px; height: 6px;
    border-radius: 50%;
    background: var(--ps-tint);
    opacity: .5;
    animation: psDrift 7.5s ease-in-out infinite;
}
@keyframes psDrift {
    0%, 100% { transform: translate3d(0, 0, 0);        opacity: .18; }
    50%      { transform: translate3d(6px, -14px, 0);  opacity: .55; }
}

/* FLANKING CAST (owner, round 1: "the page also feels empty when both
   mushrooms are at one side. maybe one standing left and one standing
   right?"). Orange Mushroom BOTTOM-LEFT, Violetta BOTTOM-RIGHT, the
   guest's own character BOTTOM-CENTRE once it exists -- the couple
   flanking their guest. Three EQUAL-FLEX sides, so the guest arriving
   after J2 does not nudge either mascot sideways.

   Round 4: the cast is a FIXED-HEIGHT BAND pinned to the floor of the open
   middle, "standing just above the dialogue box". Absolutely positioned,
   so it is immune to anything mounted above it. */
.vn-cast {
    position: absolute;
    z-index: 1;
    left: 8px; right: 8px; bottom: 12px;
    height: var(--vn-cast-h);
    display: flex;
    align-items: flex-end;
}
.vn-side {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    align-items: flex-end;
}
.vn-side--left  { justify-content: flex-start; }
.vn-side--right { justify-content: flex-end; }
/* the guest's side is a COLUMN: the nametag floats over the head of the
   character standing under it (round 5) */
.vn-side--mid {
    flex-direction: column;
    justify-content: flex-end;
    align-items: center;
    gap: 3px;
}

/* ---------------- THE GUEST'S NAME, ON THE CANVAS (round 5) --------
   Owner: after the identity step there is no "welcome [name]" line in the
   dialogue -- "the name lives ON the canvas", and once the character is
   saved it floats "above the avatar's head, like a game nametag".

   Two presentations of one string, and js/vn.js picks by whether there is
   an avatar yet. Both are set with textContent, so a guest-typed name is
   never markup. --vn-tscale is the auto-fit multiplier: a very long name
   steps down instead of overflowing (there is nothing to scroll). */
.vn-name {
    position: absolute;
    z-index: 1;
    left: 12px; right: 12px;
    top: calc(var(--vn-logo-h) + 2px);
    height: var(--vn-name-h);
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    text-align: center;
    white-space: nowrap;
    font-family: var(--serif);
    font-size: calc(clamp(21px, 6vw, 30px) * var(--vn-tscale, 1));
    line-height: 1.1;
    letter-spacing: .01em;
    color: var(--accent-deep);
    pointer-events: none;
    animation: vnFade .45s var(--ease) both;
}
.vn-name[hidden] { display: none; }
/* the floating nametag. A solid chip with a soft shadow -- no outline, same
   rule as every other surface in here. */
.vn-gtag {
    flex: 0 0 auto;
    max-width: 100%;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    padding: 2px 9px;
    background: var(--card-2);
    border: 0;
    border-radius: 999px;
    box-shadow: var(--vn-soft-sm);
    font-family: var(--pixel);
    font-size: calc(var(--px-sm) * var(--vn-tscale, 1));
    line-height: 1.4;
    letter-spacing: .04em;
    color: var(--accent-deep);
    animation: vnFade .45s var(--ease) both;
}
.vn-gtag[hidden] { display: none; }

/* ---------------- speaker emphasis -------------------------------- */
/* the listener dims and settles, the speaker lifts. transform/filter
   only: neither affects layout, so an emphasis change cannot shift a
   sibling. The name tag is still the real "who is talking" signal. */
.vn-slot {
    display: inline-flex;
    align-items: flex-end;
    filter: brightness(.9) saturate(.8);
    transform: translateY(3px) scale(.94);
    transition: transform var(--dur-med) var(--ease), filter var(--dur-med) ease;
}
.vn[data-speaker="vio"]  .vn-slot[data-who="vio"],
.vn[data-speaker="mush"] .vn-slot[data-who="mush"],
.vn[data-speaker="both"] .vn-slot[data-who="vio"],
.vn[data-speaker="both"] .vn-slot[data-who="mush"] {
    filter: none;
    transform: translateY(0) scale(1.06);
}
.vn-slot--guest { filter: none; transform: none; }
.vn-slot--guest.vn-empty { min-width: 0; }

/* gentle idle bob, so a still frame never looks frozen. Uses the
   `translate` property, NOT transform, so it composes with the facing
   flip below and with the emphasis transform on the parent slot. */
.vn-slot .mbox { animation: vnIdle var(--bob-dur, 3.2s) ease-in-out infinite; }
.vn-slot[data-who="vio"] .mbox { animation-delay: .9s; }
.vn-slot--guest .mbox,
.vn-slot--guest img { animation: vnIdle 3.6s ease-in-out infinite; animation-delay: .45s; }
@keyframes vnIdle { 0%, 100% { translate: 0 0; } 50% { translate: 0 -5px; } }

/* FACING (owner, round 4: "the orange mushroom should face right, toward
   the middle"). His frames are drawn facing the viewer's LEFT, i.e. off
   stage from the left-hand slot, so he is mirrored -- and ONLY him.

   Violetta is deliberately left alone: her gaze already drifts to the
   viewer's left, which reads as inward from the RIGHT-hand slot, and
   mirroring her would turn her outward.

   scaleX lives on .mbox (the wrapper mascot() emits), never on .vn-slot:
   the slot's transform is the speaker-emphasis lift, and the idle bob uses
   the `translate` property, so all three compose instead of clobbering. */
.vn-slot[data-who="mush"] .mbox { transform: scaleX(-1); }

/* the guest arriving on stage after J2 */
.vn-enter { animation: vnEnter .55s var(--ease-bounce) both; }
@keyframes vnEnter {
    from { opacity: 0; translate: 0 14px; scale: .8; }
    to   { opacity: 1; translate: 0 0;    scale: 1; }
}

/* mascots hopping for a celebration beat (do:'jump') */
.vn-jump .vn-side--left .vn-slot .mbox,
.vn-jump .vn-side--right .vn-slot .mbox { animation: vnHop .55s ease-in-out 2; }
.vn-jump .vn-side--right .vn-slot .mbox { animation-delay: .12s; }
@keyframes vnHop { 0%, 100% { translate: 0 0; } 45% { translate: 0 -14px; } }

/* ---------------- stage prop (date card) -------------------------
   Absolutely positioned INSIDE the elastic stage row: it can appear and
   disappear without touching a single fixed height. */
.vn-prop {
    position: absolute;
    z-index: 2;
    left: 10px; right: 10px; top: 10px;
    text-align: center;
    font-family: var(--pixel);
    font-size: var(--px-sm);
    color: var(--ink);
    pointer-events: auto;
}
.vn-prop[hidden] { display: none; }
.vn-rise { animation: vnRise .5s var(--ease) both; }
@keyframes vnRise {
    from { opacity: 0; translate: 0 10px; }
    to   { opacity: 1; translate: 0 0; }
}
.vn-card {
    display: inline-block;
    max-width: 100%;
    background: var(--card);
    border: 0;
    border-radius: var(--vn-r);
    box-shadow: var(--vn-soft-sm);
    padding: 8px 12px;
    text-align: center;
}
.vn-card .vn-ct {
    display: block;
    font-family: var(--serif);
    font-size: var(--px-xl);
    line-height: 1.15;
    color: var(--accent-deep);
}
.vn-card .vn-cs {
    display: block;
    font-family: var(--sans);
    font-size: var(--text-xs);
    color: var(--ink-soft);
    margin-top: 2px;
}

/* ---------------- row 2: THE BOTTOM BOX ---------------------------
   Owner, round 4: "put the dialogue at the bottom, just like the home
   screen". The home screen's dialogue IS a card (ui-kit .dlg-panel), and
   its choices sit inside that card under the text -- so the dock wears the
   same chrome and holds both: the sentence, then the fixed button band.

   Fixed height, and the two children split it with flex: the text area
   takes the remainder and the button band is a hard var(--vn-act-h). That
   is what pins the primary button to one y across every beat of every
   scene while the sentence above it changes length.

   ROUND 5: no ink border on it either -- a solid card fill with a soft
   drop shadow (owner: "use solid colour fills with drop shadows instead").
   Everything inside it CLIPS; js/vn.js fits the sentence and the buttons to
   the space instead of scrolling them. */
.vn-dock {
    min-height: 0;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    background: var(--card);
    border: 0;
    border-radius: var(--vn-r);
    box-shadow: var(--vn-soft);
    /* round 6: 9px, not 12 -- a two-line dock cannot spend 24px on padding */
    padding: 9px 13px;
    overflow: hidden;
}
.vn-dlg {
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
}
.vn-dlg .dlg {
    display: block;
    height: 100%;
    padding-top: 0;
    animation: none;          /* dlgIn translates; the zone must not move */
}
.vn-dlg .dlg-portrait { display: none; }
/* the dock already IS the card, so the inner panel is a plain column */
.vn-dlg .dlg-panel {
    position: relative;
    height: 100%;
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;                 /* round 6: the two-line dock is tight */
    background: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    padding: 0;
}
/* the speaker cue: the ui-kit name chip, in normal flow at the top of the
   box. Static rather than straddling the border, because the dock clips
   its overflow and a half-outside chip would be cut.
   Round 5: no ink outline on the chip either -- the accent fill and a soft
   shadow carry it (this is scoped to .vn-dlg; the home screen's own name
   tag keeps its border). */
.vn-dlg .dlg-name {
    position: static;
    top: auto; left: auto;
    flex: 0 0 auto;
    align-self: flex-start;
    border: 0;
    border-radius: var(--vn-r-sm);
    box-shadow: var(--vn-soft-sm);
}
.vn-dlg .dlg-text {
    flex: 1 1 auto;
    width: 100%;
    min-height: 0;
    margin: 0;
    /* ZERO SCROLLBARS (round 5): the sentence CLIPS and js/vn.js's fitFont
       steps --vn-tscale down until it fits, so it never needed to scroll --
       the owner caught this exact box scrolling on the wish beat. */
    overflow: hidden;
    font-size: var(--text-base);
    line-height: 1.5;
}
/* the optional story image (the J2 photo preview) is pinned to the right
   of the zone so it cannot push the sentence around */
.vn-dlg .dlg-fig {
    position: absolute;
    right: 2px; bottom: 0;
    margin: 0;
    z-index: 1;
}
.vn-dlg .dlg-fig img { max-height: 64px; }
.vn-dlg .dlg-next { right: 4px; bottom: 0; }

/* ---------------- the content slot, INSIDE the open middle --------
   Round 4: forms and cards mount in the OPEN MIDDLE, centred, above the
   cast band. Absolutely positioned, so mounting one cannot push the
   dialogue up or the buttons down -- and on a pure-story beat it is empty
   and the middle is just the lit room the owner will decorate.

   pointer-events go away while it is empty: it spans the whole upper
   middle, which is the biggest tap-to-advance target on the screen, and a
   full-bleed invisible div would eat every one of those taps. */
.vn-content {
    position: absolute;
    z-index: 2;
    left: 10px; right: 10px;
    /* the card area is what is left of the canvas BETWEEN the logo (plus the
       guest's nameplate, when there is one) and the cast on the floor */
    top: calc(var(--vn-logo-h) + var(--vn-name-h) + 6px);
    bottom: calc(var(--vn-cast-h) + 6px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 0;
    /* ZERO SCROLLBARS (round 5): this slot used to scroll, and the owner
       caught the character picker doing it. It clips now, and js/vn.js's
       fitZoom shrinks the mounted panel until it FITS -- which is why the
       panel may be centred again without any risk of overflowing past the
       top where a scroll container could not reach it. */
    overflow: hidden;
}
.vn-content:empty { pointer-events: none; }
.vn-content > * { width: 100%; }
.vn-fpanel {
    background: var(--card);
    /* no ink rectangle (round 5) -- fill plus a soft shadow */
    border: 0;
    border-radius: var(--vn-r);
    box-shadow: var(--vn-soft-sm);
    /* owner, round 4: "add more internal spacing/padding in the cards" -- up
       from round 3's flat 10px, and still short enough that the tallest card
       (a tour showcase) lands inside the card area without a clipped edge */
    padding: 13px 14px;
    box-sizing: border-box;
    animation: vnRise .42s var(--ease) both;
}
.vn-fpanel .btn + .btn { margin-top: var(--space-sm); }
/* the option GRID panel keeps the tighter padding: seven cells in two rows
   plus the unfoldable MapleSEA field have to land inside the card area, and
   a clipped bottom edge reads as a bug even though the slot scrolls. */
.vn-fpanel--grid { padding: 9px 10px; }
.vn-fnote {
    font-family: var(--sans);
    font-size: var(--text-xs);
    color: var(--ink-mute);
    text-align: center;
    margin-top: 6px;
}

/* a tutorial preview card: what the feature is and where to find it.
   NO deep link out (owner, round 2: "dont need to do the look at
   schedule part or force them to write a wish, just show them that they
   can"). It is a showcase, not a door. */
/* Sized to LAND INSIDE the open middle's card area (everything above the
   cast band). A taller card still scrolls (nothing is unreachable), but a
   clipped edge reads as a bug, so the icon, the leading and the chip are all
   trimmed to fit at 360-375px wide. */
.vn-tcard { text-align: center; }
.vn-tcard .jy-tico {
    width: 42px; height: 42px;
    margin: 0 auto 8px;
    box-shadow: 0 2px 5px -2px rgba(122,97,73,.35);
}
.vn-tcard .jy-tico svg { width: 22px; height: 22px; }
.vn-tt {
    display: block;
    font-family: var(--serif);
    font-size: var(--px-lg);
    line-height: 1.1;
    color: var(--ink);
}
.vn-tx {
    display: block;
    font-family: var(--sans);
    font-size: var(--text-sm);
    line-height: 1.4;
    color: var(--ink-soft);
    margin-top: 7px;
}
.vn-where {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin-top: 9px;
    padding: 5px 12px;
    background: var(--card-2);
    border: 0;                       /* round 5: fill, not outline */
    border-radius: 999px;
    box-shadow: var(--vn-soft-sm);
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--ink-soft);
}
.vn-tcard .stage-card { margin-top: var(--space-sm); }
/* Journey._inert() wrapper: borrowed markup (Portal.stageCard()) rebuilt as a
   status readout. The tour links nowhere, so nothing inside is clickable --
   the cursor has to say so too. */
.vn-readout { display: block; }
.vn-readout, .vn-readout * { cursor: default; }
.vn-readout .stage-card { text-decoration: none; }
/* The Play card is the one tour card that carries a second block (the live
   stage-games unlock clock, borrowed from Portal.stageCard()). At full size
   that block is 90px plus 24px of margins, which pushes this ONE card past
   the card area and makes it the only beat whose panel scrolls internally.
   Compacting it here is not cosmetic: it is the difference between a card
   that scrolls a little and one that scrolls a lot. */
.vn-tcard .vn-readout .stage-card { margin: 8px 0 0; }
.vn-tcard .vn-readout .sg-trim { display: none; }
.vn-tcard .vn-readout .sg-row { padding: 7px 10px; }
.vn-tcard .vn-readout .sg-t { font-size: var(--px-md); }
.vn-tcard .vn-readout .sg-note,
.vn-tcard .vn-readout .sg-badge { font-size: var(--px-xs); }

/* J3's save-the-date. Round 4: the owner called this type "TOO BIG" -- the
   sizes below (and the overrides at the bottom of the file) bring the title
   to 22-28px and the date chip to 16-20px, and the gap and padding open up so
   it reads as an elegant card rather than a shout.
   (J0's own title card is GONE in round 5: the canvas itself is the hero
   there, logo + date, so there is nothing left to put in a card.) */
.vn-hero {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    /* no padding of its own: .vn-fpanel's round-4 padding is the "more
       internal spacing in the cards" the owner asked for, and a shorthand
       here would silently win over it */
    box-sizing: border-box;
}
.vn-hero .vn-ct {
    display: block;
    font-family: var(--serif);
    font-size: var(--text-3xl);
    line-height: 1.05;
    color: var(--accent-deep);
}
.vn-hero .vn-tx { margin-top: 2px; }

/* The option grids reuse .char-grid / .char-cell from css/games.css so the
   journey's picker looks like the profile editor's picker -- but tighter,
   because all seven roster options have to be visible in the card area
   without the second row falling below the fold. (These overrides are
   scoped to .vn-picks; the profile editor's own grid is untouched.) */
.vn-picks { margin: 0; }
.vn-picks.char-grid {
    /* FOUR columns since round 3 merged the MapleSEA option into this grid as
       a seventh cell: 4x2 keeps every option in the reserved slot, where 3
       columns would have pushed a third row under the fold. */
    grid-template-columns: repeat(4, 1fr);
    gap: 4px;
}
.vn-picks .char-cell { padding: 3px 2px 3px; }
.vn-picks .char-name { font-size: var(--px-xs); line-height: 1.2; }
.vn-cellart {
    display: flex;
    align-items: flex-end;
    justify-content: center;
    min-height: 42px;
}
.vn-row { display: flex; gap: 8px; align-items: stretch; }
.vn-row .field { flex: 1 1 auto; min-width: 0; }
.vn-row .btn { flex: 0 0 auto; }
/* (round 6 retired .vn-prev, the little in-grid MapleSEA thumbnail: the
   preview is a segment of its own now -- see .vn-bprev / .vn-bpart below.) */

/* ---- the "which one are you?" chooser (round 4) ----------------------
   The compact identity door shows a list in exactly one situation: two real
   guests share the typed name. Both rows have to land inside the card area,
   so the borrowed .picker-btn rows are compacted here (the standalone venue
   door's own list is untouched -- this is scoped to .vn). */
.vn #join-results .picker-btn { padding: 6px 10px; gap: 9px; }
.vn #join-msg { margin-top: 4px; }
.vn #join-results .join-ph {
    width: 30px; height: 30px; line-height: 30px;
    font-size: var(--px-sm);
}
.vn #join-results .pk-sub { font-size: var(--text-xs); }

/* ---- THE MATCH-CONFIRM SUB-BEAT (Aug 2026) --------------------------
   "Is this you -- <Name>?" used to eject the guest from the story to the
   #/picker page. It is a sub-beat of the identity beat now: the mascot
   asks in the dock (Portal._joinCompact -> opts.say -> VN api.say) and
   this is the one card that answers, standing in the same content slot
   the name field just left. */
.join-confirm {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 6px 0 2px;
}
.join-ph--lg {
    width: 56px; height: 56px; line-height: 56px;
    border-radius: 50%;
    text-align: center;
    font-family: var(--display);
    font-weight: 600;
    font-size: 24px;
    color: var(--accent-deep);
    background: color-mix(in srgb, var(--accent-soft) 40%, var(--card));
}
.jc-name {
    font-family: var(--display);
    font-weight: 600;
    font-size: clamp(20px, 5.5vw, 26px);
    line-height: 1.15;
    color: var(--ink);
    text-align: center;
}
.jc-sub {
    font-family: var(--sans);
    font-weight: 600;
    font-size: var(--px-xs);
    letter-spacing: var(--track-eyebrow);
    text-transform: uppercase;
    color: var(--dim);
}

/* ---- (round 7 retired the MapleSEA option entirely) ------------------
   The peer cell (.vn-mcell) and the IGN segment panel (.vn-msea) are both
   GONE, along with round 5's little in-grid thumbnail (.vn-prev) before
   them: character creation is presets only now (owner blessed -- see
   VN_STORY_BRIEF.md, and the ROUND 7 block in js/journey.js). Nothing here
   replaces them; the grid simply spends the freed room, below.
   tests/vn_test.js asserts none of these three selectors comes back. */

/* ---- SEGMENT B: the chosen look, alone and large (round 6) -----------
   Owner: "two previews of how my char looks like, squashing them all". There
   is exactly ONE preview now, it has the card to itself, and it is rendered
   big (156px art from AvatarKit.imgHtml) with its caption under it. */
.vn-bprev { text-align: center; }
.vn-bpart {
    display: flex;
    align-items: flex-end;
    justify-content: center;
    min-height: 92px;
    animation: vnRise .35s var(--ease) both;
}
.vn-bprev .vn-fnote { margin-top: 8px; }
.vn-bprev .btn { margin-top: 9px; }
/* the "◀ Back" control is secondary: it reads quieter than the commit above
   it, and it is deliberately full-width so the tap target is unmissable */
.vn-actions .vn-back { font-size: clamp(14px, 3.8vw, 16px); opacity: .92; }

/* ---------------- the actions band, INSIDE the bottom box ---------
   Every choice button, every panel CTA and the default "Next ▶" land here,
   TOP aligned in a band of a fixed height at the bottom of the dock --
   which is where the home screen puts .dlg-choices. So the primary control
   is at an identical y on every beat of every scene, and there is ALWAYS
   one there (js/vn.js showNextBtn).

   The two children are mutually exclusive; :empty collapses the unused one
   so whichever is in play starts at the top of the band. */
.vn-actions {
    flex: 0 0 var(--vn-act-h);
    min-height: 0;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    /* ZERO SCROLLBARS (round 5): a button stack that does not fit is fitted
       by js/vn.js (fitZoom), never scrolled -- a scrolled button band is how
       a second button ends up under an invisible fold. */
    overflow: hidden;
    margin-top: 8px;
}
.vn-actions > .vn-act:empty { display: none; }
.vn-actions > .dlg-choices:empty { display: none; }
.vn-act { display: flex; flex-direction: column; gap: 8px; }
.vn-act .btn { margin: 0; }

/* ---- BIG BUTTONS (round 7) ---------------------------------------
   Owner, twice now, on the actions band: a "glaring no-no". These are read
   in a dim room and tapped with a thumb, standing up, holding a drink.

   --vn-btn-h is the whole control: min-height rather than height, so a
   two-line label grows the button instead of clipping it, and the budget
   above reserves room for TWO of these plus their gap so js/vn.js's
   fitZoom never has cause to scale the stack down (which is what was
   silently undoing every size here before).

   Scoped to .vn-actions, so the profile editor, the home screen and the
   arcade keep their own button metrics. Both control families get it: the
   journey mounts .btn, the story mounts .dlg-choice. */
.vn-actions .btn,
.vn-actions .dlg-choice {
    min-height: var(--vn-btn-h);
    padding: 14px 16px;
    line-height: 1.25;
}
/* the relocated Dialogue choice list: no top margin, it is the top of the
   row now, not a tail on a panel */
.vn-actions .dlg-choices { margin-top: 0; }
/* a "not yet" button has to LOOK not-yet, or the guest taps a dead control */
.vn-actions .btn:disabled { opacity: .45; }

/* ---------------- SOFT CHROME: every control in the stage ---------
   Owner, round 5: "remove the hard outline rectangles ... buttons must
   still read as tappable." So the controls keep their fill (the accent
   gradient on a primary, paper on a secondary) and trade the ink border
   for a drop shadow plus the press-down they already had. Scoped to .vn,
   so the profile editor and the home screen are untouched. */
.vn .btn,
.vn-actions .btn,
.vn-actions .dlg-choice,
.vn .char-cell,
.vn .field,
.vn .picker-btn,
.vn .window {
    border: 0;
    border-radius: var(--vn-r-sm);
    box-shadow: var(--vn-soft-sm);
}
/* a pressed control loses its shadow and sinks -- the whole tappable cue */
.vn .btn:active,
.vn-actions .dlg-choice:active,
.vn .char-cell:active,
.vn .picker-btn:active { box-shadow: none; transform: translateY(2px); }
/* the chosen cell reads by FILL now that there is no border to thicken */
.vn .char-cell.on {
    background: var(--accent-soft);
    box-shadow: var(--vn-soft);
}
/* the field needs to read as an input without an outline: a recessed fill */
.vn .field {
    background: var(--paper-2);
    box-shadow: inset 0 2px 5px -3px rgba(36,31,46,.45), var(--vn-soft-sm);
}
.vn .field:focus { outline: 2px solid var(--accent-soft); outline-offset: 1px; }
.vn .window { background: none; box-shadow: none; padding: 0; }
/* the borrowed decorations that still carried an ink rectangle: the tour
   card's icon tile (journey.css) and the photo drop zone's dashed box
   (games.css). Same treatment, same scope. */
.vn .jy-tico { border: 0; border-radius: var(--vn-r-sm); box-shadow: var(--vn-soft-sm); }
.vn .photo-up {
    border: 0;
    border-radius: var(--vn-r);
    background: var(--card-2);
    box-shadow: inset 0 0 0 2px rgba(36,31,46,.06), var(--vn-soft-sm);
}
/* choices fade in where they are -- opacity only, never a translate */
.vn-actions .dlg-choices:not(.dlg-choices--wait) { animation: vnFade .3s var(--ease) both; }
@keyframes vnFade { from { opacity: 0; } to { opacity: 1; } }

/* ---------------- MOBILE: the soft keyboard ----------------------
   The one place the fixed heights are allowed to change, because the
   visual viewport really did collapse. Driven from js/vn.js (.vn--kbd).
   The stage gives up its room first (it is the elastic row), then the
   reserved heights step down so the field and its button both stay on
   screen at 360x640. */
.vn--kbd {
    /* the CAST hides, not the middle row. The mounted field lives inside the
       open middle now, so collapsing that row would take the guest's input
       with it. Instead: logo stands down, the middle keeps its remainder and
       hands its whole height to the panel, and the dock shrinks. Same budget
       rule as always -- fractions of the MEASURED space, which the keyboard
       just shrank. */
    --vn-logo-h: 0px;
    --vn-name-h: 0px;
    --vn-dock-h: clamp(150px, calc(var(--vn-avail) * .46), 300px);
    --vn-act-h: clamp(58px, calc(var(--vn-avail) * .17), 84px);
    /* round 7: the big thumb target CANNOT apply with a keyboard up -- the
       pair goes side by side in here (below) and 58px each would eat the
       field. 44px is still the accessibility floor. */
    --vn-btn-h: 44px;
    --vn-gaps: 8px;
    row-gap: 4px;
}
.vn--kbd .vn-logo { display: none; }
.vn--kbd .vn-name { display: none; }
.vn--kbd .vn-mid {
    border-width: 0;
    background: none;
    box-shadow: none;
}
.vn--kbd .vn-mid::before,
.vn--kbd .vn-mid::after { display: none; }
.vn--kbd .vn-cast { visibility: hidden; }
/* with the cast out of sight the panel gets the whole middle */
.vn--kbd .vn-content { top: 0; bottom: 0; left: 2px; right: 2px; }
.vn--kbd .vn-dock { padding: 8px 10px 9px; }
.vn--kbd .vn-dlg .dlg-text { font-size: var(--text-sm); }
/* Two STACKED buttons do not fit in a 60px row, and .vn-actions scrolls --
   so the second one ("Never mind" under "Use my character ▶", "Skip for now"
   under "That is me ▶") sat below an invisible fold with the keyboard up.
   Side by side in compact mode: both reachable without scrolling, primary
   on the leading edge. */
.vn--kbd .vn-act { flex-direction: row; flex-wrap: nowrap; gap: 6px; align-items: stretch; }
.vn--kbd .vn-act .btn {
    flex: 1 1 0;
    min-width: 0;
    padding: 10px 6px;
    font-size: var(--text-sm);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* the relocated Dialogue choice list keeps stacking (choices are whole
   sentences, not two-word CTAs); only the button pair goes side by side */
.vn--kbd .vn-actions .dlg-choices { flex-direction: column; }

/* Short viewports (landscape phones, small laptops). The fractions already
   scale, so all these do is lower the px FLOORS -- four floors that add up to
   more than the screen are the one way the budget can still overflow. */
@media (max-height: 660px) {
    .vn {
        --vn-logo-h: clamp(42px, calc(var(--vn-mid-max) * .16), 72px);
        /* the dock still takes a BIGGER share on a short screen than on a tall
           one -- the button band has a hard floor (below), so every pixel the
           dock gives up comes out of the sentence, and a one-line dialogue box
           is the thing that makes a short phone feel broken. Round 6 drops the
           share (.43 -> .34) because the sentence is now two short lines: at
           360x640 that is 210px of dock holding 205px of content.

           ROUND 7 arithmetic at 360x640 (--vn-avail ~= 600px):
             --vn-btn-h        52   the owner's floor, still comfortable
             two + 8px gap    112   what the band must hold
             --vn-act-h  .19  114   = 600 * .19
             dock, non-act     96   = 16 padding + 22 chip + 4 + ~46 text + 8
             --vn-dock-h .36  216   >= 96 + 114, with 6px spare */
        --vn-dock-h: clamp(224px, calc(var(--vn-avail) * .36), 252px);
        /* two stacked buttons at --vn-btn-h plus their gap, with headroom --
           the second one landing below a fold is the whole bug */
        --vn-act-h:  clamp(112px, calc(var(--vn-avail) * .19), 130px);
        --vn-btn-h: 52px;
        /* and the cast band gives way so a mounted card still fits above it */
        --vn-cast-h: clamp(80px, calc(var(--vn-mid-max) * .34), 150px);
    }
    /* the nameplate and the cast both give ground on a short screen, so the
       character picker still lands whole in the card area at 360x640 -- but
       round 6 keeps the cast band above js/vn.js's smallest sprite step, so
       "gives ground" can never mean "turns into an icon" again. */
    .vn--named { --vn-name-h: clamp(25px, calc(var(--vn-mid-max) * .09), 38px); }
    .vn--panel { --vn-cast-h: clamp(84px, calc(var(--vn-mid-max) * .22), 112px); }
    .vn-dock { padding: 8px 11px; }
}
@media (max-height: 540px) {
    .vn {
        --vn-logo-h: 0px;                  /* the logo is the first thing to go */
        --vn-name-h: 0px;
        /* a landscape phone (~460px of budget) cannot carry a 58px pair AND a
           readable sentence, so the target steps to the accessibility floor
           rather than letting fitZoom shrink it past that: 2 x 44 + 8 = 96,
           against a 101px band inside a 193px dock. */
        --vn-dock-h: clamp(190px, calc(var(--vn-avail) * .42), 224px);
        --vn-act-h:  clamp(100px, calc(var(--vn-avail) * .22), 118px);
        --vn-btn-h: 44px;
        --vn-cast-h: clamp(72px, calc(var(--vn-mid-max) * .34), 120px);
    }
    .vn:not(.vn--hero) .vn-logo { display: none; }
    .vn:not(.vn--hero) .vn-name { display: none; }
    .vn--panel { --vn-cast-h: clamp(76px, calc(var(--vn-mid-max) * .20), 96px); }
    .vn-sky { display: none; }
}

/* ---------------- confetti (do:'confetti') ------------------------ */
.vn-fx {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
    z-index: 5;
    display: none;
}
.vn-fx.on { display: block; }
.vn-fx i {
    position: absolute;
    top: -14px;
    width: 8px; height: 12px;
    border-radius: 2px;
    animation-name: vnFall;
    animation-timing-function: linear;
    animation-iteration-count: infinite;
}
@keyframes vnFall {
    0%   { transform: translateY(-20px) rotate(0deg);   opacity: 0; }
    12%  { opacity: 1; }
    100% { transform: translateY(105vh) rotate(540deg); opacity: .9; }
}

/* ---------------- reduced motion ---------------------------------
   Every animation here is decoration over an already-fixed layout, so
   turning them all off leaves a completable, non-shifting journey. */
@media (prefers-reduced-motion: reduce) {
    .vn-slot { transition: none; }
    .vn-slot .mbox,
    .vn-slot--guest .mbox,
    .vn-slot--guest img,
    .vn-enter,
    .vn-rise,
    .vn-fpanel,
    .vn-name,
    .vn-gtag,
    .vn-jump .vn-side--left .vn-slot .mbox,
    .vn-jump .vn-side--right .vn-slot .mbox,
    .vn-actions .dlg-choices:not(.dlg-choices--wait) { animation: none; }
    .vn-fx { display: none; }
}

/* =====================================================================
   ROUND 3: READ IT AT ARM'S LENGTH
   Owner, round 3: "bigger fonts everywhere", the date "is so small". The
   journey is read on a phone held at arm's length in a dim ballroom, so
   every string in it steps up. Scoped to .vn / .jy--vn so nothing else on
   the site changes size. The zones are fixed-height and scroll internally,
   so bigger type cannot re-introduce the clipping -- it just fills the
   space the removed bottom link gave back.
   ===================================================================== */
/* --vn-tscale is the AUTO-FIT multiplier js/vn.js sets inline on this very
   element (round 5). At 1 -- the normal case -- this is exactly the round-3
   size the owner signed off on; on the one beat whose line is too long for
   the dock it steps down instead of scrolling. */
.vn-dlg .dlg-text {
    font-size: calc(clamp(17px, 4.7vw, 22px) * var(--vn-tscale, 1));
    line-height: 1.42;
}
.vn-dlg .dlg-name { font-size: var(--px-sm); }
.vn--kbd .vn-dlg .dlg-text { font-size: calc(clamp(15px, 4vw, 18px) * var(--vn-tscale, 1)); }

/* the buttons: choices and CTAs both. Round 7 lifts the FLOOR to 16px --
   the old clamp bottomed out at 15px on a 360px phone, which is where the
   owner was reading them. */
.vn-actions .btn,
.vn-actions .dlg-choice { font-size: clamp(16px, 4.4vw, 18px); }
.vn--kbd .vn-act .btn { font-size: var(--text-sm); }

/* the showcase / tutorial cards */
.vn-tt { font-size: clamp(19px, 5vw, 24px); }
.vn-tx { font-size: clamp(14px, 3.9vw, 17px); line-height: 1.42; }
.vn-where { font-size: var(--px-sm); padding: 4px 11px; }
.vn-fnote { font-size: clamp(13px, 3.5vw, 15px); }
.vn-fpanel .quiz-count { font-size: clamp(14px, 3.9vw, 17px); }
.vn-picks .char-name { font-size: clamp(11px, 3vw, 13px); }

/* THE CENTRE CARDS (owner, round 4: "the big centre text is too big now --
   reduce it to something elegant"). Title 22-28px, date chip 16-20px. Round
   3 had pushed these to 36/48px, which was right when they were the only
   thing on a bare page and is a shout now that they sit in a card in the
   open middle. The DIALOGUE size is untouched -- the owner said that one was
   right. */
.vn-hero .vn-ct { font-size: clamp(22px, 6vw, 28px); letter-spacing: .01em; }
.vn-hero .vn-tx { font-size: clamp(14px, 3.9vw, 17px); }
.vn-hero .vn-where { font-size: clamp(16px, 4.3vw, 20px); }

/* the MapleSEA IGN field has to stay >=16px or iOS zooms the page on focus,
   which would fight the pinned frame */
.vn .field { font-size: max(16px, clamp(16px, 4.2vw, 18px)); }

/* ---------------- panels sized to the card area --------------------
   NOTHING scrolls any more (round 5), so a panel that does not fit is
   FITTED by js/vn.js -- and a panel fitted at 0.6 is a panel nobody can
   read. These sizes are what keep the fit at or near 1.0 on the two
   viewports the owner checks: the photo drop zone takes what the card area
   actually has, and the picker's cells are tight enough that all seven
   bodies plus the MapleSEA tile land in two rows. */
.vn .photo-up {
    height: auto;
    min-height: clamp(72px, calc(var(--vn-mid-max) - var(--vn-logo-h)
        - var(--vn-name-h) - var(--vn-cast-h) - 92px), 132px);
    gap: 6px;
}
.vn .photo-up .pu-ico { width: 30px; height: 30px; }
/* the seven-cell grid, trimmed so both rows land in the card area at
   360x640 as well as 390x844 (owner, round 5: the picker must not scroll) */
.vn-cellart { min-height: 28px; }
.vn-picks.char-grid { gap: 3px; }
.vn-picks .char-cell { padding: 2px 1px; }
.vn-picks .char-name { font-size: clamp(10px, 2.8vw, 12px); line-height: 1.15; }

/* =====================================================================
   ROUND 6: THE BODY PICKER, ALONE AND BIGGER  (grown again in round 7)

   Owner: "selecting the characters and previewing can be in their
   standalone mini segment ... one at a time is fine." The grid no longer
   shares the card with an IGN field or a thumbnail, so it can spend the
   whole card area: THREE columns instead of four, and real padding.

   ROUND 7 removed the "My MapleSEA character" cell, which is what finally
   paid for full-size art. Six roster cells in three columns is TWO rows
   where seven was three, so the art goes 56 -> 64px.

   On a SHORT viewport (the max-height branch below) it stays at four
   columns -- that is the layout that fits a 360x640 card area, and it is
   not worth re-risking on a phone nobody can re-test on the night -- but
   its cells grow too (46 -> 52px).

   BOTH numbers are mirrored in Journey._segArt(), which is the authority:
   AvatarKit bakes the art size as inline px (a photo_char composite
   positions its head off that number), so a CSS cap cannot shrink it
   without breaking the composite. Change them together.
   ===================================================================== */
.vn-picks--solo.char-grid { grid-template-columns: repeat(3, 1fr); gap: 7px; }
.vn-picks--solo .char-cell { padding: 7px 3px 6px; }
.vn-picks--solo .vn-cellart { min-height: 64px; }
.vn-picks--solo .char-name { font-size: clamp(11px, 3.1vw, 14px); line-height: 1.2; }
@media (max-height: 720px) {
    .vn-picks--solo.char-grid { grid-template-columns: repeat(4, 1fr); gap: 5px; }
    .vn-picks--solo .char-cell { padding: 5px 2px; }
    /* matches Journey._segArt()'s short-viewport cell step (52px) */
    .vn-picks--solo .vn-cellart { min-height: 52px; }
    .vn-picks--solo .char-name { font-size: clamp(10px, 2.9vw, 12px); }
}
/* the grid panel can breathe now that it is the only thing in the card */
.vn-fpanel--grid { padding: 11px 11px; }

/* =====================================================================
   ROUND 6: COMPACTER SHOWCASE CARDS, SO THE CAST STAYS BIG

   Owner: "right now when introducing different parts of the site, all the
   characters get shrunked too much." The fix is not a smaller cast, it is a
   smaller CARD: the J5 tour cards and the J4 wish card carry one clause of
   copy each now (js/journey.js), so the icon, the leading and the chip can
   all come in and the cast band keeps its full height above the dock.
   ===================================================================== */
.vn-tcard .jy-tico {
    width: 36px; height: 36px;
    margin: 0 auto 6px;
}
.vn-tcard .jy-tico svg { width: 19px; height: 19px; }
.vn-tcard .vn-tt { font-size: clamp(17px, 4.5vw, 21px); }
.vn-tcard .vn-tx { margin-top: 5px; font-size: clamp(13px, 3.6vw, 16px); }
.vn-tcard .vn-where { margin-top: 7px; }
.vn-content .vn-tcard { padding: 11px 12px; }

/* =====================================================================
   PLAYSTORY -- the Play tab as ONE guided story (js/playstory.js)

   M1.5, owner's feedback: "i want it to be a guided story with animations
   (i will add in more sprites), then go chapter by chapter. the games Are
   still minigames within a page within the page and feels disjointed",
   plus "keep in mind the visual size, having the user character in frame".

   Five halves (the file is the same shape as the JS):

     1. THE PAGE. #/games gets exactly the treatment #/start gets, for
        exactly the same reasons (see the note at the top of this file):
        no header, no nav, no #app-content padding AND no transition on it
        (200ms of extra height is enough to let the whole fixed composition
        scroll), and the document clipped so no stray descendant can
        re-introduce a scroll under the fixed rows.

        The HEADER is the one element a stylesheet cannot win on its own:
        Portal.route sets an INLINE display on it for every page that is
        not the picker or the journey. Hence !important -- and hence
        leaving PlayStory is just removing the body class, because the
        inline value is already the correct one.

     2. THE FRAME. .jy--ps is a .jy--vn frame (every height, pin and
        measurement above applies unchanged) plus a one-line footer that
        carries the crumb AND the Chapters chip -- replay and chapter jump
        without a landing page.

     3. THE TRAVEL TRANSITION. The animated interstitial between two
        chapters: the cast walks off, the canvas pans and lifts, then the
        next chapter's cast walks in. Motion only -- nothing here moves a
        fixed row, and reduced motion drops all of it.

     4. THE EXTRA SPRITE LAYER (.ps-extra / .ps-actor). Named actors from
        PlayStory.SPRITES standing on the canvas floor -- the owner's cats
        for tip-toe, and whatever he sends next. Sized as a FRACTION of the
        canvas so a new sprite is never right on one phone only.

     5. THE GAMES. Two mounts: .ps-nstage, a TRANSPARENT box for a native
        game (the cast stays visible behind it, the game is the scene), and
        .ps-stage, the blended iframe box for a booth with no native port --
        no border, no dark rectangle, paper-toned boot veil. After round 8
        all four booths in the story are native, so .ps-stage is the
        courtesy path for whatever gets added next.

     6. THE THREE ROUND-8 GAMES (.pg-*). The pairing board, Treats &
        Retreat and the Proposal Ring, all on the .ps-nstage mount. Same
        rule as the masher: every size is an inline style off the measured
        canvas, and the rules here only say what a piece looks like.
   ===================================================================== */
body.play-vn { overflow: hidden; }
/* ...AND THE BROWSER'S OWN OVERSCROLL GESTURES GO WITH IT. `overflow: hidden`
   stops the document scrolling; it does NOT stop Chrome on Android reading a
   downward drag on an unscrollable page as PULL-TO-REFRESH. Chapter 2 asks
   the guest to hold a finger down for several seconds at a time, which is
   exactly that gesture with the timing of a stationary hold -- and the cost is
   not a lost hold, it is a page RELOAD in the middle of a chapter. The
   journey's start page carries the same exposure (one fixed, unscrollable
   composition, long presses on it), so it is named here beside the story. */
body.play-vn,
body[data-page="start"] { overscroll-behavior: none; }
/* the spec reads this off the ROOT element, and only Chrome is generous about
   propagating it up from body. Kept as its OWN rule on purpose: an engine that
   does not know :has() drops the whole ruleset it appears in, and dropping
   this one costs nothing that the rule above has not already tried. */
:root:has(body.play-vn),
:root:has(body[data-page="start"]) { overscroll-behavior: none; }
body.play-vn #app-content {
    padding: 0;
    padding-bottom: 0;
    transition: none;
}
body.play-vn #app-nav { display: none; }
body.play-vn #app-header { display: none !important; }
/* the guide dock pads #app-content off body.dock-open; there is no nav to
   sit above on a full-bleed stage, so that reservation goes too */
body.play-vn.dock-open #app-content { padding-bottom: 0; }
body.play-vn #buddies { display: none; }

/* ---------------- THE VN OWNS THE SCREEN --------------------------
   The pixel chapter bar along the bottom ("CH 1 · THE ARRANGEMENT
   ☰ CHAPTERS") is RETIRED -- it was the collision the owner called the
   play tab's mess, and the audit measured five font families on this one
   screen because of it. The footer is empty now and gives its pixels
   back to the canvas.

   In its place: ONE quiet eyebrow pill in the frame's top-left corner,
   opposite the exit ✕, in exactly the same voice as every other eyebrow
   on the site. It is still the only navigation on the page. */
.jy--ps .jy-foot { padding-top: 0; min-height: 0; }

/* the chapter name is still written for assistive tech; on screen the
   story introduces itself, which is what a story is for */
.jy--ps .ps-crumb {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0; border: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
}
.ps-pill {
    position: absolute;
    top: max(6px, env(safe-area-inset-top));
    left: 6px;
    z-index: 8;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    font-family: var(--sans);
    font-weight: 600;
    font-size: var(--px-xs);
    letter-spacing: var(--track-eyebrow);
    text-transform: uppercase;
    color: var(--ink-soft);
    background: color-mix(in srgb, var(--card) 78%, transparent);
    border: 1px solid var(--line);
    border-radius: var(--radius-pill);
    box-shadow: none;
    padding: 0 14px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: color var(--dur-med);
}
.ps-pill:active { color: var(--accent-deep); }
.ps-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* the crest must clear BOTH corner controls */
.jy--ps .vn-logo { padding-left: 96px; padding-right: 52px; }

/* ---------------- the Chapters overlay ----------------------------
   A compact sheet over the stage. NOT a scene and NOT a page: opening it
   neither destroys nor advances the story underneath, which is what lets
   it be reached from the result card as well as from the chip. */
.ps-ov {
    position: absolute;
    inset: 0;
    z-index: 40;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 16px 12px;
    background: rgba(40, 32, 46, .38);
    backdrop-filter: blur(3px);
    -webkit-backdrop-filter: blur(3px);
    opacity: 0;
    transition: opacity .18s var(--ease);
}
.ps-ov.on { opacity: 1; }
.ps-ov[hidden] { display: none; }
.ps-sheet {
    width: 100%;
    max-width: 340px;
    max-height: 100%;
    /* ZERO SCROLLBARS, ANYWHERE, EVER (owner, round 5) covers the sheet too:
       it CLIPS, and it is sized never to need to. Six chapter rows plus a
       header and one button is ~290px; the short-viewport rule at the bottom
       of this block shaves the rows rather than introducing the one scroll
       container this stylesheet deliberately does not have. */
    overflow: hidden;
    background: var(--card);
    border-radius: var(--vn-r);
    box-shadow: var(--vn-soft);
    padding: 10px 11px 11px;
    animation: psSheet .24s var(--ease-bounce) both;
}
@keyframes psSheet {
    from { transform: translateY(14px) scale(.97); opacity: 0; }
    to   { transform: none; opacity: 1; }
}
.ps-sh {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 8px;
}
.ps-st {
    flex: 1 1 auto;
    font-family: var(--sans);
    font-weight: 600;
    font-size: var(--px-xs);
    letter-spacing: var(--track-eyebrow);
    text-transform: uppercase;
    color: var(--dim);
}
.ps-sx {
    flex: 0 0 auto;
    width: 32px; height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: 1px solid var(--line);
    border-radius: 50%;
    color: var(--dim);
    font-size: 13px;
    cursor: pointer;
}
.ps-sx:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* CHAPTER SELECT -- a clean vertical list, never a game-lobby grid.
   Row: eyebrow number, Cormorant title, one Quicksand tease, and a quiet
   marker (rose tick / hairline lock). No coloured left rail, no BEST
   chip at the end of every line, nothing pixel. */
.ps-list { display: flex; flex-direction: column; }
.ps-row-go {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 11px 4px;
    background: none;
    border: 0;
    border-bottom: 1px solid var(--line);
    border-radius: 0;
    text-align: left;
    font: inherit;
    color: var(--ink);
    cursor: pointer;
    transition: background var(--dur-med);
}
.ps-row-go:last-child { border-bottom: 0; }
.ps-row-go:active { background: var(--card-2); }
.ps-row-go:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ps-row-go.at .ps-rt { color: var(--accent-deep); }
.ps-row-go.locked { cursor: default; opacity: .55; }
.ps-rtx { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.ps-rn {
    font-family: var(--sans);
    font-weight: 600;
    font-size: var(--px-xs);
    letter-spacing: var(--track-eyebrow);
    text-transform: uppercase;
    color: var(--dim);
}
.ps-rt {
    font-family: var(--display);
    font-weight: 600;
    font-size: clamp(16px, 4.4vw, 19px);
    line-height: 1.2;
    color: var(--ink);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.ps-rs {
    font-family: var(--sans);
    font-size: var(--text-xs);
    color: var(--dim);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.ps-rm { flex: 0 0 auto; display: flex; align-items: center; }
.ps-tick { font-style: normal; font-size: 15px; color: var(--rose); }
/* a hairline padlock, drawn -- ICONS has none and a filled badge would be
   louder than the row it sits on */
.ps-lock {
    position: relative;
    width: 11px; height: 9px;
    border: 1.2px solid var(--dim);
    border-radius: 2px;
    display: block;
}
.ps-lock::before {
    content: '';
    position: absolute; left: 50%; top: -6px;
    width: 7px; height: 7px;
    margin-left: -3.5px;
    border: 1.2px solid var(--dim);
    border-bottom: 0;
    border-radius: 4px 4px 0 0;
}
.ps-more-btn { margin-top: 12px; }

/* A SHORT VIEWPORT SHAVES THE SHEET, it does not scroll it: a landscape
   phone, or a portrait one with the browser chrome expanded, still has to
   show all six chapters and the Extras button. */
@media (max-height: 620px) {
    .ps-row-go { padding: 7px 4px; }
    .ps-rs { display: none; }           /* the tease is the first to go */
    .ps-more-btn { margin-top: 8px; }
    .ps-cards { display: none; }        /* Extras keeps the facts + the board */
}

/* the Extras panel behind the overlay's own row: the facts, the
   scoreboard, and the two live cards (the big-screen door and Kahoot) */
.ps-more { display: flex; flex-direction: column; gap: 9px; }
.ps-frow { display: flex; align-items: center; gap: 8px; }
.ps-rl {
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--ink-soft);
    flex: 0 0 auto;
}
.ps-facts { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; }
.ps-fact {
    width: 30px; height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 30px;
    padding: 0;
    background: var(--card-2);
    border: 0;
    border-radius: 50%;
    box-shadow: var(--vn-soft-sm);
    cursor: pointer;
    color: var(--gold, var(--accent));
}
.ps-fact svg { width: 15px; height: 15px; }
.ps-fact.got { background: var(--card); }
.ps-board {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 7px;
    margin: 0;
}
.ps-board svg { width: 15px; height: 15px; }
.ps-cards { display: flex; flex-direction: column; gap: 7px; }
/* borrowed markup (Portal.stageCard / liveCard) compacted into the sheet.
   NB: unlike the journey's tour these stay LIVE -- Extras is where links
   out are allowed, so the stage-games door and Kahoot's Join both work. */
.ps-cards .stage-card,
.ps-cards .live-card { margin: 0; animation: none; }
.ps-cards .sg-trim { display: none; }
.ps-cards .sg-row { padding: 6px 9px; }
.ps-cards .sg-t { font-size: var(--px-md); }
.ps-cards .sg-note,
.ps-cards .sg-badge { font-size: var(--px-xs); }
.ps-cards .live-card { padding: 8px 10px; }
.ps-cards .live-card .lc-note { display: none; }
.ps-cards .live-card .btn { margin-top: 6px; }

/* ---------------- THE TRAVEL TRANSITION ---------------------------
   Owner: "a guided story with animations ... then go chapter by chapter".
   The seam between two chapters is a real animated interstitial:
   .vn--travel walks the cast off and lifts the canvas, .vn--arrive walks
   the next chapter's cast back on. js/playstory.js adds and removes both;
   everything here is motion on absolutely-positioned children, so no fixed
   row can move and no measurement can change. */
@keyframes psOffL {
    from { transform: translateX(0);     opacity: 1; }
    to   { transform: translateX(-150%); opacity: 0; }
}
@keyframes psOffR {
    from { transform: translateX(0);    opacity: 1; }
    to   { transform: translateX(150%); opacity: 0; }
}
@keyframes psOffM {
    from { transform: translateY(0);   opacity: 1; }
    to   { transform: translateY(40%); opacity: 0; }
}
@keyframes psInL {
    from { transform: translateX(-150%); opacity: 0; }
    to   { transform: translateX(0);     opacity: 1; }
}
@keyframes psInR {
    from { transform: translateX(150%); opacity: 0; }
    to   { transform: translateX(0);    opacity: 1; }
}
@keyframes psInM {
    from { transform: translateY(30%); opacity: 0; }
    to   { transform: translateY(0);   opacity: 1; }
}
/* the canvas itself: a soft pan + wash, so the BACKDROP changes too and the
   transition reads as travelling somewhere rather than sprites sliding */
@keyframes psPan {
    from { transform: translateX(0)     scale(1);    opacity: .42; }
    60%  { transform: translateX(-6%)   scale(1.06); opacity: .1; }
    to   { transform: translateX(0)     scale(1);    opacity: .42; }
}
@keyframes psWash {
    from { opacity: 0; }
    45%  { opacity: .85; }
    to   { opacity: 0; }
}
.vn--travel .vn-side--left  { animation: psOffL .5s var(--ease) both; }
.vn--travel .vn-side--right { animation: psOffR .5s var(--ease) both; }
.vn--travel .vn-side--mid   { animation: psOffM .5s var(--ease) both; }
.vn--travel .vn-sky         { animation: psPan 1.1s var(--ease) both; }
.vn--travel .ps-extra       { animation: psOffR .5s var(--ease) both; }
/* the wash rides on the canvas's own decorative layer, so it needs no
   element of its own and cannot be left behind by a torn-down scene */
.vn--travel .vn-mid::before { animation: psWash 1.1s var(--ease) both; }
.vn--arrive .vn-side--left  { animation: psInL .55s var(--ease-bounce) both; }
.vn--arrive .vn-side--right { animation: psInR .55s var(--ease-bounce) both; }
.vn--arrive .vn-side--mid   { animation: psInM .5s var(--ease-bounce) both; }
.vn--arrive .ps-extra       { animation: psInR .6s var(--ease-bounce) both; }

/* ---------------- THE EXTRA SPRITE LAYER --------------------------
   The owner's own sprites (PlayStory.SPRITES), standing on the canvas
   floor beside the cast: "i will give you the cats for the tip toe".

   Scenery, so: absolutely positioned (cannot affect a measurement),
   pointer-events off (cannot eat a tap meant for a game), and sized as a
   FRACTION of the canvas by the inline height js/playstory.js writes --
   which is what makes a newly-dropped sprite the right size on every
   screen instead of just on the one it was drawn against. */
.ps-extra {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
}
.ps-actor {
    position: absolute;
    bottom: 14px;                     /* the same floor line the cast stands on */
    width: auto;
    transform: translateX(-50%);
    image-rendering: pixelated;
    filter: drop-shadow(0 6px 8px rgba(36, 31, 46, .28));
    animation: vnIdle 4.2s ease-in-out infinite;
}

/* ---------------- a game, mounted on the stage --------------------
   The game gets the CANVAS, not a sheet. Three things buy it height:
   the dock drops to a single hint line, the actions band goes away
   entirely (the game is the only control surface while it runs), and the
   crest goes COMPACT but stays -- the owner asked for it to remain
   visible during play. */
.vn--game {
    --vn-dock-h: clamp(60px, calc(var(--vn-avail) * .11), 88px);
    --vn-act-h: 0px;
    --vn-logo-h: clamp(40px, calc(var(--vn-mid-max) * .10), 58px);
}
.vn--game .vn-actions { display: none; }
.vn--game .vn-logo .vl-nick,
.vn--game .vn-logo .vl-div { display: none; }
.vn--game .vn-content {
    top: calc(var(--vn-logo-h) + 4px);
    bottom: 6px;
    left: 6px; right: 6px;
}

/* ---- NATIVE: the game IS the scene ------------------------------
   Owner: "the games Are still minigames within a page within the page and
   feels disjointed". A native game's box is TRANSPARENT and stops above
   the cast band, so the mascots keep standing on the floor underneath the
   action and the backdrop keeps showing through. Nothing about the
   composition changes when a game starts -- which is the whole point. */
.vn--native .vn-content {
    background: transparent;
    bottom: calc(var(--vn-cast-h) * .34 + 6px);
}
.vn--native .vn-cast { z-index: 3; }        /* the cast stays in front */
/* A GAME THAT DRAWS ITS OWN ACTORS takes the scenery pair off the floor
   for its duration (js/playstory.js GAME_OWNS_EXTRAS): Treats & Retreat
   needs the cats to twitch and trot, so it draws its own, and the owner's
   cats.png must not also be asleep on the right at the same time. */
.vn--noextra .ps-extra { display: none; }
.ps-nstage {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
    background: transparent;
    overflow: hidden;
    touch-action: manipulation;
    /* A GAME SURFACE IS NOT A DOCUMENT. iOS answers a long press on a stage
       full of <img> with the callout sheet ("Copy / Share / Add to Photos")
       and a text selection -- and putting either of those up CANCELS the
       pointer stream, which in Treats & Retreat means the hold the guest is
       still physically holding just died. Games that need the gesture
       outright add .pg-nopan on top of this. */
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
}
/* the art itself is the thing a thumb rests on, so it says so too */
.ps-nstage img {
    user-select: none;
    -webkit-user-select: none;
    -webkit-touch-callout: none;
    -webkit-user-drag: none;
}

/* =====================================================================
   THE WORDLESS BEAT  (chapter 5 -- .vn--still)
   ---------------------------------------------------------------------
   The brief allows exactly ONE sincere beat in the whole experience and
   it has no words in it: after the last line the two of them move
   together and hold. Everything in here is SUBTRACTION.

     * the dock goes MUTE. The box stays -- removing it would shift the
       composition, which is the one thing this stage never does -- but
       the name tag, the portrait and the tap chevron all go, so there is
       no speaker and nothing to read. js/playstory.js empties the
       sentence itself.
     * the actions band is EMPTY for STILL_MS. Nothing to tap, on
       purpose. A button on screen would make the pause read as loading.
     * the two of them CLOSE THE GAP. The cast row is three equal-flex
       sides, so a translate on the outer two is all it takes.
     * the guest STEPS BACK. They have been in every other memory; this
       one is not theirs.
     * a ♥ rises between them. No text in it -- the brief is explicit.

   REDUCED MOTION drops the movement and keeps the duration: the silence
   is the content, the walking is decoration. */
.vn--still .vn-dlg .dlg-name,
.vn--still .vn-dlg .dlg-portrait,
.vn--still .vn-dlg .dlg-next { visibility: hidden; }
.vn--still .vn-side--left  { transform: translateX(38%);  transition: transform .9s var(--ease); }
.vn--still .vn-side--right { transform: translateX(-38%); transition: transform .9s var(--ease); }
.vn--still .vn-side--mid   { opacity: 0; transition: opacity .5s var(--ease); }
/* a breath of a lean, so "standing close" is not two sprites parked in a
   line -- 2deg, and toward each other */
.vn--still .vn-side--left  .mbox { transform: rotate(2deg); }
.vn--still .vn-side--right .mbox { transform: rotate(-2deg); }
/* the ♥ rides the existing prop slot, so it needs no element of its own and
   a torn-down scene cannot leave it behind */
.ps-heart {
    display: block;
    margin-top: 6vh;
    font-size: clamp(22px, 6vw, 32px);
    line-height: 1;
    color: var(--rose, var(--accent));
    opacity: 0;
    animation: psHeart 2.2s var(--ease) .5s both;
}
@keyframes psHeart {
    0%   { opacity: 0; transform: translateY(6px) scale(.7); }
    35%  { opacity: .95; transform: translateY(0) scale(1); }
    100% { opacity: .8; transform: translateY(-8px) scale(1); }
}
/* the ONE way out, after the silence. It fades in rather than snapping:
   the beat is allowed to end gently. */
.ps-quiet { animation: vnFade .6s var(--ease) both; }

/* =====================================================================
   THE PLACEHOLDER BEATS  (.ps-stub)
   ---------------------------------------------------------------------
   Owner: "if the game is not ready yet, its okay, just put a placeholder
   as if the game is there." So a stub is staged like a scene and NOT like
   a "coming soon" card: the props stand on the same transparent canvas a
   native game gets, the cast stays on the floor behind them, and one tap
   plays a consequence.

   EVERY MOTION IS DRIVEN BY ONE CLASS, .go, added by js/playstory.js on
   that tap -- which is why a new stub is markup plus a rule, and why the
   reduced-motion block at the bottom of this file can opt the whole
   family out in one place. */
.ps-stub { cursor: pointer; touch-action: manipulation; }
/* the stub's own note: what this beat WILL be. Small, low-contrast, in the
   corner -- the owner reads it, a guest's eye slides off it. */
.ps-note {
    position: absolute;
    left: 0; right: 0; bottom: 2px;
    text-align: center;
    font-family: var(--sans);
    font-size: var(--text-xs);
    color: var(--ink-mute);
    opacity: .72;
    pointer-events: none;
}
.ps-note--card { position: static; margin-top: 6px; opacity: .8; }
/* the guest's own character, in frame for the stub too (owner: "having the
   user character in frame") */
.ps-stub .ps-guest {
    position: absolute;
    left: 8%; bottom: 6%;
    pointer-events: none;
}
.ps-stub .ps-guest img,
.ps-stub .ps-guest .mbox { display: block; }

/* ---- CH1: the pairing board -------------------------------------
   Her card is already slotted. His is loose underneath. One tap snaps it
   in beside hers, the slot lights, and a paired stamp lands. */
.ps-board {
    position: absolute;
    left: 50%; top: 8%;
    transform: translateX(-50%);
    width: min(78%, 320px);
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 8px;
    padding: 10px;
    border-radius: var(--vn-r);
    background: var(--card);
    box-shadow: var(--vn-soft-sm);
}
.ps-bh {
    grid-column: 1 / -1;
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .1em;
    color: var(--ink-mute);
    text-align: center;
}
.ps-slot {
    min-height: 46px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    border-radius: var(--vn-r-sm);
    background: var(--paper-2);
}
.ps-slot b { font-family: var(--pixel); font-size: var(--px-sm); color: var(--ink); }
.ps-slot em {
    font-family: var(--sans);
    font-size: var(--text-xs);
    font-style: normal;
    color: var(--ink-mute);
}
.ps-slot--set { box-shadow: inset 0 0 0 2px var(--accent-soft); }
.ps-slot--open { box-shadow: inset 0 0 0 2px rgba(36, 31, 46, .1); }
.ps-cardj {
    grid-column: 2;
    justify-self: center;
    margin-top: 4px;
    padding: 8px 14px;
    border-radius: var(--vn-r-sm);
    background: linear-gradient(var(--accent), var(--accent-deep));
    color: #fff;
    box-shadow: var(--vn-soft-sm);
    transform: translate(-38%, 14px) rotate(-7deg);
    transition: transform .34s var(--ease-bounce);
}
.ps-cardj b { font-family: var(--pixel); font-size: var(--px-sm); }
.ps-stub.go .ps-cardj { transform: translate(0, -52px) rotate(0deg); }
.ps-stub.go .ps-slot--open {
    box-shadow: inset 0 0 0 2px var(--accent-deep);
    animation: psSnap .3s var(--ease) both;
}
@keyframes psSnap {
    0%   { transform: scale(1); }
    45%  { transform: scale(1.08); }
    100% { transform: scale(1); }
}

/* ---- CH2: treats and retreat ------------------------------------
   The bowl goes down, both cats commit to it, and the two of them walk out
   of frame while it lasts. The cats are placeholder silhouettes: the real
   art arrives through PlayStory.SPRITES (assets/custom/cats.png) and the
   registry's graceful-absence rule means neither one waits for the other. */
.ps-treats { position: absolute; inset: 0; pointer-events: none; }
.ps-bowl {
    position: absolute;
    left: 50%; bottom: 16%;
    transform: translate(-50%, -26px) scale(.8);
    font-size: clamp(30px, 9vw, 46px);
    line-height: 1;
    opacity: 0;
    transition: transform .3s var(--ease-bounce), opacity .3s var(--ease);
}
.ps-kit {
    position: absolute;
    right: 12%; bottom: 15%;
    width: clamp(26px, 8vw, 40px);
    height: clamp(20px, 6vw, 30px);
    border-radius: 50% 50% 42% 42%;
    background: var(--ink);
    opacity: .78;
    transition: transform .55s var(--ease), opacity .3s var(--ease);
}
/* the ears, so a silhouette reads as a cat and not as a stone */
.ps-kit::before,
.ps-kit::after {
    content: '';
    position: absolute;
    top: -7px;
    width: 0; height: 0;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-bottom: 9px solid var(--ink);
}
.ps-kit::before { left: 3px; }
.ps-kit::after  { right: 3px; }
.ps-kit--b { right: 3%; bottom: 9%; transform: scale(.82); opacity: .62; }
.ps-stub.go .ps-bowl { opacity: 1; transform: translate(-50%, 0) scale(1); }
.ps-stub.go .ps-kit   { transform: translateX(-32%); }
.ps-stub.go .ps-kit--b { transform: translateX(-46%) scale(.82); }
/* ...and the guest's character edges away with them */
.ps-stub.go .ps-guest { animation: psPeek .6s var(--ease) both; }
@keyframes psPeek {
    0%   { transform: translateX(0); }
    100% { transform: translateX(-22px); }
}

/* ---- CH3: one tap on the mushroom -------------------------------
   THE FALLBACK ONLY. Chapter 3's game shipped native in M1.5; this beat is
   what plays if that mount ever throws, so the story keeps going instead of
   showing an apology card. One tap squashes him, a +1 rises, and her record
   sits under it unbothered. */
.ps-mash { position: absolute; inset: 0; pointer-events: none; }
.ps-mush {
    position: absolute;
    left: 50%; top: 38%;
    width: clamp(48px, 16vw, 92px);
    height: clamp(42px, 14vw, 82px);
    transform: translateX(-50%);
    border-radius: 50% 50% 38% 38%;
    background: linear-gradient(var(--accent), var(--accent-deep));
    box-shadow: var(--vn-soft-sm);
    transition: transform .24s var(--ease-bounce);
}
/* two eyes, so a dome reads as him and not as a hill */
.ps-mush::before,
.ps-mush::after {
    content: '';
    position: absolute;
    bottom: 22%;
    width: 12%;
    height: 22%;
    border-radius: 50%;
    background: var(--card);
    opacity: .85;
}
.ps-mush::before { left: 26%; }
.ps-mush::after  { right: 26%; }
.ps-mplus {
    position: absolute;
    left: 50%; top: 30%;
    transform: translateX(-50%);
    font-family: var(--pixel);
    font-size: var(--px-sm);
    color: var(--accent-deep);
    opacity: 0;
}
.ps-mrec {
    position: absolute;
    left: 0; right: 0; top: 16%;
    text-align: center;
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .1em;
    color: var(--ink-mute);
}
.ps-stub.go .ps-mush { transform: translateX(-50%) scaleY(.84); }
.ps-stub.go .ps-mplus { animation: psPlus .8s var(--ease) both; }
@keyframes psPlus {
    0%   { opacity: 0; transform: translate(-50%, 6px) scale(.8); }
    40%  { opacity: 1; transform: translate(-50%, -10px) scale(1.1); }
    100% { opacity: .9; transform: translate(-50%, -22px) scale(1); }
}

/* ---- CH4: one star on the ring ----------------------------------
   Tap once: a star arcs on, the ring locks, and the sunset deepens. MODE B
   FIXED -- there is no mode choice in the real game either, and the note
   in the corner says so. */
.ps-torii { position: absolute; inset: 0; pointer-events: none; }
.ps-gate {
    position: absolute;
    left: 50%; top: 12%;
    transform: translateX(-50%);
    font-size: clamp(52px, 17vw, 92px);
    line-height: 1;
    color: var(--rose, var(--accent-deep));
    opacity: .5;
}
.ps-ring {
    position: absolute;
    left: 50%; top: 48%;
    transform: translate(-50%, -50%);
    font-size: clamp(34px, 11vw, 56px);
    line-height: 1;
    color: var(--gold, var(--accent));
    transition: text-shadow .5s var(--ease), transform .5s var(--ease-bounce);
}
.ps-star {
    position: absolute;
    left: 22%; top: 62%;
    font-size: clamp(15px, 4.6vw, 22px);
    line-height: 1;
    color: var(--gold, var(--accent));
    opacity: 0;
}
.ps-stub.go .ps-ring {
    transform: translate(-50%, -50%) scale(1.14);
    text-shadow: 0 0 14px var(--gold, var(--accent));
}
.ps-stub.go .ps-star { animation: psArc .7s var(--ease) both; }
@keyframes psArc {
    0%   { opacity: 0; left: 22%; top: 62%; transform: scale(.6); }
    55%  { opacity: 1; left: 38%; top: 26%; transform: scale(1.2); }
    100% { opacity: 1; left: 50%; top: 44%; transform: scale(1); }
}
/* the sunset deepens across the canvas. The wash is the CHAPTER's own tint
   (--ps-tint, lavender here), so this one rule serves every stub that wants
   its scene to warm up on the tap. */
.ps-stub.go::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(120% 80% at 50% 100%, var(--ps-tint), transparent 70%);
    animation: psSun 1s var(--ease) both;
}
@keyframes psSun { from { opacity: 0; } to { opacity: .5; } }

/* ---- the DAY ONE / DAY TWO / DAY THREE vignette cards (ch4 act 1) --
   A `do:'prop:...'` beat, so it costs no tap and the card is already up
   when the line under it lands. */
.ps-day {
    font-family: var(--pixel);
    font-size: var(--px-sm);
    letter-spacing: .12em;
    color: var(--ink);
}

/* the native masher (js/playgames-native.js). Every size in here comes
   from the measured canvas as an inline style; these rules only say what
   the pieces LOOK like, never how big they are. */
.pn-bar {
    position: absolute;
    left: 0; right: 0; top: 0;
    height: var(--pn-bar-h, 6px);
    border-radius: 999px;
    background: var(--card-2);
    overflow: hidden;
}
.pn-fill {
    display: block;
    height: 100%;
    width: 100%;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--accent), var(--gold, var(--accent-deep)));
}
.pn-hurry .pn-fill { background: linear-gradient(90deg, var(--rose, #e0607f), var(--gold, #f0b429)); }
.pn-count {
    position: absolute;
    left: 0; right: 0;
    top: calc(var(--pn-bar-h, 6px) + 6px);
    text-align: center;
    font-family: var(--serif);
    font-size: var(--pn-count-px, 44px);
    line-height: 1;
    color: var(--accent-deep);
    pointer-events: none;
}
.pn-vs {
    position: absolute;
    left: 10%; right: 10%;
    top: calc(var(--pn-bar-h, 6px) + var(--pn-count-px, 44px) + 12px);
    pointer-events: none;
}
.pn-track {
    position: relative;
    display: block;
    height: 8px;
    border-radius: 999px;
    background: var(--card-2);
    overflow: hidden;
}
.pn-vf {
    display: block;
    height: 100%;
    width: 0;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--accent-soft), var(--accent));
    transition: width .1s linear;
}
.pn-pin { display: none; }
.pn-vl {
    display: block;
    margin-top: 4px;
    text-align: center;
    font-family: var(--sans);
    font-size: var(--text-xs);
    color: var(--ink-soft);
}
.pn-passed .pn-vl { color: var(--accent-deep); }
.pn-passed .pn-vf { background: linear-gradient(90deg, var(--gold, var(--accent)), var(--accent-deep)); }
/* the two performers: the guest's own character and the mushroom, both
   standing on the same floor line, both sized off the measured canvas */
.pn-actor {
    position: absolute;
    pointer-events: none;
    transform-origin: bottom center;
    transition: transform .12s cubic-bezier(.34, 1.7, .5, 1);
}
.pn-actor img { display: block; }
.pn-mush img { filter: drop-shadow(0 8px 10px rgba(36, 31, 46, .32)); }
.pn-guest .mbox,
.pn-guest img { animation: none; }          /* the game drives its bounce */

/* ---- THE GUEST SWINGS, and the mushroom WANDERS IN (round 7) ------
   The brief wants the guest's own character playing its attack pose on
   every tap. No avatar type can render a pose today (see the ATTACK note
   at the top of js/playgames-native.js -- roster sprites have no attack
   sheet, and AvatarKit hardcodes action=A00.0 for imported MapleSEA
   characters), so every type gets the same punch-LUNGE instead: a lean
   toward the mushroom and back. It reads as a swing on a sprite, on an
   imported character and on a photo bobblehead alike.

   .pn-enter is the other half of the eased entry: no 3-2-1 any more, so
   the mushroom walks into frame while the last lines are still on screen
   and the first tap is what starts the clock. */
.pn-punch { animation: pnPunch .19s var(--ease) both; }
@keyframes pnPunch {
    0%   { transform: translateX(0) rotate(0deg); }
    38%  { transform: translateX(11px) rotate(7deg); }
    100% { transform: translateX(0) rotate(0deg); }
}
.pn-mush.pn-enter { animation: pnWalkIn .85s var(--ease) both; }
@keyframes pnWalkIn {
    from { transform: translateX(30%); opacity: 0; }
    to   { transform: translateX(0);   opacity: 1; }
}
.pn-fx { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pn-spark {
    position: absolute;
    font-family: var(--pixel);
    font-size: 17px;
    font-style: normal;
    color: var(--accent-deep);
    animation: pnSpark .6s ease-out forwards;
}
@keyframes pnSpark {
    from { transform: translateY(0) scale(1); opacity: .95; }
    to   { transform: translateY(-46px) scale(1.28); opacity: 0; }
}
.pn-flash::after {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(circle at 60% 70%, var(--accent-soft), transparent 62%);
    animation: pnFlash .24s ease-out both;
}
@keyframes pnFlash { from { opacity: .8; } to { opacity: 0; } }

/* =====================================================================
   THE THREE ROUND-8 NATIVE GAMES  (.pg-*, js/playgames-native.js)
   ---------------------------------------------------------------------
   The pairing board (ch1), Treats & Retreat (ch2) and the Proposal Ring
   (ch4 act 2). Same contract as the masher above: EVERY SIZE AND POSITION
   IS AN INLINE STYLE derived from the measured canvas, and the rules in
   here only say what a piece looks like. If you find yourself adding a px
   height to this block, the number belongs in that file's layout()
   instead -- that is what makes the pieces big on a big screen and still
   in frame on a 360px one.

   All three sit inside .ps-nstage (transparent, .vn--native) so the cast
   keeps standing on the floor behind the action, and all three reuse the
   masher's .pn-bar / .pn-fill strip for their own meter and its
   .pn-actor / .pn-guest / .pn-fx / .pn-spark furniture.
   ===================================================================== */

/* ---- CH1: THE PAIRING BOARD -------------------------------------
   A staffing board with three class rows, a hand of teacher cards below
   it, the guest standing at the right and the coordinator waiting off to
   the left for the bell. */
/* THE GAME OWNS THE GESTURE while it is up: .ps-nstage allows manipulation,
   which on a touch screen lets the browser decide a slow drag was a pan and
   cancel the pointer stream mid-card. Worn by the pairing board (a drag) AND
   by Treats & Retreat (a multi-second hold, which drifts past the browser's
   slop just by being held and is claimed the same way -- with the hold dying
   silently under a thumb that never moved off the screen). */
.pg-nopan { touch-action: none; }
.pg-plate {
    position: absolute;
    border-radius: var(--vn-r);
    background: var(--card);
    box-shadow: var(--vn-soft-sm);
    pointer-events: none;
}
.pg-head {
    position: absolute;
    text-align: center;
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .1em;
    color: var(--ink-mute);
    pointer-events: none;
}
.pg-slot {
    position: absolute;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    padding-bottom: 4px;
    border-radius: var(--vn-r-sm);
    background: var(--paper-2);
    box-shadow: inset 0 0 0 2px rgba(36, 31, 46, .09);
    pointer-events: none;
    transition: box-shadow .16s var(--ease);
}
.pg-slot em {
    font-family: var(--sans);
    font-style: normal;
    font-size: var(--text-xs);
    color: var(--ink-mute);
    white-space: nowrap;
}
/* the slot under the thumb, mid-drag */
.pg-slot.over {
    box-shadow: inset 0 0 0 3px var(--accent);
    background: var(--card-warm, var(--paper-2));
}
/* A CARD IS THE TOUCH TARGET. Its box comes from layout() (~75x62 on a
   360px phone), which is why nothing in here sets a size. */
.pg-card {
    position: absolute;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 4px;
    border-radius: var(--vn-r-sm);
    background: var(--card);
    box-shadow: var(--vn-soft-sm);
    color: var(--ink);
    text-align: center;
    cursor: grab;
    touch-action: none;              /* the drag is ours, not the browser's */
    transition: left .22s var(--ease-bounce), top .22s var(--ease-bounce),
                box-shadow .16s var(--ease);
}
.pg-card b {
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .04em;
    line-height: 1.2;
}
.pg-card--jh { background: linear-gradient(var(--accent), var(--accent-deep)); color: #fff; }
.pg-card--yf { background: linear-gradient(var(--c-lav, var(--accent-soft)), var(--accent-soft)); }
.pg-card.set { box-shadow: var(--vn-soft-sm), inset 0 0 0 2px var(--accent-soft); }
.pg-card.sel { box-shadow: 0 0 0 3px var(--accent), var(--vn-soft-sm); }
/* mid-drag: no transition, or the card lags the thumb */
.pg-card.pg-held {
    transition: none;
    z-index: 6;
    transform: scale(1.06) rotate(-2deg);
    box-shadow: 0 10px 18px rgba(36, 31, 46, .28);
}
/* HIS CARD IS EAGER. A small idle wiggle, so the one card that matters is
   the one that catches the eye first.

   THE `animation: none` BLOCK BELOW IS LOAD-BEARING, not tidiness: a running
   animation beats a plain declaration, so without it the wiggle would keep
   overriding .pg-held's transform and his card would judder in place instead
   of following the thumb. It stops when he is picked up, when he is placed,
   and when the board is finished. */
.pg-eager { animation: pgEager 2.6s var(--ease) infinite; }
.pg-card.pg-held,
.pg-card.set,
.pg-won .pg-card,
.pg-meh .pg-card { animation: none; }
.pg-won .pg-card,
.pg-meh .pg-card { cursor: default; }
/* while a drag is live, the slots are the only thing worth reading */
.pg-dragging .pg-head { opacity: .55; }
@keyframes pgEager {
    0%, 76%, 100% { transform: rotate(-3deg); }
    84%           { transform: rotate(3deg) translateY(-2px); }
    92%           { transform: rotate(-1deg); }
}
/* ...AND HE WRIGGLES OUT of a slot that is not next to hers. Exactly one
   gentle sabotage: js/playgames-native.js flips a latch, so the same move
   a moment later is allowed. */
.pg-wrig { animation: pgWrig .6s var(--ease) both; z-index: 6; }
@keyframes pgWrig {
    0%   { transform: rotate(0); }
    30%  { transform: rotate(-7deg); }
    55%  { transform: rotate(7deg); }
    100% { transform: rotate(-3deg) scale(.96); }
}
.pg-snap { animation: psSnap .3s var(--ease) both; }
/* the coordinator: a prop, and only ever a silhouette. She walks in when
   the bell goes and finishes the board -- which is why there is no fail
   state on this beat. */
.pg-coord {
    position: absolute;
    opacity: 0;
    transform: translateX(-120%);
    transition: opacity .4s var(--ease), transform .7s var(--ease);
    pointer-events: none;
}
.pg-coord::before,
.pg-coord::after {
    content: '';
    position: absolute;
    background: var(--ink);
    opacity: .5;
}
.pg-coord::before {                  /* the head */
    left: 50%; top: 0;
    width: 34%; padding-bottom: 34%;
    border-radius: 50%;
    transform: translateX(-50%);
}
.pg-coord::after {                   /* ...and the rest of her */
    left: 12%; right: 12%; top: 32%; bottom: 0;
    border-radius: 40% 40% 12% 12%;
}
.pg-rang .pg-coord { opacity: 1; transform: translateX(0); }

/* ---- CH2: TREATS & RETREAT --------------------------------------
   Dusk street: the road at the right edge, the cats at the basket in the
   middle, and the three of them walking out of frame while the bowl
   lasts. */
.pg-road {
    position: absolute;
    opacity: .5;
    background:
        repeating-linear-gradient(180deg, var(--ink-mute) 0 8%, transparent 8% 16%) 50% 0 / 3px 100% no-repeat,
        linear-gradient(90deg, transparent, rgba(36, 31, 46, .12));
    pointer-events: none;
}
.pg-cats { position: absolute; pointer-events: none; }
/* the placeholder silhouettes. They are the base layer, so the beat plays
   before the owner's art lands -- and .pg-catart-on hides them the moment
   it does. Sized off --pg-cat-h, which js/playgames-native.js sets from
   the measured canvas. */
.pg-kit {
    position: absolute;
    bottom: 0;
    left: 4%;
    width: calc(var(--pg-cat-h, 60px) * .58);
    height: calc(var(--pg-cat-h, 60px) * .44);
    border-radius: 50% 50% 42% 42%;
    background: var(--ink);
    opacity: .78;
    transform-origin: bottom center;
    transition: transform .3s var(--ease);
}
.pg-kit::before,
.pg-kit::after {
    content: '';
    position: absolute;
    top: calc(var(--pg-cat-h, 60px) * -.13);
    width: 0; height: 0;
    border-left: calc(var(--pg-cat-h, 60px) * .09) solid transparent;
    border-right: calc(var(--pg-cat-h, 60px) * .09) solid transparent;
    border-bottom: calc(var(--pg-cat-h, 60px) * .16) solid var(--ink);
}
.pg-kit::before { left: 12%; }
.pg-kit::after  { right: 12%; }
.pg-kit--b { left: 48%; opacity: .6; transform: scale(.86); }
.pg-catart {
    position: absolute;
    left: 0; bottom: 0;
    height: 100%;
    width: auto;
    image-rendering: pixelated;
    filter: drop-shadow(0 6px 8px rgba(36, 31, 46, .26));
}
.pg-catart-on .pg-kit { display: none; }
/* THE TELEGRAPH. An ear twitch first, then the look: the ~450ms warning
   is the whole skill of the beat, so it has to be visible. */
.pg-tele .pg-kit { transform: translateY(-6%) scale(1.03); }
.pg-tele .pg-catart { transform: translateY(-3%); }
.pg-look .pg-kit { transform: translateY(-14%) scaleY(1.16); }
.pg-look .pg-catart { transform: translateY(-7%) scaleY(1.06); }
.pg-pip {
    position: absolute;
    left: 50%; top: -6%;
    transform: translate(-50%, -100%) scale(.6);
    font-family: var(--pixel);
    color: var(--rose, var(--accent-deep));
    opacity: 0;
    transition: opacity .14s var(--ease), transform .18s var(--ease-bounce);
}
.pg-tele .pg-pip { opacity: .7; transform: translate(-50%, -100%) scale(.9); }
.pg-look .pg-pip { opacity: 1; transform: translate(-50%, -110%) scale(1.15); }
.pg-bowl {
    position: absolute;
    line-height: 1;
    opacity: 0;
    transform: translateY(-40%) scale(.7);
    transition: opacity .3s var(--ease), transform .34s var(--ease-bounce);
    pointer-events: none;
}
.pg-fed .pg-bowl { opacity: 1; transform: translateY(0) scale(1); }
/* the three of them. A positioned huddle, not a flex row: three square
   mascot boxes side by side would be wider than a phone. */
.pg-party {
    position: absolute;
    pointer-events: none;
    transition: left .12s linear;
}
.pg-p { position: absolute; bottom: 0; }
.pg-p .mbox,
.pg-p img { display: block; }
.pg-walk .pg-party { animation: pgBob .42s ease-in-out infinite; }
@keyframes pgBob {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-3%); }
}
/* caught: a cat trots over, and they lose a step. Never a fail. */
.pg-trot .pg-kit { animation: pgTrot .7s var(--ease) both; }
@keyframes pgTrot {
    0%   { transform: translateX(0); }
    45%  { transform: translateX(-46%) scale(1.04); }
    100% { transform: translateX(0); }
}
.pg-trot .pg-party { animation: pgDragged .5s var(--ease) both; }
@keyframes pgDragged {
    0%   { transform: translateX(0); }
    60%  { transform: translateX(6%) rotate(1.5deg); }
    100% { transform: translateX(0); }
}
.pg-won .pg-party { animation: pgWave .6s var(--ease) both; }
@keyframes pgWave {
    0%   { transform: translateY(0); }
    40%  { transform: translateY(-7%) rotate(-2deg); }
    100% { transform: translateY(0); }
}

/* ---- CH4 ACT 2: THE PROPOSAL RING ------------------------------
   The torii out in the lake, the ring centre stage, one spark orbiting
   it and a marked arc that tightens a little per star. MODE B FIXED:
   there is no mode choice anywhere in here, by the brief. */
.pg-gate {
    position: absolute;
    transform: translateX(-50%);
    line-height: 1;
    color: var(--rose, var(--accent-deep));
    opacity: .38;
    pointer-events: none;
}
.pg-lake {
    position: absolute;
    left: 0; right: 0;
    background: linear-gradient(180deg, rgba(255, 255, 255, .28), transparent 60%),
                linear-gradient(180deg, var(--ps-tint, var(--accent-soft)), transparent);
    opacity: .55;
    pointer-events: none;
}
/* THE SUNSET DEEPENS PER STAR, through the chapter's own tint -- the same
   --ps-tint mechanism the placeholder beat used. --pg-warm is the star
   count over five, set by js/playgames-native.js. */
.pg-wash {
    position: absolute;
    background: radial-gradient(120% 80% at 50% 100%, var(--ps-tint, var(--accent-soft)), transparent 70%);
    opacity: calc(var(--pg-warm, 0) * .62);
    transition: opacity .7s var(--ease);
    pointer-events: none;
}
.pg-stars {
    position: absolute;
    left: 0; right: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: .35em;
    line-height: 1;
    pointer-events: none;
}
.pg-st {
    font-style: normal;
    color: var(--ink-mute);
    opacity: .3;
    transition: opacity .3s var(--ease), transform .3s var(--ease-bounce), color .3s var(--ease);
}
.pg-st.on {
    color: var(--gold, var(--accent));
    opacity: 1;
    transform: scale(1.2);
    text-shadow: 0 0 10px var(--gold, var(--accent));
}
.pg-ring { position: absolute; pointer-events: none; }
.pg-rsvg { display: block; width: 100%; height: 100%; overflow: visible; }
.pg-rt {
    fill: none;
    stroke: var(--ink-mute);
    stroke-width: 3;
    opacity: .28;
}
/* THE ARC. Drawn as a dash on the rim: the length is the arc's share of
   the circumference and the rotation is its centre, both set inline. */
.pg-ra {
    fill: none;
    stroke: var(--gold, var(--accent));
    stroke-width: 6;
    stroke-linecap: round;
    /* dimmer while the spark is still drifting in demo, so the ring reads as
       waiting rather than as already asking for something */
    opacity: .55;
    transition: stroke-dasharray .4s var(--ease), opacity .4s var(--ease);
}
.pg-armed .pg-ra { opacity: .9; }
.pg-spark {
    position: absolute;
    width: 12%; height: 12%;
    margin: -6% 0 0 -6%;
    border-radius: 50%;
    background: radial-gradient(circle, #fff 10%, var(--gold, var(--accent)) 55%, transparent 72%);
    box-shadow: 0 0 12px var(--gold, var(--accent));
}
/* a miss STUMBLES the spark and breaks nothing. That is the whole rule of
   this beat: fails never break the ring, retries only. */
.pg-stumble .pg-spark { animation: pgStumble .42s var(--ease) both; }
@keyframes pgStumble {
    0%   { transform: scale(1); }
    40%  { transform: scale(.7) rotate(-18deg); filter: saturate(.4); }
    100% { transform: scale(1); }
}
.pg-hit .pg-ra { animation: pgHit .34s var(--ease) both; }
@keyframes pgHit {
    0%   { stroke-width: 6; opacity: .85; }
    45%  { stroke-width: 11; opacity: 1; }
    100% { stroke-width: 6; opacity: .85; }
}
/* THE FIFTH STAR. A freeze beat before the result card arrives over it:
   the proposal is allowed to land. */
.pg-land .pg-rt { stroke: var(--gold, var(--accent)); opacity: .8; }
.pg-land .pg-ring { animation: pgLand .9s var(--ease) both; }
@keyframes pgLand {
    0%   { transform: scale(1); }
    35%  { transform: scale(1.1); }
    100% { transform: scale(1.04); }
}
.pg-land .pg-spark { opacity: 0; transition: opacity .3s var(--ease); }
.pg-love {
    position: absolute;
    left: 50%; top: 30%;
    transform: translate(-50%, 0);
    font-size: clamp(24px, 8vw, 46px);
    line-height: 1;
    color: var(--rose, var(--accent));
    opacity: 0;
    pointer-events: none;
}
.pg-land .pg-love { animation: psHeart 1.6s var(--ease) .2s both; }
/* Jiahao stands at the ring with the guest beside him -- the brief's
   "steady his hands" framing, and the reason the guest is in this frame
   at all. */
.pg-jh { z-index: 2; }
.pg-land .pg-jh { animation: pgWave .6s var(--ease) both; }

/* ---- IFRAME: a booth with no native port, BLENDED ---------------
   Nothing in the story reaches this after round 8 -- all four booths are
   native. It is the courtesy path for a booth that still runs in
   minigames/couple-arcade.html, and what M1.5 removed from it is the
   WINDOW look: no box chrome, no dark
   rectangle, and the arcade's own page fill overridden to this paper
   colour over the &bg= param (js/playstory.js _paper -> the arcade
   validates it). The box opts OUT of the auto-fit pass (js/vn.js fitAll:
   .vn--game on the root, .vn-nofit on the child) because a live game
   reports whatever it lays out, and a resize tick would re-zoom it
   mid-play. */
.vn-content > .ps-stage {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
    border: 0;
    border-radius: var(--vn-r);
    overflow: hidden;
    background: var(--card);          /* paper, not the arcade's ink */
    box-shadow: none;                 /* and no card edge: it is the scene */
}
.ps-frame {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
    background: transparent;
}
/* the boot veil is a SCENE TRANSITION now, in the stage's own palette,
   rather than a black loading screen inside a light page */
.ps-boot {
    position: absolute;
    inset: 0;
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 6px;
    background:
        radial-gradient(120% 70% at 50% 100%, rgba(255, 255, 255, .5), transparent 72%),
        linear-gradient(180deg, var(--paper-2) 0%, var(--card) 58%, var(--card-warm) 100%);
    animation: psVeil .3s var(--ease) both;
    transition: opacity .26s var(--ease);
}
@keyframes psVeil { from { opacity: 0; } to { opacity: 1; } }
.ps-boot.out { opacity: 0; }
.ps-boot .ps-bt {
    font-family: var(--pixel);
    font-size: var(--px-lg);
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--ink);
}
.ps-boot .ps-bs {
    font-family: var(--sans);
    font-size: var(--text-sm);
    color: var(--ink-soft);
}
.ps-boot .bs-dots i { background: var(--accent); }

/* ---------------- the result card, and the error card ------------ */
.ps-res { text-align: center; }
.ps-score { display: flex; flex-direction: column; align-items: center; gap: 1px; }
.ps-big {
    font-family: var(--serif);
    font-size: var(--text-3xl);
    line-height: 1;
    color: var(--accent-deep);
}
.ps-lbl {
    font-family: var(--pixel);
    font-size: var(--px-xs);
    letter-spacing: .1em;
    text-transform: uppercase;
    color: var(--ink-mute);
}
.ps-chip-f { margin-top: 8px; }
.ps-res .ps-best { display: inline-block; margin-top: 8px; }
/* A FAILED MOUNT IS A STORY BEAT, NOT AN EXIT (M1.5). It used to tear the
   stage down and paint the old winding map, which is most of what the
   owner experienced as "it randomly goes back to the old version". */
.ps-oops { text-align: center; }

@media (prefers-reduced-motion: reduce) {
    .ps-sheet,
    .ps-boot,
    .ps-actor,
    .ps-amb i,
    .ps-heart,
    .ps-quiet,
    .ps-stub.go .ps-star,
    .ps-stub.go .ps-mplus,
    .ps-stub.go .ps-guest,
    .ps-stub.go .ps-slot--open,
    .ps-stub.go::after,
    .pn-spark,
    .pn-punch,
    .pn-mush.pn-enter,
    .pn-flash::after,
    .pg-eager,
    .pg-wrig,
    .pg-snap,
    .pg-walk .pg-party,
    .pg-trot .pg-kit,
    .pg-trot .pg-party,
    .pg-won .pg-party,
    .pg-stumble .pg-spark,
    .pg-hit .pg-ra,
    .pg-land .pg-ring,
    .pg-land .pg-jh,
    .pg-land .pg-love,
    .vn--travel .vn-side--left,
    .vn--travel .vn-side--right,
    .vn--travel .vn-side--mid,
    .vn--travel .vn-sky,
    .vn--travel .ps-extra,
    .vn--travel .vn-mid::before,
    .vn--arrive .vn-side--left,
    .vn--arrive .vn-side--right,
    .vn--arrive .vn-side--mid,
    .vn--arrive .ps-extra { animation: none; }
    /* THE WORDLESS BEAT keeps its FULL duration and loses only the walk:
       the silence is the content, the movement is decoration. The two of
       them are simply already standing together when it begins. */
    .vn--still .vn-side--left,
    .vn--still .vn-side--right,
    .vn--still .vn-side--mid { transition: none; }
    .ps-heart { opacity: .85; }
    /* A STUB'S CONSEQUENCE STILL HAPPENS -- it IS the beat, and the
       `animation: none` above would otherwise leave the star at opacity 0
       and the sunset wash at nothing, i.e. a tap with no visible result.
       It just does not travel to get there. */
    .ps-cardj,
    .ps-bowl,
    .ps-kit,
    .ps-mush,
    .ps-ring { transition: none; }
    .ps-stub.go .ps-star { opacity: 1; left: 50%; top: 44%; }
    .ps-stub.go .ps-mplus { opacity: .9; }
    .ps-stub.go::after { opacity: .5; }

    /* THE THREE NATIVE GAMES keep every OUTCOME and lose only the travel:
       a card still lands in its slot, the bowl still goes down, the cats
       still sit up when they look, a star still lights and the ring still
       ends warm. The `animation: none` above would otherwise leave the ♥
       at opacity 0 and the bowl invisible, i.e. an input with no result. */
    .pg-card,
    .pg-slot,
    .pg-coord,
    .pg-party,
    .pg-kit,
    .pg-catart,
    .pg-bowl,
    .pg-pip,
    .pg-st,
    .pg-ra,
    .pg-wash { transition: none; }
    .pg-land .pg-love { opacity: .9; }
    .pg-land .pg-ring { transform: scale(1.04); }
}
