/* ─────────────────────────────────────────────────────────────────────────
   EFFECTS LIBRARY — grain + sweep
   Jeff's call, 2026-08-26: build an effects library and be ambitious with it.

   SCOPE: linked by the 10 MAIN pages only, never by the 1,087 artwork record
   pages. That is deliberate. Artwork pages carry a second, content-hash cache
   token stamped by scripts/stamp-artwork-css.py; touching anything they load
   rewrites all 1,087 files and the deploy guard then aborts on the
   uncommitted tree. Keeping the library off them costs nothing here and
   removes that whole class of deploy failure.

   The main pages are light-only — dark-mode.css and ui.css are artwork-page
   stylesheets — so this file needs the paper palette and nothing else.

   THE ONE HARD LINE (data-first, ruled 2026-08-26): an effect may look like
   anything, but it must never imply the data says more than it does. Both
   effects here are atmosphere over the whole surface; neither attaches to a
   number, a date, or a work, so neither can overstate one.
   ───────────────────────────────────────────────────────────────────────── */

:root{
  --grain-opacity:.20;
  --grain-scale:180px;
  --sweep-dur:1400ms;
  --sweep-ease:cubic-bezier(.16,1,.3,1);   /* matches --paper-ease: settles, never bounces */
}

/* ── GRAIN ────────────────────────────────────────────────────────────────
   A paper tooth laid over the whole viewport. The archive is a record of
   physical objects photographed over fifty years; a perfectly smooth screen
   surface is the one thing none of those objects ever had.

   feTurbulence generates it at load — no image request, no bytes over the
   wire, and it scales to any viewport without tiling artefacts. baseFrequency
   .8 is fine enough to read as tooth rather than noise; numOctaves 4 keeps it
   from looking regular.

   multiply, not overlay: the paper is #f4f1ea and multiply darkens only where
   the grain is dark, so the ground keeps its warmth. Overlay pushed it grey.

   z-index 9997 — ABOVE content, so the tooth sits over type and image alike
   the way it would on a printed page, but BELOW the drawer ladder (9998 /
   9999) so the mobile menu and its scrim stay clean.

   STATIC. Animated grain is the single most expensive thing you can composite
   every frame, and on this site it would also be the least honest — real
   paper does not shimmer. */
.fx-grain{
  position:fixed; inset:0; z-index:9997;
  pointer-events:none;                     /* never intercepts a tap */
  opacity:var(--grain-opacity);
  mix-blend-mode:multiply;
  /* .20 is MEASURED, not taste. The first value (.055) looked reasonable in
     source and composited to a 2.7-level spread out of 255 — below the point
     where a display can show it at all, i.e. an effect that cost a full-screen
     blend and rendered nothing. Sampled the actual rasterised turbulence and
     composited it over #f4f1ea across seven opacities:
        .055 -> 2.7 levels    .12 -> 5.9    .20 -> 9.9    .32 -> 15.9
     .20 gives a ~10-level spread and darkens the paper 244 -> 237.5, which
     reads as tooth while keeping the ground warm. .32 starts to grey it.
     NOTE the first measurement of this was WRONG: it read the RGB channel and
     called the grain flat, when feTurbulence carries most of its signal in
     ALPHA (16..230 here). Measure the COMPOSITED result, not the source. */
  background-image:url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'>\
<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/>\
<feColorMatrix type='saturate' values='0'/></filter>\
<rect width='180' height='180' filter='url(%23n)'/></svg>");
  background-size:var(--grain-scale) var(--grain-scale);
  /* Promote once, then leave it alone. Without this the blend re-rasterises
     against scrolling content on every frame. */
  will-change:transform;
  transform:translateZ(0);
}
/* Coarse pointers do the same blend on far less GPU. Lighten rather than
   remove — the texture still reads at .04 and scroll stays smooth. */
@media (hover:none){ :root{ --grain-opacity:.16 } }

/* Printing already has real tooth. */
@media print{ .fx-grain{ display:none } }

/* ── SWEEP ────────────────────────────────────────────────────────────────
   The target mark, used as a reveal. Concentric rings expand once from the
   centre of the element and fade out, leaving the content behind them.

   This is the site's own mark doing the work: 459 of 1,087 works carry a
   target motif, which is why it became the favicon and the brand mark. As a
   transition it is the archive's most frequent image made temporal — a
   reveal that could not belong to any other site.

   Fires ONCE per element, on entry, via .is-in from effects.js.

   One property owner per element (the rule that cost three dead drone
   animations before it was learned): the RINGS own transform, the host owns
   nothing. Never put a sweep on an element that is already being moved. */
