/*
 * Portal motion layer — shared animation vocabulary.
 * Everything here is decoration: pages must read fine with it disabled,
 * and prefers-reduced-motion turns all of it off at the bottom of this file.
 */

/* ── Live pulse (live dots, running badges) ──────────────────────── */
/* The pulse lives on a RING pseudo, not the dot's own box-shadow: the dot's
   shadow is where the static context halo lives (portal-kit.css), and the
   old keyframe's 0%% frame erased it every cycle — the halos never rendered
   at all. --portal-pulse-color/-radius are the variant knobs. */
@keyframes portal-pulse-ring {
    0%   { box-shadow: 0 0 0 0 var(--portal-pulse-color, var(--portal-c-accent-muted)); }
    70%  { box-shadow: 0 0 0 var(--portal-pulse-radius, 6px) transparent; }
    100% { box-shadow: 0 0 0 0 transparent; }
}

.portal-live-dot { position: relative; }

.portal-live-dot::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    pointer-events: none;
    animation: portal-pulse-ring 2s ease-out infinite;
}

.portal-live-dot-idle::after { animation: none; }

/* Generic "this element is live" pulse for page adoption (#13's sub-commits
   replace br-pulse / infra-pulse with this). */
.portal-pulse { animation: portal-pulse-ring var(--portal-dur-5, 900ms) ease-out infinite; }

/* ── Entrances ───────────────────────────────────────────────────── */
@keyframes portal-fade-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}

@keyframes portal-rise-in {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}

.portal-fade-in {
    animation: portal-fade-in var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out) both;
}

/* FILL MODE IS `backwards`, NOT `both`, ON EVERY ENTRANCE THAT MOVES.
 *
 * `both` holds the animation's FINAL keyframe for as long as the class is on.
 * Every entrance here ends at the element's natural state, so that looks like
 * a no-op and is not one: a held `transform` — even `translateY(0)` or
 * `scale(1)` — makes the element a stacking context AND a containing block for
 * `position: fixed` descendants.
 *
 * Measured on /vision-system (UI-IDEAS #AH): with .portal-rise-in left on the
 * filter bar, the tournament picker's `z-index: 1000` menu was clamped inside
 * the bar and a later sibling painted over it. The z-index was right; the
 * context it was measured in was not.
 *
 * JS callers can strip the class (portalKit.enterOnce does), but the class is
 * applied STATICALLY in templates on eight pages — several with a picker or a
 * kebab menu inside the tiles — where nothing ever takes it off and the
 * stacking context is permanent.
 *
 * `backwards` still applies the FROM keyframe during the delay, which is what
 * .portal-stagger's nth-child delays need to stay invisible until their turn.
 * It just stops holding the TO keyframe afterwards, so the element goes back to
 * being an ordinary element. Pixels after the animation are unchanged, because
 * the final frame was the natural state all along.
 *
 * portal-fade-in keeps `both` on purpose: opacity creates no containing block.
 */
.portal-rise-in {
    animation: portal-rise-in var(--portal-dur-3, 320ms) var(--portal-ease-out, ease-out) backwards;
}

/* Zoom-in: 4% up to full, with the fade. The sheet/card idiom — it reads as
   the thing coming toward you rather than up from below, which suits a surface
   that replaced what was there instead of arriving beneath it. */
@keyframes portal-scale-in {
    from { opacity: 0; transform: scale(.96); }
    to   { opacity: 1; transform: scale(1); }
}

/* Spring: overshoots to settle.
 *
 * It broke the house "no overshoot" rule and sat here as a candidate to
 * compare against rather than a sanctioned default. v3 sanctions it, narrowly:
 * --portal-ease-spring (portal-theme.css) is the curve, allowed on
 * transform/translate of NON-BLURRED elements only — button and card release,
 * the segmented thumb, the tab-bar pill. Never on the iOS sheet, the AI
 * drawer, modals, menus or a chrome strip, all of which blur: a spring on a
 * blurred surface re-samples the backdrop every frame, which is exactly the
 * per-frame GPU cost the degrade gates exist to avoid, on exactly the hardware
 * they protect.
 *
 * The overshoot is 2.04% now rather than the 40% this shipped with — enough to
 * read as a release, small enough that a 2px lift does not visibly overshoot
 * into 3. The keyframe NAME stays: kit-css.spec.js lists it among ENTRANCES. */
