/*
 * yzworkstation motion layer. The file is loaded after shared components and
 * before page styles so motion stays centralized and easy to disable.
 */

/* Used by the hero only; nothing animates on scroll any more. */
@keyframes enter-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}

@keyframes hero-char-rise {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: none; }
}

@keyframes motion-dot-one { 0%, 80%, 100% { opacity: 0.28; } 40% { opacity: 1; } }
@keyframes motion-dot-two { 0%, 20%, 100% { opacity: 0.28; } 60% { opacity: 1; } }
@keyframes motion-dot-three { 0%, 40%, 100% { opacity: 0.28; } 80% { opacity: 1; } }

/*
 * osu!lazer's shake (osu.Game/Extensions/DrawableExtensions.Shake), segment
 * for segment: 8px each way, 80ms per swing with a half swing at either end,
 * two full cycles, 320ms in all.
 *
 *   MoveToX(+8, 40, OutSine) → (−8, 80, InOutSine) → (+8, 80, InOutSine)
 *   → (−8, 80, InOutSine) → (0, 40, InSine)
 *
 * The previous 4px single cycle read as a wobble rather than as a refusal.
 */
@keyframes motion-shake {
  0% { transform: translateX(0); animation-timing-function: var(--ease-out-sine); }
  12.5% { transform: translateX(8px); animation-timing-function: var(--ease-inout-sine); }
  37.5% { transform: translateX(-8px); animation-timing-function: var(--ease-inout-sine); }
  62.5% { transform: translateX(8px); animation-timing-function: var(--ease-inout-sine); }
  87.5% { transform: translateX(-8px); animation-timing-function: var(--ease-in-sine); }
  100% { transform: translateX(0); }
}

@keyframes theme-reveal {
  from { clip-path: circle(0 at var(--theme-x, 50%) var(--theme-y, 50%)); }
  to { clip-path: circle(150vmax at var(--theme-x, 50%) var(--theme-y, 50%)); }
}

@keyframes motion-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

html.motion-standard [data-motion-hero],
html.motion-full [data-motion-hero],
html.motion-minimal [data-motion-hero] {
  opacity: 0;
  transform: translateY(16px);
}

html.motion-standard [data-motion-hero].is-entered,
html.motion-minimal [data-motion-hero].is-entered {
  animation: enter-rise var(--dur-entrance) var(--ease-out) both;
  animation-delay: min(calc(var(--hero-index, 0) * var(--stagger)), calc(var(--stagger-max) * var(--stagger)));
}

html.motion-full [data-motion-hero].is-entered {
  animation: enter-rise var(--dur-entrance) var(--ease-out) both;
  animation-delay: min(calc(var(--hero-index, 0) * var(--stagger)), calc(var(--stagger-max) * var(--stagger)));
}

html.motion-minimal [data-motion-hero].is-entered { animation-name: motion-fade-in; }
html.motion-full h1[data-motion-split="true"][data-motion-hero] {
  opacity: 1;
  transform: none;
  animation: none;
}
html.motion-none [data-motion-hero],
html.motion-none [data-motion-hero].is-entered {
  opacity: 1;
  transform: none;
  animation: none;
}

.home-hero__inner > [data-motion-hero]:nth-child(1) { --hero-index: 0; }
.home-hero__inner > [data-motion-hero]:nth-child(2) { --hero-index: 1; }
.home-hero__inner > [data-motion-hero]:nth-child(3) { --hero-index: 2; }

html.motion-full .home-hero h1[data-motion-split="true"] .motion-char {
  display: inline-block;
  opacity: 0;
  transform: translateY(16px);
  animation: hero-char-rise var(--dur-entrance) var(--ease-out) both;
  animation-delay: calc(var(--char-index) * 28ms);
}

/* Cross-fade the elevation pseudo-layer instead of animating box-shadow. */
html.motion-standard .card,
html.motion-full .card,
html.motion-minimal .card,
html.motion-standard .catalog-card,
html.motion-full .catalog-card,
html.motion-minimal .catalog-card {
  isolation: isolate;
  position: relative;
}

html.motion-standard .card::after,
html.motion-full .card::after,
html.motion-minimal .card::after,
html.motion-standard .catalog-card::after,
html.motion-full .catalog-card::after,
html.motion-minimal .catalog-card::after {
  content: "";
  position: absolute;
  z-index: 0;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--shadow-card-hover);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-normal) var(--ease-out);
}

html.motion-standard .card:hover::after,
html.motion-standard .card:focus-visible::after,
html.motion-full .card:hover::after,
html.motion-full .card:focus-visible::after,
html.motion-standard .catalog-card:hover::after,
html.motion-standard .catalog-card:focus-visible::after,
html.motion-full .catalog-card:hover::after,
html.motion-full .catalog-card:focus-visible::after {
  opacity: 1;
}

/*
 * With the pseudo-layer doing the elevation, the element's own shadow has to
 * stop moving — otherwise hover paints `--shadow-card-hover` twice, once from
 * the element and once from the layer above it, at double strength and with a
 * repaint on every frame of the fade. `.card` keeps its resting shadow rather
 * than dropping to `none`, or it would sink before the layer had risen.
 */