.fx-sweep{ position:relative }
.fx-sweep::before,
.fx-sweep::after{
  content:""; position:absolute; left:50%; top:50%;
  width:var(--sweep-size,120px); aspect-ratio:1; margin:calc(var(--sweep-size,120px) / -2);
  border:1px solid var(--red,#c8102e);
  border-radius:50%;
  transform:scale(.12); opacity:0;
  pointer-events:none;
}
.fx-sweep.is-in::before{ animation:fx-ring var(--sweep-dur) var(--sweep-ease) both }
/* The second ring trails the first by a third of the duration — one ring
   reads as a bubble, two read as a mark being struck. */
.fx-sweep.is-in::after{ animation:fx-ring var(--sweep-dur) var(--sweep-ease) calc(var(--sweep-dur) / 3) both }

@keyframes fx-ring{
  0%   { transform:scale(.12); opacity:0 }
  18%  { opacity:.55 }                      /* never full strength: this is a trace, not a border */
  100% { transform:scale(1); opacity:0 }
}

/* Larger hosts want a proportionally larger mark; set --sweep-size per use. */
.fx-sweep--sm{ --sweep-size:64px }
.fx-sweep--lg{ --sweep-size:260px }

/* ── REDUCED MOTION ───────────────────────────────────────────────────────
   Grain STAYS: it is texture, not motion, and removing it would change the
   page's colour for people who asked about movement. The sweep goes entirely
   — it is pure motion with no content behind it to preserve. */
@media (prefers-reduced-motion:reduce){
  .fx-sweep::before, .fx-sweep::after{ animation:none !important; display:none }
}

/* ── CROSSFADE (image reveal) ─────────────────────────────────────────────
   Every plate image on the site is loading="lazy" decoding="async" with no
   transition, so images SNAP in as they decode — 45 of them down favorites,
   and the effect on a slow connection is a page that twitches while you read.
   A dissolve is the difference between a slideshow and an archive.

   NOTE ON WHAT THIS IS NOT: it was designed as a pair-swap (composite vs
   source, related work vs work). There is no such pair anywhere on this site
   and no Related Works component — that premise was asserted from memory and
   was wrong. This is the real use: reveal on decode.

   FAIL-SAFE BY CONSTRUCTION. The hidden state lives in a class that only
   JavaScript ever adds, and it is added ONLY to images that have not already
   decoded. With JS off, a cached image, or a script error, nothing is ever
   hidden — the page behaves exactly as it does today. An image reveal that
   can hide the archive on failure would be a bad trade at any duration. */
.fx-img{
  opacity:0;
  transition:opacity var(--press-dur,520ms) var(--paper-ease,cubic-bezier(.16,1,.3,1));
}
.fx-img.is-loaded{ opacity:1 }

@media (prefers-reduced-motion:reduce){
  /* Not merely instant — never hidden in the first place. */
  .fx-img{ opacity:1; transition:none }
}

/* ── PARALLAX ─────────────────────────────────────────────────────────────
   Vertical drift on scroll, driven by --fx-py (written by effects.js).

   ONE PROPERTY OWNER, and here it decides the whole implementation. Every
   .target-mark on this site already owns its own `transform` — a scale from
   the target-open entrance. Putting parallax on the same element is exactly
   the bug that left register-of-loss's drones merely pulsing for months: two
   animations writing transform, last declared wins, one silently dies.

   So parallax rides the WRAPPER (.target-clip), which owns nothing. The clip
   moves and takes the mark with it; the mark keeps its scale. */
.fx-parallax{
  transform:translate3d(0, var(--fx-py, 0px), 0);
  will-change:transform;
}
@media (prefers-reduced-motion:reduce){
  .fx-parallax{ transform:none !important }
}

/* ── HOLD-UNTIL-SEEN ──────────────────────────────────────────────────────
   For reveals that were written as LOAD animations with long delays.

   index.html's below-fold reveals fired on a clock — animation-delay 1300ms to
   2700ms — regardless of whether anyone had scrolled to them. On a slow
   connection, or for a reader who scrolls straight down, they played to nobody
   and the content was simply there, already revealed, when it came into view.
   An animation triggered by a clock instead of by entry is not a reveal.

   This pauses the existing animation rather than replacing it, so every
   keyframe, duration, easing and stagger the page already had is preserved
   exactly — effects.js only restarts the clock at the moment of entry.

   No fx-hold class is ever added by CSS alone: JavaScript adds it, so with JS
   off the animations behave exactly as they do today. Nothing can be left
   permanently invisible by this. */
.fx-hold{ animation-play-state:paused }
.fx-hold.is-in{ animation-play-state:running }

@media (prefers-reduced-motion:reduce){
  /* Those elements already have their own reduce rules setting the end state;
     never leave one paused and hidden. */
  .fx-hold{ animation-play-state:running }
}
