/* ==========================================================================
   Motion.

   The site shipped with one `transition` declaration across six stylesheets
   and no keyframes anywhere. That was not restraint, it was an omission:
   restraint is a powerful second move and an invisible first one, and a
   static page reads as unfinished rather than as considered to a visitor who
   has met four animated competitor sites the same morning.

   brand-guidelines.md carries a Motion section, added on 17 September 2026.
   Four rules govern everything in this file:

   1. Motion carries meaning or it does not ship. A reveal says a new section
      has started. A count-up says a number is the point of the block. Drift
      says there is a ground behind the content. Nothing here moves to be
      seen moving.

   2. The accent never animates as a field. Teal is metrics, links, CTAs and
      highlighted numbers, never a dominant field -- and a moving teal
      gradient is precisely the "bold gradient / playful" signal the palette
      decision rejects for ministry and bank buyers. The geometry is charcoal
      and navy hairlines at 6% to 17% opacity; see .geo-* in pages.css. It
      was 4% to 9% and was reviewed as "barely noticeable", which was
      accurate: at 5% a hairline on this paper ground is below the threshold
      at which a viewer registers a line at all.

   3. Everything is skippable. Content is complete and readable before any of
      this runs. Nothing appears only on scroll: a `.rv` block that never
      receives `.is-in` is fully visible, because the hidden state is applied
      by script and only when the script has confirmed it can remove it
      again. A stalled or blocked motion.js therefore leaves a readable page,
      which is the opposite of the usual scroll-reveal failure.

   4. Nothing moves for more than five seconds without a pause control. That
      is WCAG 2.2.2 and it applies to the client band, the one continuously
      moving element here. The control is a real button, visible rather than
      hover-only, because a hover does not exist on a phone.

   CSP: style-src is 'self' with no unsafe-inline, and a guard test greps
   every rendered page for a style attribute. So there are no per-element
   delays computed into markup -- staggering uses the finite .rv--2 .. .rv--6
   classes below. Do not introduce an inline `animation-delay`.

   prefers-reduced-motion: base.css carries a blanket reset near the foot of
   the file that kills every transition and animation, so everything here is
   covered the moment it ships. (No line number: it had already moved, and a
   stale line number in a comment is worse than none.) The reset cannot reach a transform applied by script,
   which is why motion.js checks the same media query for itself.
   ========================================================================== */


/* --------------------------------------------------------------------------
   Scroll reveal

   The hidden state is applied by .js-motion on <html>, which motion.js sets
   as its first act. Without the script the selector never matches and every
   .rv block renders at rest. That ordering is the whole safety property.
   -------------------------------------------------------------------------- */

/* ⚠ Travel was 14px over 0.62s and the review could not see it. 14px is
   about the distance at which a movement reads as a layout shift rather than
   as an entrance; the eye registers the element changed and not that it
   arrived. 26px with a hair of scale is unmistakably an entrance and is
   still nowhere near the 60px-plus bounce that makes a B2B page feel like a
   template. */
@keyframes rv-in {
  from { opacity: 0; transform: translateY(26px) scale(.985); }
  to   { opacity: 1; transform: none; }
}

.js-motion .rv { opacity: 0; }
.js-motion .rv.is-in {
  animation: rv-in .72s cubic-bezier(.16, .84, .3, 1) both;
}

/* Stagger steps. A finite set, because a computed per-element delay would
   have to be an inline style. Six is enough for any row on the site: the
   longest is the four-card service grid. */
.js-motion .rv--2.is-in { animation-delay: .09s; }
/* .rv--3 to .rv--6 exist for rows a page adds later. They are declared as a
   complete set rather than added one at a time, because the alternative is a
   computed animation-delay and that would have to be an inline style, which
   the Content-Security-Policy forbids. */
.js-motion .rv--3.is-in { animation-delay: .18s; }
.js-motion .rv--4.is-in { animation-delay: .27s; }
.js-motion .rv--5.is-in { animation-delay: .36s; }
.js-motion .rv--6.is-in { animation-delay: .45s; }

/* Children of a revealed block, staggered without needing a class each. Used
   by the card grids, where the cards are generated in a loop and cannot
   carry an index.

   ⚠ Keyed off an .is-in ANCESTOR, not .is-in on itself. The structural rule
   below puts .is-in on the section, and a card grid is two levels inside
   one; requiring the class on the grid itself was why these only fired
   where I had also written .rv onto the grid by hand. */
