/* ---------------------------------------------------------------------------
   Tokens.
--------------------------------------------------------------------------- */
:root {
  --paper: #ffffff;

  /* The line is painted white and blended with `difference`. Over the white
     page that resolves to black; over the portrait it resolves to the
     portrait's negative. One declaration covers both states. */
  --line-blend: #ffffff;

  --font-line: "Helvetica Neue", Helvetica, Arial, sans-serif;

  --step-line: clamp(1.375rem, 1rem + 1.9vw, 2rem);
  --tracking-line: -0.02em;
  --measure-line: 36rem;

  --step-meta: 0.875rem;
  --edge: 24px;

  /* Hit area padding around the name, and the gap between cursor and image. */
  --hit-pad: 8px;
  --reveal-gap: 8px;

  /* Landscape frames run 35% wider than the upright one, which keeps a
     comparable area on screen given the difference in proportion. The min()
     caps them on a phone, where 435px would run off the screen. */
  --reveal-w: min(322px, 62vw);
  --reveal-w-landscape: min(435px, 84vw);

  --reveal-fade: 120ms;
  --reveal-swap: 90ms;

  /* Word reveal: how long each word takes, and the gap between one word
     starting and the next. */
  --word-duration: 520ms;
  --word-stagger: 70ms;
  --word-rise: 0.4em;

  /* Magnetism. Strength is a ratio of the distance to the cursor, so peak
     pull lands at half the radius: 400 × 0.05 ÷ 4 = 5px at most. */
  --magnet-radius: 400px;
  --magnet-strength: 0.05;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  min-height: 100vh;
  min-height: 100svh;
  background: var(--paper);
  font-family: var(--font-line);
  -webkit-font-smoothing: antialiased;
}

/* --- The portrait --------------------------------------------------------- */
/* Fixed, and moved only via transform so tracking the pointer never triggers
   layout. Hidden from the accessibility tree — it is decorative. */
/*
  The figure is a zero-height anchor sitting exactly where the bottom edge of
  the photo belongs. Frames hang upward from it, so photos of different
  heights share a bottom edge and swapping between them moves nothing. That
  is what removes the jump: there is no height to animate, because the
  container never has one.
*/
.reveal {
  position: fixed;
  top: 0;
  left: 0;
  margin: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--reveal-fade) ease;
  will-change: transform;
}

.reveal--visible {
  opacity: 1;
}

/* Photos hang from a shared bottom edge and centre themselves on the anchor,
   so frames of different widths still sit square over the cursor. Only the
   current one is opaque. */
.reveal__frame {
  position: absolute;
  bottom: 0;
  left: 0;
  display: block;
  width: var(--reveal-w);
  transform: translateX(-50%);
  opacity: 0;
  transition: opacity var(--reveal-swap) ease;
}

.reveal__frame--landscape {
  width: var(--reveal-w-landscape);
}

.reveal__frame--current {
  opacity: 1;
}

.reveal__img {
  display: block;
  width: 100%;
  height: auto;
}

/* --- Masthead ------------------------------------------------------------- */
/*
  Absolute rather than fixed: `position: fixed` would create a stacking
  context and cut these off from the portrait, so they'd stop inverting.
  The blend sits on the header so the pair inverts as one group.
*/
.masthead {
  position: absolute;
  top: var(--edge);
  left: var(--edge);
  right: var(--edge);
  display: flex;
  justify-content: space-between;
  gap: 1rem;

  color: var(--line-blend);
  mix-blend-mode: difference;
  font-size: var(--step-meta);
  font-weight: 500;
  line-height: 1.43;
  letter-spacing: var(--tracking-line);
}

.masthead__link {
  color: inherit;
  text-decoration: none;
}

/* An underline only on hover — a colour shift would invert along with the
   difference blend and behave differently over a photo than over white. */
.masthead__link:hover,
.masthead__link:focus-visible {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.25em;
}

.masthead__link:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 0.35em;
}

/* --- The line ------------------------------------------------------------- */
/*
  `position: relative` with an automatic z-index is deliberate: it puts the
  stage in the same painting layer as the fixed portrait so DOM order decides
  which sits on top, without creating a stacking context. A stacking context
  here would cut the blend off from the portrait and the effect would die.
*/
.stage {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: 1.5rem;

  /* The stage covers the whole viewport and paints above the masthead, so it
     would otherwise swallow every click meant for those links. Only the line
     itself needs to receive the pointer. */
  pointer-events: none;
}

.line {
  margin: 0;
  max-width: var(--measure-line);
  color: var(--line-blend);
  mix-blend-mode: difference;
  font-size: var(--step-line);
  line-height: 1.2;
  letter-spacing: var(--tracking-line);
  text-align: center;
  text-wrap: balance;
  will-change: transform;
  pointer-events: auto;
}

/* Padding grows the hover target by 8px; the matching negative margin keeps
   the surrounding text exactly where it was. */
.name {
  padding: var(--hit-pad);
  margin: calc(var(--hit-pad) * -1);

  /* Holding a word on a touchscreen otherwise raises the selection handles
     and the iOS callout menu, which would land on top of the photo. */
  -webkit-touch-callout: none;
  user-select: none;
  -webkit-user-select: none;
  touch-action: none;
}

/* --- Word reveal ---------------------------------------------------------- */
/*
  Each word carries its position in the sentence as `--i`, set once by the
  script, and multiplies the stagger by it. `backwards` holds the word at the
  start of the animation during its delay, so nothing flashes before its turn.

  Opacity here is safe alongside the blend: `.line` composites its own subtree
  first and only then blends the result, so a half-faded word reads as grey
  rather than breaking the effect.
*/
@keyframes word-in {
  from {
    opacity: 0;
    transform: translateY(var(--word-rise));
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.word {
  display: inline-block;
  animation: word-in var(--word-duration) cubic-bezier(0.22, 1, 0.36, 1)
    backwards;
  animation-delay: calc(var(--i, 0) * var(--word-stagger));
}


@media (hover: hover) and (pointer: fine) {
  .name {
    cursor: default;
  }
}

@media (prefers-reduced-motion: reduce) {
  .reveal {
    transition: none;
  }

  .word {
    animation: none;
  }
}
