/* motion-patterns v0.2.0 | MIT | https://github.com/rujilabs/motion-patterns */
/* Shared motion tokens. Every entry uses these; no raw cubic-bezier() anywhere else. */
:root {
  /* curves */
  --ease-out: cubic-bezier(.2, .7, .2, 1);      /* default for entries */
  --ease-spring: cubic-bezier(.3, 1.6, .5, 1);  /* overshoot, small elements only; a real spring below */
  --ease-inout: cubic-bezier(.4, 0, .2, 1);     /* draw-on, crossfades, start-and-stop moves */
  --ease-paper: cubic-bezier(.2, .9, .25, 1.15); /* paper layers: a slight landing overshoot */
  --ease-in: cubic-bezier(.55, 0, 1, .45);      /* accelerating: impacts and exits */

  /* durations */
  --dur-quick: 200ms;   /* hover, press, toggles */
  --dur-base: 600ms;    /* rise, pop, stamp */
  --dur-slow: 900ms;    /* large images, paper layers, calm pages */
  --dur-draw: 1600ms;   /* SVG stroke draw-on */

  /* rhythm and distance */
  --stagger: 80ms;        /* delay step between siblings */
  --stagger-paper: 500ms; /* delay step between paper layers */
  --rise-y: 18px;         /* rise travel */
}

/* Browsers with linear() easing get a real damped spring (damping ratio .45): one clear overshoot,
   one small settle. Others keep the cubic-bezier above. */
@supports (animation-timing-function: linear(0, 1)) {
  :root {
    --ease-spring: linear(0, 0.039, 0.14, 0.282, 0.444, 0.61, 0.767, 0.905, 1.018, 1.104, 1.162, 1.195, 1.205, 1.198, 1.178, 1.149, 1.116, 1.082, 1.05, 1.021, 0.998, 0.98, 0.967, 0.96, 0.958, 0.959, 0.963, 0.969, 0.976, 0.983, 0.989, 0.995, 1);
  }
}

/* crossfade */
/*
 * crossfade: stacked frames take turns. Each frame gets 4s: a 1s fade-in on top of the one before,
 * then it holds. The outgoing frame stays opaque until the incoming one is fully in, so the
 * background never shows through. The first frame starts visible (negative delay), so nothing is
 * blank on load. 3 frames by default; .crossfade-2 and .crossfade-4 for other counts.
 */
.crossfade { display: grid; }
.crossfade > * {
  grid-area: 1 / 1;
  opacity: 0;
  animation: crossfade-3 12s var(--ease-inout) infinite;
  animation-delay: var(--xf-delay);
}
.crossfade > :nth-child(1) { --xf-delay: -1s; }
.crossfade > :nth-child(2) { --xf-delay: 3s; }
.crossfade > :nth-child(3) { --xf-delay: 7s; }
.crossfade > :nth-child(4) { --xf-delay: 11s; }
.crossfade-2 > * { animation-name: crossfade-2; animation-duration: 8s; }
.crossfade-4 > * { animation-name: crossfade-4; animation-duration: 16s; }

@keyframes crossfade-2 { 0% { opacity: 0; } 12.5% { opacity: 1; } 62.5% { opacity: 1; } 75% { opacity: 0; } 100% { opacity: 0; } }
@keyframes crossfade-3 { 0% { opacity: 0; } 8.33% { opacity: 1; } 41.67% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 0; } }
@keyframes crossfade-4 { 0% { opacity: 0; } 6.25% { opacity: 1; } 31.25% { opacity: 1; } 37.5% { opacity: 0; } 100% { opacity: 0; } }

/*
 * crossfade-zoom: the first child of each frame (the image) eases from 108% to 100% while its
 * frame is on screen. Captions and other children stay still.
 */
.crossfade-zoom > * { overflow: clip; }
.crossfade-zoom > * > :first-child { animation: xf-zoom 12s linear infinite; animation-delay: var(--xf-delay); }
.crossfade-zoom.crossfade-2 > * > :first-child { animation-duration: 8s; }
.crossfade-zoom.crossfade-4 > * > :first-child { animation-duration: 16s; }
@keyframes xf-zoom { from { scale: 1.08; } 60% { scale: 1; } to { scale: 1; } }