.js-motion .rv--stagger > * { opacity: 0; }
.js-motion .is-in .rv--stagger > * { animation: rv-in .55s cubic-bezier(.22, .61, .36, 1) both; }
.js-motion .is-in .rv--stagger > :nth-child(2) { animation-delay: .06s; }
.js-motion .is-in .rv--stagger > :nth-child(3) { animation-delay: .12s; }
.js-motion .is-in .rv--stagger > :nth-child(4) { animation-delay: .18s; }
.js-motion .is-in .rv--stagger > :nth-child(5) { animation-delay: .24s; }
.js-motion .is-in .rv--stagger > :nth-child(6) { animation-delay: .3s; }
.js-motion .is-in .rv--stagger > :nth-child(n+7) { animation-delay: .36s; }


/* --------------------------------------------------------------------------
   The structural reveal

   Every section's children, on every page, with no class in the template.
   This is the rule that fixed the "can't see any animations" finding: the
   opt-in version reached seventeen elements in the whole build.

   The first child of a .sec__grid is the rail -- the number, the title and
   the aside -- and it comes in from the inline start while everything after
   it rises. That separation is the point: a section whose rail and content
   arrive together arrives as one rectangle, which is what the page looked
   like even where the animation was firing.

   ⚠ `> .shell > .sec__grid >` is deliberately that specific. A looser
   descendant selector would catch the .sec__grid of a nested section and
   every card inside a grid, and the cards have their own rule above.
   -------------------------------------------------------------------------- */

.js-motion .sec > .shell > .sec__grid > * { opacity: 0; }
.js-motion .sec.is-in > .shell > .sec__grid > * {
  animation: rv-in .72s cubic-bezier(.16, .84, .3, 1) both;
}
.js-motion .sec.is-in > .shell > .sec__grid > :first-child {
  animation-name: rv-side;
  animation-duration: .66s;
}
.js-motion .sec.is-in > .shell > .sec__grid > :nth-child(2) { animation-delay: .1s; }
.js-motion .sec.is-in > .shell > .sec__grid > :nth-child(3) { animation-delay: .18s; }
.js-motion .sec.is-in > .shell > .sec__grid > :nth-child(n+4) { animation-delay: .24s; }

/* The closing band and the client band are full-bleed and carry no
   .sec__grid, so they reveal as a whole. */
.js-motion .close > .shell,
.js-motion .clients > .shell { opacity: 0; }
.js-motion .close.is-in > .shell,
.js-motion .clients.is-in > .shell {
  animation: rv-in .72s cubic-bezier(.16, .84, .3, 1) both;
}


/* --------------------------------------------------------------------------
   The client band marquee

   The track is rendered twice and translates by -50%, so the loop closes on
   itself with no visible snap. Duration scales with nothing: it is a fixed
   slow speed, because a marquee whose speed depends on how many clients
   there are would change every time one is added.

   `data-paused` is set by motion.js on the button press. It is an attribute
   rather than a class so the state is visible in the DOM to anyone
   debugging, and so the button's aria-pressed and the animation cannot
   disagree.
   -------------------------------------------------------------------------- */

@keyframes cli-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

.clients__track { animation: cli-scroll 64s linear infinite; }
.clients__track[data-paused] { animation-play-state: paused; }

/* Right-to-left pages run the track the other way, or the names appear to
   fly in from the wrong side of the reading direction. */
[dir="rtl"] .clients__track { animation-direction: reverse; }

/* Hover pauses it too, on top of the button. Not instead of: a hover is a
   convenience for a mouse and the button is the accessible control. */
.clients__vp:hover .clients__track,
.clients__vp:focus-within .clients__track { animation-play-state: paused; }


/* --------------------------------------------------------------------------
   Geometry drift

   Two layers at different rates, which is what makes the ground read as
   depth rather than as a pattern printed on the page. Very slow and very
   low-amplitude: at these values a reader notices the ground only if they
   stop and look at it, which is the intended effect.
   -------------------------------------------------------------------------- */

@keyframes geo-drift-a {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-2.4%, 1.6%, 0); }
}
@keyframes geo-drift-b {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(2.8%, -1.2%, 0); }
}
@keyframes geo-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

