/* ==========================================================================
   Opening screen.

   A cover page: her work scattered as a contact sheet, the name set across a
   clear band through the middle of it, arriving one glyph at a time before it
   flies onto the hero headline. Three arrivals are built; `data-anim` on the
   plate picks one, and the component's ANIMATION constant sets it.

   All three animate the GLYPHS only. The handoff measures the row boxes, and
   a transform anywhere above a glyph would move those rects and send the name
   to the wrong place.
   ========================================================================== */

.loading-screen {
  position: fixed;
  inset: 0;
  z-index: 2000; /* above the header (900) and the About panel (1000) */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--grid-gutter-width);
  overflow: hidden;
  background-color: var(--primitive-black);

  /* Never swallow a click, even while on screen. */
  pointer-events: none;

  /* No-JS floor: if the bundle never runs, the screen still clears on its own.
     JavaScript normally beats this (its hard stop is 1.8s), so this only fires
     when scripting is unavailable. */
  animation: loadingExitAuto 0.8s cubic-bezier(0.22, 1, 0.36, 1) 1.85s forwards;
  will-change: opacity;
}

/* The flight. The plate stays black all the way through it: the name is white
   and has to cross the whole screen to reach the headline, so it needs a ground
   the entire time. The page appears when the plate goes, not before.

   The collage has no counterpart in the hero, so it leaves first — otherwise
   the name would fly across her own photographs. */
.loading-screen[data-phase="handoff"] {
  animation: none;
}
.loading-screen[data-phase="handoff"] .loading-screen__field {
  opacity: 0;
  transition: opacity 0.4s linear;
}

/* The JS path uses a DIFFERENT keyframe name on purpose.
   Re-declaring the same animation with a shorter delay does NOT restart it —
   the browser keeps the running animation and simply re-evaluates it. By the
   time this applies, `currentTime` is already ~1450ms against an 800ms
   animation, i.e. past the end, so the plate snapped out of existence instead
   of fading. A distinct name forces a fresh animation that really runs from
   opacity 1 to 0. */
.loading-screen[data-phase="done"] {
  /* Longer than it was: the plate is black at this point rather than
     already transparent, so this fade is what reveals the page. */
  animation: loadingExitNow 0.45s linear forwards;
}

/* ---------- the contact sheet ----------
   Tiles are placed by hand at percentages of the viewport, so the field
   recomposes with the screen instead of reflowing. `--tile-unit` is the one
   dial that scales all eleven, and it is capped so no tile outgrows the 320w
   file it is served — which is what keeps the opening light. */
.loading-screen__field {
  position: absolute;
  inset: 0;
  z-index: 0;
  /* Larger per unit than desktop, deliberately. The bands are placed at
     percentages of the viewport, and on a phone the name is only ~30px tall,
     so at desktop proportions the middle came out as 260px of empty black.
     Bigger prints reach further in and close the gap. */
  --tile-unit: min(3.6vw, 14px);
}
@media (width >= 744px) {
  .loading-screen__field {
    --tile-unit: min(1.35vw, 14px);
  }
}
/* The name stops growing at 112px, so past this width the field would thin out
   around it — a scatter of small prints on a lot of black. The tiles keep
   growing instead. */
@media (width >= 1600px) {
  .loading-screen__field {
    --tile-unit: min(1.15vw, 20px);
  }
}

.loading-screen__tile {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: calc(var(--w) * var(--tile-unit));
  margin: 0;
  /* A contact-sheet print: white border, deeper at the foot, caption in it. */
  background-color: #fff;
  padding: calc(var(--tile-unit) * 0.34);
  box-shadow: 0 2px 14px rgb(0 0 0 / 10%);
  opacity: 0;
  transform: scale(1.06);
  animation: loadingTileIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  animation-delay: calc(var(--t) * 45ms);
}
/* Every tile at once on a phone is a mess rather than a spread. */
@media (width < 744px) {
  .loading-screen__tile[data-wide] {
    display: none;
  }
}
/* The ones beside the name only fit once the viewport is wider than it is. */
@media (width < 1440px) {
  .loading-screen__tile[data-xl] {
    display: none;
  }
}

.loading-screen__tile-image {
  display: block;
  width: 100%;
  aspect-ratio: var(--ar);
  object-fit: cover;
  /* The blur-up placeholder, so a tile is never an empty box. */
  background-size: cover;
  background-position: center;
}

.loading-screen__tile-label {
  /* The site has no mono face, and this is one word per tile on a screen that
     lasts two seconds — not worth a download. A system mono is the right
     amount of typewriter for a contact-sheet caption. */
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: max(9px, calc(var(--tile-unit) * 0.72));
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #111;
  padding: calc(var(--tile-unit) * 0.42) 1px calc(var(--tile-unit) * 0.1);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The well the name sits in. Tiles are allowed alongside and slightly under
   the ends of the name, so this IS doing contrast work — the ramp is set from
   the measurement in `scripts/check-contrast.mjs`, not by eye. Never
   transformed, at any phase: the name inside is what the handoff measures. */
.loading-screen__inner {
  position: relative;
  z-index: 1;
  display: flex;
  justify-content: center;
}
.loading-screen__inner::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(
    ellipse 68% 30% at 50% 50%,
    rgb(0 0 0 / 92%) 0%,
    rgb(0 0 0 / 88%) 55%,
    rgb(0 0 0 / 55%) 78%,
    rgb(0 0 0 / 0%) 100%
  );
}

/* ---------- the name ---------- */