html:is(.motion-standard, .motion-full, .motion-minimal) :is(.card, .catalog-card) {
  transition: transform var(--dur-normal) var(--ease-out), border-color var(--dur-fast) ease;
}
html:is(.motion-standard, .motion-full, .motion-minimal) .card:is(:hover, :focus-visible) {
  box-shadow: var(--shadow-card);
}
html:is(.motion-standard, .motion-full, .motion-minimal) .catalog-card:is(:hover, :focus-visible) {
  box-shadow: none;
}

html.motion-standard .card__icon,
html.motion-full .card__icon { transition: transform var(--dur-normal) var(--ease-spring); }
html.motion-standard a.card:hover .card__icon,
html.motion-standard a.card:focus-visible .card__icon,
html.motion-full a.card:hover .card__icon,
html.motion-full a.card:focus-visible .card__icon { transform: scale(1.08); }

html.motion-full .card[data-motion-tilt]:hover,
html.motion-full .catalog-card[data-motion-tilt]:hover {
  transform: translateY(-3px) rotateX(var(--tilt-y, 0deg)) rotateY(var(--tilt-x, 0deg));
  transform-style: preserve-3d;
}

html.motion-full [data-motion-tilting]:hover {
  transition: transform 0s;
}

html.motion-standard .button:active,
html.motion-full .button:active,
html.motion-standard .card:active,
html.motion-full .card:active,
html.motion-standard .catalog-card:active,
html.motion-full .catalog-card:active {
  transform: scale(0.97);
  transition: transform var(--dur-instant) var(--ease-spring);
}

html.motion-minimal .card:hover,
html.motion-minimal .catalog-card:hover { background: var(--color-surface-strong); }

/* Mobile navigation opens from the toggle with a clipped panel and a short
 * item stagger; closing is a single, quick fade to avoid waiting in reverse. */
@media (max-width: 48rem) {
  html.motion-standard .site-nav,
  html.motion-full .site-nav,
  html.motion-minimal .site-nav {
    transform: none;
    clip-path: inset(0 0 100% 0 round var(--radius-lg));
    transition: clip-path var(--dur-normal) var(--ease-out), opacity var(--dur-normal) var(--ease-out), visibility 0s linear var(--dur-normal);
  }

  html.motion-standard .nav-open .site-nav,
  html.motion-full .nav-open .site-nav,
  html.motion-minimal .nav-open .site-nav {
    clip-path: inset(0 round var(--radius-lg));
  }

  html.motion-standard .site-nav > *,
  html.motion-full .site-nav > *,
  html.motion-minimal .site-nav > * {
    opacity: 0;
    transform: translateY(-8px);
    transition: opacity var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
  }

  html.motion-standard .nav-open .site-nav > *,
  html.motion-full .nav-open .site-nav > *,
  html.motion-minimal .nav-open .site-nav > * {
    opacity: 1;
    transform: none;
    transition-delay: calc(var(--nav-index, 0) * 40ms);
  }

  .site-nav > *:nth-child(1) { --nav-index: 0; }
  .site-nav > *:nth-child(2) { --nav-index: 1; }
  .site-nav > *:nth-child(3) { --nav-index: 2; }
  .site-nav > *:nth-child(4) { --nav-index: 3; }
  .site-nav > *:nth-child(5) { --nav-index: 4; }
}

/* Forms, loading feedback and transient layers. */
html.motion-standard .form-field input:focus-visible,
html.motion-standard .form-field select:focus-visible,
html.motion-standard .form-field textarea:focus-visible,
html.motion-full .form-field input:focus-visible,
html.motion-full .form-field select:focus-visible,
html.motion-full .form-field textarea:focus-visible {
  animation: focus-ring-pop var(--dur-fast) var(--ease-spring) both;
}

@keyframes focus-ring-pop {
  from { transform: scale(0.96); }
  to { transform: scale(1); }
}

form[aria-busy="true"] button[type="submit"] {
  position: relative;
  color: transparent;
  pointer-events: none;
}

form[aria-busy="true"] button[type="submit"]::after {
  content: "•••";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--color-on-accent);
  letter-spacing: 0.18em;
  animation: submit-dots 1s infinite;
}

@keyframes submit-dots {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 1; }
}

html.motion-full form [data-form-status][data-state="error"] { animation: motion-shake 320ms ease-out both; }
.toast { animation: toast-in 500ms var(--ease-out-quint) both; }
/* The same message again: a short nudge rather than a second copy. */
.toast.is-bumped { animation: toast-bump 400ms var(--ease-out-elastic-half); }
@keyframes toast-bump { from { transform: scale(0.96); } to { transform: none; } }
@keyframes toast-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.toast.is-leaving { animation: toast-out 200ms var(--ease-in-quint) both; }
@keyframes toast-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(12px); } }
.yz-dialog::backdrop { background: rgb(0 0 0 / 0); }
.yz-dialog[open] { animation: dialog-in var(--dur-normal) var(--ease-out) both; }
.yz-dialog[open]::backdrop { animation: backdrop-in var(--dur-normal) ease-out both; }
@keyframes dialog-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
@keyframes backdrop-in { from { background: rgb(0 0 0 / 0); } to { background: rgb(0 0 0 / 0.32); } }

/* Catalogue filtering: fade before hidden, with optional FLIP supplied by JS. */
html.motion-standard .catalog-card.is-filtering-out,
html.motion-full .catalog-card.is-filtering-out,
html.motion-minimal .catalog-card.is-filtering-out {
  opacity: 0;
  transform: scale(0.98);
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-in), transform var(--dur-fast) var(--ease-in);
}

