/* ==========================================================================
   SREO Consulting — motion system
   Source of truth for every movement on the site. See DESIGN.md, "Motion".

   Rules
   - UI durations 200 to 600ms; line draws up to 1000ms; a photograph's colour 800ms;
     nothing longer. Three named exceptions: the single map-marker pulse at 1800ms and
     the waiting dots at 1.2s a cycle (both repeating rather than one-shot, which is what
     this rule is about), the hero's crossfade at 1200ms, the brief's own figure for a
     full-width photograph replacing another (F3), and the slow zoom under each hero frame
     while it shows (10s, longer than any frame stays): the original site's Ken Burns, 1.5% a
     second, asked for by SREO.
     The closing signal dot used to be a third. It was removed rather than named.
   - Entrances use --ease-entrance; hover and state changes use --ease-state.
     No bounce, no elastic.
   - Scroll-linked effects animate transform and opacity only.
   - Reveal once; never replay on scroll-up.
   - Stagger 60 to 90ms, six items per group at most.
   - Every effect shows its final state instantly under prefers-reduced-motion.
   - No decorative animation.
   ========================================================================== */

/* Each ground names the colour of its own rules, so a drawn line — or a printed
   border restored below — matches the rule it stands in for. Inert on their own. */
:is(.ground-up, .qa-band) { --rule: var(--inverse-border); }
.team { --rule: var(--ink-line); }

:root {
  --ease-entrance: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-state: cubic-bezier(0.4, 0, 0.2, 1);
  --dur-1: 200ms;
  --dur-2: 300ms;
  --dur-3: 400ms;
  --dur-4: 600ms;
  --dur-draw: 1000ms;
  --dur-press: 80ms;   /* a control going down under the pointer (Part J, J1) */
  --dur-vt: 200ms;     /* the crossfade between pages (Part J, J3) */
  --dur-hero-fade: 1200ms;   /* one hero frame fading in over the last (F3) */
  --dur-hero-zoom: 10000ms;  /* the zoom under a hero frame: longer than the 7s hold plus the 1.2s fade, so it never ends while the frame shows (F3) */
  --dur-wipe: 700ms;
  --dur-rule: 800ms;
  /* A photograph's colour arriving. Longer than any UI duration and on the same clock
     as a drawn rule, because it is the same kind of event: something the page does to
     itself as the reader arrives, not an answer to something the reader did. */
  --dur-grade: 800ms;
  --stagger: 80ms;
}

/* Everything below is opt-in: it needs JavaScript (html.js), a reader who has not
   asked for reduced motion, a page that is not in static review mode, and a screen.
   Paper is not scrolled, so no effect here could ever finish on it: a printed page
   would keep whatever state the reader happened to leave behind, which for a reveal
   or a drawn rule means nothing at all. Print therefore gets the page as written. */
