/* ==========================================================================
   travispayne.me: motion

   One grammar, five verbs, assigned by what an element is rather than where it
   sits:

     mono labels   decode      a placard is typed on, left to right
     display type  rises       each word comes up through its own baseline
     body copy     lifts       the plain reveal, a little taller than before
     rules         draw        hairlines grow from the mark they start at
     prints        are exposed a block of the study's accent opens the frame,
                               then the picture wipes across it and settles

   Nothing here changes the page at rest. Every rule is either a from-state or
   an animation that runs once and is then removed (.mo-done), so what you see
   after the last frame is main.css, untouched.

   js/motion.js sets html.mo before first paint and does the assigning. Without
   that class this file is inert except for the page transitions at the bottom
   and the failsafe directly below. Under reduced motion the script never sets
   the class, and the transitions sit inside a no-preference query.
   ========================================================================== */

/* Failsafe. .reveal starts at opacity 0 and waits for a script to hand it back.
   If neither script ever arrives (a dropped request, a blocked CDN) the page
   would stay blank with no sign of why, so after four seconds the content
   returns on its own. Either script arriving calls it off: motion.js sets
   html.mo, main.js sets html.js, and each has its own way of handing the
   content back if it then fails. */
@keyframes mo-failsafe { to { opacity: 1; transform: none; } }
html:not(.mo):not(.js) .reveal:not(.in) { animation: mo-failsafe 1ms 4s forwards; }

html.mo {
  /* The wipe curve: slow out of the gate, fast through the middle, slow home.
     Everything that opens like a shutter rides this; everything that arrives
     and settles rides main.css's own --ease-out. */
  --mo-wipe: cubic-bezier(0.76, 0, 0.24, 1);
}