@keyframes portal-enter-spring {
    0% { transform: scale(.86); opacity: 0; }
    100% { transform: scale(1); opacity: 1; }
}

.portal-scale-in {
    animation: portal-scale-in var(--portal-dur-3, 320ms) var(--portal-ease-out, ease-out) backwards;
}

.portal-enter-spring {
    animation: portal-enter-spring var(--portal-dur-3, 320ms) var(--portal-ease-spring, ease-out) backwards;
}

/* Stagger helper: apply portal-rise-in to children of a .portal-stagger list */
.portal-stagger > * {
    animation: portal-rise-in var(--portal-dur-3, 320ms) var(--portal-ease-out, ease-out) backwards;
}

/* ── Ambient motion — the shortlist for a surface at rest ─────────
 *
 * Four idle treatments, kept together because they are ALTERNATIVES: a surface
 * picks exactly one. They exist as a set because the home hero's motion got
 * there by inheritance (it came attached to the check-in screensaver, where
 * movement is burn-in protection for an all-day iPad) rather than by being
 * chosen against anything. All four are exhibited side by side at
 * /style-guide#sg-ambient-motion — look before you pick. Entrances are a
 * separate question with its own set: /style-guide#sg-entrances.
 *
 * Every one of them is decoration by definition: this is a surface with nothing
 * to say. They are all off under reduced motion at the foot of this file, and a
 * page that needs its motion to SURVIVE that (a kiosk guarding its panel) owns
 * that exemption locally — see check-in.css.
 *
 * Provenance, because "what does Apple do" is the question that produced this
 * set: Apple's own motion is overwhelmingly interaction-driven springs, and
 * their interface art does not bob at rest. Where they do loop, it means
 * something — the aerial screensavers drift, Breathe and the Siri orb pulse,
 * Liquid Glass moves the light rather than the object, and wallpaper parallax
 * answers the person instead of a timer. One of those four is behind each class
 * below. */

@keyframes portal-ambient-float {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-12px); }
}

/* Aerial-screensaver drift: two axes, single-digit pixels, a 2% breath, and
   long enough that the loop point is not catchable. The calm answer when a
   surface should feel alive rather than printed. */
@keyframes portal-ambient-drift {
    0%   { transform: translate3d(0, 0, 0) scale(1); }
    25%  { transform: translate3d(5px, -4px, 0) scale(1.012); }
    50%  { transform: translate3d(2px, 5px, 0) scale(1.02); }
    75%  { transform: translate3d(-5px, 1px, 0) scale(1.012); }
    100% { transform: translate3d(0, 0, 0) scale(1); }
}

/* Breathe: reads as waiting-on-you. Use it where that is TRUE — on a surface
   with nothing pending it is a pulse asking to be clicked. */
@keyframes portal-ambient-breathe {
    0%, 100% { transform: scale(1); }
    45% { transform: scale(1.045); }
}

/* Sheen: the object holds still and the light crosses it. The one that fits a
   glass surface, because it animates the material rather than the contents.
   Long dwell (65% of the cycle) then a quick pass — a sweep that runs
   continuously reads as a loading shimmer, which is a different promise. */
@keyframes portal-ambient-sheen {
    0%, 65% { transform: translateX(-60%); }
    100% { transform: translateX(60%); }
}

.portal-ambient-float   { animation: portal-ambient-float 5s ease-in-out infinite; }
.portal-ambient-drift   { animation: portal-ambient-drift 24s ease-in-out infinite; }
.portal-ambient-breathe { animation: portal-ambient-breathe 7s cubic-bezier(.4, 0, .2, 1) infinite; }

/* Sheen is the odd one: it needs a band to move across the surface, so it is a
   wrapper that draws its own. The host must establish a containing block and
   clip — `position: relative; overflow: hidden` — which any panel worth
   sheening already does. */
.portal-ambient-sheen { position: relative; }

.portal-ambient-sheen::after {
    content: "";
    position: absolute;
    inset: -40%;
    pointer-events: none;
    background: linear-gradient(
        105deg,
        transparent 42%,
        var(--portal-c-specular-strong, rgba(255, 255, 255, .13)) 50%,
        transparent 58%
    );
    transform: translateX(-60%);
    animation: portal-ambient-sheen 7s ease-in-out infinite;
}