/* pause: hover or focus on the frames, or a .crossfade-pause toggle inside a .crossfade-group */
.crossfade:hover *,
.crossfade:focus-within *,
.crossfade-group:has(.crossfade-pause :checked) .crossfade * { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .crossfade > *, .crossfade-zoom > * > :first-child { animation: none; }
  .crossfade > :first-child { opacity: 1; }
  .crossfade-pause { display: none; }
}

/* draw */
/*
 * draw: SVG stroke draw-on. Every path carries pathLength="1", so the dash values run 0..1 and
 * a long path and a short one finish together. The path stays transparent until its first frame:
 * a round line cap would otherwise show a dot where the line is about to start.
 */
.js .draw path { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; }
.js .go .draw path,
.js .go.draw path { animation: draw var(--dur-draw) var(--ease-inout) both; }
@keyframes draw {
  from { stroke-dashoffset: 1; opacity: 0; }
  1%   { opacity: 1; }
  to   { stroke-dashoffset: 0; opacity: 1; }
}

/*
 * draw-seq: on the svg. Strokes draw one after another, each starting halfway through the last.
 * The selectors repeat the .go context so they outrank the animation shorthand above.
 */
.js .go .draw-seq path:nth-of-type(2), .js .go.draw-seq path:nth-of-type(2) { animation-delay: calc(var(--dur-draw) * .5); }
.js .go .draw-seq path:nth-of-type(3), .js .go.draw-seq path:nth-of-type(3) { animation-delay: calc(var(--dur-draw) * 1); }
.js .go .draw-seq path:nth-of-type(4), .js .go.draw-seq path:nth-of-type(4) { animation-delay: calc(var(--dur-draw) * 1.5); }
.js .go .draw-seq path:nth-of-type(5), .js .go.draw-seq path:nth-of-type(5) { animation-delay: calc(var(--dur-draw) * 2); }

/*
 * draw-line: an underline drawn under any inline text, no SVG. The line is a background, so it
 * wraps with the text across lines. Thickness and offset come from --line-w and --line-y.
 */