html.motion-standard .catalog-card.is-filtering-in,
html.motion-full .catalog-card.is-filtering-in,
html.motion-minimal .catalog-card.is-filtering-in {
  opacity: 0;
  transform: scale(0.98);
}

html.motion-standard .catalog-card:not(.is-filtering-in),
html.motion-full .catalog-card:not(.is-filtering-in) {
  transition: transform var(--dur-normal) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}

/* Game feedback is presentational only; state is written before the class or
 * data attribute changes so reduced-motion users get the same game. */
html.motion-standard .tile-2048[data-motion-merged="true"],
html.motion-full .tile-2048[data-motion-merged="true"] { animation: tile-merge 180ms var(--ease-spring) both; }
html.motion-standard .tile-2048[data-motion-new="true"],
html.motion-full .tile-2048[data-motion-new="true"] { animation: tile-new 180ms var(--ease-out) both; }
@keyframes tile-merge { 0%, 100% { transform: scale(1); } 45% { transform: scale(1.12); } }
@keyframes tile-new { from { opacity: 0; transform: scale(0.4); } to { opacity: 1; transform: scale(1); } }

html.motion-standard .mine-cell[data-motion-reveal="true"],
html.motion-full .mine-cell[data-motion-reveal="true"] { animation: mine-reveal var(--dur-fast) var(--ease-out) both; animation-delay: var(--mine-delay, 0ms); }
@keyframes mine-reveal { from { opacity: 0; transform: scale(0.86); } to { opacity: 1; transform: none; } }

html.motion-standard .lights-cell[data-motion-press="true"],
html.motion-full .lights-cell[data-motion-press="true"] { animation: lights-press var(--dur-fast) var(--ease-out) both; }
@keyframes lights-press { from { transform: scale(0.94); } to { transform: scale(1); } }

html.motion-standard .memory-card[data-revealed="true"],
html.motion-full .memory-card[data-revealed="true"] { animation: memory-flip var(--dur-normal) var(--ease-out) both; backface-visibility: hidden; }
@keyframes memory-flip { from { transform: rotateY(180deg); } to { transform: rotateY(0); } }

html.motion-minimal *,
html.motion-none * {
  animation-duration: 1ms !important;
  animation-iteration-count: 1 !important;
}

html.motion-none *,
html.motion-none *::before,
html.motion-none *::after { transition-duration: 1ms !important; }

@media (prefers-reduced-motion: reduce) {
  html.motion-none .motion-char { opacity: 1 !important; transform: none !important; }
}

@media (prefers-reduced-transparency: reduce) {
  html.motion-full .site-header { background: var(--color-bg); backdrop-filter: none; }
}

@view-transition { navigation: auto; }

/*
 * The chrome keeps its own name so it stays put while the page body crosses
 * over. This only works when the incoming document *has* a header at its first
 * render — a name present on one side and absent on the other reads as an
 * element leaving, and the bar blanks for the length of the transition.
 * chrome-init.js is what guarantees the match; the `[data-chrome-cached]`
 * condition below is what keeps the very first navigation of a session, before
 * that cache exists, from paying for the mismatch.
 */
html.motion-full body[data-chrome-cached="true"] .site-header { view-transition-name: yz-site-header; }
html.motion-full body[data-chrome-cached="true"] .site-footer { view-transition-name: yz-site-footer; }

html.motion-full::view-transition-old(root) {
  animation: page-old var(--dur-transition) var(--ease-in) both;
  transform-origin: center;
}

html.motion-full::view-transition-new(root) {
  animation: page-new var(--dur-transition) var(--ease-emphasis) both;
}

html.motion-full::view-transition-old(yz-site-header),
html.motion-full::view-transition-new(yz-site-header),
html.motion-full::view-transition-old(yz-site-footer),
html.motion-full::view-transition-new(yz-site-footer) { animation: none; }

@keyframes page-old {
  to { opacity: 0; transform: scale(0.99); }
}
@keyframes page-new {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}

html.motion-full[data-theme-transition="true"]::view-transition-old(root) {
  animation: none;
}

html.motion-full[data-theme-transition="true"]::view-transition-new(root) {
  animation: theme-reveal var(--dur-transition) var(--ease-emphasis) both;
}

/* ===================================================== weighted feedback ===
 *
 * Motion with mass, in the spirit of a well-tuned game UI: things that respond
 * to a press compress slightly and spring back rather than fading. The spring
 * curve is the only one allowed to overshoot, and only on elements the pointer
 * is actually touching.
 */

/* A tab's text colour follows the same 500ms OutQuint as its indicator
 * (OsuTabItem.FadeHovered / FadeUnhovered). OutQuint puts four fifths of the
 * change in the first ~140ms, so it is not slow — it just keeps settling. */
html.motion-standard .segmented button,
html.motion-full .segmented button {
  transition: color 500ms var(--ease-out-quint), transform var(--dur-fast) var(--ease-spring);
}

html.motion-standard .segmented button:active,
html.motion-full .segmented button:active {
  transform: scale(0.94);
}



/* The row lifts its own name and arrow, not the whole block, so a long list
 * does not appear to ripple when the pointer crosses it. */
html.motion-standard .index-row:active,
html.motion-full .index-row:active {
  transform: scale(0.995);
  transition: transform var(--dur-instant) var(--ease-spring);
}

html.motion-standard .index-row__tag,
html.motion-full .index-row__tag {
  transition:
    background-color var(--duration-fast) ease,
    color var(--duration-fast) ease,
    transform var(--dur-normal) var(--ease-spring);
}