@keyframes mo-rise   { from { transform: translateY(calc(100% + 0.5em)); } }
@keyframes mo-drop   { from { transform: translateY(-170%); } }
@keyframes mo-up     { from { opacity: 0; transform: translateY(18px); } }
@keyframes mo-pop    { from { opacity: 0; transform: scale(0.92); } }
@keyframes mo-fade   { from { opacity: 0; } }
@keyframes mo-draw   { from { transform: scaleX(0); } }
@keyframes mo-open   { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes mo-open-y { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes mo-settle { from { transform: scale(1.16); } }
@keyframes mo-plus   { from { transform: rotate(-135deg) scale(0); } }
/* The keyframes above name only a from-state on purpose. Each lands on whatever
   the element's own rule says, so a logo at 60% opacity arrives at 60% and a
   hover transform still applies the instant the animation is cleared. */

/* --------------------------------------------------------------------------
   The plain lift. main.css's reveal, with more travel and a longer settle.
   -------------------------------------------------------------------------- */
html.mo .reveal:not(.mo-done) { transition-duration: 880ms; }
html.mo .reveal:not(.in) { transform: translateY(24px); }

/* Everything else is choreographed from inside. The wrapper stays hidden until
   it is revealed, then shows at once with no travel of its own, and its parts
   take over: each runs an animation that holds its from-state through its
   delay, so nothing flashes in the frame between. Both rules let go at
   .mo-done, which hands back whatever transition the element had of its own
   (a decision row's rule changes colour on hover, for one). */
html.mo .reveal[data-mo]:not([data-mo="up"]):not(.mo-done) { transform: none; transition: none; }

/* --------------------------------------------------------------------------
   Display type. motion.js wraps each word (or, for the name on the home page,
   each letter) at the moment of reveal and unwraps it when the move is done,
   so the heading is plain text again before anyone can resize the window.

   The mask is a clip-path rather than overflow, for two reasons. An
   inline-block with overflow set takes its bottom edge as its baseline, which
   drops every word below the line it belongs to. And the region has to reach
   past the line box: at a 1.08 line-height the descenders hang outside it.
   -------------------------------------------------------------------------- */
html.mo .mo-w { display: inline-block; clip-path: inset(-0.15em -0.3em -0.28em); }
html.mo .mo-wi {
  display: inline-block;
  animation: mo-rise 1000ms var(--ease-out)
    calc(var(--o, 0ms) + var(--i, 0) * var(--step, 45ms)) both;
}
html.mo .mo-word { display: inline-block; white-space: nowrap; }
html.mo [data-mo="lines"] { --step: 85ms; }
html.mo [data-mo="chars"] { --step: 34ms; }
/* The full stop arrives last and from the other direction: it is dropped onto
   the end of the name, and bounces once. */
html.mo [data-mo="chars"] .dim .mo-wi {
  animation-name: mo-drop; animation-duration: 900ms;
  animation-timing-function: var(--ease-spring);
}

/* --------------------------------------------------------------------------
   Labels. The text itself is decoded by motion.js; what is drawn here is the
   furniture that sits beside it.
   -------------------------------------------------------------------------- */
html.mo .kicker.in:not(.mo-done)::before,
html.mo .in:not(.mo-done) .kicker::before {
  animation: mo-open 700ms var(--mo-wipe) var(--o, 0ms) both;
}
/* The plus mark turns into place, the way a registration mark is dropped. */
html.mo [data-mo="head"].in:not(.mo-done) .index::before,
html.mo .in:not(.mo-done) .plate__no::before {
  animation: mo-plus 900ms var(--ease-spring) var(--o, 0ms) both;
}
html.mo [data-mo="head"] h2 { --o: 110ms; }

/* --------------------------------------------------------------------------
   Prints. Two moves on two elements, no extra markup: the frame opens left to
   right as a block of the study's accent, then the picture wipes in over it
   from the same edge. Because the picture arrives from the left, what is left
   of the block retreats to the right, which reads as the block passing through.
   The picture starts a little large and settles, so it is still landing after
   the wipe has finished.

   .mo-frame is put on the media box by motion.js. Anything with its own
   controls (an embed, a film with a scrubber) is left on the plain lift: a wipe
   across a control bar reads as a glitch.
   -------------------------------------------------------------------------- */
html.mo .in:not(.mo-done) .mo-frame,
html.mo .in:not(.mo-done).mo-frame {
  background-color: var(--accent);
  animation: mo-open 640ms var(--mo-wipe) var(--o, 0ms) both;
}
html.mo .in:not(.mo-done) .mo-frame :is(img, video),
html.mo .in:not(.mo-done).mo-frame :is(img, video) {
  animation:
    mo-open 780ms var(--mo-wipe) calc(var(--o, 0ms) + 330ms) both,
    mo-settle 1500ms var(--ease-out) calc(var(--o, 0ms) + 330ms) both;
}
/* Where the label sits under the print it follows the picture in. Where it is
   the hover placard it is left alone: an animation holding it at full opacity
   would pin the placard up over the print until the cleanup. */
@media not ((hover: hover) and (min-width: 760px)) {
  html.mo :is([data-mo="print"], [data-mo="wall"]).in:not(.mo-done) figcaption {
    animation: mo-up 760ms var(--ease-out) calc(var(--o, 0ms) + 620ms) both;
  }
}

/* Work cards: the print, then its label set line by line under it. The tick
   draws, the meta row decodes, the title rises, the description lifts. */
html.mo [data-mo="card"].in:not(.mo-done) .work-card__body::before {
  transform-origin: 0 50%;
  animation: mo-draw 640ms var(--ease-out) 380ms both;
}
html.mo [data-mo="card"] h3 { --o: 480ms; }
html.mo [data-mo="card"].in:not(.mo-done) .work-card__body > p:not(.work-card__meta) {
  animation: mo-up 820ms var(--ease-out) 660ms both;
}

/* --------------------------------------------------------------------------
   Rows and groups.
   -------------------------------------------------------------------------- */
/* A row is ruled, then filled in: the wipe carries its top hairline across and
   the contents lift a beat behind it. */
html.mo [data-mo="row"].in:not(.mo-done) {
  animation: mo-open 760ms var(--mo-wipe) both;
}
html.mo [data-mo="row"].in:not(.mo-done) > * {
  animation: mo-up 760ms var(--ease-out) 140ms both;
}

/* A set of like things arrives as a sequence. --o is each one's place in it,
   written by motion.js. Buttons pop rather than lift: the hero panel is sized
   to the copy it sits behind, and a button that travels would stretch it. */
html.mo [data-mo="stagger"].in:not(.mo-done) > * {
  animation: mo-up 780ms var(--ease-out) var(--o, 0ms) both;
}
html.mo [data-mo="stagger"].in:not(.mo-done) > .btn {
  animation-name: mo-pop; animation-timing-function: var(--ease-spring);
}

/* The details bar. On a study page it is the bottom of the dark block, so the
   band itself is there from the first frame: a block that arrives in two
   pieces is two blocks. What is set into it is the detail. Each column's
   hairline is drawn downward, its heading decodes, its fact is filed under it.
   (The band is deliberately not wiped open with a clip-path. That lifts it
   onto its own layer, and at a fractional pixel ratio a seam of paper shows
   between it and the intro above for as long as the layer lasts.) */
html.mo .factbar.reveal { opacity: 1; }
html.mo .factbar.reveal:not(.in) dl { visibility: hidden; }
html.mo [data-mo="facts"].in:not(.mo-done) dl > div {
  animation: mo-open-y 700ms var(--mo-wipe) var(--o, 0ms) both;
}
html.mo [data-mo="facts"].in:not(.mo-done) dd {
  animation: mo-up 760ms var(--ease-out) calc(var(--o, 0ms) + 260ms) both;
}

/* The four surfaces. The rules over the columns already draw themselves
   (main.css); the copy under each follows its own rule down. */
html.mo [data-mo="range"].in:not(.mo-done) .range-cell > h3 {
  animation: mo-up 800ms var(--ease-out) calc(var(--o, 0ms) + 240ms) both;
}
html.mo [data-mo="range"].in:not(.mo-done) .range-cell > p:not(.kicker) {
  animation: mo-up 800ms var(--ease-out) calc(var(--o, 0ms) + 340ms) both;
}

/* Outcome figures. Each numeral is a column that rolls to its value, the
   columns to the right spinning further, the way an odometer's do. The final
   digit stays in the flow, invisible, so the column is exactly as wide as the
   figure it will show and nothing shifts when the strip is taken away. */
html.mo [data-mo="stats"].in:not(.mo-done) .n {
  animation: mo-up 800ms var(--ease-out) calc(var(--o, 0ms) + 80ms) both;
}
html.mo [data-mo="stats"].in:not(.mo-done) .l {
  animation: mo-up 800ms var(--ease-out) calc(var(--o, 0ms) + 300ms) both;
}
html.mo .mo-dg { display: inline-block; position: relative; clip-path: inset(0 -0.3em); }
html.mo .mo-dg__f { visibility: hidden; }
html.mo .mo-dg__s {
  position: absolute; top: 0; left: 50%; translate: -50% 0;
  display: flex; flex-direction: column; align-items: center;
  animation: mo-roll var(--dur, 1200ms) var(--ease-out) var(--at, 0ms) both;
}
html.mo .mo-dg__s > span { display: block; }
@keyframes mo-roll { to { transform: translateY(calc(var(--n) * -1lh)); } }

/* --------------------------------------------------------------------------
   The opening. On the home page the field fades up, the panel opens across the
   copy's ground, and then the copy is set on it. .mo-go is the cue, set by
   motion.js once the display face has loaded; .mo-set clears the lot.

   The panel's wipe is a clip-path on the shell. That would make it a backdrop
   root and starve a blurred glass of its backdrop, which is why it is removed
   at .mo-set rather than left resting at inset(0).
   -------------------------------------------------------------------------- */
html.mo .hero.has-sort:not(.mo-set) #hero-sort { animation: mo-fade 1800ms ease-out both; }
html.mo .hero.has-sort:not(.mo-go) .hero-panel {
  clip-path: inset(0 100% 0 0 round var(--hp-radius, 10px));
}
html.mo .hero.has-sort.mo-go:not(.mo-set) .hero-panel {
  animation: mo-panel 900ms var(--mo-wipe) both;
}
@keyframes mo-panel {
  from { clip-path: inset(0 100% 0 0 round var(--hp-radius, 10px)); }
  to   { clip-path: inset(0 0 0 0 round var(--hp-radius, 10px)); }
}
html.mo .hero.has-sort:not(.mo-go) .hero-info { opacity: 0; }
html.mo .hero.has-sort.mo-go:not(.mo-set) .hero-info { animation: mo-fade 700ms ease 1700ms both; }

/* "Let's make it clear." The last word starts out of focus and is pulled sharp
   while it rises. */
html.mo .site-footer__cta h2.in:not(.mo-done) em {
  animation: mo-focus 1600ms cubic-bezier(0.2, 0.7, 0.2, 1) 260ms both;
}
@keyframes mo-focus { from { filter: blur(0.24em); } }

/* The next study's arrow follows its title in from the left. */
html.mo .next-title.in:not(.mo-done) .arrow {
  animation: mo-arrow 900ms var(--ease-out) 420ms both;
}
@keyframes mo-arrow { from { opacity: 0; transform: translateX(-1.4em); } }

/* --------------------------------------------------------------------------
   Scroll-linked. These are tied to the scroll position itself, not triggered
   by it, so they run backwards when you do. Compositor-only properties, and
   only where the browser can drive an animation from a view timeline.
   -------------------------------------------------------------------------- */
@supports (animation-timeline: view()) {
  /* A study's intro sinks into its dark block as the page leaves it. */
  html.mo .study-hero > .container {
    animation: mo-sink linear both;
    animation-timeline: view(); animation-range: exit 0% exit 100%;
  }
  @keyframes mo-sink { to { transform: translateY(4.5rem); opacity: 0.15; } }

  /* On the home page the copy and its panel drift against the field instead.
     Transform only: an opacity here would sit above the panel's glass. */
  html.mo .hero > .container {
    animation: mo-drift linear both;
    animation-timeline: view(); animation-range: exit 0% exit 100%;
  }
  @keyframes mo-drift { to { transform: translateY(5rem); } }

  /* The career line is drawn by reading it. The range keeps the tip of the
     line 60% of the way down the window for the whole length of the list:
     `cover` runs from the line's top meeting the bottom of the window to its
     bottom leaving the top, so starting 40vh in and stopping 60vh short is
     exactly the stretch in which that point is inside the line. */
  html.mo .timeline::before {
    transform-origin: 50% 0;
    animation: mo-grow linear both;
    animation-timeline: view(); animation-range: cover 40vh cover calc(100% - 60vh);
  }
  @keyframes mo-grow { from { transform: scaleY(0); } }

  /* Each role's node is struck as the line reaches it. No end keyframe: it
     lands on the node's own rule, so the hover fill still works afterwards. */
  html.mo .timeline li::before {
    animation: mo-node linear both;
    animation-timeline: view(); animation-range: cover 40vh cover calc(40vh + 4.5rem);
  }
  @keyframes mo-node {
    0%  { transform: scale(0); }
    55% { transform: scale(1.55); background: var(--accent); }
  }
}

/* --------------------------------------------------------------------------
   Page transitions. Cross-document view transitions: no router, no script
   needed for the move itself. The new page slides up over the old one, which
   falls back and dims; going back, it comes down from the top instead.

   Two things travel on their own layers. The header holds still, so the chrome
   never appears to reload. And a study's title is one object across pages: the
   card you clicked hands its heading to the study's h1, and the "next" link at
   the foot of a study does the same. motion.js names the right element on the
   way out (pageswap) and on the way in (pagereveal); the h1 carries the name by
   default so an arrival always has somewhere to land.

   The slide is scoped to the `nav` type, which motion.js adds on arrival, so a
   same-document transition (the lightbox) keeps the default crossfade.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }

  .site-header { view-transition-name: site-header; }
  .study-hero h1 { view-transition-name: study-title; }

  /* The h1 has to be on the page, at rest, when the snapshot is taken, or the
     card's title would travel to an empty space. html.vt is set before first
     paint whenever the page was reached from another page on this site. */
  html.vt .study-hero h1.reveal { opacity: 1; transform: none; transition: none; }

  html:active-view-transition-type(nav)::view-transition-old(root),
  html:active-view-transition-type(nav)::view-transition-new(root) { mix-blend-mode: normal; }
  html:active-view-transition-type(nav)::view-transition-old(root) {
    animation: mo-vt-back 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
  }
  html:active-view-transition-type(nav)::view-transition-new(root) {
    animation: mo-vt-up 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
  }
  html:active-view-transition-type(back)::view-transition-new(root) { animation-name: mo-vt-down; }
  @keyframes mo-vt-back { to { transform: scale(0.955); filter: brightness(0.86); } }
  @keyframes mo-vt-up   { from { transform: translateY(100%); } }
  @keyframes mo-vt-down { from { transform: translateY(-100%); } }

  /* The header is swapped, not blended. Its two states can differ in height
     (settled after a scroll, tall at the top of the next page), and a crossfade
     between them doubles every letter for the length of the move. */
  ::view-transition-old(site-header) { display: none; }
  ::view-transition-new(site-header) { animation: none; }

  /* The title takes longer than the page under it, on purpose. A study's h1 is
     paper-coloured and its card title is ink: the dark block has to be under
     the heading before the crossfade reaches the light end. */
  ::view-transition-group(study-title) {
    animation-duration: 680ms;
    animation-timing-function: cubic-bezier(0.7, 0, 0.2, 1);
  }
  /* Its colour changes early in the flight rather than across it, for the same
     reason in both directions: whichever ground it is heading for covers the
     window in the first third of the move, and a heading still wearing the old
     page's colour is then light on light, or dark on dark. */
  html:active-view-transition-type(nav)::view-transition-old(study-title),
  html:active-view-transition-type(nav)::view-transition-new(study-title) { mix-blend-mode: normal; }
  html:active-view-transition-type(nav)::view-transition-old(study-title) { animation: mo-vt-out 260ms ease 40ms both; }
  html:active-view-transition-type(nav)::view-transition-new(study-title) { animation: mo-vt-in 260ms ease 40ms both; }
  @keyframes mo-vt-out { to { opacity: 0; } }
  @keyframes mo-vt-in  { from { opacity: 0; } }
}
