/* ═══════════════════════════════════════════════════════════════════════════
   PoTraffic Motion — keyframes and motion utilities.

   This file is the ONLY home for @keyframes. They previously existed in both
   pt-tokens.css and app.css with different values (the pulse ring 6px in one
   and 8px in the other), and since app.css loads last the file commented
   "canonical keyframes" was the one being overridden.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Keyframes ──────────────────────────────────────────────────────────── */
@keyframes pt-fade-in     { from { opacity: 0; } to { opacity: 1; } }
@keyframes pt-slide-up    { from { opacity: 0; transform: translate3d(0, 8px, 0); }
                            to   { opacity: 1; transform: translate3d(0, 0, 0); } }
@keyframes pt-slide-down  { from { opacity: 0; transform: translate3d(0, -8px, 0); }
                            to   { opacity: 1; transform: translate3d(0, 0, 0); } }
@keyframes pt-scale-press { from { transform: scale(1); } to { transform: scale(0.97); } }
@keyframes pt-shimmer     { 0%   { background-position: -200% 0; }
                            100% { background-position:  200% 0; } }
@keyframes pt-pulse       { 0%   { box-shadow: 0 0 0 0 rgb(16 185 129 / 0.5); }
                            70%  { box-shadow: 0 0 0 8px rgb(16 185 129 / 0); }
                            100% { box-shadow: 0 0 0 0 rgb(16 185 129 / 0); } }
@keyframes pt-sweep       { 0%   { transform: translate3d(-100%, 0, 0); }
                            100% { transform: translate3d(0%, 0, 0); } }


/* ── Entrance utilities ─────────────────────────────────────────────────── */
.pt-anim-fade { animation: pt-fade-in   var(--pt-dur-base) var(--pt-ease) both; }
.pt-anim-up   { animation: pt-slide-up  var(--pt-dur-base) var(--pt-ease) both; }
.pt-anim-down { animation: pt-slide-down var(--pt-dur-base) var(--pt-ease) both; }

/* Stagger entrance.

   This read `animation-delay: calc(var(--pt-i, 0) * 50ms)` and was documented as
   "children accept style='--pt-i:N'". No child anywhere in the app ever set it,
   so every delay fell back to 0 and the stagger has always been a plain
   simultaneous fade — a feature that has never once run.

   `sibling-index()` supplies the ordinal the inline style was standing in for,
   with no markup involvement at all. Where it is unsupported the calc is
   invalid, the declaration is dropped, and the delay is 0 — exactly the
   behaviour that shipped for the whole life of the rule, so there is nothing to
   fall back to.

   Capped at the 8th child: a delay that keeps growing down a long list means the
   last card fades in a second and a half after the first, which stops reading as
   choreography and starts reading as slowness. */
.pt-stagger > * {
    animation: pt-slide-up var(--pt-dur-base) var(--pt-ease) both;
}

@supports (animation-delay: calc(sibling-index() * 1ms)) {
    .pt-stagger > * {
        animation-delay: calc(min(sibling-index(), 8) * 40ms);
    }
}

/* A staggered list must not delay content for someone who asked for less
   motion — the reduced-motion block below zeroes durations, and a leftover
   delay would leave items blank for the length of the stagger. */
@media (prefers-reduced-motion: reduce) {
    .pt-stagger > * { animation-delay: 0s !important; }
}

/* Default page entrance. */
article.content { animation: pt-slide-up var(--pt-dur-base) var(--pt-ease); }

/* ── Interactive press feedback ─────────────────────────────────────────── */
.pt-press {
    transition: background-color var(--pt-dur-fast) var(--pt-ease),
                border-color var(--pt-dur-fast) var(--pt-ease),
                box-shadow var(--pt-dur-fast) var(--pt-ease);
}
.pt-press:active { transform: translateY(1px); }

.pt-press-glow { transition: box-shadow var(--pt-dur-fast) var(--pt-ease); }
.pt-press-glow:active {
    box-shadow: 0 0 0 3px color-mix(in oklab, var(--pt-color-brand-500) 35%, transparent);
}

/* ── Indeterminate progress line ────────────────────────────────────────── */
.pt-progress-line {
    position: relative;
    overflow: hidden;
    height: 2px;
    width: 100%;
    /* Was --pt-color-brand-100, a near-white track that glared on a dark page.
       Mixing the brand into the current surface tracks the theme instead. */
    background: color-mix(in oklab, var(--pt-color-brand-500) 22%, var(--pt-bg-surface));
    border-radius: var(--pt-radius-full);
}
.pt-progress-line::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg,
        transparent 0%,
        var(--pt-color-brand-on-surface) 50%,
        transparent 100%);
    animation: pt-sweep 1.6s var(--pt-ease) infinite;
    will-change: transform;
}

/* ── Live status dot ────────────────────────────────────────────────────── */
.pt-live-dot {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--pt-color-success);
    animation: pt-pulse 2s infinite var(--pt-ease);
}

/* A scroll-driven reveal (`animation-timeline: view()`) used to live here and was
   applied to the route page's analysis cards. It is deliberately gone: a card
   taller than the scrollport can fail to complete its `entry` range, and the
   failure mode is content stuck at opacity 0. An app whose job is to show you a
   number must never trade legibility for a flourish. Elements that appear
   mid-session use .pt-enter below, which always ends visible.

   ── Entry animation for elements that appear mid-session ───────────────────
   @starting-style gives newly-inserted DOM a from-state without a JS tick or a
   "mounted" class, which is exactly what Blazor's conditional blocks need. */