html.motion-full .index-row:hover .index-row__tag {
  transform: scale(1.06);
}

/* Search field reacts to focus with a short pull rather than a static ring. */
html.motion-standard .catalog-search input,
html.motion-full .catalog-search input {
  transition:
    border-color var(--duration-fast) ease,
    box-shadow var(--dur-normal) var(--ease-out),
    transform var(--dur-normal) var(--ease-spring);
}

html.motion-standard .catalog-search input:focus-visible,
html.motion-full .catalog-search input:focus-visible {
  transform: translateY(-1px);
}

html.motion-minimal .segmented button:active,
html.motion-minimal .index-row:active,
html.motion-none .segmented button:active,
html.motion-none .index-row:active {
  transform: none;
}

/* Glass geometry is coordinated by YZMotion.createOmniMotion. These small
 * colour and press accents share the segmented indicator's spring token. */
html:is(.motion-standard, .motion-full) .omni__glass {
  transition: color var(--dur-fast) ease;
}

/*
 * Cold-start raster warm-up. The rail's first expansion used to spend about
 * 116ms in a GPU raster flush with no matching main-thread work: a dozen
 * rounded, bordered, blurred pieces had never been drawn, and the first frame
 * that needed them paid for all of them at once. `createOmniMotion` sets
 * `data-omni-warm` once the page is idle, which promotes them before anyone
 * presses anything. The hint is left on because this control is used over and
 * over, and these are a dozen small boxes rather than a surface worth
 * conserving compositor memory over.
 */
html:is(.motion-standard, .motion-full) .omni[data-omni-warm] :is(.omni__glass, .omni__cap, .omni__bridge, .omni__unit, .omni__search-content) {
  will-change: transform;
}
html:is(.motion-standard, .motion-full) .omni[data-omni-warm] :is(.omni__field, .segmented, .omni__folded) {
  will-change: transform, opacity;
}
html:is(.motion-standard, .motion-full) .omni__icon {
  transition: transform var(--dur-slow) var(--ease-spring), color var(--dur-fast) ease;
}
html:is(.motion-standard, .motion-full) .omni[data-omni-mode="tabs"]:has(.omni__hit--search:active) .omni__icon {
  transform: translate(-50%, -50%) scale(0.86) rotate(-10deg);
  transition-duration: var(--dur-press);
}
html:is(.motion-standard, .motion-full) .omni[data-omni-mode="search"]:has(.omni__hit--tabs:active) .omni__folded svg {
  transform: scale(0.86);
  transition: transform var(--dur-press) var(--ease-spring);
}
@media (hover: hover) and (pointer: fine) {
  .omni[data-omni-mode="tabs"]:has(.omni__hit--search:hover) .omni__glass--search,
  .omni[data-omni-mode="search"]:has(.omni__hit--tabs:hover) .omni__glass--tabs {
    color: var(--color-accent);
  }
}
html.motion-minimal .omni__field,
html.motion-minimal .omni .segmented,
html.motion-minimal .omni__folded { transition: opacity var(--dur-fast) ease-out; }
html:is(.motion-minimal, .motion-none) .omni__icon { transform: translate(-50%, -50%); }
html:is(.motion-minimal, .motion-none) .omni__folded,
html:is(.motion-minimal, .motion-none) .omni__dot { transform: none; }
html.motion-none .omni * { transition: none; animation: none; }

/* ==================================================== expressive controls ===
 *
 * Controls behave like physical objects: they compress under the pointer and
 * spring back past their resting size before settling, and a short flash
 * confirms the hit. The overshoot is what makes it read as weight rather than
 * as a fade, and it is confined to elements the pointer is actually on.
 */

:root {
  --ease-elastic: cubic-bezier(0.16, 1.36, 0.3, 1);
  --dur-press: 90ms;
  --dur-release: 460ms;
}

/*
 * The press model is lifted from osu!lazer's own buttons rather than
 * approximated. ppy/osu, osu.Game/Graphics/UserInterface:
 *
 *   OsuButton          OnMouseDown  Content.ScaleTo(0.9f, 4000, OutQuint)
 *                      OnMouseUp    Content.ScaleTo(1, 1000, OutElastic)
 *   OsuAnimatedButton  OnMouseDown  Content.ScaleTo(0.75f, 2000, OutQuint)
 *   (IconButton)       OnMouseUp    Content.ScaleTo(1, 1000, OutElastic)
 *
 * The long press duration is the point. It is not a quick compress: an
 * OutQuint over four seconds dents a button by about 1% in a normal 100ms
 * click and keeps sinking for as long as it is held, so a deliberate hold
 * reads as pressure building. A click is carried by the flash and the sound,
 * not by the scale. The release is a real decaying ring (see tokens.css),
 * which a single Bézier overshoot could only imitate once.
 *
 * Buttons do not grow on hover. lazer never scales an ordinary button under
 * the pointer; it lights it — see the hover flash below.
 */
html:is(.motion-standard, .motion-full) :is(.button, .icon-button, .nav-toggle) {
  position: relative;
  overflow: hidden;
  transform: scale(1);
  transition:
    transform 1000ms var(--ease-out-elastic),
    background-color var(--duration-fast) ease,
    border-color var(--duration-fast) ease,
    color var(--duration-fast) ease;
}

