/*
Photo reveal — the accepted entrance of an image: it fades in while rising a little,
settling out of a slight enlargement and pulling into focus, once, the first time it is
reached.

It was written for the hero photograph and is a component now because the metrics block's
four portraits arrive the same way. A block opts in and writes no keyframe of its own:

    <div class="my-block__frame photo-reveal photo-reveal--pending">
      <img …>
    </div>

--- Separate properties, not one transform -----------------------------------------------

The fade, the rise and the settle are opacity, translate and scale — separate properties,
deliberately. The hero's card also sways on rotate and follows the cursor on transform, and
two animations on one property do not compose: the last one simply wins. Gathering this
entrance into a transform would have taken the sway away with it.

--- Coming out of focus -------------------------------------------------------------------

The sharpening is a second animation on `filter` rather than another line inside
photo-reveal-in, for two reasons that both come down to the fact that filter is the one
expensive property here.

It needs its own clock. The movement runs on --ease-out-soft, which spends most of its
interval already near the end; a blur riding that curve is resolved before there is enough
opacity to see that it was ever blurred. The sharpen runs longer than it looks like it
should (1900ms against the 2400ms fade) and on a flatter curve, so that the softness is
still readable while the picture is legible.

It needs to be able to not exist. --photo-reveal-blur-name is the keyframe list, so an
instance can set `none` and carry no filter whatsoever. `filter: blur(0)` is not a free way
to opt out: any non-none filter still puts the element on its own composited layer, still
makes it a containing block for absolutely positioned descendants, and is still rasterised
through the filter path every frame. The hero — accepted storyboard, two absolutely placed
badges — sets `none`.

The sharpen fills `backwards` and not `both`. Backwards holds the blurred first frame
through the delay, which is what the staggered collections need; ending without a forwards
fill drops the property back to the base `none`, so the filter — and the layer it forces —
goes away the moment the entrance is over instead of being carried for the life of the
page. blur(0px) and no filter at all render identically, so nothing is visible at the seam.

The waiting state carries no filter either, and does not need one: it is at opacity 0, and
a frame can sit in it for up to --photo-reveal-wait while its photograph downloads. Blurring
something invisible would only mean holding a filtered layer through the whole wait.

The cost was measured rather than assumed, because a blurred layer had already been taken
out of the page background once for being too expensive. tools/probe-fps.mjs plays the
entrance under a frame clock, with a control run that forces the keyframe name to none, so
the figure quoted is the difference the blur makes and not a statement about the machine.

Block 05, full Chrome on the GPU at 1920: median frame 8.3ms either way; the blur costs it
at the tail, worst frame 17-23ms against the control's 9. Under a 6x CPU throttle, p95
16.9ms, worst 24.9ms, and not one frame over 32ms — the 60fps budget is never missed, so
there is no slideshow to find on a slow device. At 390 the two runs are indistinguishable.
Block 04, four portraits sharpening at once on a 200ms stagger, has one frame over 32ms at
the very start of the window, and the control has the same one at the same place: that is
the scroll, not the filter.

What made the background unaffordable was not blur as such but blur animated forever on a
full-viewport layer. This one is 1.9s, bounded, and then gone.

--- Composing with something already running --------------------------------------------

An element can only have one `animation` list, so an element that is already animating
cannot simply add this one. Rather than have such a block restate the entrance — which is
the copy this component exists to prevent — the list here has a slot in it:
--photo-reveal-with names a second animation to run alongside. The hero puts its idle sway
in that slot; a block with nothing to run alongside leaves it empty.

Empty means `animation-name: none` and not a keyframe list that does nothing. The slot used
to be filled by a `photo-reveal-none` that held `opacity: inherit` at both ends, and a
keyframe list that mentions opacity claims opacity whether or not it changes it: every
instance without a companion carried two animations competing for the property, decided
only by which came last in the list, and carried the loser round an infinite loop for as
long as the page was open. Read back off the page with getAnimations(), the four portraits
of block 04 each reported photo-reveal-none animating opacity alongside the entrance.

The list is written as longhands rather than as the `animation` shorthand because `none` is
ambiguous inside the shorthand — it is also a valid animation-fill-mode — and the slot has
to resolve to a name.

--- Staggering a collection --------------------------------------------------------------

photo-reveal.js stamps the ordinal on every instance inside one group, and the delay is
that ordinal times --photo-reveal-step. There is no list of delays anywhere and nothing to
edit when the collection grows — the standing rule about animating collections rather than
elements. A single instance is index 0 and the step never touches it.

--- Waiting for the picture ---------------------------------------------------------------

The entrance does not start until the photograph it is revealing exists. The frames below
the first screen are loading="lazy", so the fetch and the entrance are two races started at
roughly the same moment by the same scroll, and on a cold connection the fade wins: the
empty frame rises and settles, and the picture then appears in one step against a frame
already most of the way opaque. Measured on a throttled cold load, card 2 of block 04 had
its image at 662ms with its frame already at 0.83 — that snap is what reads as a blink, and
no amount of tuning the keyframes removes it, because the keyframes were never wrong.

photo-reveal.js holds the entrance until the image reports itself loaded, and gives up
after --photo-reveal-wait so that a picture that never arrives cannot leave the frame
invisible for good.

--- Without the script -------------------------------------------------------------------

The final state is the default: with no JS the picture is simply there. The waiting state
is behind `.js`, set on <html> in <head> before the first paint, so the finished frame is
never seen winding back.
*/

@keyframes photo-reveal-in {
  from {
    opacity: 0;
    translate: 0 var(--photo-reveal-shift);
    scale: var(--photo-reveal-scale);
  }

  to {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }
}

/*
Blurring out of `none` rather than out of `blur(0)`: the base value of the property is
`none`, and a keyframe that has to interpolate a filter list against `none` is defined to
treat the missing entry as its identity — blur(0) — so the pair is well formed either way.
Written as blur(0) at the end for the reader, not for the engine.
*/
@keyframes photo-reveal-sharpen {
  from {
    filter: blur(var(--photo-reveal-blur));
  }

  to {
    filter: blur(0);
  }
}

.photo-reveal {
  --photo-reveal-at: calc(
    var(--photo-reveal-delay) + var(--photo-reveal-index, 0) * var(--photo-reveal-step)
  );
}

.js .photo-reveal--pending {
  opacity: 0;
  translate: 0 var(--photo-reveal-shift);
  scale: var(--photo-reveal-scale);
}

.photo-reveal--in {
  animation-name: var(--photo-reveal-with, none), var(--photo-reveal-blur-name, none),
    photo-reveal-in;
  animation-duration: var(--photo-reveal-with-dur, 1s), var(--photo-reveal-blur-dur),
    var(--photo-reveal-dur);
  animation-timing-function: var(--photo-reveal-with-ease, linear),
    var(--photo-reveal-blur-ease), var(--photo-reveal-ease);
  animation-delay: 0s, var(--photo-reveal-at), var(--photo-reveal-at);
  animation-iteration-count: infinite, 1, 1;
  animation-fill-mode: none, backwards, both;
}

/*
No motion means the picture is in place from the start — in place and in focus: a blur
resolving is motion to anyone who asked not to be shown any, so `filter` is cleared here
along with the three geometric properties.

Spelled out rather than left to the blanket duration clamp in base.css: on the companion
slot, which is a closed loop, clamping the duration would freeze whatever is in it at its
last keyframe.
*/
@media (prefers-reduced-motion: reduce) {
  .photo-reveal,
  .js .photo-reveal--pending,
  .photo-reveal--in {
    opacity: 1;
    translate: none;
    scale: none;
    filter: none;
    animation: none;
  }
}
