  /* ================================================================
     PULL TO REFRESH  —  shared across index / request / account
     ----------------------------------------------------------------
     Structure: everything that was already in the document body is now inside
     #ptr-page, and #ptr-stage sits in front of it in the DOM holding
     the logo. Pulling translates #ptr-page down; the logo is revealed
     in the gap that opens above it.

     The logo is NOT hidden behind an opaque page layer, which was the
     obvious way to build this and the wrong one — an opaque wrapper
     would have covered the document body's own radial-gradient background, and
     giving the wrapper `position: relative` so it could paint would
     have re-based #stars and #clouds (both absolutely positioned and
     document-sized) onto a new containing block. Instead #ptr-stage
     is only ever as tall as the gap itself and clips its own
     overflow, so the logo simply cannot be drawn anywhere the page
     still covers. Same result, nothing else touched.

     #ptr-page carries NO transform at rest — the property is removed
     entirely, not set to none — because a transformed (or
     will-change: transform'd) ancestor becomes the containing block
     for position:fixed descendants, and account.html has four of
     those inside the wrapped content. During a pull the transform
     does exist, but a pull can only ever start at scrollTop 0, where
     the wrapper's top edge and the viewport's top edge coincide; a
     fixed child anchored to the top therefore renders exactly where
     it would if it had simply moved down with the page, which is
     what a pull-to-refresh should do anyway. Anything anchored to
     the BOTTOM would size against the wrapper instead of the
     viewport, so the gesture refuses to start while any overlay,
     drawer or menu is open (see PTR_BLOCKERS in the script).
     ================================================================ */

  html, body {
    /* Suppress the browser's own pull-to-refresh. Chrome Android and
       Safari 16+ honour this; the preventDefault() in the script
       covers anything older. */
    overscroll-behavior-y: none;
  }

  /* ---------- The ceiling ----------------------------------------
     Carson's report, installed-as-a-PWA only: the mascot was flying
     up, splatting and holding somewhere he couldn't see, and only
     became visible partway into the drop.

     Not a z-index problem, and worth writing down because it looks
     exactly like one. This page's own .top-frost-bar is transparent
     during a pull — the gesture can only start at scrollTop 0, where
     --frost-p is 0 — and #ptr-stage.ptr-flinging sits at z-index 200,
     above every layer this document has. What's covering the mark is
     iOS's OWN status bar: standalone mode ships
     apple-mobile-web-app-status-bar-style=black-translucent plus
     viewport-fit=cover, so the layout viewport genuinely starts at
     the physical top of the screen and the OS composites its glass
     over the first env(safe-area-inset-top) pixels of it. That
     compositing happens above the whole web view. No page-level
     stacking value can get on top of it, so the fix is not to try:
     the animation just has to stop treating y=0 as the ceiling.

     --ptr-ceiling is that number — the thickness of the chrome the
     mark must stay clear of. 0 in a tab (env() is 0 there anyway, but
     stated explicitly so tab mode is provably byte-identical to the
     behaviour that already ships), the real inset in standalone.
     Everything else derives from it, so this is the only value to
     touch when tuning. If the site banner should act as a ceiling too,
     this is where to say so: calc(var(--safe-top) + var(--banner-h, 0px)).

     The probe exists because JS needs this as a NUMBER and
     getComputedStyle on an unregistered custom property hands back the
     unresolved token stream rather than a resolved length. A
     zero-width fixed box whose height IS the variable sidesteps that
     entirely: measure() reads its bounding rect, gets a real px value
     in every engine, and picks up orientation changes for free because
     measure() already re-runs on resize. visibility: hidden rather
     than display: none — a display: none box has no layout to read. */
  :root { --ptr-ceiling: 0px; }
  @media (display-mode: standalone) {
    :root { --ptr-ceiling: var(--safe-top); }
  }
  #ptr-ceiling {
    position: fixed;
    top: 0; left: 0;
    width: 0;
    height: var(--ptr-ceiling);
    visibility: hidden;
    pointer-events: none;
  }

  #ptr-stage {
    position: fixed;
    top: 0; left: 0; right: 0;
    height: 0;              /* JS sets this to the size of the gap */
    overflow: hidden;       /* ...which is what clips the logo */
    z-index: 0;
    pointer-events: none;
    display: block;
  }

  /* For the flight only, the stage is promoted above everything and
     stops clipping, so the launch plays over the page rather than
     underneath it. 200 clears account.html's sidebar (82), the
     confirm overlays (70), the context menu (90) and request.html's
     splash (90). */
  #ptr-stage.ptr-flinging {
    height: 100vh;
    overflow: visible;
    z-index: 200;
  }


  /* The wrapper has to be invisible to layout. index.html and
     request.html both centre their content with a flex document body,
     and a plain block wrapper swallows that entirely — <main> loses
     its centring and its stretch, and the whole page shifts. Copying
     the parent's formatting context, and taking up all of it as a flex
     item, makes the wrapper a no-op wherever it is dropped in.
     `inherit` on properties that are not normally inherited is doing
     real work here: it means this one rule adapts to a flex body
     (index, request) and a block body (account) without either page
     needing its own version. */
  #ptr-page {
    display: inherit;
    flex-direction: inherit;
    justify-content: inherit;
    align-items: inherit;
    flex-wrap: inherit;
    gap: inherit;
    flex: 1 1 auto;
    min-width: 0;
  }

  #ptr-logo {
    position: absolute;
    left: 50%;
    /* top / margin-left are set from the script so the geometry lives
       in one place. */
    /* The svg is 5x taller than the mark (viewBox 64x320) to leave
       room to stretch into, so a naive "center" origin would sit well
       BELOW the mark and swing it around on the end of a lever. The
       body spans y=4..60 of 320 — its own centre is at 10%. */
    transform-origin: 50% 10%;
  }

  #ptr-logo svg {
    display: block;
    overflow: visible;
    /* Same clamp() index.html's own .brand-mark uses, so the mark that
       stretches is exactly the size of the mark in the page header.
       The script MEASURES what this renders as rather than
       recomputing the clamp in a second place. */
    width: clamp(40px, 0.9vw + 33px, 50px);
    height: calc(clamp(40px, 0.9vw + 33px, 50px) * 5);
  }

  /* Deliberately its own class, not .brand-mark-body — sharing that
     one would inherit index.html's load-time greeting animation. */
  .ptr-mark-body { fill: #3E6C7A; }

  /* The strain wobble: a couple of px. Any more reads as a rendering
     bug rather than effort. */
  @keyframes ptr-tremble {
    0%, 100% { transform: translateX(-1.1px) rotate(-0.5deg); }
    50%      { transform: translateX(1.1px)  rotate(0.5deg); }
  }
  #ptr-logo.ptr-straining .ptr-logo-inner { animation: ptr-tremble 0.09s linear infinite; }

  /* --- the exit -------------------------------------------------
     Launches on a lean, as though it were going to clear the top of
     the window, and clips the ceiling instead.

     Three things make the hit land hard rather than politely:
       - the last stretch of the rise is ~40ms and eased INTO the wall
         rather than out of it, so it arrives fast;
       - rotation snaps to 0 exactly at contact, so the mark aligns
         flat to the surface it just hit instead of hitting at an
         angle;
       - the squash is extreme (1.9 x 0.32) and is HELD for ~190ms. A
         hard impact that recovers immediately reads as a bounce; the
         hold is what reads as a splat, and it needs real screen time
         or the eye skips it entirely.
     The lean is mostly killed by the impact, but a little residual
     drift survives into the fall — that's what stops the drop looking
     like a plain straight-down animation. */
  @keyframes ptr-bonk {
    0%    { transform: translate(0, 0) rotate(0deg) scale(1, 1);
            animation-timing-function: cubic-bezier(.4, 0, .75, 1); }
    /* coiled, leaning back away from the direction it's about to go */
    9%    { transform: translate(-1.5vw, 54px) rotate(-5deg) scale(1.26, 0.74);
            animation-timing-function: cubic-bezier(.02, .72, .22, 1); }
    /* up and out on a lean, stretched thin along its travel */
    20%   { transform: translate(11vw, -4px) rotate(16deg) scale(0.8, 1.26);
            animation-timing-function: cubic-bezier(.6, 0, 1, .5); }
    /* IMPACT — flat to the ceiling, pancaked */
    23%   { transform: translate(15vw, -28px) rotate(0deg) scale(1.9, 0.32);
            animation-timing-function: linear; }
    /* ---- THE HOLD. Values drift a hair rather than sitting on
       identical numbers: a truly frozen pose reads as a dropped frame
       rather than a deliberate beat. ---- */
    29.6% { transform: translate(15.4vw, -28px) rotate(0deg) scale(1.88, 0.33);
            animation-timing-function: linear; }
    37%   { transform: translate(15.8vw, -27px) rotate(0deg) scale(1.72, 0.4);
            animation-timing-function: cubic-bezier(.3, .8, .5, 1); }
    /* peels off the ceiling */
    40.5% { transform: translate(16vw, -20px) rotate(2deg) scale(1.35, 0.62);
            animation-timing-function: ease-out; }
    44.5% { transform: translate(16.4vw, -4px) rotate(5deg) scale(0.95, 1.06);
            animation-timing-function: ease-out; }
    49%   { transform: translate(17vw, 5px) rotate(8deg) scale(1.03, 0.97);
            animation-timing-function: ease-in-out; }
    /* the beat — hangs there, dazed, before gravity remembers it */
    53.5% { transform: translate(17.5vw, 8px) rotate(9deg) scale(1, 1);
            animation-timing-function: linear; }
    58.5% { transform: translate(17.5vw, 8px) rotate(9deg) scale(1, 1);
            animation-timing-function: linear; }
    /* drop: y on t-squared, x linear, sampled at quarters. Segment
       timing is linear BECAUSE the gravity is in the numbers. */
    68.9%  { transform: translate(19.1vw, 8.3vh) rotate(16deg) scale(1, 1);
             animation-timing-function: linear; }
    79.25% { transform: translate(20.75vw, 29.6vh) rotate(26deg) scale(1, 1);
             animation-timing-function: linear; }
    89.6%  { transform: translate(22.4vw, 65.2vh) rotate(38deg) scale(0.98, 0.98);
             animation-timing-function: linear; }
    100%   { transform: translate(24vw, 115vh) rotate(52deg) scale(0.94, 0.94); }
  }
  #ptr-logo.ptr-ko { animation: ptr-bonk 1.35s linear forwards; }

  @media (prefers-reduced-motion: reduce) {
    #ptr-logo.ptr-straining .ptr-logo-inner { animation: none; }
    #ptr-logo.ptr-ko { animation: none; opacity: 0; }
  }