@supports (transition-behavior: allow-discrete) {
    @media (prefers-reduced-motion: no-preference) {
        .pt-enter {
            opacity: 1;
            translate: 0 0;
            transition: opacity var(--pt-dur-base) var(--pt-ease),
                        translate var(--pt-dur-base) var(--pt-ease);
        }
        @starting-style {
            .pt-enter { opacity: 0; translate: 0 8px; }
        }
    }
}

/* ── View transitions ───────────────────────────────────────────────────────
   Applied to theme and density switches, which are the state changes the app
   fully controls (an attribute on <html>). SPA route changes are deliberately
   left alone: Blazor owns that DOM update, and wrapping it would mean guessing
   when the re-render lands. */
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: var(--pt-dur-slow);
    animation-timing-function: var(--pt-ease);
}

/* A card-morphs-into-the-page transition was considered for dashboard → route
   detail and deliberately NOT shipped. Both halves of it are unavailable here:
   `@view-transition { navigation: auto }` fires on cross-DOCUMENT navigation
   only, and this is a Blazor WebAssembly SPA where the URL changes without one;
   the same-document API needs `document.startViewTransition(cb)` where `cb`
   resolves after the new DOM is in place, and Blazor exposes no hook for "the
   re-render has landed". Worse, the detail page loads async — the frame right
   after navigation shows a progress line, not the answer — so the snapshot
   would capture the loading state and the morph would tween into nothing.

   The theme/density transition below stays because that one IS a state change
   the app fully owns: an attribute write on <html>, synchronous, no render to
   wait for. */

@media (prefers-reduced-motion: reduce) {
    ::view-transition-old(root),
    ::view-transition-new(root) { animation: none; }
}

/* ── Reduced motion ─────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }

    .pt-progress-line::after,
    .pt-shimmer::after,
    .pt-live-dot {
        animation: none;
        opacity: 0.7;
    }
}

/* ── Shimmer overlay ────────────────────────────────────────────────────── */
.pt-shimmer { position: relative; overflow: hidden; }
.pt-shimmer::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, transparent,
                color-mix(in oklab, var(--pt-bg-surface) 60%, transparent), transparent);
    animation: pt-sweep 1.4s linear infinite;
}

/* ═══════════════════════════════════════════════════════════════════════════
   Tap targets. The rule this replaces was commented "minimum 44×44" but set
   min-height to --pt-space-2 (8px, rising to 24px under 640px), applied no
   min-width, and hit every <a> on the page including inline prose links.

   :where() keeps specificity at zero so any component can override without a
   fight, and the selector is limited to real controls — a link inside running
   text is excluded, since padding it to 44px would wreck the paragraph.

   Gated on `pointer: coarse` because 44px is a finger measurement. Forcing it
   on a mouse would fight the compact desktop density this app is going for,
   and a cursor does not need the help.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (pointer: coarse) {
    :where(button, [role="button"], input[type="button"], input[type="submit"],
           select, .rz-button, a.pt-tap) {
        min-block-size: var(--pt-tap-min);
        min-inline-size: var(--pt-tap-min);
    }
}

/* ═══════════════════════════════════════════════════════════════
   View transitions
   ═══════════════════════════════════════════════════════════════

   Blazor WASM swaps the routed component in place; the browser sees one
   long-lived document, so the native cross-document transition never fires.
   js/pt-viewtransition.js wraps each navigation in document.startViewTransition()
   instead, which gives the same effect for a SPA.

   Everything here is inside @supports: where the API is missing the app renders
   exactly as it did before, with no fallback animation to maintain. */

@supports (view-transition-name: none) {
    ::view-transition-old(root),
    ::view-transition-new(root) {
        /* The default is a cross-fade of the whole page, which on a page whose
           header and nav do not change reads as the entire UI flickering. Both
           halves are animated explicitly instead. */
        animation-duration: var(--pt-dur-base);
        animation-timing-function: var(--pt-ease);
    }

    ::view-transition-old(root) {
        animation-name: pt-vt-out;
    }

    ::view-transition-new(root) {
        animation-name: pt-vt-in;
    }

    /* The shell does not participate. Naming these keeps the header, the bottom
       nav and the toasts OUT of the snapshot, so they stay put while the content
       beneath them changes — which is what makes the transition read as "the page
       changed" rather than "everything blinked". */
    .app-header,
    .pt-bottom-nav,
    .pt-ambient,
    .pt-particles {
        view-transition-name: none;
    }
}

@keyframes pt-vt-out {
    to { opacity: 0; transform: translateY(-4px); }
}

@keyframes pt-vt-in {
    from { opacity: 0; transform: translateY(6px); }
}

/* Reduced motion: no slide, no fade. The API still runs so the DOM swap is
   atomic, but there is nothing to see. */
@media (prefers-reduced-motion: reduce) {
    ::view-transition-old(root),
    ::view-transition-new(root) {
        animation: none;
    }
}

[data-motion="off"] ::view-transition-old(root),
[data-motion="off"] ::view-transition-new(root) {
    animation: none;
}