.geo-l1 { animation: geo-drift-a 34s ease-in-out infinite alternate; }
.geo-l2 { animation: geo-drift-b 46s ease-in-out infinite alternate; }

/* The closing band's rings rotate about their own centre, which for an SVG
   group means the transform-box has to be the fill box or the origin lands
   at the top-left of the viewport instead. */
.geo-spin {
  animation: geo-spin 150s linear infinite;
  transform-origin: 300px 300px;
  transform-box: view-box;
}


/* --------------------------------------------------------------------------
   Drawn-on figures

   An SVG path drawn by animating its own dash offset. It only applies to
   paths that opt in with .dr, because a figure whose every stroke draws
   itself takes longer to become readable than a reader will wait.

   pathLength="1" on the element is what makes one dash value work for paths
   of any real length; without it each path would need its own measured
   dasharray, which is a per-element value and therefore an inline style.
   -------------------------------------------------------------------------- */

@keyframes dr-in {
  from { stroke-dashoffset: 1; }
  to   { stroke-dashoffset: 0; }
}

.js-motion .dr { stroke-dasharray: 1; stroke-dashoffset: 1; }
.js-motion .is-in .dr { animation: dr-in 1.1s ease-out both; }


/* --------------------------------------------------------------------------
   Hover and focus

   Cards lift on hover by one pixel and gain a rule, which is the least
   motion that still says a whole card is clickable rather than only the link
   inside it. Anything larger reads as a consumer product.
   -------------------------------------------------------------------------- */

.orient > div,
.feat__c,
.toolcard {
  transition: transform .18s ease, box-shadow .18s ease;
}
.orient > div:hover,
.feat__c:hover,
.toolcard:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 18px rgba(15, 36, 56, .07);
}

.btn { transition: background .16s, border-color .16s, color .16s, transform .12s; }
.btn:active { transform: translateY(1px); }

.clients__row img { transition: opacity .2s, filter .2s; }
.cli__t { transition: opacity .2s; }


/* --------------------------------------------------------------------------
   The counted number

   motion.js writes textContent and never a style, so there is nothing to do
   here except reserve the width. Tabular figures stop the block reflowing on
   every frame as the digits change, which is the one thing that would make a
   count-up feel cheap.
   -------------------------------------------------------------------------- */

[data-countup] { font-variant-numeric: tabular-nums; }


/* --------------------------------------------------------------------------
   The reduced-motion contract, restated

   base.css already carries a blanket reset for transition and animation.
   This block is narrower and does one extra thing the blanket cannot: it
   guarantees that a .rv element is visible, because with `animation: none`
   the `both` fill mode never applies and the element would otherwise be left
   at the opacity: 0 that the hidden state set.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .js-motion .rv,
  .js-motion .rv--stagger > *,
  .js-motion .sec > .shell > .sec__grid > *,
  .js-motion .close > .shell,
  .js-motion .clients > .shell,
  .js-motion :is(.c-before, .c-after, .c-node, .c-marker, .c-marker--part, .c-marker--hollow, .c-radar-dot) {
    opacity: 1 !important; transform: none !important;
  }
  .js-motion .dr { stroke-dasharray: none; stroke-dashoffset: 0; }
  .js-motion .c-gauge-arc { stroke-dashoffset: 0; }
}


/* --------------------------------------------------------------------------
   Slide from the inline start

   For a section rail: the number and the title come in from the side rather
   than from below, so the rail and its content do not arrive as one slab.
   Logical direction, so Arabic slides the other way without a counterpart
   rule -- translateX would have needed one in rtl.css.
   -------------------------------------------------------------------------- */

@keyframes rv-side {
  from { opacity: 0; transform: translate(var(--rv-side, -20px)); }
  to   { opacity: 1; transform: none; }
}
.js-motion .rv--side.is-in { animation: rv-side .66s cubic-bezier(.16, .84, .3, 1) both; }
[dir="rtl"] .js-motion .rv--side.is-in { --rv-side: 20px; }


/* --------------------------------------------------------------------------
   The section rule under a title

   46px of teal drawn left to right as the section arrives. The cheapest
   possible "a section starts here", and the flat page had nothing of the
   kind. scaleX on a 46px element rather than an animated width, so it
   composites instead of triggering layout on every frame.
   -------------------------------------------------------------------------- */