html:is(.motion-standard, .motion-full) .button:active {
  transform: scale(0.9);
  transition-duration: 4000ms, var(--duration-fast), var(--duration-fast), var(--duration-fast);
  transition-timing-function: var(--ease-out-quint), ease, ease, ease;
}

html:is(.motion-standard, .motion-full) :is(.icon-button, .nav-toggle):active {
  transform: scale(0.75);
  transition-duration: 2000ms, var(--duration-fast), var(--duration-fast), var(--duration-fast);
  transition-timing-function: var(--ease-out-quint), ease, ease, ease;
}

/*
 * Hover: flash, then settle. OsuButton.OnHover is
 *
 *   Hover.FadeTo(0.2f, 40, OutQuint).Then().FadeTo(0.1f, 800, OutQuint);
 *
 * a spike in 40ms that decays over 800ms onto the resting highlight. Nub (the
 * toggle) does the same with its colour. The resting highlight here is each
 * button's own `:hover` background, so this layer carries only the spike on
 * top of it — rising from zero and returning to zero means it can never be
 * caught mid-value when the pointer leaves.
 */
html:is(.motion-standard, .motion-full) :is(.button, .icon-button)::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
}

@media (hover: hover) and (pointer: fine) {
  html:is(.motion-standard, .motion-full) :is(.button, .icon-button):hover::before {
    animation: control-hover-flash 840ms both;
  }
}

@keyframes control-hover-flash {
  0% { opacity: 0; animation-timing-function: var(--ease-out-quint); }
  4.76% { opacity: 0.14; animation-timing-function: var(--ease-out-quint); }
  100% { opacity: 0; }
}

/*
 * Hit flash, on the click rather than on the press:
 *
 *   OsuButton.OnClick  flashLayer.FadeOutFromOne(500, OutQuint)
 *
 * A click is a release, so `:active` fired it too early — while the button was
 * still going down. motion.js adds `data-motion-flash` on `click` (which also
 * covers Enter and Space) and clears it when the animation ends.
 */
html:is(.motion-standard, .motion-full) :is(.button, .icon-button, .nav-toggle)::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: currentColor;
  opacity: 0;
  pointer-events: none;
}

html:is(.motion-standard, .motion-full) [data-motion-flash]::after {
  animation: control-flash 500ms var(--ease-out-quint) both;
}

@keyframes control-flash {
  from { opacity: 0.3; }
  to { opacity: 0; }
}

/*
 * Section header entrance. lazer's overlays rise into place on PopIn
 * (WaveContainer: content MoveToY(0, 800, OutQuint)); here the title block
 * does the same, item by item, 60ms apart. Only the band at the top of the
 * page moves — it is in view before anything is scrolled, which is the one
 * place ANIMATIONS.md §3.1 allows an entrance.
 */
@keyframes hero-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}

@keyframes hero-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

html:is(.motion-standard, .motion-full) .page-hero__inner > *,
html:is(.motion-standard, .motion-full) .page-hero > .page-tabs,
html:is(.motion-standard, .motion-full) .page-hero > .shell > .page-tabs {
  animation: hero-rise 800ms var(--ease-out-quint) both;
}

html:is(.motion-standard, .motion-full) .page-hero__inner > :nth-child(2) { animation-delay: 60ms; }
html:is(.motion-standard, .motion-full) .page-hero__inner > :nth-child(3) { animation-delay: 120ms; }
html:is(.motion-standard, .motion-full) .page-hero__inner > :nth-child(4) { animation-delay: 180ms; }
html:is(.motion-standard, .motion-full) .page-hero__inner > :nth-child(n + 5) { animation-delay: 240ms; }
html:is(.motion-standard, .motion-full) .page-hero .page-tabs { animation-delay: 200ms; }

html.motion-minimal .page-hero__inner > *,
html.motion-minimal .page-hero .page-tabs {
  animation: hero-fade 300ms var(--ease-out-quint) both;
}

/* Form controls answer focus with a short lift instead of only a ring. */
html.motion-standard .form-field input,
html.motion-full .form-field input,
html.motion-standard .form-field select,
html.motion-full .form-field select,
html.motion-standard .form-field textarea,
html.motion-full .form-field textarea {
  transition:
    border-color var(--duration-fast) ease,
    box-shadow var(--dur-normal) var(--ease-out),
    transform var(--dur-release) var(--ease-elastic);
}

html.motion-full .form-field input:focus-visible,
html.motion-full .form-field select:focus-visible,
html.motion-full .form-field textarea:focus-visible {
  transform: translateY(-2px);
}

html.motion-minimal .button:active,
html.motion-minimal .icon-button:active,
html.motion-none .button:active,
html.motion-none .icon-button:active {
  transform: none;
}

html.motion-minimal .button::after,
html.motion-none .button::after,
html.motion-minimal .icon-button::after,
html.motion-none .icon-button::after {
  display: none;
}

/* ======================================================== mobile menu ===
 *
 * The panel used to cross-fade as one block, which reads as a flicker on a
 * phone. It now drops with weight and the links follow in sequence, so the
 * menu appears to arrive rather than to blink into place.
 */