/* One row per word, side by side: the splash reads as a single line, and the
   rows are separate only so each can fly to its own headline line later.

   The size is deliberately NOT the headline's scale. It is whatever keeps the
   full name on one line — at 375px the headline's 50px would set "VITORIA
   HORTMANN" about 506px wide and clip it off both edges. Matching sizes was
   never the requirement; the flight scales the rows to the headline anyway. */
.loading-screen__name {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  color: var(--primitive-white);
  /* Kept in step with the hero's `.titleName` — the handoff lands this name on
     that one, so a difference in weight would show at the swap. */
  font-weight: 500;
  text-transform: uppercase;
  /* Tracking and leading are the hero's own (.titleName), not the display
     scale's: -0.05em is tuned for lowercase, and matching them here means the
     rows are already the right shape before they move. */
  letter-spacing: -0.01em;
  line-height: 0.95;
  /* 8.2vw ≈ the name's own aspect with room to spare, so it never reaches the
     gutters; vw includes the scrollbar, hence the margin rather than 8.5. */
  font-size: min(8.2vw, 112px);
}

.loading-screen__line {
  display: flex;
  flex-wrap: nowrap;
  white-space: nowrap;
}

/* Paints nothing; it only holds the word gap open. */
.loading-screen__space {
  white-space: pre;
}

.loading-screen__char {
  display: inline-block;
}

.loading-screen__char-inner {
  display: inline-block;
  white-space: pre; /* keeps the space between the two words */
}

/* ---------- arrival: rise ----------
   Each glyph pushes up out of its own mask. `clip-path` rather than `overflow`
   so the descenders of neighbouring glyphs are not shaved. */
.loading-screen[data-anim="rise"] .loading-screen__char {
  clip-path: inset(-0.05em 0 -0.12em 0);
}
.loading-screen[data-anim="rise"] .loading-screen__char-inner {
  transform: translateY(110%);
  animation: loadingCharRise 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  animation-delay: calc(var(--i) * 40ms);
}

/* ---------- arrival: focus ----------
   A lens finding focus, one glyph after the next. No mask here — a clip would
   cut the blur off square, which is the one thing that gives it away as a
   filter rather than as an out-of-focus letter. The glyph also comes in very
   slightly large, because a lens racking into focus changes magnification. */
.loading-screen[data-anim="focus"] .loading-screen__char-inner {
  opacity: 0;
  filter: blur(14px);
  transform: scale(1.14);
  animation: loadingCharFocus 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  animation-delay: calc(var(--i) * 45ms);
}

/* ---------- arrival: wipe ----------
   The name is uncovered left to right. The mask is the animation here, so it
   sits on the outer box and the inner one never moves. Both ends are 4-value
   `inset()` so the two interpolate; the negatives keep the antialiased edges
   of the glyph outside the clip. */
.loading-screen[data-anim="wipe"] .loading-screen__char {
  clip-path: inset(-0.12em 100% -0.12em 0);
  animation: loadingCharWipe 0.62s cubic-bezier(0.33, 1, 0.68, 1) forwards;
  animation-delay: calc(var(--i) * 38ms);
}

/* ---------- the handoff ----------
   FLIP, per word: each row carries the measured translate/scale that lands it
   on its own line of the hero headline. Because the two rows have different
   targets, the one-line lockup comes apart on the way and settles into the
   headline's two lines. Same words, same face, so on arrival they are identical
   and the swap is invisible. */
/* `:not(intro)` rather than `[data-phase="handoff"]`, and this is load-bearing.
   Scoped to the handoff alone, the rule stops matching the instant the phase
   becomes "done" — the transform reverts to none and the name SNAPS back to
   the middle of the screen, in full view, for the whole length of the plate's
   fade. It has to hold its landed position until the plate is gone.

   Safe in the fallback path too: when `begin()` bails without measuring, the
   variables fall back to a no-op translate(0,0) scale(1). */
.loading-screen:not([data-phase="intro"]) .loading-screen__line {
  transform: translate(var(--handoff-x, 0), var(--handoff-y, 0)) scale(var(--handoff-scale, 1));
  transition: transform 0.9s cubic-bezier(0.65, 0, 0.35, 1);
  will-change: transform;
}

/* ---------- keyframes ---------- */

@keyframes loadingTileIn {
  to { opacity: 1; transform: scale(1); }
}

@keyframes loadingCharRise {
  to { transform: translateY(0); }
}

@keyframes loadingCharFocus {
  to { opacity: 1; filter: blur(0); transform: scale(1); }
}

@keyframes loadingCharWipe {
  to { clip-path: inset(-0.12em -0.05em -0.12em 0); }
}

@keyframes loadingExitAuto {
  from { opacity: 1; }
  to   { opacity: 0; visibility: hidden; }
}

@keyframes loadingExitNow {
  from { opacity: 1; }
  to   { opacity: 0; visibility: hidden; }
}

/* ---------- reduced motion ----------
   No rise — the name is simply there, and the plate fades. */
@media (prefers-reduced-motion: reduce) {
  .loading-screen {
    animation: loadingExitAuto 0.3s linear 0.9s forwards;
  }
  .loading-screen[data-phase="done"] {
    animation: loadingExitNow 0.3s linear forwards;
  }
  /* No flight under reduced motion — the component skips straight to "done". */
  .loading-screen:not([data-phase="intro"]) .loading-screen__line {
    transition: none;
    transform: none;
  }
  .loading-screen__tile {
    opacity: 1;
    transform: none;
    animation: none;
  }
  /* Whichever variant is selected, the name is simply there. */
  .loading-screen__char,
  .loading-screen__char-inner {
    clip-path: none;
    opacity: 1;
    filter: none;
    transform: none;
    animation: none;
  }
}