@keyframes ttl-rule {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
.js-motion .sec__ttl::after { transform: scaleX(0); transform-origin: left center; }
[dir="rtl"] .js-motion .sec__ttl::after { transform-origin: right center; }
.js-motion .is-in .sec__ttl::after,
.js-motion .rv.is-in .sec__ttl::after {
  animation: ttl-rule .5s cubic-bezier(.22, .61, .36, 1) .12s both;
}


/* --------------------------------------------------------------------------
   The capability bars in the hero figure

   Each grows from its own start edge. The stagger is nth-child on the row
   rather than a delay per bar, because a delay per bar is a computed value
   and would have to be an inline style.

   transform-box: fill-box is what makes transform-origin resolve against
   the rect instead of against the SVG viewport; without it every bar grows
   from the top-left corner of the figure.
   -------------------------------------------------------------------------- */

@keyframes bar-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
.js-motion .rec__fill { transform: scaleX(0); transform-box: fill-box; transform-origin: left center; }
.js-motion .is-in .rec__fill { animation: bar-grow .9s cubic-bezier(.16, .84, .3, 1) both; }
.js-motion .is-in .rec__row:nth-child(2) .rec__fill { animation-delay: .08s; }
.js-motion .is-in .rec__row:nth-child(3) .rec__fill { animation-delay: .16s; }
.js-motion .is-in .rec__row:nth-child(4) .rec__fill { animation-delay: .24s; }
.js-motion .is-in .rec__row:nth-child(5) .rec__fill { animation-delay: .32s; }
.js-motion .is-in .rec__row:nth-child(n+6) .rec__fill { animation-delay: .4s; }

/* Every other bar in the viz kit grows the same way. c-before and c-after are
   the two rect classes the slope, coverage, bar_pair, weighted_bar and
   segments charts all draw their bars with -- one selector, so a bar on
   /services, /case-studies or /frameworks moves exactly like the hero's,
   rather than the hero being the one figure on the site that does. */
.js-motion :is(.c-before, .c-after) {
  transform: scaleX(0); transform-box: fill-box; transform-origin: left center;
}
.js-motion .is-in :is(.c-before, .c-after) {
  animation: bar-grow .8s cubic-bezier(.16, .84, .3, 1) both;
}

/* The gauge arc, swept on rather than scaled -- a stroked circle has no
   "left edge" to grow from. r is 42 on every gauge in the kit (viz/metric.html
   never varies it), so the circumference is the same fixed number everywhere
   and this can be a plain keyframe rather than a per-instance computed value,
   which an inline style could not be under this CSP anyway. */
@keyframes gauge-in {
  from { stroke-dashoffset: 263.9; }
  to   { stroke-dashoffset: 0; }
}
.js-motion .c-gauge-arc { stroke-dashoffset: 263.9; }
.js-motion .is-in .c-gauge-arc { animation: gauge-in 1s cubic-bezier(.16, .84, .3, 1) both; }

/* A chart's own point markers -- the slope's two dots and the opportunity
   map's numbered circles -- pop in after their bar or line has landed, which
   is what makes a figure read as measured rather than as decoration that
   happened to include some numbers. Reuses node-in, defined below for the
   stepper; .c-node here rather than every marker class individually, since
   circle markers on this site never carry an accent gradient that scaling
   would distort. */
.js-motion .c-node,
.js-motion :is(.c-marker, .c-marker--part, .c-marker--hollow, .c-radar-dot) {
  transform: scale(0); transform-box: fill-box; transform-origin: center;
}
.js-motion .is-in :is(.c-node, .c-marker, .c-marker--part, .c-marker--hollow, .c-radar-dot) {
  animation: node-in .38s cubic-bezier(.34, 1.3, .5, 1) .5s both;
}


/* --------------------------------------------------------------------------
   Table rows

   A results table is the site's signature object and it arrived as a block.
   Six steps, then everything after row six shares the last delay -- the
   longest table on the site is the 35-row case index and a 35-step stagger
   would take four seconds to finish.
   -------------------------------------------------------------------------- */

.js-motion .is-in .rv--rows tbody tr { animation: rv-in .5s cubic-bezier(.22, .61, .36, 1) both; }
.js-motion .is-in .rv--rows tbody tr:nth-child(2) { animation-delay: .05s; }
.js-motion .is-in .rv--rows tbody tr:nth-child(3) { animation-delay: .1s; }
.js-motion .is-in .rv--rows tbody tr:nth-child(4) { animation-delay: .15s; }
.js-motion .is-in .rv--rows tbody tr:nth-child(5) { animation-delay: .2s; }
.js-motion .is-in .rv--rows tbody tr:nth-child(6) { animation-delay: .25s; }
.js-motion .is-in .rv--rows tbody tr:nth-child(n+7) { animation-delay: .3s; }


/* --------------------------------------------------------------------------
   The stepper's nodes

   The dot on each step scales up after its text has arrived, which is what
   makes five steps read as a sequence rather than as five boxes.
   -------------------------------------------------------------------------- */

@keyframes node-in {
  from { transform: scale(0); }
  to   { transform: scale(1); }
}
.js-motion .step > div::before { transform: scale(0); }
.js-motion .is-in .step > div::before { animation: node-in .42s cubic-bezier(.34, 1.3, .5, 1) both; }
.js-motion .is-in .step > div:nth-child(2)::before { animation-delay: .07s; }
.js-motion .is-in .step > div:nth-child(3)::before { animation-delay: .14s; }
.js-motion .is-in .step > div:nth-child(4)::before { animation-delay: .21s; }
.js-motion .is-in .step > div:nth-child(5)::before { animation-delay: .28s; }


/* --------------------------------------------------------------------------
   The ghost numeral

   Fades and drifts in behind a --wide plate's heading. Slower and further
   than anything else here, because it is the background and it should still
   be settling when the reader has started on the first line.
   -------------------------------------------------------------------------- */

@keyframes ghost-in {
  from { opacity: 0; transform: translateY(18px); }
  to   { opacity: 1; transform: none; }
}
.js-motion .sec__ghost { opacity: 0; }
.js-motion .is-in .sec__ghost,
.js-motion .rv.is-in .sec__ghost { animation: ghost-in 1.1s ease-out .1s both; }


/* --------------------------------------------------------------------------
   The header, condensing

   motion.js adds .is-scrolled to <html> past 40px. The header loses 12px of
   height and gains a rule and a shadow, which is the difference between a
   header sitting on the page and one floating over it. It is meaning, not
   decoration: it says the page has moved.
   -------------------------------------------------------------------------- */

/* ⚠ These select .site-header and .head, which are the classes the
   template actually emits: <header class="site-header"> wrapping <div
   class="shell pad head">. The first version of this block selected .hd and
   .hd__row, which exist nowhere in the build -- so motion.js applied
   .is-scrolled correctly and it matched nothing, and the header was
   measurably identical at the top of the page and 900px down. Dead CSS does
   not fail a test; it fails silently and forever. */
.site-header { transition: box-shadow .22s ease; }
.head { transition: min-height .22s ease, padding-block .22s ease; }
.is-scrolled .site-header { box-shadow: 0 6px 22px rgba(15, 36, 56, .07); }
.is-scrolled .head { min-height: calc(var(--header-h) - 14px); padding-block: 8px; }


/* --------------------------------------------------------------------------
   The slow spin, for the dark plate's arcs

   The orbit ground already spins at 150s. The dark plate's arcs are larger
   on screen, and the same angular rate on a bigger radius is a faster edge,
   so this one is slower.
   -------------------------------------------------------------------------- */

.geo-spin--slow { animation-duration: 210s; }


/* --------------------------------------------------------------------------
   Reduced motion, for everything added above

   base.css kills transitions and animations wholesale, which leaves any
   element whose resting state was set to a zero transform stuck at zero --
   an invisible bar, an unscaled dot, an undrawn rule. Each of those resting
   states is undone here explicitly. The list has to be kept in step with
   the blocks above; a guard test checks that every `transform: scale(0)` in
   this file has a counterpart in this block.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .js-motion .rec__fill,
  .js-motion .step > div::before,
  .js-motion .sec__ttl::after { transform: none !important; }
  .js-motion .sec__ghost { opacity: 1 !important; }
  .js-motion .is-in .rv--rows tbody tr { opacity: 1 !important; transform: none !important; }
  .is-scrolled .head { min-height: calc(var(--header-h) - 2px); padding-block: 14px; }
}