@media screen and (prefers-reduced-motion: no-preference) {

  /* Images ---------------------------------------------------------------- */
  /* Every photograph sits in a box that already holds its space, filled with a flat token colour.
     That block is a cover over the picture, and it retracts downward once the file has both loaded
     and come into view, so the photograph appears from the top, a slow connection shows a
     considered block rather than a gap, and nothing moves when the file lands. The cover is a
     pseudo element rather than a clip on the image itself: an image clipped to nothing has no area,
     and the browser then neither lazy-loads it nor reports it as on screen.
     The hero keeps its own fourteen-second settle, so only the bands scale here. */
  html.js:not(.is-static) :is(.field-photo, .photo-band)::before {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    background: inherit;
    clip-path: inset(0 0 0 0);
    transition: clip-path var(--dur-wipe) var(--ease-entrance);
    pointer-events: none;
  }
  html.js:not(.is-static) :is(.field-photo, .photo-band).is-revealed::before { clip-path: inset(100% 0 0 0); }
  /* The hero's cover sits above all three frames. */
  html.js:not(.is-static) .field-photo::before { z-index: 4; }

  /* The hero sequence (brief F3) -------------------------------------------- */
  /* The hero sequence (F3): three frames, seven seconds each. The next fades in over the
     current for 1200ms on the state curve while a slow zoom runs under every frame from the
     moment it shows: scale 1 to 1.15 over 10s, linear, which is the 1.5% a second of the
     original site's slideshow (Elementor's Ken Burns "in", the setting SREO had and asked to
     keep, 2026-09-23). A frame shows for 8.2s at most, so the zoom is still running when it
     goes; the frame leaving keeps zooming under the fade and is only reset once it is hidden,
     so nothing snaps or stops. The zoom pauses with the sequence when the hero is off
     screen or the tab is hidden (.is-held from app.js); under reduced motion or in static
     review there is one frame and no zoom. 1200ms and 8.2s are named exceptions above. */
  html.js:not(.is-static) .hero-frame:not(:first-child) { display: block; }
  html.js:not(.is-static) .hero-frame { z-index: 0; opacity: 0; }
  /* The first frame is simply there at load, under the reveal cover, with no fade of its own:
     a fade under a cover that then retracts would be two arrivals. A frame that has been put
     away sits at 0 with no transition, so its next arrival fades from nothing. */
  html.js:not(.is-static) .hero-frame:first-child { opacity: 1; }
  html.js:not(.is-static) .hero-frame.is-out { z-index: 1; opacity: 1; transition: none; }
  html.js:not(.is-static) .hero-frame.is-in { z-index: 2; opacity: 1; transition: opacity var(--dur-hero-fade) var(--ease-state); }
  html.js:not(.is-static) .field-photo.is-revealed .hero-frame.is-in img { animation: hero-zoom var(--dur-hero-zoom) linear both; }
  /* Only the frame in hand and the one leaving move (Part M): every other frame's drift and
     settle are paused, and everything in the hero pauses while it is off screen or the tab
     is hidden (.is-held from app.js). The band's drift waits until the band has been seen. */
  html.js:not(.is-static) .hero-frame:not(.is-in):not(.is-out), html.js:not(.is-static) .hero-frame:not(.is-in):not(.is-out) img { animation-play-state: paused; }
  html.js:not(.is-static) .field-photo.is-held .hero-frame, html.js:not(.is-static) .field-photo.is-held .hero-frame img { animation-play-state: paused; }
  html.js:not(.is-static) .photo-band:not(.is-revealed) picture { animation-play-state: paused; }
  html.js:not(.is-static) .hero-frame.is-off { visibility: hidden; opacity: 0; transition: none; }
  html.js:not(.is-static) .field-photo figcaption { transition: opacity var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .field-photo figcaption.is-swapping { opacity: 0; }
  html.js:not(.is-static) .photo-band img {
    transform: scale(1.04);
    transition: transform var(--dur-wipe) var(--ease-entrance);
  }
  html.js:not(.is-static) .photo-band.is-revealed img { transform: none; }

  /* Photographs arrive in navy ---------------------------------------------- */
  /* A photograph lands as a navy duotone and resolves into its own colour behind the
     cover as the cover clears it. The navy is one flat layer blended in the colour
     mode, which takes hue and saturation from the layer and lightness from the
     photograph beneath: the picture keeps every value it was shot with and loses only
     its colour, so any opacity between the two ends is a true cross-fade and not a
     wash of paint. That it is opacity and nothing else is the point — the compositor
     can carry that on a full-bleed 1920px photograph, where animating a grayscale
     filter would repaint the whole image on every frame for 800ms.
     It starts 200ms in, by which time the cover has left the top of the picture, so
     the colour arrives into something the reader can already see rather than behind
     the cover where nobody would watch it happen.
     Forced colours get none of it: the layer would be repainted in a system colour and
     left lying over a photograph, which is the one thing the system does not recolour. */
  html.js:not(.is-static) :is(.field-photo, .photo-band) picture::before {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--navy);
    mix-blend-mode: color;
    opacity: 1;
    pointer-events: none;
    transition: opacity var(--dur-grade) var(--ease-entrance) 200ms;
  }
  html.js:not(.is-static) :is(.field-photo, .photo-band).is-revealed picture::before { opacity: 0; }
  @media (forced-colors: active) {
    html.js:not(.is-static) :is(.field-photo, .photo-band) picture::before { content: none; }
  }

  /* Photographs drift -------------------------------------------------------- */
  /* Each photograph travels a little slower than the page it is in, so a full-bleed
     picture reads as something the page is moving past rather than a panel glued to
     it. The picture is given 14% more height than its frame and is allowed to move
     5.5% of that in each direction, which is 6.3% of the frame: at both ends of the
     travel it still covers the frame completely, so no edge of the ground can ever
     appear. The movement is one transform on the picture, which is why the picture
     is a layer of its own — the hero's fourteen-second settle is a transform on the
     image inside it, and two animations cannot share one property.
     Where the browser has a scroll timeline this costs no script and no main-thread
     work at all; app.js writes the same movement, and only that movement, for the
     engines that do not have one yet. */
  html.js:not(.is-static) :is(.field-photo, .photo-band) picture { top: -7%; bottom: auto; height: 114%; }
  @supports (animation-timeline: view()) {
    html.js:not(.is-static) :is(.field-photo, .photo-band) picture {
      animation: photo-drift auto linear both;
      animation-timeline: view();
      animation-range: cover;
    }
  }

  /* Hairline draws ---------------------------------------------------------- */
  /* The 1px rule that opens a block is drawn from the left as the block arrives.
     A border cannot take a transform, so the line is a pseudo element and the border
     it replaces is only made transparent here. Everything stays declared in
     styles.css, so a reader without JavaScript, in static review mode, or asking for
     less motion sees the same page with every rule already in place.
     Row rules inside a block do not draw: forty hairlines moving at once is noise,
     and the rule that opens a block is the one the eye reads as its edge. */
  html.js:not(.is-static) [data-draw]::after {
    content: '';
    position: absolute;
    background: var(--rule, var(--border));
    transition: transform var(--dur-rule) var(--ease-entrance);
    /* A staggered card's rule waits its turn with the card: the stagger sets a delay on
       the host, and transition-delay is not inherited unless it is asked for. */
    transition-delay: inherit;
    pointer-events: none;
  }
  /* The horizontal kinds are named rather than excluded with :not(), so that the bottom
     edge below can override this geometry: :not() would outrank it and leave the line at
     the top of the block, drawn along the wrong edge. An upright rule takes its geometry
     further down, and has none at all in the narrow layout, which is what that wants. */
  html.js:not(.is-static) :is([data-draw=""], [data-draw="bottom"])::after {
    left: 0; right: 0; top: -1px;
    height: 1px;
    transform: scaleX(0);
    transform-origin: left center;
  }
  html.js:not(.is-static) :is([data-draw=""], [data-draw="bottom"]).is-drawn::after { transform: scaleX(1); }
  html.js:not(.is-static) [data-draw=""] { border-top-color: transparent; }

  /* A rule under the block rather than over it. */
  html.js:not(.is-static) [data-draw="bottom"] { border-bottom-color: transparent; }
  html.js:not(.is-static) [data-draw="bottom"]::after { top: auto; bottom: -1px; }

  /* There is no "both edges" kind. The Selected work band had one, and its lower rule
     finished drawing 760px below the fold, where nobody was looking: a rule that opens
     a block is drawn, a rule that closes one far below simply stays a border. */

  /* Below 768px the process steps drop their rule for an upright connector, so there
     is nothing there to draw: without this the motion layer would invent a line that
     the page itself does not have, which is the one thing it must never do. */
  @media (max-width: 767px) {
    html.js:not(.is-static) .process > li[data-draw]::after { content: none; }
  }

  /* The stat strip is closed by the drawn timeline directly beneath it. Two long rules
     on one screen have to move as one gesture, so this one borrows the timeline's clock
     rather than keeping the shorter rule duration. */
  html.js:not(.is-static) .stats[data-draw]::after { transition: transform var(--dur-draw) linear; }

  /* The upright rules between the statistics draw downward, on the same clock. Below
     768px the strip reflows to two columns and half of them are dropped, so they are
     left alone there. */
  @media (min-width: 768px) {
    html.js:not(.is-static) [data-draw="left"] { border-left-color: transparent; }
    html.js:not(.is-static) [data-draw="left"]::after {
      left: -1px; right: auto; top: 0; bottom: 0;
      width: 1px; height: auto;
      transform: scaleY(0);
      transform-origin: center top;
    }
    html.js:not(.is-static) [data-draw="left"].is-drawn::after { transform: scaleY(1); }
  }

  /* Windows High Contrast and friends force their own colours: a background-painted
     line is dropped or flattened, while a border survives. So there the effect stands
     down and the borders stay as the stylesheet wrote them. */
  @media (forced-colors: active) {
    html.js:not(.is-static) [data-draw]::after { content: none; }
    html.js:not(.is-static) [data-draw=""] { border-top-color: CanvasText; }
    html.js:not(.is-static) [data-draw="bottom"] { border-bottom-color: CanvasText; }
    html.js:not(.is-static) [data-draw="left"] { border-left-color: CanvasText; }
  }

  /* Enquiry form ------------------------------------------------------------ */
  /* The label rises and shrinks as the field takes something. It is the one place on
     the site that animates position and size rather than transform: the label is
     absolutely positioned, so nothing else on the page is laid out again, and the two
     ends of the move are then declared as plain positions that stay right whatever the
     type sizes become. */
  html.js:not(.is-static) .form-field label {
    transition: top var(--dur-1) var(--ease-state), font-size var(--dur-1) var(--ease-state),
                color var(--dur-1) var(--ease-state), letter-spacing var(--dur-1) var(--ease-state),
                font-weight 0s linear var(--dur-1);
  }
  /* Public Sans is four static faces, so the weight cannot glide from 400 to 500 the
     way the size does: it can only switch. The switch is timed to happen while the
     label is at its small size, where it is least seen: at the end of the rise, and at
     the start of the descent. The list above is what the rise uses; the descent is the
     resting state, whose own delay applies, and it applies to every property. */
  html.js:not(.is-static) .form-field:has(:is(input, textarea):placeholder-shown:not(:focus)) label {
    transition-delay: 0s;
  }
  /* The hint fades in as the label lands rather than after it has stopped: at 120ms the
     rest of the rise is under 2px and the label's ink already sits 20px above the hint's,
     so the two never overprint and focus reads as one move, not two beats (the sent
     check draws in at the same 120ms into its own state). When focus leaves the hint is
     gone at once, because the label is on its way back down through the same space. A
     transition takes its timing from the state it is going to, so the focused rule holds
     the entrance timing and the plain rule the instant exit. */
  html.js:not(.is-static) .form-field :is(input, textarea)::placeholder { transition: opacity 0s; }
  html.js:not(.is-static) .form-field :is(input, textarea):focus::placeholder {
    transition: opacity var(--dur-1) var(--ease-state) 120ms;
  }
  /* The rule thickens by scaling, never by changing its width: a border that grew from
     1px to 2px would lift every line of text sitting on it by a pixel. */
  html.js:not(.is-static) .form-field::after {
    transition: transform var(--dur-1) var(--ease-state), background-color var(--dur-1) var(--ease-state);
  }
  html.js:not(.is-static) :is(.submit-dots, .form-submit.is-sending .submit-label) {
    transition: opacity var(--dur-1) var(--ease-state);
  }
  /* One dot at a time, a beat apart: a busy state, not a decoration. It runs only while
     the request is in flight. */
  html.js:not(.is-static) :is(.form-submit.is-sending .submit-dots, .button.is-loading .button-dots) i {
    animation: dot-wait 1.2s var(--ease-state) infinite;
  }
  html.js:not(.is-static) :is(.form-submit.is-sending .submit-dots, .button.is-loading .button-dots) i:nth-child(2) { animation-delay: 150ms; }
  html.js:not(.is-static) :is(.form-submit.is-sending .submit-dots, .button.is-loading .button-dots) i:nth-child(3) { animation-delay: 300ms; }

  /* The menu mark ---------------------------------------------------------- */
  /* Two lines travel to the middle and turn. A state change, so --ease-state; the
     turn and the travel are one transform so they happen as one movement. */
  html.js:not(.is-static) .menu-lines i {
    transition: transform var(--dur-2) var(--ease-state);
  }

  /* Contextual cursor label ------------------------------------------------ */
  /* It fades, and only fades: the travelling is done by a transform written frame by
     frame in app.js, which a transition must not also interpolate or the label would
     lag its own lag. Visibility waits out the fade so the pill cannot be tabbed to or
     read while it is on its way out. */
  html.js:not(.is-static) .cursor-label {
    transition: opacity var(--dur-1) var(--ease-state), visibility 0s linear var(--dur-1);
  }
  html.js:not(.is-static) .cursor-label.is-on { transition-delay: 0s; }

  /* Sticky section labels -------------------------------------------------- */
  /* The pinned copy fades rather than cutting in, because it appears while the
     reader is mid-scroll. State change, so --ease-state; see styles.css for the
     label itself. It leaves by travelling out with its section, not by fading. */
  html.js:not(.is-static) .section-pin > .section-index {
    transition: opacity var(--dur-1) var(--ease-state), visibility 0s linear var(--dur-1);
  }
  html.js:not(.is-static) .is-pinned > .section-pin > .section-index { transition-delay: 0s; }

  /* Reveal on scroll ------------------------------------------------------ */
  html.js:not(.is-static) [data-reveal] {
    opacity: 0;
    transform: translateY(14px);
    transition: opacity var(--dur-4) var(--ease-entrance), transform var(--dur-4) var(--ease-entrance);
  }
  html.js:not(.is-static) [data-reveal].is-visible { opacity: 1; transform: none; }
  /* Full-bleed elements fade only: a translate would open a gap at the section edge. */
  html.js:not(.is-static) [data-reveal="fade"] { transform: none; }
  /* The timeline supplies its own entrance, so the envelope does not animate. */
  html.js:not(.is-static) [data-reveal="line"] { opacity: 1; transform: none; transition: none; }

  /* Stagger inside groups, capped at six steps */
  html.js:not(.is-static) :is(.ledger, .process, .projects-grid, .pillars, .methods-grid, .qa-grid, .country-grid, .fw-ledger) > [data-reveal]:nth-child(2) { transition-delay: calc(var(--stagger) * 1); }
  html.js:not(.is-static) :is(.ledger, .process, .projects-grid, .pillars, .methods-grid, .qa-grid, .country-grid, .fw-ledger) > [data-reveal]:nth-child(3) { transition-delay: calc(var(--stagger) * 2); }
  html.js:not(.is-static) :is(.ledger, .process, .projects-grid, .pillars, .methods-grid, .qa-grid, .country-grid, .fw-ledger) > [data-reveal]:nth-child(4) { transition-delay: calc(var(--stagger) * 3); }
  html.js:not(.is-static) :is(.ledger, .process, .projects-grid, .pillars, .methods-grid, .qa-grid, .country-grid, .fw-ledger) > [data-reveal]:nth-child(5) { transition-delay: calc(var(--stagger) * 4); }
  html.js:not(.is-static) :is(.ledger, .process, .projects-grid, .pillars, .methods-grid, .qa-grid, .country-grid, .fw-ledger) > [data-reveal]:nth-child(n+6) { transition-delay: calc(var(--stagger) * 5); }
  /* Where we work (2026-10-14): a region's rows follow their heading in, 40ms apart. Eight rows at most, so the
     last begins at 280ms and the group is still by 0.9s. A group the filter brings back arrives the same way. */
  html.js:not(.is-static) .country-group[data-reveal] > .country-row { opacity: 0; transform: translateY(8px); transition: opacity var(--dur-4) var(--ease-entrance), transform var(--dur-4) var(--ease-entrance); }
  html.js:not(.is-static) .country-group[data-reveal].is-visible > .country-row { opacity: 1; transform: none; }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(2) { transition-delay: calc(var(--stagger) / 2); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(3) { transition-delay: var(--stagger); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(4) { transition-delay: calc(var(--stagger) * 1.5); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(5) { transition-delay: calc(var(--stagger) * 2); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(6) { transition-delay: calc(var(--stagger) * 2.5); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(7) { transition-delay: calc(var(--stagger) * 3); }
  html.js:not(.is-static) .country-group[data-reveal] > .country-row:nth-of-type(n+8) { transition-delay: calc(var(--stagger) * 3.5); }

  /* Team: each visual row is its own group. The two layouts use mutually
     exclusive media queries, so the result never depends on cascade order. */
  @media (min-width: 768px) {
    html.js:not(.is-static) .team-lead > [data-reveal]:nth-child(2) { transition-delay: calc(var(--stagger) * 1); }
    html.js:not(.is-static) .team-lead > [data-reveal]:nth-child(3) { transition-delay: calc(var(--stagger) * 2); }
    html.js:not(.is-static) .team-lead > [data-reveal]:nth-child(4) { transition-delay: calc(var(--stagger) * 3); }
    html.js:not(.is-static) .team-grid > [data-reveal]:nth-child(4n+2) { transition-delay: calc(var(--stagger) * 1); }
    html.js:not(.is-static) .team-grid > [data-reveal]:nth-child(4n+3) { transition-delay: calc(var(--stagger) * 2); }
    html.js:not(.is-static) .team-grid > [data-reveal]:nth-child(4n+4) { transition-delay: calc(var(--stagger) * 3); }
  }
  @media (max-width: 767px) {
    html.js:not(.is-static) .team-grid > [data-reveal]:nth-child(2n) { transition-delay: var(--stagger); }
  }

  /* Timeline: the rule draws left to right, and each tick fades in as the line
     reaches it. The tick delays therefore follow position on the axis rather than
     the 60-90ms stagger; see DESIGN.md, "Motion", for why this is an exception. */
  /* The axis draws at constant speed, so each tick lights exactly as the line
     reaches it. An eased draw crosses 75% of the axis in a quarter of the time
     and the ticks then trail it visibly. */
  html.js:not(.is-static) .timeline-line { transform: scaleX(0); transition: transform var(--dur-draw) linear; }
  html.js:not(.is-static) .timeline.is-visible .timeline-line { transform: scaleX(1); }
  html.js:not(.is-static) .timeline-marks li { opacity: 0; transition: opacity var(--dur-1) var(--ease-entrance); transition-delay: var(--delay, 0ms); }
  html.js:not(.is-static) .timeline.is-visible .timeline-marks li { opacity: 1; }
  @media (max-width: 767px) {
    /* vertical axis on phones, so the draw runs top to bottom */
    html.js:not(.is-static) .timeline-line { transform: scaleY(0); }
    html.js:not(.is-static) .timeline.is-visible .timeline-line { transform: scaleY(1); }
  }

  /* Where we work: map markers -------------------------------------------- */
  /* Eighteen markers in region order, three passes of six on one cadence (--i is the index mod 6). */
  html.js:not(.is-static) .map-dot { opacity: 0; }
  html.js:not(.is-static) .map-figure.is-visible .map-dot {
    animation: dot-in var(--dur-3) var(--ease-entrance) both;
    animation-delay: calc(320ms + var(--i) * var(--stagger));
  }
  /* One pulse per marker, once. */
  html.js:not(.is-static) .map-figure.is-visible .dot-ring {
    animation: dot-pulse 1800ms var(--ease-entrance) both;
    animation-delay: calc(520ms + var(--i) * var(--stagger));
  }
}

/* Half the travel above the resting position, half below, so a picture sitting in the
   middle of the window is where it would have been with no drift at all. */
@keyframes photo-drift { from { transform: translateY(-5.5%); } to { transform: translateY(5.5%); } }
@keyframes dot-in { from { opacity: 0; } to { opacity: 1; } }
/* The waiting dots breathe rather than bounce: nothing on this site moves position
   to say it is busy. */
@keyframes dot-wait { 0%, 70%, 100% { opacity: .35; } 35% { opacity: 1; } }
@keyframes dot-pulse { from { transform: scale(1); opacity: .5; } to { transform: scale(2.2); opacity: 0; } }

/* Reduced motion: final state, instantly ----------------------------------
   transition-property: none is what makes this instant. Shortening the duration alone
   still creates a transition, and its first frame paints the starting value, so a state
   change flashes its old appearance for a frame before landing. Taking the properties
   out means no transition is created and the new state is on screen immediately, which
   is what static review mode below already does. Measured on the contact form's sent
   state: with duration alone it showed the unsent button for a frame; with this it does
   not. The duration and delay lines stay as a backstop for any transition declared with
   !important, which none currently is. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition-property: none !important;
    transition-duration: .01ms !important;
    transition-delay: 0s !important;
    scroll-behavior: auto !important;
  }
}

/* Static review mode (?static=1): no movement, final state ---------------- */
html.is-static *, html.is-static *::before, html.is-static *::after {
  animation: none !important;
  transition: none !important;
}

/* Page transitions (Part J, J3) ------------------------------------------------------------
   Moving between the site's pages is a 200ms crossfade (--dur-vt) with the header named as a
   shared element, so the logo and the nav hold still while the page beneath them changes.
   Cross-document view transitions need nothing but this opt-in on both pages; an engine
   without them simply navigates, and a reader who asked for less motion gets a plain
   navigation from the media query below. Static review mode skips the transition from
   app.js, since a class on <html> cannot be expressed in this at-rule. */
@view-transition { navigation: auto; }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: var(--dur-vt); animation-timing-function: var(--ease-state); }
.site-header { view-transition-name: site-header; }
::view-transition-group(site-header) { animation-duration: var(--dur-vt); }
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

/* The zoom under a hero frame (F3): 1.5% a second, linear, as the original site's slideshow. */
@keyframes hero-zoom { from { transform: scale(1); } to { transform: scale(1.15); } }

/* The map panel's words turn over when a new country comes into it (2026-09-27): a short fade
   and a 4px rise on the state curve, nothing on the sheet itself. */
@media (prefers-reduced-motion: no-preference) {
  html.js:not(.is-static) .map-panel.is-turning > * { animation: panel-turn 260ms var(--ease-state) both; }
}
@keyframes panel-turn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }

/* The map (2026-09-29): a country coming into hand sends one ripple out from its marker. */
@media (prefers-reduced-motion: no-preference) {
  html.js:not(.is-static) .map-dot.is-active .dot-ring { animation: map-ripple 700ms var(--ease-out) both;   /* at rest well inside the site's one-second ceiling for motion */ }
}
@keyframes map-ripple { 0% { opacity: .9; transform: scale(1); } 100% { opacity: 0; transform: scale(6); } }

/* Headlines rise line by line (2026-09-29). Until the words are cut the heading waits unseen
   (only when script runs and motion is welcome); each word is masked, and its line's words rise
   together, 110ms after the line above. The mask reaches a little past the glyphs so descenders
   and italic overhangs are not cropped. */
@media (prefers-reduced-motion: no-preference) {
  html.js:not(.is-static) [data-lines]:not(.lines-ready):not(.lines-done) { opacity: 0; }
  html.js:not(.is-static) [data-lines] .lw { display: inline-block; overflow: clip; padding: 0 .08em .16em; margin: 0 -.08em -.16em; vertical-align: top; }
  html.js:not(.is-static) [data-lines] .lwi { display: inline-block; transform: translateY(112%); }
  html.js:not(.is-static) [data-lines].is-lining .lwi { transform: none; transition: transform 950ms var(--ease-entrance); transition-delay: calc(var(--line, 0) * 110ms); }
}
/* The counter's digits: each holds its final figure's width, so nothing beside it moves. */
.roll { display: inline; }
.roll-digit { display: inline-block; text-align: center; }

/* The hero recedes (2026-09-30). As the page scrolls past it, the photograph draws in from
   full bleed to the page's own column and takes on a navy veil, so it settles back into the
   grid rather than simply leaving. It is driven by the scroll, not by time: it moves only while
   the page moves and holds wherever the reader stops. It begins 300px of scroll before the
   photograph's top reaches the window's top (since 2026-09-30, when the shorter hero put the photograph
   higher on the first screen: a share of the window would have started it at rest on a laptop) and is complete just
   after the photograph's top has passed under the header (15% into its exit), so the reader
   sees all of it. Engines without scroll-driven
   animation, reduced motion and static review keep the photograph as it is. */
.field-photo { --hero-recede: max(64px, calc((100% - 1280px) / 2)); --recede-start: -300px; }   /* the start: 300px of scroll before the photo's top meets the window's top (the timeline's inset is 0, or the page's scroll-padding would move that top 88px down) */
@media (max-width: 1199px) { .field-photo { --hero-recede: 40px; } }
@media (max-width: 767px) { .field-photo { --hero-recede: 20px; --recede-start: -180px; } }   /* a phone's hero is shorter: the photo sits 245px down at rest */
@media (max-width: 359px) { .field-photo { --hero-recede: 16px; } }
@supports (animation-timeline: view()) {
  @media screen and (prefers-reduced-motion: no-preference) {
    html.js:not(.is-static) .field-photo { view-timeline: --hero-photo block; view-timeline-inset: 0px; }
    html.js:not(.is-static) .hero-side { animation: hero-recede linear both; animation-timeline: --hero-photo; animation-range: exit var(--recede-start, -300px) exit 15%; }
    html.js:not(.is-static) .hero-dim { animation: hero-veil linear both; animation-timeline: --hero-photo; animation-range: exit var(--recede-start, -300px) exit 15%; }
  }
}
/* transform only, so the compositor carries it and the photograph is never repainted while the page scrolls
   (it was a clip-path until 2026-09-30, which repainted the whole picture on every frame) */
@keyframes hero-recede { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes hero-veil { from { opacity: 0; } to { opacity: .4; } }

/* Sectors (2026-09-30). The twelve tiles arrive along the diagonal, 60ms a step (--d, the tile's row
   plus its column, from app.js), so the grid opens from its top-left corner in under a second. In
   hand, the navy rises from the tile's foot, the number lifts away as "Read more" rises into
   its place, the arrow's ring fills, and the tile leans a degree or two toward the pointer (--rx and
   --ry from app.js) with a soft cyan light under it. Leaving, the navy drains back down a little
   faster than it came. Every transition is done inside the site's second; nothing loops. */
@media screen and (prefers-reduced-motion: no-preference) {
  html.js:not(.is-static) .sector-grid > li { opacity: 0; transform: translateY(18px); transition: opacity 560ms var(--ease-entrance), transform 560ms var(--ease-entrance); transition-delay: calc(var(--d, 0) * 60ms); }
  html.js:not(.is-static) .sector-grid.is-visible > li { opacity: 1; transform: none; }
  html.js:not(.is-static) .sector-tile { transition: transform 420ms var(--ease-entrance), box-shadow 420ms var(--ease-entrance), opacity var(--dur-2) var(--ease-state), color var(--dur-2) var(--ease-state), border-color var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .sector-tile:hover { transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); box-shadow: 0 22px 44px -26px rgb(27 27 74 / .6); }
  html.js:not(.is-static) .sector-tile::before { transition: transform 380ms var(--ease-state); }
  html.js:not(.is-static) .sector-tile:is(:hover, :focus-visible)::before { transition: transform 520ms var(--ease-entrance); }
  html.js:not(.is-static) .sector-tile::after { transition: opacity var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .sector-tile:hover::after { opacity: 1; }
  html.js:not(.is-static) .sector-label > span { transition: transform 420ms var(--ease-entrance); }
  html.js:not(.is-static) .sector-arrow { transition: background-color var(--dur-2) var(--ease-state), border-color var(--dur-2) var(--ease-state), color var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .sector-arrow .icon { transition: transform 420ms var(--ease-entrance); }
  html.js:not(.is-static) .sector-tile:is(:hover, :focus-visible) .sector-arrow .icon { transform: translate(1.5px, -1.5px); }
  /* A sector's description (2026-10-05) rises into place as the page dims behind it, and its head's
     words follow a beat later; closing is instant, as with every dialog on the site. */
  html.js:not(.is-static) .sector-dialog[open] { animation: sector-panel-in 460ms var(--ease-entrance) both; }
  html.js:not(.is-static) .sector-dialog[open]::backdrop { animation: sector-veil-in 320ms var(--ease-state) both; }
  html.js:not(.is-static) .sector-dialog[open] :is(.sector-head h2, .sector-text) { animation: sector-words-in 520ms var(--ease-entrance) 90ms both; }
}
@keyframes sector-panel-in { from { opacity: 0; transform: translateY(18px) scale(.985); } }
@keyframes sector-veil-in { from { opacity: 0; } }
@keyframes sector-words-in { from { opacity: 0; transform: translateY(10px); } }

/* The report shelf (2026-09-30). The covers rise onto the shelf one after another as it comes into
   view (70ms a step, at most five steps, so the last to move is done inside a second). A cover in
   hand lifts off the shelf, leans toward the pointer (--rx and --ry from app.js) and catches a
   sheen where the pointer is; its ring fills. Nothing loops. */
@media screen and (prefers-reduced-motion: no-preference) {
  html.js:not(.is-static) .shelf-item { opacity: 0; transform: translateY(26px); transition: opacity 600ms var(--ease-entrance), transform 600ms var(--ease-entrance); transition-delay: calc(min(var(--i, 0), 5) * 70ms); }
  html.js:not(.is-static) .shelf.is-visible .shelf-item { opacity: 1; transform: none; }
  html.js:not(.is-static) .cover { transition: transform 460ms var(--ease-entrance), box-shadow 460ms var(--ease-entrance); }
  html.js:not(.is-static) .cover:hover { transform: translateY(-10px) perspective(1000px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); box-shadow: var(--cv-spine), 0 34px 44px -24px rgb(27 27 74 / .6); }
  html.js:not(.is-static) .cover::after { transition: opacity var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .cover:hover::after { opacity: 1; }
  html.js:not(.is-static) :is(.cover-open, .shelf-btn) { transition: background-color var(--dur-2) var(--ease-state), border-color var(--dur-2) var(--ease-state), color var(--dur-2) var(--ease-state); }
  html.js:not(.is-static) .cover-open .icon { transition: transform 420ms var(--ease-entrance); }
  html.js:not(.is-static) .cover:is(:hover, :focus-visible) .cover-open .icon { transform: translate(1.5px, -1.5px); }
}