/* Pointer parallax is deliberately NOT here: it is the one option in the set
   that cannot be a class, since the offset comes from the cursor. The recipe —
   a transition on transform plus two custom properties written on pointermove —
   is wired up in the gallery exhibit (style-guide.js). Lift it from there if a
   page wants it; it becomes a kit helper the day a second page does. */

/* ── Pointer light ────────────────────────────────────────────────────
 * The specular that answers the hand. portalKit.pointerLight() (one
 * delegated pointermove, bound only on (hover: hover) and (pointer:
 * fine) and never under reduced motion) writes the pointer into the two
 * registered properties below; this ::after re-resolves its radial from
 * them. Consumers opt in with the data attribute — hero surfaces only,
 * never a polled grid: a light following the pointer across a stat card
 * is noise. The colour is --portal-c-specular-pointer, one per theme in
 * portal-components.css.
 *
 * An ::after OVERLAY, never a background: the screensaver gate pins
 * .home-logo-wrap's background to the topbar's, and a background-image
 * here would be a second author for the same pixels.
 *
 * @property registration is what makes the pair TWEEN — pointerout
 * clears the inline values and the pair glides home to rest (50% 0%)
 * on --portal-dur-2 instead of snapping. An engine without @property
 * still tracks the pointer; it only skips the settle. */
@property --portal-lx {
    syntax: "<percentage>";
    inherits: true;
    initial-value: 50%;
}

@property --portal-ly {
    syntax: "<percentage>";
    inherits: true;
    initial-value: 0%;
}

@media (hover: hover) and (pointer: fine) {
    [data-portal-light] {
        position: relative;
        transition: --portal-lx var(--portal-dur-2) var(--portal-ease-out),
                    --portal-ly var(--portal-dur-2) var(--portal-ease-out);
    }

    [data-portal-light]::after {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: inherit;
        pointer-events: none;
        background: radial-gradient(
            18rem circle at var(--portal-lx, 50%) var(--portal-ly, 0%),
            var(--portal-c-specular-pointer),
            transparent 70%
        );
    }
}

/* Reduced motion: the JS never binds, so the light holds its rest pose —
   a static top glow is material, not motion. The transition dies too, for
   any values written before the preference flipped mid-session. */
@media (prefers-reduced-motion: reduce) {
    [data-portal-light] {
        transition: none;
    }
}

.portal-stagger > *:nth-child(2)  { animation-delay: 0.04s; }
.portal-stagger > *:nth-child(3)  { animation-delay: 0.08s; }
.portal-stagger > *:nth-child(4)  { animation-delay: 0.12s; }
.portal-stagger > *:nth-child(5)  { animation-delay: 0.16s; }
.portal-stagger > *:nth-child(6)  { animation-delay: 0.2s; }
.portal-stagger > *:nth-child(n+7) { animation-delay: 0.24s; }

/* Indexed stagger. Same rise, but the delay is read from a custom property the
   renderer stamps per child instead of from nth-child, because the cap above
   flattens anything wider than six: a 16-column bracket entering on
   .portal-stagger reads as "six, then everything at once". Step is half of
   dur-1 (60ms), the same beat the Live Bracket lights its winner path on.
   Stamp --portal-sweep-i with the child's index; unstamped children all sit at
   0 and the row simply arrives together. */
.portal-sweep > * {
    /* backwards, for the reason spelled out above .portal-rise-in — this is
       the same transform-bearing rise, just delayed from a stamped index. */
    animation: portal-rise-in var(--portal-dur-3, 320ms) var(--portal-ease-out, ease-out) backwards;
    animation-delay: calc(var(--portal-sweep-i, 0) * (var(--portal-dur-1, 120ms) / 2));
}

/* ── Live-update flash (JS adds the class, animationend removes it) ─ */
@keyframes portal-flash {
    0%   { background-color: var(--portal-c-accent-muted); }
    100% { background-color: transparent; }
}

.portal-flash {
    animation: portal-flash var(--portal-dur-5, 900ms) var(--portal-ease-out, ease-out) both;
    border-radius: var(--bs-border-radius);
}

/* ── Live-value tick (stat values changing over the live bus) ──────
 * Color-only — no scale, no layout shift (tabular-nums already prevents
 * jitter). JS adds the class and removes it on animationend, same contract
 * as .portal-flash. */
@keyframes portal-tick {
    0%   { color: var(--portal-c-accent); }
    100% { color: inherit; }
}

.portal-tick {
    animation: portal-tick var(--portal-dur-4, 700ms) var(--portal-ease-out, ease-out) both;
}