@media (max-width: 48rem) {
  /*
   * osu!lazer's OsuMenu, as written:
   *
   *   AnimateOpen   FadeIn(280, OutQuint)          UpdateSize  300, OutQuint
   *   AnimateClose  Delay(50).FadeOut(280, OutQuint)
   *
   * The 50ms before closing is deliberate there and here: a pointer or thumb
   * that brushes the toggle a second time should not make the panel blink.
   * Menus do not bounce in lazer — the weight is in the OutQuint, not in an
   * overshoot.
   */
  html.motion-standard .site-nav,
  html.motion-full .site-nav {
    transform: translateY(-0.75rem) scaleY(0.96);
    transform-origin: top center;
    transition:
      opacity 280ms var(--ease-out-quint) 50ms,
      transform 280ms var(--ease-out-quint) 50ms,
      visibility 0s linear 330ms;
  }

  html.motion-standard .nav-open .site-nav,
  html.motion-full .nav-open .site-nav {
    transform: translateY(0) scaleY(1);
    transition:
      opacity 280ms var(--ease-out-quint),
      transform 300ms var(--ease-out-quint),
      visibility 0s;
  }

  html.motion-standard .site-nav a,
  html.motion-full .site-nav a {
    opacity: 0;
    transform: translateY(-0.5rem);
    transition:
      opacity var(--dur-normal) var(--ease-out),
      transform var(--dur-normal) var(--ease-out);
  }

  html.motion-standard .nav-open .site-nav a,
  html.motion-full .nav-open .site-nav a {
    opacity: 1;
    transform: none;
  }

  /* Sequence in on open; on close everything leaves together so the menu
   * does not make the user wait through a reverse cascade. */
  html.motion-standard .nav-open .site-nav a:nth-child(1),
  html.motion-full .nav-open .site-nav a:nth-child(1) { transition-delay: 40ms; }
  html.motion-standard .nav-open .site-nav a:nth-child(2),
  html.motion-full .nav-open .site-nav a:nth-child(2) { transition-delay: 85ms; }
  html.motion-standard .nav-open .site-nav a:nth-child(3),
  html.motion-full .nav-open .site-nav a:nth-child(3) { transition-delay: 130ms; }

  html.motion-minimal .site-nav a,
  html.motion-none .site-nav a {
    opacity: 1;
    transform: none;
  }
}

/* ==================================================== theme cross-fade ===
 *
 * Fallback for browsers without View Transitions (iOS Safari included), so a
 * theme switch on a phone is a fade rather than an instant flip.
 */
html[data-theme-fade="true"]:not(.motion-none) body,
html[data-theme-fade="true"]:not(.motion-none) .site-header,
html[data-theme-fade="true"]:not(.motion-none) .card,
html[data-theme-fade="true"]:not(.motion-none) .index-row,
html[data-theme-fade="true"]:not(.motion-none) .catalog-card,
html[data-theme-fade="true"]:not(.motion-none) .game-card,
html[data-theme-fade="true"]:not(.motion-none) .tool-tile {
  transition:
    background-color var(--dur-slow) var(--ease-out),
    border-color var(--dur-slow) var(--ease-out),
    color var(--dur-slow) var(--ease-out);
}

/* ===================================================== ambient detail ===
 *
 * Small responses on the parts of the interface that are touched most. Each
 * one is short, transform- or opacity-only, and disappears below the standard
 * tier so a weak device never pays for them.
 */

/* Header lifts its shadow once the page is scrolled off the top. */
html.motion-standard .site-header,
html.motion-full .site-header {
  transition:
    background-color var(--dur-normal) ease,
    border-color var(--dur-normal) ease,
    box-shadow var(--dur-normal) var(--ease-out);
}

/* Wordmark answers a hover with a small settle. */
html.motion-full .wordmark {
  transition: transform var(--dur-release) var(--ease-elastic);
}

html.motion-full .wordmark:hover {
  transform: translateY(-1px);
}

/* Nav underline grows from the centre under the pointer and stays drawn
 * under the current page — 500ms OutQuint, lazer's tab-control timing. */
html.motion-standard .site-nav a,
html.motion-full .site-nav a {
  position: relative;
}

html.motion-standard .site-nav a::after,
html.motion-full .site-nav a::after {
  content: "";
  position: absolute;
  right: 0.9rem;
  bottom: 0.6rem;
  left: 0.9rem;
  height: 2px;
  border-radius: 2px;
  background: var(--color-accent);
  transform: scaleX(0);
  transition: transform 500ms var(--ease-out-quint);
}

html.motion-standard .site-nav a:hover::after,
html.motion-full .site-nav a:hover::after,
html.motion-standard .site-nav a[aria-current="page"]::after,
html.motion-full .site-nav a[aria-current="page"]::after {
  transform: scaleX(1);
}

@media (max-width: 48rem) {
  html.motion-standard .site-nav a::after,
  html.motion-full .site-nav a::after {
    display: none;
  }
}

/* 2048-style tiles and game cells settle instead of snapping. */
html.motion-standard .tile,
html.motion-full .tile,
html.motion-standard .tile-2048,
html.motion-full .tile-2048 {
  transition:
    background-color var(--dur-normal) var(--ease-out),
    color var(--dur-normal) var(--ease-out),
    transform var(--dur-release) var(--ease-elastic);
}

/* Leaderboard rows arrive in sequence when a board is loaded. */
html.motion-standard .leaderboard-list li,
html.motion-full .leaderboard-list li {
  animation: row-arrive var(--dur-normal) var(--ease-spring) both;
}