.draw-line {
  background: linear-gradient(currentColor, currentColor) no-repeat 0 calc(100% - var(--line-y, 0px)) / 100% var(--line-w, .08em);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.js .draw-line { background-size: 0 var(--line-w, .08em); }
.js .go .draw-line,
.js .draw-line.go { animation: draw-line var(--dur-draw) var(--ease-inout) both; }
@keyframes draw-line {
  from { background-size: 0 var(--line-w, .08em); }
  to   { background-size: 100% var(--line-w, .08em); }
}

/* draw-hover: a link underline that draws in from the left on hover or focus and leaves to the right. */
.draw-hover {
  text-decoration: none;
  background: linear-gradient(currentColor, currentColor) no-repeat 100% 100% / 0 1px;
  transition: background-size var(--dur-base) var(--ease-out);
}
.draw-hover:hover,
.draw-hover:focus-visible { background-size: 100% 1px; background-position: 0 100%; }

@media (prefers-reduced-motion: reduce) {
  .js .draw path,
  .js .go .draw path,
  .js .go.draw path { stroke-dashoffset: 0; opacity: 1; animation: none; }
  .js .draw-line,
  .js .go .draw-line,
  .js .draw-line.go { background-size: 100% var(--line-w, .08em); animation: none; }
  .draw-hover { transition: none; }
}

/* drift */
/*
 * drift: slow ambient float. x, y and a slight tilt each run on their own cycle (7s, 11s, 13s).
 * Because the lengths share no factor, the combined path only repeats every 1001s, so the eye
 * never catches the loop. Uses the individual translate and rotate properties, which sit on top
 * of the element's own transform instead of replacing it.
 * Travel stays within --drift-amp (default 10px); keep it at 12px or less.
 */
@property --drift-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --drift-y { syntax: "<length>"; inherits: false; initial-value: 0px; }

@keyframes drift-x {
  from { --drift-x: calc(var(--drift-amp, 10px) * -.5); }
  to   { --drift-x: calc(var(--drift-amp, 10px) * .5); }
}
@keyframes drift-y {
  from { --drift-y: calc(var(--drift-amp, 10px) * .3); }
  to   { --drift-y: calc(var(--drift-amp, 10px) * -.3); }
}
@keyframes drift-tilt {
  from { rotate: -1.5deg; }
  to   { rotate: 1.5deg; }
}
.drift {
  translate: var(--drift-x) var(--drift-y);
  transform-box: fill-box;
  transform-origin: center;
  animation:
    drift-x 7s ease-in-out infinite alternate,
    drift-y 11s ease-in-out infinite alternate,
    drift-tilt 13s ease-in-out infinite alternate;
}

/* phases: neighbours that start at different points never move in step */
.drift-2 { animation-delay: -3s, -7s, -2s; }
.drift-3 { animation-delay: -5s, -2s, -9s; }

/* drift-slow: for large or background shapes */
.drift-slow { animation-duration: 11s, 17s, 19s; }

@media (prefers-reduced-motion: reduce) {
  .drift { animation: none; translate: none; rotate: none; }
}

/* fade */
/*
 * fade: opacity only, on the slow duration. For images and backgrounds where movement would
 * distract. Never on the page's main (LCP) image: that one should simply be there.
 */
@keyframes fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.fade { animation: fade var(--dur-slow) var(--ease-out) both; }

/* fade-in: held until play.js marks it (or an ancestor) with .go. For images below the fold. */
.js .fade-in { animation: fade var(--dur-slow) var(--ease-out) both paused; }
.js .go .fade-in,
.js .fade-in.go { animation-play-state: running; }

/* fade-settle: a photo fades in while easing from 3% larger. Reads as the image coming into focus. */
@keyframes fade-settle {
  from { opacity: 0; transform: scale(1.03); }
  to   { opacity: 1; transform: none; }
}
.fade-settle { animation: fade-settle var(--dur-slow) var(--ease-out) both; }
.js .fade-settle.fade-in { animation-name: fade-settle; }

/*
 * fade-frame: put it on the image's parent. It reserves the space (no layout shift) and shows a
 * tint while the image fades in, so there is never a white flash. Set --fade-bg to a colour from
 * the image, and --fade-ratio to its aspect ratio.
 */
.fade-frame { aspect-ratio: var(--fade-ratio, auto); overflow: clip; background: var(--fade-bg, #e9e6e1); }
.fade-frame > img, .fade-frame > svg, .fade-frame > picture { display: block; width: 100%; height: 100%; object-fit: cover; }

@media (prefers-reduced-motion: reduce) {
  .fade, .fade-settle, .js .fade-in { animation: none; }
}

/* marquee */
/*
 * marquee: an endless row. The track holds the content twice; the copy is aria-hidden.
 * Each copy is at least as wide as the row, so the loop never shows a gap on wide screens.
 * Moving content that runs longer than 5 seconds needs a way to pause it (WCAG 2.2.2):
 * hover and focus pause it, and the optional .marquee-pause toggle pauses it for anyone.
 */
.marquee {
  overflow: clip;
  min-width: 0;
  max-width: 100%;
  container-type: inline-size;
  /* the row fades out at both edges instead of being cut */
  mask-image: linear-gradient(90deg, transparent, #000 var(--marquee-fade, 48px), #000 calc(100% - var(--marquee-fade, 48px)), transparent);
}
.marquee-solid { mask-image: none; }
.marquee-track { display: flex; width: max-content; animation: marquee var(--marquee-dur, 38s) linear infinite; }
.marquee-track > * { flex: none; min-inline-size: 100cqi; }
.marquee-reverse .marquee-track { animation-direction: reverse; }
@keyframes marquee { to { transform: translateX(-50%); } }

/* pause: hover or focus on the row, or the toggle anywhere inside a .marquee-group */
.marquee:hover .marquee-track,
.marquee:focus-within .marquee-track,
.marquee-group:has(.marquee-pause :checked) .marquee-track { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  /* no movement: hide the duplicate and let the first copy wrap, so every item is readable */
  .marquee { mask-image: none; }
  .marquee-track { animation: none; width: auto; }
  .marquee-track > :nth-child(n + 2) { display: none; }
  .marquee-track > * { min-inline-size: 0; flex: 1 1 auto; flex-wrap: wrap; }
  .marquee-pause { display: none; }
}

/* paper */
/*
 * paper: a cut-paper diorama that builds layer by layer, back to front.
 * Layers are flat SVG shapes. Each carries the double paper shadow (an SVG filter in the markup)
 * and its index in data-layer. One grain overlay sits over the whole scene.
 * Layers slide up from below and land with a small overshoot. Upright pieces marked .fold hinge
 * up from their base instead, like a pop-up book. The back card (layer 0) only fades.
 * The scene is complete at rest: hidden start states only apply with play.js (.js) before .go.
 */
.paper .layer { transform-box: fill-box; transform-origin: 50% 100%; }
.js .paper:not(.go) .layer,
.js .paper:not(.go) .paper-after { opacity: 0; }
.js .paper.go .layer {
  animation: paper-rise var(--dur-slow) var(--ease-paper) both;
  animation-delay: calc(var(--i, 0) * var(--stagger-paper));
}
.js .paper.go .layer.fold { animation-name: paper-fold; }
.js .paper.go .layer[data-layer="0"] { animation-name: paper-card; animation-timing-function: var(--ease-out); }

.paper .layer[data-layer="1"]  { --i: 1; }
.paper .layer[data-layer="2"]  { --i: 2; }
.paper .layer[data-layer="3"]  { --i: 3; }
.paper .layer[data-layer="4"]  { --i: 4; }
.paper .layer[data-layer="5"]  { --i: 5; }
.paper .layer[data-layer="6"]  { --i: 6; }
.paper .layer[data-layer="7"]  { --i: 7; }
.paper .layer[data-layer="8"]  { --i: 8; }
.paper .layer[data-layer="9"]  { --i: 9; }
.paper .layer[data-layer="10"] { --i: 10; }
.paper .layer[data-layer="11"] { --i: 11; }
.paper .layer[data-layer="12"] { --i: 12; }

@keyframes paper-rise {
  from { opacity: 0; transform: translateY(46%) rotate(var(--tilt, 0deg)); }
  60%  { opacity: 1; }
  to   { opacity: 1; transform: none; }
}
/* a hinge at the base: the piece stands up from flat and overshoots slightly before settling */
@keyframes paper-fold {
  from { opacity: 0; transform: scaleY(.04); }
  25%  { opacity: 1; }
  to   { opacity: 1; transform: none; }
}
@keyframes paper-card {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/*
 * paper-after: anything (a headline, a button, ambient extras) that should arrive once the build
 * has landed. Set --paper-layers on the scene or a parent to the highest data-layer number.
 */
.js .paper.go .paper-after,
.js .go .paper-after {
  animation: paper-card var(--dur-base) var(--ease-out) both;
  animation-delay: calc(var(--paper-layers, 8) * var(--stagger-paper) + var(--dur-slow));
}

/* paper-quick: the same build at twice the pace, for scenes that are not the page's hero */
.paper-quick { --stagger-paper: 250ms; }

.paper .grain { mix-blend-mode: multiply; opacity: .35; pointer-events: none; }

@media (prefers-reduced-motion: reduce) {
  .js .paper .layer,
  .js .paper:not(.go) .layer,
  .js .paper.go .layer,
  .js .paper .paper-after,
  .js .paper:not(.go) .paper-after,
  .js .paper.go .paper-after,
  .js .go .paper-after { opacity: 1; animation: none; }
}

/* pop */
/*
 * pop: springy scale-in for badges, dots, icons and stickers. Not for text blocks.
 * Scale and opacity run as two animations: the scale springs, the opacity is done in 200ms,
 * so the element is fully visible while it is still settling.
 */
@keyframes pop-scale {
  from { transform: scale(.6); }
  to   { transform: none; }
}
@keyframes pop-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.pop, .js .pop-in {
  transform-origin: var(--pop-origin, center);
  animation: pop-scale var(--dur-base) var(--ease-spring) both, pop-fade var(--dur-quick) linear both;
}

/* pop-in: held until play.js marks it (or an ancestor) with .go */
.js .pop-in { animation-play-state: paused; }
.js .go .pop-in,
.js .pop-in.go { animation-play-state: running; }

/* pop-press: a tactile press for buttons. Shrinks fast on press, springs back on release. */
.pop-press { transition: transform var(--dur-quick) var(--ease-spring); }
.pop-press:active { transform: scale(.96); transition-duration: 80ms; }

@media (prefers-reduced-motion: reduce) {
  .pop, .js .pop-in { animation: none; }
  .pop-press, .pop-press:active { transition: none; transform: none; }
}

/* pulse */
/*
 * pulse: a soft halo that swells and fades behind a status dot. It beats, then rests: a 2s cycle
 * where the halo is gone by 60% and the dot sits still for the rest, so it reads as a heartbeat,
 * not an alarm. Sized in em, so it scales with the text beside it. Never on buttons.
 */
@keyframes pulse {
  0%   { transform: scale(1); opacity: .6; }
  60%  { transform: scale(3.2); opacity: 0; }
  100% { transform: scale(3.2); opacity: 0; }
}
.pulse {
  position: relative;
  display: inline-block;
  flex: none;
  width: .6em;
  height: .6em;
  border-radius: 50%;
  background: currentColor;
}
.pulse::before,
.pulse::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  animation: pulse 2s var(--ease-out) var(--pulse-count, infinite);
}
.pulse::after { display: none; }

/* pulse-double: a second halo half a beat behind, like sonar. For the one thing that is live. */
.pulse-double::after { display: block; animation-delay: .5s; }

/* pulse-count: beat a few times, then rest for good. For "new" or "updated", which are not live. */
.pulse-count { --pulse-count: 3; }

@media (prefers-reduced-motion: reduce) {
  /* no beat: a still halo keeps the dot reading as a status, not a bullet */
  .pulse::before, .pulse::after { animation: none; }
  .pulse::before { opacity: .25; transform: scale(1.9); }
}

/* reveal */
/*
 * reveal: motion tied to scroll position with animation-timeline: view(), so it plays forward and
 * back as the reader scrolls. The rise uses --ease-out over the range, so it settles rather than
 * sliding at a constant speed.
 * Keep animation-timeline in its own declaration AFTER the shorthand: the shorthand resets it.
 * No overflow: hidden on any ancestor (it breaks the view timeline); use overflow: clip.
 */
@keyframes reveal {
  from { opacity: 0; translate: 0 var(--rise-y); }
  to   { opacity: 1; translate: none; }
}
/* reveal-clip: an image unveils from the bottom edge up */
@keyframes reveal-clip {
  from { clip-path: inset(100% 0 0 0); }
  to   { clip-path: inset(0); }
}
/* reveal-parallax: the image inside the frame moves slower than the page */
@keyframes reveal-parallax {
  from { translate: 0 -6%; }
  to   { translate: 0 6%; }
}
@keyframes reveal-progress { to { scale: 1 1; } }

@supports (animation-timeline: view()) {
  .reveal { animation: reveal var(--ease-out) both; }
  .reveal { animation-timeline: view(); animation-range: entry 0% entry 100%; }

  .reveal-clip { animation: reveal-clip var(--ease-out) both; }
  .reveal-clip { animation-timeline: view(); animation-range: entry 0% cover 40%; }

  .reveal-parallax { overflow: clip; }
  .reveal-parallax > :first-child { scale: 1.14; animation: reveal-parallax linear both; }
  .reveal-parallax > :first-child { animation-timeline: view(); animation-range: cover; }
}

/* Browsers without scroll timelines: play.js adds .go once, and the element rises in on a timer. */
@supports not (animation-timeline: view()) {
  .js .reveal { opacity: 0; translate: 0 var(--rise-y); transition: opacity var(--dur-base) var(--ease-out), translate var(--dur-base) var(--ease-out); }
  .js .reveal.go { opacity: 1; translate: none; }
}

/* reveal-progress: a reading progress bar for the whole page. Hidden where scroll timelines are missing. */
.reveal-progress {
  position: fixed;
  inset: 0 0 auto;
  z-index: 10;
  height: 3px;
  background: currentColor;
  transform-origin: 0 50%;
  scale: 0 1;
}
@supports (animation-timeline: scroll()) {
  .reveal-progress { animation: reveal-progress linear both; }
  .reveal-progress { animation-timeline: scroll(root); }
}

@media (prefers-reduced-motion: reduce) {
  .reveal, .reveal-clip, .reveal-parallax > :first-child { animation: none; }
  .reveal-parallax > :first-child { scale: none; }
  .js .reveal { opacity: 1; translate: none; transition: none; }
}

/* rise */
/*
 * rise: entry from below. Opacity lands at 60% of the run so the text is readable early;
 * the movement keeps settling to the end. That split is what makes it feel calm, not floaty.
 */
@keyframes rise {
  from { opacity: 0; transform: translateY(var(--rise-y)); }
  60%  { opacity: 1; }
  to   { opacity: 1; transform: none; }
}
.rise { animation: rise var(--dur-base) var(--ease-out) both; }

/* rise-in: same move, held until play.js marks the element (or an ancestor) with .go */
.js .rise-in { animation: rise var(--dur-base) var(--ease-out) both paused; }
.js .go .rise-in,
.js .rise-in.go { animation-play-state: running; }

/* rise-soft: adds a short blur. For one display line or a hero lede, not for lists or body text. */
@keyframes rise-soft {
  from { opacity: 0; transform: translateY(var(--rise-y)); filter: blur(6px); }
  60%  { opacity: 1; }
  to   { opacity: 1; transform: none; filter: none; }
}
.rise-soft { animation: rise-soft var(--dur-slow) var(--ease-out) both; }

/*
 * stagger: children of .stagger start one --stagger step apart. The delay stops growing after
 * 8 steps, so a long list never makes the reader wait for its last item.
 */
.stagger > :nth-child(2) { animation-delay: calc(var(--stagger) * 1); }
.stagger > :nth-child(3) { animation-delay: calc(var(--stagger) * 2); }
.stagger > :nth-child(4) { animation-delay: calc(var(--stagger) * 3); }
.stagger > :nth-child(5) { animation-delay: calc(var(--stagger) * 4); }
.stagger > :nth-child(6) { animation-delay: calc(var(--stagger) * 5); }
.stagger > :nth-child(7) { animation-delay: calc(var(--stagger) * 6); }
.stagger > :nth-child(8) { animation-delay: calc(var(--stagger) * 7); }
.stagger > :nth-child(n + 9) { animation-delay: calc(var(--stagger) * 8); }
@supports (animation-delay: calc(sibling-index() * 1ms)) {
  .stagger > * { animation-delay: calc(min(sibling-index() - 1, 8) * var(--stagger)); }
}

@media (prefers-reduced-motion: reduce) {
  .rise, .rise-soft, .js .rise-in { animation: none; }
}

/* stamp */
/*
 * stamp: pressed onto the surface from above. It accelerates into the surface (ease-in), squashes
 * to 96% on contact, then settles (ease-out). Opacity is done by 20%, so nothing ghosts in late.
 * Tilt comes from a class or --tilt, never inline style.
 */
@keyframes stamp {
  0%   { opacity: 0; transform: translateY(-18px) rotate(calc(var(--tilt, 0deg) + 6deg)) scale(1.3); animation-timing-function: var(--ease-in); }
  20%  { opacity: 1; }
  55%  { opacity: 1; transform: rotate(var(--tilt, 0deg)) scale(.96); animation-timing-function: var(--ease-out); }
  100% { opacity: 1; transform: rotate(var(--tilt, 0deg)); }
}
.stamp { transform: rotate(var(--tilt, 0deg)); animation: stamp var(--dur-base) linear both; }

/*
 * stamp-ink: a rubber-stamp seal ("Paid", "Approved"). Lands from 1.6x with a slight blur and
 * settles at 88% opacity with multiply blending, so the paper shows through like ink.
 * Use it on light surfaces; multiply disappears on dark ones.
 */
@keyframes stamp-ink {
  0%   { opacity: 0; transform: rotate(var(--tilt, -8deg)) scale(1.6); filter: blur(2px); animation-timing-function: var(--ease-in); }
  50%  { opacity: .92; transform: rotate(var(--tilt, -8deg)) scale(.98); filter: none; animation-timing-function: var(--ease-out); }
  100% { opacity: .88; transform: rotate(var(--tilt, -8deg)); }
}
.stamp-ink {
  opacity: .88;
  transform: rotate(var(--tilt, -8deg));
  mix-blend-mode: multiply;
  animation: stamp-ink var(--dur-base) linear both;
}

/* stamp-in: add next to .stamp or .stamp-ink to hold it until play.js marks it with .go */
.js .stamp-in { animation-play-state: paused; }
.js .go .stamp-in,
.js .stamp-in.go { animation-play-state: running; }

.tilt-l { --tilt: -3deg; }
.tilt-r { --tilt: 4deg; }

@media (prefers-reduced-motion: reduce) {
  .stamp, .stamp-ink { animation: none; }
}

/* stretch-squeeze */
/*
 * stretch and squeeze: variable-width type. Needs a variable font with a wdth axis, declared
 * with a font-stretch range in its @font-face, or the browser ignores font-stretch.
 * Preload the font file, or the first run plays on a fallback font that cannot change width.
 * stretch: hover or focus widens a condensed word. squeeze: a headline arrives wide and settles
 * condensed, readable from the first frame.
 */
@font-face {
  font-family: "Anybody";
  src: url("fonts/anybody-vf.woff2") format("woff2");
  font-weight: 100 900;
  font-stretch: 50% 150%;
  font-display: swap;
}
.stretch, .squeeze { font-family: "Anybody", system-ui, sans-serif; }

/*
 * stretch: the word reserves its widest size up front (a hidden copy from data-text), so widening
 * on hover never pushes the words next to it. The copy is visibility: hidden, which also keeps it
 * out of the accessibility tree.
 */
.stretch {
  display: inline-grid;
  justify-items: center;
  font-stretch: 62%;
  letter-spacing: .01em;
  transition: font-stretch var(--dur-slow) var(--ease-out), letter-spacing var(--dur-slow) var(--ease-out);
}
.stretch[data-text]::after {
  content: attr(data-text);
  font-stretch: 112%;
  letter-spacing: 0;
  height: 0;
  overflow: hidden;
  visibility: hidden;
}
.stretch-host:hover .stretch,
.stretch-host:focus-visible .stretch { font-stretch: 112%; letter-spacing: 0; }

@keyframes squeeze {
  from { font-stretch: 150%; letter-spacing: .02em; }
  to   { font-stretch: 62%; letter-spacing: -.01em; }
}
/* squeeze-weight: also goes from light to heavy as it condenses */
@keyframes squeeze-weight {
  from { font-stretch: 150%; font-weight: 300; letter-spacing: .02em; }
  to   { font-stretch: 62%; font-weight: 800; letter-spacing: -.01em; }
}
/* each line stays one line: the wide start state may overflow (clip the parent), but the height never jumps */
.squeeze > span { display: block; white-space: nowrap; font-stretch: 62%; animation: squeeze 1.3s var(--ease-out) both; }
.squeeze-weight > span { font-weight: 800; animation-name: squeeze-weight; }
.squeeze > span:nth-child(2) { animation-delay: calc(var(--stagger) * 2); }
.squeeze > span:nth-child(3) { animation-delay: calc(var(--stagger) * 4); }
.squeeze > span:nth-child(4) { animation-delay: calc(var(--stagger) * 6); }

@media (prefers-reduced-motion: reduce) {
  .squeeze > span { animation: none; }
  .stretch { transition: none; }
}