/* ── Hover lift for interactive cards ──────────────────────────────
 * .portal-hoverable is the opt-in for any card-tier surface that responds to
 * a pointer (a linked stat tile, a draggable dashboard panel). It used to
 * only work on .portal-stat-card, so anything else wanting the behaviour
 * hand-rolled a shadow — which is how the dashboard ended up with a literal,
 * dark-only, specular-erasing one. */
.portal-stat-card,
.portal-accent-card,
.portal-page-themed .card.portal-hoverable {
    /* Transform on the SPRING, box-shadow on ease-out. They are different
       gestures: the surface moving is a physical thing and gets a release; the
       light on its edge is not, and springing it reads as a flicker. */
    transition: transform var(--portal-dur-2, 200ms) var(--portal-ease-spring, ease),
                box-shadow var(--portal-dur-1, 120ms) var(--portal-ease-out, ease),
                border-color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
}

/* Hover COMPOSES the edge bundle with the raised elevation — it does not
   replace the box-shadow. The old rule set a single literal shadow, which
   silently deleted the card's specular edge on hover (the card got LESS
   glassy the moment you touched it) and was dark-only, so light theme lifted
   into a black smudge. Press compresses to the pressed step. */
a.portal-stat-card:hover,
.portal-stat-card.portal-hoverable:hover,
.portal-page-themed .card.portal-hoverable:hover {
    /* Lift + elevation ONLY. There used to be a teal rim here as well
       (--portal-c-accent-border); it went with the v3 dark material on
       2026-08-13. A card whose resting edge is a black keyline and a
       specular catch does not want a coloured outline drawn over it on
       hover — at 30% alpha on a dark surface it read as a stray blue box,
       which is the same "outline drawn on top" look the keyline pass
       existed to remove. The lift now carries the affordance on its own,
       and it reads better than it used to: --portal-c-shadow-raised got
       substantially deeper in the same pass. */
    transform: translateY(-2px);
    /* THREE layers, composed, in this order:
         the RAISED edge bundle  — the specular steps, which is the only part
                                   of a dark-theme lift anyone can actually see
         the lift flood          — an inset spread OVER the surface, never a
                                   background swap. Replacing the background is
                                   how a hovered card stops being a card, which
                                   /tools shipped for an afternoon
         the raised shadow       — real on paper, nearly invisible on the dark
                                   canvas, and carried anyway because light
                                   theme is where it does the work
       Never a lone literal here: that deletes the specular and the card gets
       LESS glassy the moment you touch it. */
    box-shadow: var(--portal-c-edge-card-raised),
                inset 0 0 0 999px var(--portal-c-surface-lift),
                var(--portal-c-shadow-raised);
}

a.portal-stat-card:active,
.portal-stat-card.portal-hoverable:active,
.portal-page-themed .card.portal-hoverable:active {
    /* Press SCALES rather than just dropping the lift. A translate back to 0
       is the absence of a hover, not a response to a click; a 1.5% compression
       is the response. Safe here because a card is never a blurred surface —
       scaling one of those re-samples the backdrop every frame. */
    transform: translateY(0) scale(0.985);
    box-shadow: var(--portal-c-edge-card-pressed), var(--portal-c-shadow-pressed);
    /* In fast, out on the spring: a press should feel instant and a release
       should feel like a release. */
    transition-duration: var(--portal-dur-0, 80ms);
}

/* ── Overlay motion: Bootstrap modals ────────────────────────────────
 * Same gesture as the alertify dialogs (alertify-motion.css): rise 10px +
 * 2% scale in over dur-3 with the emphasized ease, recede over dur-2.
 * Replaces stock Bootstrap's translate(0,-50px)/.3s drop-in. Scoped to
 * themed pages; the .fade guard skips fight-scheduler's hand-rolled
 * transitionless .modal (that page adopts this spec in its Gil batch).
 * Transitions take timing from the DESTINATION state — enter timing lives
 * on .show, exit timing on the base rule. The .modal wrapper's own
 * duration is load-bearing: Bootstrap keys display:none off transitionend
 * on .modal itself, so the wrapper fade must last at least as long as the
 * dialog exit or the exit gets clipped. */
body:has(.portal-page-themed) .modal.fade {
    transition: opacity var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out);
}

