/* ─────────────────────────────────────────────────────────────────────────────
 * _shared/motion.css — the JFSN mobile motion system.
 *
 * WHAT THIS REPLACES. Before this file there were 34 hand-written
 * `animation-timeline` blocks across 9 pages and 87 @keyframes across 7, each
 * a separate implementation of about six ideas. The drifting brand mark alone
 * was five near-identical copies differing only in animation-range (200vh on
 * three pages, 500vh on two). That is the same failure this repository has
 * found five times in guards: a thing maintained in N places drifts in N
 * places. Six primitives live here; pages opt in by linking the file.
 *
 * SCOPE, and why it is narrow.
 *   · mobile only — @media(max-width:700px), the site's own mobile line (24
 *     uses across the secondary pages, against 7 for 760 and 3 for 640).
 *     Desktop and tablet behaviour is deliberately untouched.
 *   · secondary pages only — index.html and catalogue.html are the site's two
 *     front doors (see the tier note in base.css) and are not linked to this.
 *   · no JavaScript, and no new scroll listener anywhere. Every effect below is
 *     a CSS scroll-driven animation, which runs off the main thread. That is
 *     not a stylistic preference: catalogue.html measures ~330ms per frame
 *     while scrolling at 4x CPU throttle with every effect already OFF, and a
 *     main-thread scroll handler on a phone is how that number gets worse.
 *
 * REDUCED MOTION IS HANDLED EXPLICITLY AT THE BOTTOM, and must be.
 * base.css's global guard sets animation-duration:.01ms — which does NOTHING
 * here, because animation-duration is ignored when animation-timeline is not
 * `auto`. Verified rather than assumed: with prefers-reduced-motion:reduce the
 * existing pages do stop, because each sets `animation:none` by hand. Anything
 * added below must appear in that block or it will animate for a reader who
 * asked for stillness, and no test that only checks durations will notice.
 *
 * THE FAILURE MODE TO FEAR is silence, not breakage. stories.html carries a
 * comment about a view() timeline that never activated while the feature was
 * fully supported — it is layout-dependent, and an inert timeline looks
 * completely normal. So the assertions for this file measure a COMPUTED
 * TRANSFORM AT TWO SCROLL POSITIONS. Never that a class, a timeline or a
 * @supports block exists.
 *
 * NOTHING HERE ANIMATES OPACITY, and that is an accessibility constraint
 * before it is a taste. The first build faded the mast to .35 and brought
 * captions, tiles and headings in from 0. The gate reads an element WHILE it
 * is animating, and caught six strings below AA mid-fade — h1 "About" at
 * 2.25:1, an .ink-stamp inside a fading list item at 1.93:1. Text that is
 * still on screen and readable-in-principle is not readable at 35% opacity,
 * and this site holds a hard line of zero contrast failures.
 *
 * Removing it cost nothing and gained three things: the motion is now
 * compositor-only (no paint), it cannot regress contrast at any scroll
 * position, and it stopped being the "generic fade-in web animation" the brief
 * ruled out. Things move; they do not appear. If a primitive is ever added
 * here that needs opacity to read, it is the wrong primitive.
 *
 * RHYTHM IS STRUCTURAL, NOT RANDOM. "Not everything animated" is expressed
 * with :nth-of-type(), so which elements move is deterministic and therefore
 * testable. A random rhythm cannot be asserted and cannot be reproduced.
 * ───────────────────────────────────────────────────────────────────────────── */

/* Registered at TOP LEVEL, deliberately. A custom property is only animatable
   once registered, and @property is specified as a top-level rule — Chrome
   happened to honour it inside the @media block, which is exactly the kind of
   thing that works until it does not. inherits:true is load-bearing: .frame
   animates the value and its child <img> reads it. initial-value:1 means a
   failure to resolve leaves the plate at true size rather than blank. */
@property --m-crop{syntax:'<number>';inherits:true;initial-value:1}

:root{
  --m-ease:cubic-bezier(.16,1,.3,1);   /* --paper-ease. No new easing: the
                                          motion spec forbids a fifth without
                                          retiring one of the four. */
}