html.motion-standard .leaderboard-list li:nth-child(1),
html.motion-full .leaderboard-list li:nth-child(1) { animation-delay: 0ms; }
html.motion-standard .leaderboard-list li:nth-child(2),
html.motion-full .leaderboard-list li:nth-child(2) { animation-delay: 45ms; }
html.motion-standard .leaderboard-list li:nth-child(3),
html.motion-full .leaderboard-list li:nth-child(3) { animation-delay: 90ms; }
html.motion-standard .leaderboard-list li:nth-child(n + 4),
html.motion-full .leaderboard-list li:nth-child(n + 4) { animation-delay: 130ms; }

/* Status text changes get a short fade so a new message is noticed. */
html.motion-standard .game-status,
html.motion-full .game-status,
html.motion-standard .form-status,
html.motion-full .form-status,
html.motion-standard .catalog-status,
html.motion-full .catalog-status {
  transition: color var(--duration-fast) ease;
}

/* Selects and checkboxes get the same press weight as buttons. */
html.motion-full .form-field select,
html.motion-full .game-check {
  transition: transform var(--dur-release) var(--ease-elastic), border-color var(--duration-fast) ease;
}

html.motion-full .form-field select:active {
  transform: scale(0.985);
}

html.motion-minimal .site-nav a::after,
html.motion-none .site-nav a::after {
  transition: none;
}

html.is-scrolled.motion-standard .site-header,
html.is-scrolled.motion-full .site-header {
  box-shadow: 0 1px 0 color-mix(in srgb, var(--color-line) 80%, transparent),
    0 8px 24px color-mix(in srgb, var(--color-text) 6%, transparent);
}

/* ==========================================================================
   Catalogue rows — the sweep
   ==========================================================================

   The index is the one surface on the site people move a pointer *across*
   rather than *to*, so it is the one that has to reward the movement. The
   model is osu!lazer's list items, and the signature is asymmetry: the
   highlight arrives in a frame and leaves over half a second. Hovering ten
   rows in a second therefore leaves a short comet tail of fading rows behind
   the pointer instead of a single lit box teleporting down the list.

   That asymmetry is expressed as a transition duration that differs between
   the rest and the hover rule — not as a keyframe, which cannot be
   interrupted halfway when the pointer moves on.

   `sound.js` puts a pitch ladder on the same event, so the tail is audible
   as well as visible. The two are independent by design: turning either off
   leaves the other intact and the row perfectly usable with neither.
   ========================================================================== */

html.motion-standard .index-row::before,
html.motion-full .index-row::before {
  /* Leaving: slow. This duration is what draws the tail. */
  transition: opacity var(--dur-slow) var(--ease-out);
}

html.motion-standard .index-row:hover::before,
html.motion-full .index-row:hover::before,
html.motion-standard .index-row:focus-visible::before,
html.motion-full .index-row:focus-visible::before {
  /* Arriving: one frame. */
  transition-duration: 45ms;
}

/*
 * The accent edge. It grows from the vertical centre so a fast sweep reads as
 * a line being drawn down the list rather than a column of blocks appearing.
 */
html.motion-standard .index-row::after,
html.motion-full .index-row::after {
  content: "";
  position: absolute;
  inset-block: 0.35rem;
  inset-inline-start: 0;
  z-index: 1;
  width: 3px;
  border-radius: 999px;
  background: var(--color-accent);
  transform: scaleY(0);
  opacity: 0;
  transition:
    transform var(--dur-slow) var(--ease-out),
    opacity var(--dur-slow) var(--ease-out);
}

html.motion-standard .index-row:hover::after,
html.motion-full .index-row:hover::after,
html.motion-standard .index-row:focus-visible::after,
html.motion-full .index-row:focus-visible::after {
  transform: scaleY(1);
  opacity: 1;
  transition:
    transform 120ms var(--ease-spring),
    opacity 45ms linear;
}

/*
 * The row itself moves. Only transform, so a hundred rows changing state
 * during a sweep stay on the compositor.
 */
html.motion-standard .index-row,
html.motion-full .index-row {
  transition:
    background-color var(--duration-fast) ease,
    transform var(--dur-slow) var(--ease-out);
}

html.motion-standard .index-row:hover,
html.motion-full .index-row:hover,
html.motion-standard .index-row:focus-visible,
html.motion-full .index-row:focus-visible {
  transform: translateX(3px);
  transition-duration: 90ms;
  transition-timing-function: var(--ease-spring);
}

/* The body already shifts; the ladder deserves a little more travel. */
html.motion-full .index-row:hover .index-row__body {
  transform: translateX(0.45rem);
}

html.motion-standard .index-row .index-row__body,
html.motion-full .index-row .index-row__body {
  transition: transform var(--dur-slow) var(--ease-out);
}

html.motion-standard .index-row:hover .index-row__body,
html.motion-full .index-row:hover .index-row__body {
  transition: transform 140ms var(--ease-spring);
}

/*
 * The arrow is the only part that overshoots, and only on the way in. It is
 * the smallest element in the row, so it can carry the most exaggeration
 * without making the list feel unstable.
 */
html.motion-full .index-row__arrow {
  transition:
    transform var(--dur-slow) var(--ease-out),
    color var(--dur-slow) var(--ease-out);
}

html.motion-full .index-row:hover .index-row__arrow {
  transform: translateX(0.4rem);
  transition:
    transform 180ms var(--ease-spring),
    color 45ms linear;
}

html.motion-standard .index-row__tag,
html.motion-full .index-row__tag {
  transition:
    background-color var(--dur-slow) var(--ease-out),
    color var(--dur-slow) var(--ease-out);
}