/* background-color + backdrop-filter, NOT opacity. The scrim dims through its
   colour now (portal-components.css explains why: opacity < 1 empties the
   element's backdrop and kills its own blur), so animating opacity here would
   animate nothing visible AND leave the blur inert until the transition ended,
   snapping it on. Same duration, same easing — only the properties changed.

   OFFCANVAS ONLY. The modal backdrop is deliberately transitionless — see the
   long note at the scrims block in portal-components.css. Bootstrap measures
   THIS declaration to decide how long to leave the dialog in `display: none`,
   so any duration here is dead time the user spends looking at a dimming screen
   with nothing on it. The modal's dim moved to `.modal` and rides the fade
   below instead; the backdrop keeps only the 2px defocus, which is allowed to
   arrive in one frame. */
body:has(.portal-page-themed) .offcanvas-backdrop.fade {
    transition: background-color var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out),
                backdrop-filter var(--portal-dur-2, 200ms) var(--portal-ease-out, ease-out);
}

body:has(.portal-page-themed) .modal-backdrop.fade {
    transition: none;
}

/* NO scale() here: .modal-content carries backdrop-filter, and a
   fractional scale on its ancestor makes Chromium re-sample the blurred
   backdrop through a changing transform each frame — background text
   shimmers. Translate-only samples 1:1 and stays clean (same reason the
   alertify keyframes are scale-free — see alertify-motion.css). */
body:has(.portal-page-themed) .modal.fade .modal-dialog {
    transform: translateY(10px);
    transition: transform var(--portal-dur-2, 200ms) var(--portal-ease-in-out, cubic-bezier(0.45, 0, 0.25, 1));
}

body:has(.portal-page-themed) .modal.fade.show .modal-dialog {
    transform: none;
    transition: transform var(--portal-dur-3, 320ms) var(--portal-ease-emphasized, cubic-bezier(0.05, 0.7, 0.1, 1));
}

/* ── Overlay motion: menus ───────────────────────────────────────────
 * The menus were the one overlay family in the portal with NO motion at all.
 * Modals rise, alertify rises, the sheet rises, the AI drawer slides — and the
 * surface people actually open twenty times a day (the user menu, the
 * notifications bell, every table's action menu, every portalSelect) simply
 * appeared, fully formed, in one frame. Next to everything else it read as the
 * app skipping a frame rather than as speed.
 *
 * An ANIMATION, not a transition: Bootstrap toggles `.dropdown-menu` between
 * display:none and block, and a transition cannot run from display:none. Same
 * reason alertify-motion.css uses keyframes.
 *
 * This shipped ENTER-ONLY for a while, on the grounds that Bootstrap sets
 * display:none the moment `.show` comes off so there was no exit frame to
 * animate without owning its JS. Owning its JS is what portal-kit.js's menu
 * exit now does — see the long note there, and the exit block below.
 *
 * dur-1, not the dialogs' dur-3. A menu is a continuation of the click that
 * opened it, so it has to feel like it is already there; the motion is only
 * enough to say where it came from. 4px, which is the smallest distance that
 * still reads as direction.
 *
 * TRANSLATE ONLY — no scale, for the reason the modal block above gives: these
 * carry --portal-blur-overlay, and a fractional scale makes Chromium re-sample
 * the blurred backdrop through a changing transform every frame, which
 * shimmers the text behind them.
 *
 * AND IT MOVES `translate`, NOT `transform`, WHICH IS THE WHOLE TRICK HERE.
 * Popper positions these menus by writing an inline `transform:
 * translate(0px, 38px)` — the offset from the anchor IS a transform. A
 * keyframe touching `transform` therefore does not offset the menu, it
 * REPLACES its position: measured, the menu sat 38px too high for the entire
 * animation and then snapped down to its real place on the last frame. That is
 * a worse version of the defect this section exists to remove, and it looks
 * completely correct in the CSS.
 *
 * `translate` is an independent property that COMPOSES with `transform`
 * (transform is applied after it), so Popper keeps its positioning and the
 * animation only ever adds its own 4px on top. */
@keyframes portal-menu-in {
    from { opacity: 0; translate: 0 -4px; }
    to   { opacity: 1; translate: 0 0; }
}

@keyframes portal-menu-in-up {
    from { opacity: 0; translate: 0 4px; }
    to   { opacity: 1; translate: 0 0; }
}

body:has(.portal-page-themed) .dropdown-menu.show,
body:has(.portal-page-themed) .ts-dropdown {
    animation: portal-menu-in var(--portal-dur-1, 120ms) var(--portal-ease-out, ease-out);
}