@media (max-width:700px){
@supports (animation-timeline:view()){

  /* ── WHAT THIS FILE TAKES OVER FROM, and only on mobile ───────────────────
     Adding a system BESIDE the 34 hand-written blocks would be the worst of
     both: two sources of truth, and the new one losing. It was, briefly —
     favorites declares `figure{animation:...}` in its own <style>, so all 45
     plates animated from the page while this file believed it was moving 2 in
     every 3. The rhythm was a claim the build did not honour, and only a check
     that counted STILL tiles could see it, because everything moving and the
     right things moving look identical unless you count.

     These four selectors are matched at the SAME specificity the pages use, so
     source order decides and this file wins (it is linked last, deliberately —
     see the comment on the <link> in each page). The primitives below then
     re-apply at higher specificity, which is what restores the rhythm.

     DESKTOP KEEPS ALL OF IT. These are inside max-width:700px, so above the
     mobile line every page still runs exactly the animation it shipped with.
     That is the brief's "keep desktop/tablet behaviour stable", done by
     scoping rather than by deletion.

     NOT superseded, on purpose: about's `.prose blockquote`, register-of-
     loss's `dl.rows div`, motif-recurrence's `.w`, favorites' `.target-mark`
     and the five `.jfsn-brand-mark` copies. None of them is one of these six
     ideas; silencing them would remove motion and replace it with nothing. */
  figure,section,.site,.site .thumb{animation:none}

  /* ── 1. THE OPENING — page title and intro ────────────────────────────────
     Not an entrance: the mast is already on screen at load, so an entry
     animation would either never fire or fire invisibly. This is an EXIT.
     The title leaves at one speed and the metadata under it at another, so
     the header comes apart as you pull away from it rather than sliding as a
     single card. Differential speed is the whole effect; the amounts are small
     because the mast is type, and type that moves far reads as broken. */
  .mast h1{
    animation:m-open-title linear both;
    animation-timeline:scroll();
    animation-range:0 88vh;
    will-change:transform
  }
  .mast .meta,.mast p,.mast .standfirst{
    animation:m-open-meta linear both;
    animation-timeline:scroll();
    animation-range:0 88vh;
    will-change:transform
  }
  @keyframes m-open-title{to{transform:translateY(-34px)}}
  @keyframes m-open-meta {to{transform:translateY(-12px)}}

  /* ── 2. THE PLATE — scroll-driven crop, not a fade ────────────────────────
     The figure keeps its box; the image inside is oversized and settles back
     to 1.0 as it crosses. Because the box is unchanged there is no layout
     shift, and because the overflow is hidden what you see is a genuine crop
     opening up rather than an element appearing.

     EXCLUDED ON to-scale.html, and this is not a preference. That page draws
     21 works at one true pixels-per-inch and the gate divides each rendered
     box by the work's recorded inches. getBoundingClientRect() returns the
     TRANSFORMED box, so a scale here would both break that assertion and make
     the page lie about the one thing it exists to state. Translation would be
     safe and scale is not; the whole primitive is withheld rather than trying
     to remember which half. */
  /* The wrapper is `.frame`, NOT `figure`. Written against figure > img first
     and measured as silent: favorites nests the plate as
     figure > a.plate-link > div.frame > img, so neither the child combinator
     nor the overflow landed anywhere. Putting overflow:hidden on `figure`
     would also have clipped the figcaption, which is inside the same anchor —
     the same way overflow-x:auto once clipped all 21 captions off To Scale.

     AND THE TIMELINE CANNOT LIVE ON THE IMAGE. This is the hazard stories.html
     already documents — a view() timeline that is fully supported and simply
     never advances. overflow:hidden makes `.frame` a scroll container, so an
     img inside it resolves view() against `.frame` rather than against the
     page. The img fills the frame exactly, so it is "covered" at every scroll
     position, progress pins at 100%, and the crop sits at its finished value
     forever. Measured: static at scale(1) at all five sample positions, with
     no error anywhere.

     So the clipper drives and the image reads. `.frame` is outside its own
     clip, so its timeline is the page's; it animates a registered custom
     property and the img consumes it. Registration is what makes --m-crop
     animatable at all — the same @property mechanism to-scale.html uses for
     --pxin. */
  body[data-section]:not([data-section="to-scale"]) .frame{
    overflow:hidden;
    animation:m-crop linear both;
    animation-timeline:view();
    animation-range:entry 0% cover 46%
  }
  body[data-section]:not([data-section="to-scale"]) .frame > img{
    transform:scale(var(--m-crop));
    will-change:transform
  }
  @keyframes m-crop{from{--m-crop:1.14}to{--m-crop:1}}
  /* ── WHAT THE RECORD PAGES DELIBERATELY DO NOT GET ───────────────────────
     figure.plate — the work itself — is not animated, cropped or scaled, on
     any of the 1,087 pages. Three separate reasons, any one sufficient:

       1. It is the artwork. The whole brief is that the interface is quiet and
          the work is not. A plate that scales as you scroll is the interface
          becoming the attraction.
       2. It already has the page's one real gesture. The mobile plate cap runs
          72vh at rest and 46vh once it pins — built, measured, live. A second
          scroll-driven transform on the same element would fight it.
       3. It would break the gate and make the page lie. plateCapCheck measures
          the plate's rendered width at rest against stuck, and
          getBoundingClientRect() returns the TRANSFORMED box — the same trap
          that keeps the crop off to-scale.html. A scale here reports a cap
          that is not the cap.

     The ledger and the viewer chrome are likewise untouched: REVIEW §8
     deliberately took weight OUT of the viewer on 2026-09-03, and putting
     motion back into it would undo that by another route.

     What records do get is the secondary furniture — the four related plates
     (crop, stagger, caption lag) and the section handoff. */

  /* NOT extended to working-history's .site-preview-frame, though the name
     invites it and it is the only other `frame` on the secondary pages. Those
     eight wrap an <iframe>, not an <img>: each is a live rendering of a
     previous generation of the site at the viewport named in its own
     data-vw/data-vh. Scaling one would make the page misstate the width the
     site is being shown at — the same error as cropping a work on to-scale,
     and the same error as the .ratio-field chart in §6. The crop is for
     photographs of works. Reach is favorites' 45 plates and nothing else. */

  /* ── 3. IMAGE + CAPTION — the two move at different speeds ────────────────
     The caption is the depth cue. It starts lower and arrives later than the
     plate it belongs to, so the pair reads as two planes rather than one
     block. 70 figure/figcaption pairs across favorites, to-scale and
     register-of-loss use this, which is why it is a primitive and not a
     page-specific flourish. The caption carries no scale, so it is safe on
     to-scale as well and is NOT excluded there. */
  /* .related-plate .cap is the record pages' caption. They carry no
     <figcaption> at all — 1,087 pages, zero of them — so the element selector
     alone reached none of them. Same primitive, same keyframes. */
  figcaption,
  .related-plate .cap{
    animation:m-caption linear both;
    animation-timeline:view();
    animation-range:entry 0% cover 62%
  }
  @keyframes m-caption{from{transform:translateY(22px)}to{transform:none}}

  /* ── 4. TILES AND LIST ITEMS — stagger without a timer ────────────────────
     Each item is driven by its OWN view() timeline, so the stagger is a
     product of where things are on the page rather than of a JS cascade that
     has to be re-run whenever the grid reflows. Two thirds of them move; the
     middle of every three is left still, which is what keeps a long grid from
     reading as a slot machine. */
  /* `.site` is here because working-history's eight built sites ARE this
     page's tiles — the page had its own `.site` reveal, superseded above, and
     dropping it without adopting the elements would have taken motion away on
     mobile rather than consolidating it. */
  /* .related-grid is the record pages' four related works — the one grid on
     the page and the only tile-shaped thing on it. */
  .related-grid > *:nth-of-type(3n+1),
  .related-grid > *:nth-of-type(3n),
  .grid > *:nth-of-type(3n+1),
  .grid > *:nth-of-type(3n),
  .site:nth-of-type(3n+1),
  .site:nth-of-type(3n),
  li:nth-of-type(3n+1),
  li:nth-of-type(3n){
    animation:m-tile linear both;
    animation-timeline:view();
    animation-range:entry 0% cover 40%
  }
  @keyframes m-tile{from{transform:translateY(26px) scale(.985)}to{transform:none}}

  /* ── 5. THE HANDOFF — sections overlap instead of abutting ────────────────
     A section heading rises over the tail of the section before it, so the
     boundary is a pass rather than a cut. The negative range start is what
     creates the overlap: the heading has already begun moving while the
     previous section still owns the screen. 31 sections across the seven
     pages. */
  /* Descendant, not child. working-history and motif-recurrence put their
     headings inside wrapper divs (.site > div > div > h2), so a child
     combinator reached neither — measured silent on both. .mast is excluded
     because the page title is primitive 1's job and must not be animated
     twice. */
  body[data-section] section h2,
  body[data-section] .site h2{
    animation:m-handoff linear both;
    animation-timeline:view();
    animation-range:entry -12% cover 34%;
    will-change:transform
  }
  @keyframes m-handoff{from{transform:translateY(40px)}to{transform:none}}

  /* ── 6. THE FULL-BLEED MOMENT — the one surprise, rationed ────────────────
     Every ninth figure on the two plate walls breaks the gutter and takes the
     whole width of the phone, and its crop opens further than the others.
     Rationed on purpose: MOTION-SPEC-2027 ranks motion and says a fourth
     tier-1 gesture requires retiring one, so this is deliberately the only
     thing on a secondary page that behaves like an event. At 9n on favorites'
     45 figures that is five moments in a long scroll, which is rare enough to
     stay an event.

     Withheld from to-scale for the reason in §2 — a work drawn at true scale
     cannot be widened to the viewport without the page lying. */
  /* favorites only. register-of-loss was in this list until its markup was
     read: its two <figure>s are `.ratio-field`, an aria-hidden SVG chart, not
     plates. Widening a data visualisation to the viewport and scaling it is
     not a full-screen artwork moment; it is a broken chart. */
  body[data-section="favorites"] .grid > figure:nth-of-type(9n){
    width:100vw;margin-left:calc(50% - 50vw);margin-right:calc(50% - 50vw)
  }
  /* Driven from the clipper for the same reason as the crop above. */
  body[data-section="favorites"] .grid > figure:nth-of-type(9n) .frame{
    animation:m-full linear both;
    animation-timeline:view();
    animation-range:entry 0% exit 100%
  }
  body[data-section="favorites"] .grid > figure:nth-of-type(9n) .frame > img{width:100%}
  @keyframes m-full{
    from{--m-crop:1.24}
    45% {--m-crop:1.02}
    to  {--m-crop:1.10}
  }

  /* ── 7 WAS THE DRIFT, AND IT IS DELIBERATELY NOT HERE ────────────────────
     `.jfsn-brand-mark` is declared five times across about, stories,
     working-history, register-of-loss and motif-recurrence, identical but for
     animation-range (200vh on three, 500vh on two). It is the clearest
     duplication on the site and consolidating it was the first thing I
     intended to do here.

     It is not done, because it cannot be done from this file without changing
     DESKTOP. Those five rules are not width-scoped, and each pairs an infinite
     float on the auto timeline with a scroll-driven exit — two animations,
     with float durations that differ per page as well. Reproducing that here
     means either moving the rule out of the mobile query (touching every
     width, on five pages, for no mobile gain) or leaving desktop running the
     old copies while mobile runs a new one, which is two sources of truth
     instead of one and strictly worse than five honest copies.

     It is a real cleanup and it belongs in its own change, where desktop can
     be measured before and after. Noted here so the next person does not
     rediscover the duplication and assume nobody looked. */

}}

/* ── REDUCED MOTION ───────────────────────────────────────────────────────────
   Explicit, and not optional. animation-duration is ignored on a scroll-driven
   animation, so base.css's global .01ms guard cannot reach anything above. Each
   primitive is named here so that a new one added without a line in this block
   is visible as an omission rather than hidden as an inheritance.

   overflow:hidden stays. It is layout, not motion, and removing it would move
   the crop back out over the caption. */
@media (prefers-reduced-motion:reduce){
  .mast h1,.mast .meta,.mast p,.mast .standfirst,
  .frame,.frame > img,.frame > picture > img,figcaption,
  .grid > *,.related-grid > *,.related-plate .cap,
  .site,li,section h2,.site h2{
    animation:none !important;
    transform:none !important;
    opacity:1 !important
  }
}