html.motion-standard .index-row:hover .index-row__tag,
html.motion-full .index-row:hover .index-row__tag {
  transition-duration: 45ms;
}

html.motion-standard .index-row:active,
html.motion-full .index-row:active {
  transform: translateX(3px) scale(0.994);
  transition-duration: var(--dur-instant);
}

/*
 * Minimal keeps the flash and drops every transform, so the row still reports
 * where the pointer is without anything moving.
 */
html.motion-minimal .index-row::after {
  content: none;
}

html.motion-none .index-row::after {
  content: none;
}

/* ==========================================================================
   Workbench
   ==========================================================================

   The panel is the only part of this page that changes wholesale when a row
   is chosen, so it is the only part that animates. The list does not re-enter
   on every selection — it is where the pointer already is, and moving it
   under the cursor would fight the reader.
   ========================================================================== */

@keyframes workbench-panel-in {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}

html.motion-standard .workbench-workspace:not([hidden]),
html.motion-full .workbench-workspace:not([hidden]) {
  animation: workbench-panel-in var(--dur-normal) var(--ease-out) both;
}

html.motion-minimal .workbench-workspace:not([hidden]) {
  animation: workbench-panel-in var(--dur-fast) ease both;
}

/*
 * The result is what the reader is waiting for, so a change of output gets a
 * short fade. Keyed off the element rather than a class, because the text
 * node is replaced in place and there is no state to toggle.
 */
html.motion-standard .workbench-output,
html.motion-full .workbench-output {
  transition: opacity var(--duration-fast) ease;
}

html.motion-full .workbench-preview__image {
  animation: workbench-panel-in var(--dur-normal) var(--ease-spring) both;
}

/* The rail chips get the same press weight as every other control. */
html.motion-standard .workbench-rail__item,
html.motion-full .workbench-rail__item {
  transition:
    background-color var(--duration-fast) ease,
    color var(--duration-fast) ease,
    transform var(--dur-release) var(--ease-elastic);
}

html.motion-standard .workbench-rail__item:active,
html.motion-full .workbench-rail__item:active {
  transform: scale(0.97);
  transition-duration: var(--dur-instant);
}

/*
 * Swatches arrive left to right, capped so a nine-colour ramp still settles
 * quickly. This is the one place a stagger reads as the thing being built
 * rather than as a delay.
 */
@keyframes swatch-in {
  from { opacity: 0; transform: scaleY(0.4); }
  to { opacity: 1; transform: none; }
}

html.motion-full .workbench-swatch {
  animation: swatch-in 260ms var(--ease-out) both;
  transform-origin: bottom;
}

html.motion-full .workbench-swatch:nth-child(1) { animation-delay: 0ms; }
html.motion-full .workbench-swatch:nth-child(2) { animation-delay: 30ms; }
html.motion-full .workbench-swatch:nth-child(3) { animation-delay: 60ms; }
html.motion-full .workbench-swatch:nth-child(4) { animation-delay: 90ms; }
html.motion-full .workbench-swatch:nth-child(5) { animation-delay: 120ms; }
html.motion-full .workbench-swatch:nth-child(n + 6) { animation-delay: 150ms; }

/*
 * The workbench rows are a picker, so the selected one keeps its highlight
 * rather than releasing it when the pointer leaves — otherwise there is no
 * way to see which of 1174 operations is currently open.
 */
html.motion-standard .workbench-row[aria-selected="true"],
html.motion-full .workbench-row[aria-selected="true"] {
  transform: translateX(3px);
}

/*
 * Game shelf. The caption never moves; the picture answers the pointer, on
 * lazer's BeatmapCard timing — 500ms OutQuint in both directions, so it
 * settles instead of dropping back. The drawing inside zooms a little
 * within its frame; pressing sinks the frame slightly.
 */
html:is(.motion-standard, .motion-full) .game-card__cover {
  transition:
    transform 500ms var(--ease-out-quint),
    box-shadow 600ms var(--ease-out-quint);
}

html:is(.motion-standard, .motion-full) .game-card:is(:hover, :focus-visible) .game-card__cover {
  transform: translateY(-3px);
}

html:is(.motion-standard, .motion-full) .game-card:active .game-card__cover {
  transform: scale(0.98);
  transition-duration: 200ms;
}

html:is(.motion-standard, .motion-full) .game-card__art {
  transition: transform 800ms var(--ease-out-quint);
}

html:is(.motion-standard, .motion-full) .game-card:is(:hover, :focus-visible) .game-card__art {
  transform: scale(1.04);
}

html:is(.motion-standard, .motion-full) .game-card.is-appearing {
  animation: row-arrive 500ms var(--ease-out-quint) both;
}

html.motion-minimal .game-card.is-appearing {
  animation: hero-fade 200ms ease both;
}

/* Score rows arrive in rank order, 22ms apart (board-render.js). */
@keyframes board-row-in {
  from { opacity: 0; transform: translateX(-12px); }
  to { opacity: 1; transform: none; }
}

html:is(.motion-standard, .motion-full) .board-row.is-appearing {
  animation: board-row-in 500ms var(--ease-out-quint) both;
  animation-delay: var(--row-delay, 0ms);
}

html.motion-minimal .board-row.is-appearing {
  animation: hero-fade 200ms ease both;
}

html:is(.motion-minimal, .motion-none) .board-panel.is-switching .board-head > * {
  animation: none;
}