/* A menu that opens UPWARD has to arrive from below, or it reads as being
   pushed out of the way of its own anchor. Popper stamps the placement it
   chose, so the direction follows the menu rather than the markup. */
body:has(.portal-page-themed) .dropdown-menu.show[data-popper-placement^="top"],
body:has(.portal-page-themed) .ts-dropdown[data-popper-placement^="top"] {
    animation-name: portal-menu-in-up;
}

/* ── …and the way out ────────────────────────────────────────────────
 * The exit RETRACES the entrance at half the distance: it went 4px to
 * arrive, it goes 2px to leave. An exit that travels as far as its entrance
 * reads as the menu being thrown rather than dismissed.
 *
 * dur-0 (80ms) against the entrance's dur-1, and --portal-ease-in-out, which
 * is the house rule: enter slower than exit, exits on the in-out curve. The
 * original objection to giving menus an exit at all was that "a menu that
 * lingers on the way out is worse than one that does not" — which is true, and
 * is why this is the shortest duration in the system rather than the same 120ms
 * coming back.
 *
 * `forwards`, unlike the entrance: the last frame has to HOLD, or the menu
 * flashes back to full opacity for the frame between animationend and the real
 * hide. portal-kit.js takes the class off in the same tick as that hide, so the
 * held frame never outlives the element being displayed. (Belt and braces: it
 * also clears the class on show, because a held opacity:0 on a menu someone
 * re-opened is an invisible menu.)
 *
 * pointer-events: none, because the menu is on screen for 80ms after the click
 * that dismissed it — a fading menu that still eats a click is worse than no
 * animation.
 *
 * NOT on .ts-dropdown, deliberately, and it is the one place this family
 * splits. Tom Select's close() is its own vendor JS with no cancellable event,
 * so an exit there means re-displaying an element the vendor has already
 * hidden — and a picker's close is a COMPLETION (you chose something) rather
 * than a dismissal, which is a different gesture. The entrance is shared
 * because that one costs nothing. */
@keyframes portal-menu-out {
    from { opacity: 1; translate: 0 0; }
    to   { opacity: 0; translate: 0 -2px; }
}

@keyframes portal-menu-out-up {
    from { opacity: 1; translate: 0 0; }
    to   { opacity: 0; translate: 0 2px; }
}

body:has(.portal-page-themed) .dropdown-menu.show.portal-menu-leaving {
    animation: portal-menu-out var(--portal-dur-0, 80ms) var(--portal-ease-in-out, cubic-bezier(0.45, 0, 0.25, 1)) forwards;
    pointer-events: none;
}

body:has(.portal-page-themed) .dropdown-menu.show.portal-menu-leaving[data-popper-placement^="top"] {
    animation-name: portal-menu-out-up;
}

/* Static-backdrop feedback: Bootstrap's .modal-static pulse (scale 1.02 on
   a blocked dismiss) is only 0,3,0 — the transform pins above would
   silently out-rank it and the "you can't dismiss this" cue would never
   render (robot-inspection's gate modal, prelim-manager's overwrite
   modal). Restate it at winning specificity; the dialog's own transform
   transition animates it. */
body:has(.portal-page-themed) .modal.fade.show.modal-static .modal-dialog {
    transform: scale(1.02);
}

/* ── Theme crossfade ──────────────────────────────────────────────────
 * The footer sun/moon flip used to be a hard cut, which glass makes worse:
 * every surface, hairline and edge swaps palette in one frame. portal-theme.js
 * adds this class to <html> immediately BEFORE flipping data-bs-theme and
 * removes it on a timer just past the duration.
 *
 * Colour properties ONLY. Deliberately NOT box-shadow (it would tween the
 * whole specular/elevation stack per frame on every card at once) and
 * absolutely never the --portal-blur-* tokens — animating a backdrop-filter
 * is exactly the per-frame GPU cost the degrade gates exist to avoid, on
 * exactly the hardware they protect.
 *
 * !important is load-bearing here: it has to out-rank each component's own
 * transition for the ~250ms the class exists, and it vanishes with the class. */
html.portal-theme-xfade body,
html.portal-theme-xfade .card,
html.portal-theme-xfade .portal-stat-card,
html.portal-theme-xfade #site-footer {
    transition: background-color var(--portal-dur-2, 200ms) var(--portal-ease-in-out),
                color var(--portal-dur-2, 200ms) var(--portal-ease-in-out),
                border-color var(--portal-dur-2, 200ms) var(--portal-ease-in-out) !important;
}

/* ── Reduced motion: everything above is optional ────────────────── */
@media (prefers-reduced-motion: reduce) {
    html.portal-theme-xfade body,
    html.portal-theme-xfade .card,
    html.portal-theme-xfade .portal-stat-card,
    html.portal-theme-xfade #site-footer {
        transition: none !important;
    }

    .portal-live-dot::after,
    .portal-pulse,
    .portal-fade-in,
    .portal-rise-in,
    .portal-scale-in,
    .portal-enter-spring,
    .portal-stagger > *,
    .portal-sweep > *,
    .portal-flash,
    .portal-tick {
        animation: none;
    }

    /* The ambient set — all of it is a surface with nothing to say, so all of
       it goes. A surface whose motion is FUNCTIONAL (the kiosk's burn-in
       guard) re-enables its own under this same query, in its own file. */
    .portal-ambient-float,
    .portal-ambient-drift,
    .portal-ambient-breathe,
    .portal-ambient-sheen::after {
        animation: none;
    }

    /* Skeleton shimmer (portal-kit.css) — the static block still reads as
       a placeholder without the sweep. */
    .portal-skeleton::after {
        animation: none;
    }

    /* Section loading's anti-flash reveal (portal-kit.css) — with the
       animation gone the loader is visible immediately (static opacity 1). */
    .portal-section-loading {
        animation: none;
    }

    .portal-stat-card,
    .portal-accent-card {
        transition: none;
    }

    a.portal-stat-card:hover,
    .portal-stat-card.portal-hoverable:hover,
    .portal-page-themed .card.portal-hoverable:hover {
        transform: none;
    }

    /* Modal overrides above out-rank Bootstrap's own reduced-motion guard
       (.fade { transition: none }) — restate the kill here or we would
       defeat it. Zero-duration is exactly stock Bootstrap behavior under
       reduced motion; its show/hide callbacks handle it. */
    body:has(.portal-page-themed) .modal.fade,
    body:has(.portal-page-themed) .modal-backdrop.fade,
    /* The offcanvas scrim was missing from this list for as long as it has
       been in the family — it shared the enter rule with the modal backdrop
       above, but not the kill, so it was the one overlay in the portal that
       went on animating under prefers-reduced-motion. Splitting the two rules
       is what made that visible. */
    body:has(.portal-page-themed) .offcanvas-backdrop.fade,
    body:has(.portal-page-themed) .modal.fade .modal-dialog,
    body:has(.portal-page-themed) .modal.fade.show .modal-dialog {
        transition: none;
    }

    /* `translate`, not `transform` — clearing transform here would wipe
       Popper's positioning and drop every menu onto its anchor. */
    body:has(.portal-page-themed) .dropdown-menu.show,
    body:has(.portal-page-themed) .ts-dropdown {
        animation: none;
        translate: none;
    }

    /* The exit is killed in the JS as well as here, and both are needed. This
       rule alone would leave portal-kit.js waiting on an `animationend` that
       never fires and closing the menu on its fallback timer instead — the
       menu would still shut, 200ms late, every time. The JS check is what
       makes the dismissal instant; this is what stops the class painting
       anything if it ever lands. */
    body:has(.portal-page-themed) .dropdown-menu.show.portal-menu-leaving {
        animation: none;
        opacity: 1;
        translate: none;
    }
}

/* ── Cross-document view transitions ──────────────────────────────────
 * The least app-like thing about a multi-page app is that every click
 * repaints the world. This is the CSS-only fix: same-origin navigations
 * cross-fade instead of cutting. Chromium 126+ / Safari 18.2+; every other
 * engine ignores the at-rule and gets exactly today's behaviour.
 *
 * Scope note: this sheet is linked ONLY by base.twig. basePublic (TV, kiosk,
 * print) never loads it, and a cross-document transition requires BOTH the
 * old and new page to opt in — so kiosk surfaces, and any navigation between
 * an app page and a kiosk page, stay hard cuts on purpose.
 *
 * The chrome is deliberately NOT named. A named element is captured as its
 * own painted content, and backdrop-filter samples a backdrop that is not
 * part of that capture — so a named topbar would render its 0.2-alpha tint
 * WITHOUT its blur for the duration of the transition, i.e. a visible flash
 * of the degraded state on every click. Left in the root snapshot instead,
 * the strips are identical in the old and new capture (theme and sidebar
 * state are both stamped pre-paint, before the new snapshot is taken), so
 * cross-fading them is a no-op and the chrome simply appears to persist.
 *
 * Translate and fade only — never scale, and never near a blurred surface. */
@view-transition {
    navigation: auto;
}

/* The two halves of a cross-fade are NOT independently tunable, and this is
   the one place in the system where picking a nicer curve for the incoming
   side is actively wrong.

   The UA style sheet isolates the image pair and composites the old and new
   snapshots with mix-blend-mode: plus-lighter. plus-lighter ADDS; it only
   reconstructs the original picture while opacity_old + opacity_new === 1 at
   every frame. The UA keyframes are 1→0 and 0→1, so that invariant holds if
   and only if both sides run the SAME duration on the SAME curve — then
   old = 1 − f(t) and new = f(t) identically, whatever f is.

   These used to differ: 120ms ease-in-out out against 200ms ease-out in. Both
   curves are front-loaded, so the sum ran to 1.30 around the 30ms mark and
   stayed over 1 for the first ~65ms. Over-unity plus-lighter has nowhere to go
   but brighter — the alpha clamps at 1, so the live page underneath cannot
   absorb it the way it absorbs an undershoot. Every navigation therefore
   opened with the entire viewport ~30% hot and decaying, which is a blink, on
   a dark theme, on every click. Matched curves cost nothing and cannot do it.
   Keep them on one selector so they cannot drift apart again. */
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: var(--portal-dur-2);
    animation-timing-function: var(--portal-ease-in-out);
}

/* NO element inside the page gets its own view-transition-name — measured,
   not assumed. Naming <main> (the obvious move: let content rise while the
   root fades) promotes it to its own compositing layer permanently, on every
   page, for every device. It was visible as a 1/255 shift in every blended
   surface across the content column, which is how it was caught. That is a
   full-page-sized layer standing by forever to buy ~200ms of motion once per
   navigation — the same trade this system already refuses when it bans
   backdrop-filter on cards. If you re-add it, know what you are paying.

   The root group alone still does the job: the chrome strips are byte-identical
   in the old and new snapshots (theme and sidebar state are both stamped
   pre-paint), so cross-fading them is a no-op and the chrome reads as
   persistent while the content changes underneath it. */

/* Reduced motion: skip the snapshot entirely rather than animating to a
   zero duration — cheaper, and it cannot leave a stale frame on screen.
   The pseudo-element kill is a belt for engines that ship view transitions
   but do not yet honour @view-transition nested in @media. */
@media (prefers-reduced-motion: reduce) {
    @view-transition {
        navigation: none;
    }

    ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
        animation: none !important;
    }
}

/* The glass kill switch marks the low-end fleet (the hardware the degrade
   gates exist for). Snapshotting two full pages is exactly the cost those
   devices should not pay.
 *
 * THE TRANSITION ITSELF IS CANCELLED IN base.twig, not here — @view-transition
 * is a document-level at-rule and cannot be nested under a selector, so this
 * file can only reach the pseudo-elements, i.e. after both snapshots have
 * already been taken. base.twig's pre-paint glass script injects
 * `@view-transition { navigation: none }` when the attribute is off, which is
 * the actual fix.
 *
 * This stays as the belt, for the case where the attribute got stamped but the
 * injected style did not (a kiosk policy blocking storage takes a different
 * branch; a CSP without 'unsafe-inline' would drop the script entirely). And it
 * is a ZERO-LENGTH MATCHED PAIR rather than `animation: none`, for the same
 * reason the curves above are matched on one selector: the UA composites the
 * old and new snapshots with mix-blend-mode: plus-lighter, which only
 * reconstructs the picture while opacity_old + opacity_new === 1. `animation:
 * none` abandons the UA keyframes entirely and leaves that sum undefined —
 * measured, a glass-off navigation dipped to luminance 16.5 against a 23-26
 * baseline, three runs out of three. Keeping the UA's own keyframes and
 * collapsing their duration holds the invariant by construction. */
html[data-portal-glass="off"]::view-transition-group(*),
html[data-portal-glass="off"]::view-transition-old(*),
html[data-portal-glass="off"]::view-transition-new(*) {
    animation-duration: 1ms !important;
    animation-delay: 0s !important;
    animation-timing-function: linear !important;
}
