/* ============================================================
   AtraX — Design tokens & base
   ============================================================ */

:root {
  /* ---------- Colour ----------
     Light ground, dark punctuation, and the scroll tween between them — the
     original scheme. The SHARPNESS (geometric display face, 2-4px radii, flat nav,
     square buttons) is independent of the palette and stays.

     --accent and --slate came from the hero composition that globe.js now
     owns; the Remotion project they were lifted from has been deleted. globe.js
     reads all three back out of these tokens rather than restating them, so
     these three lines remain the single definition. */
  --accent: #E84C3C;                        /* the mark */
  --accent-bright: #FF5A32;                 /* markers, a step brighter */
  --accent-press: #C93B2C;
  --accent-soft: rgba(232, 76, 60, 0.12);
  --accent-line: rgba(232, 76, 60, 0.32);
  --cta-gradient: none;                     /* no gradients in this direction */
  --slate: #2B3242;                         /* the globe's land dots */

  /* Light surfaces — the page ground */
  --bg: #ffffff;
  --bg-muted: #f1f1f3;
  --ink: #111114;
  --ink-2: #5b5b61;
  --ink-4: #9a9aa2;
  --line: #e8e8ea;
  --line-soft: #f0f0f2;

  /* Dark surfaces — process / cta / footer punctuation */
  --d-bg: #0e0e11;
  --d-bg-2: #15151a;
  --d-ink: #ffffff;
  --d-ink-2: rgba(255, 255, 255, 0.62);
  --d-ink-3: rgba(255, 255, 255, 0.38);
  --d-line: rgba(255, 255, 255, 0.10);
  --d-line-soft: rgba(255, 255, 255, 0.06);

  /* ---------- Type families ---------- */
  --font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
  --font-head: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-body: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  /* Kept under the name --font-mono because ~40 call sites use it for eyebrows,
     meta rows and spec labels. It is no longer a monospace face: those labels
     now set in uppercase Space Grotesk, where --ls-mono carries the technical
     feel that the fixed pitch used to. */
  --font-mono: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;

  /* ---------- Type scale ----------
     Twelve steps, replacing the 40 ad-hoc sizes this file used to
     carry (22 of them fixed px, 8 of those on half-pixel steps).
     Every font-size in the system resolves to one of these. */
  --t-3xs: 10px;      /* micro labels inside dense components */
  --t-2xs: 11px;      /* mono labels, tags, card meta */
  --t-xs: 12px;       /* eyebrow */
  --t-s: 13px;        /* dense mono rows */
  --t-sm: 14px;       /* card body, nav, secondary UI */
  --t-base: 15px;     /* compact card body */
  --t-md: 16px;       /* body default */
  --t-lg: 17px;       /* long-form body (case studies, service blocks) */
  --t-xl: 19px;       /* card headings, standfirsts */
  --t-lede: clamp(16px, 1.5vw, 19px);   /* hero + page-hero sub-lines */
  --t-h4: clamp(18px, 1.6vw, 22px);
  --t-h3: clamp(20px, 2.2vw, 28px);
  --t-h2s: clamp(26px, 3.2vw, 40px);    /* sub-section h2 (service blocks, work names) */
  --t-h2: clamp(25px, 3.3vw, 42px);     /* ONE section h2, not six */
  --t-h1: clamp(28px, 3.9vw, 52px);
  --t-display: clamp(32px, 4.9vw, 64px);  /* hero only */

  /* Leading + tracking — the pairs that were previously ad hoc */
  --lh-display: 0.96;
  --lh-head: 1.05;
  --lh-body: 1.6;
  --ls-display: -0.035em;
  --ls-head: -0.025em;
  --ls-mono: 0.14em;

  /* ---------- Spacing (4px grid) ---------- */
  --s-1: 4px;   --s-2: 8px;   --s-3: 12px;  --s-4: 16px;
  --s-5: 20px;  --s-6: 24px;  --s-7: 28px;  --s-8: 32px;
  --s-10: 40px; --s-12: 48px; --s-14: 56px; --s-16: 64px;
  --s-18: 72px; --s-22: 88px; --s-26: 104px; --s-30: 120px;

  /* ---------- Shape ---------- */
  --r-sm: 2px;
  --r-md: 3px;
  --r-lg: 4px;
  --r-pill: 999px;
  /* Flat direction: surfaces are defined by their rule, not by elevation.
     These stay as tokens rather than being deleted so the ~14 call sites do
     not all need editing, and so a future direction can turn depth back on
     in one place. Accent focus rings are separate and untouched. */
  --shadow-md: none;
  --shadow-lg: none;
  --shadow-cta: none;

  /* ---------- Motion ----------
     Three curves, consolidated from six aliases. Defined here rather than
     in a section stylesheet so they resolve on every page. */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
  --ease-reveal: cubic-bezier(0.16, 1, 0.3, 1);

  /* ---------- Layout ---------- */
  --maxw: 1240px;
  --gutter: 32px;

  /* Global scroll-driven theme tokens. app.js used to write all seven of these
     from JS on every scroll frame; it now writes only --theme-t (0 = light,
     1 = dark) and the seven are DERIVED here. The channel-wise calc() form
     reproduces the lerp it replaces exactly.

     Deliberately NOT color-mix(): that premultiplies alpha, and four of these
     tween their alpha independently of their channels, so those four would
     cross-fade visibly differently from the other three. */
  --theme-t: 0;
  /* A near-step off --theme-t, for the few things that are BISTABLE rather than
     continuous. The bar's tint is a continuum and must track the page exactly;
     a solid button's colour PAIR is not, and cannot be lerped at all: inverting
     "dark fill, light text" into "light fill, dark text" drives both toward the
     same mid grey, and at the halfway point the label is the same colour as the
     button it sits on. Measured on the first attempt at .btn-invert below:
     rgb(136,136,138) on rgb(135,135,136), a contrast ratio of 1.0.

     No smooth path avoids it - cross-fading two legible states composites to
     exactly the same grey-on-grey - so the pair has to swap rather than travel.
     x60 spreads the swap over about 1.7% of the transition, which is a couple
     of frames: enough not to be a hard discontinuity, too fast to read as a
     flash. */
  --theme-step: max(0, min(1, (var(--theme-t) - 0.5) * 60));
  --page-bg:     rgb(calc(255 - 241 * var(--theme-t)) calc(255 - 241 * var(--theme-t)) calc(255 - 238 * var(--theme-t)));
  --page-text:   rgb(calc( 17 + 238 * var(--theme-t)) calc( 17 + 238 * var(--theme-t)) calc( 20 + 235 * var(--theme-t)));
  --page-text-2: rgb(calc( 91 + 164 * var(--theme-t)) calc( 91 + 164 * var(--theme-t)) calc( 97 + 158 * var(--theme-t)) / calc(1 - 0.38 * var(--theme-t)));
  --page-text-3: rgb(calc(154 + 101 * var(--theme-t)) calc(154 + 101 * var(--theme-t)) calc(162 +  93 * var(--theme-t)) / calc(1 - 0.62 * var(--theme-t)));
  /* Two more of the same family, added 2026-09-22 for the case studies. Those
     pages sit immediately above the dark CTA, so the tween is already under way
     while their last section is still on screen - and every rule in that
     template was painting on fixed --ink / --line, which is dark type on a
     darkening ground. Both resolve to exactly the light value at t = 0, so
     nothing on a light page moves. */
  --page-line:    rgb(calc(232 + 23 * var(--theme-t)) calc(232 + 23 * var(--theme-t)) calc(234 + 21 * var(--theme-t)) / calc(1 - 0.90 * var(--theme-t)));
  --page-surface: rgb(calc(241 - 220 * var(--theme-t)) calc(241 - 220 * var(--theme-t)) calc(243 - 217 * var(--theme-t)));
  --nav-bg:      rgb(calc(255 - 245 * var(--theme-t)) calc(255 - 245 * var(--theme-t)) calc(255 - 243 * var(--theme-t)) / calc(0.86 - 0.10 * var(--theme-t)));
  /* The logo's dark ink (its slate, #2B3E50) on light ground, white on dark,
     riding the same tween as the nav. Used by .lg-ink in the inline logo SVGs. */
  --logo-ink:    rgb(calc( 43 + 212 * var(--theme-t)) calc( 62 + 193 * var(--theme-t)) calc( 80 + 175 * var(--theme-t)));
  --nav-text:    rgb(calc( 58 + 197 * var(--theme-t)) calc( 58 + 197 * var(--theme-t)) calc( 64 + 191 * var(--theme-t)) / calc(1 - 0.26 * var(--theme-t)));
  --nav-border:  rgb(calc(255 * var(--theme-t)) calc(255 * var(--theme-t)) calc(255 * var(--theme-t)) / calc(0.10 + 0.02 * var(--theme-t)));

  /* The bar is a two-stop gradient, not a flat fill, so it keeps the faint
     top-light it has in dark mode. BOTH stops tween, and at t = 0 they resolve
     to the same colour, so light mode is the flat white it always was. These
     replace a hard-coded gradient that used to be swapped in at a threshold -
     see the note on .nav in sections.css. */
  --nav-bg-a:    rgb(calc(255 - 231 * var(--theme-t)) calc(255 - 231 * var(--theme-t)) calc(255 - 227 * var(--theme-t)) / calc(0.86 - 0.24 * var(--theme-t)));
  --nav-bg-b:    rgb(calc(255 - 237 * var(--theme-t)) calc(255 - 237 * var(--theme-t)) calc(255 - 233 * var(--theme-t)) / calc(0.86 - 0.44 * var(--theme-t)));

  /* The bar's lift, one token per shadow layer. Layered separately because the
     two drop shadows darken as the page does while the two inset highlights
     fade out, so they cannot share a curve. Worth having as tokens rather than
     a threshold swap for a second reason: through the middle of a transition
     the bar and the page are the same grey and the hairline is at 11% of it,
     so the drop shadow is the only thing still separating them. */
  --nav-sh-1:    rgb(calc(15 - 15 * var(--theme-t)) calc(15 - 15 * var(--theme-t)) calc(18 - 18 * var(--theme-t)) / calc(0.11 + 0.11 * var(--theme-t)));
  --nav-sh-2:    rgb(calc(15 - 15 * var(--theme-t)) calc(15 - 15 * var(--theme-t)) calc(18 - 18 * var(--theme-t)) / calc(0.06 + 0.06 * var(--theme-t)));
  --nav-sh-top:  rgb(255 255 255 / calc(0.82 - 0.68 * var(--theme-t)));
  --nav-sh-bot:  rgb(255 255 255 / calc(0.30 - 0.25 * var(--theme-t)));
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
  /* The hero globe is meant to bleed off the right edge. `overflow-x: hidden`
     on <body> alone did not contain it: between roughly 880px and 1200px the
     page could be dragged sideways by up to 67px. `clip` on the root does
     contain it and, unlike `hidden`, creates no scroll container - so sticky
     positioning and Lenis are unaffected. */
  overflow-x: clip;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}
/* Lenis adds .lenis to <html> once it takes over the scroll. Native smooth
   scrolling would fight it, so it is switched off only when Lenis is live —
   if the script fails or is skipped, the CSS smooth scroll above still runs. */
html.lenis { scroll-behavior: auto; }
/* Set by app.js while the mobile menu or the case-study modal is open, with
   or without Lenis (phones run without it). */
html.scroll-locked { overflow: hidden; }

body {
  font-family: var(--font-body);
  background: transparent;
  color: var(--ink);
  font-size: var(--t-md);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* No overflow-x here. `clip` on the root above already contains the bleed,
     and a non-visible overflow on <body> while the root is also non-visible
     stops the viewport propagation: <body> then becomes a real scroll
     container of its own, and every position:sticky inside it silently stops
     sticking. That is what was happening to .cs-aside and .brief-aside, and
     the globe section's sticky stage needs it fixed. */
}

img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

::selection { background: var(--accent); color: #fff; }

/* Visible focus ring (accessibility) */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
.btn:focus-visible { outline-offset: 4px; }

/* ---------- Shared primitives ---------- */
.wrap { width: 100%; max-width: var(--maxw); margin: 0 auto; padding: 0 var(--gutter); }

.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--t-xs);
  font-weight: 500;
  letter-spacing: var(--ls-mono);
  text-transform: uppercase;
  color: var(--page-text-3);
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
}
.eyebrow::before {
  content: "";
  width: 18px;
  height: 1px;
  background: var(--page-text-3);
  display: inline-block;
}
.eyebrow.center::before { display: none; }

h1, h2, h3, h4 { font-family: var(--font-head); font-weight: 700; line-height: var(--lh-head); letter-spacing: var(--ls-head); color: var(--ink); }

.section { padding: var(--s-30) 0; position: relative; }

/* The 12-column field every section lays out on. Direction A's core move:
   the head occupies 5 of 12 and the content starts at 7, so the page reads
   as two unequal columns instead of one centred stack. */
.grid12 {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--s-6);
}

.section-head { max-width: 720px; margin-bottom: var(--s-14); }
.section-head h2 {
  font-family: var(--font-display);
  font-size: var(--t-h2);
  font-weight: 600;
  margin: var(--s-4) 0 0;
  line-height: 1.1;
  letter-spacing: -0.012em;
  text-wrap: balance;
  color: var(--page-text);
}
.section-head p {
  margin-top: var(--s-5);
  color: var(--page-text-2);
  font-size: var(--t-xl);
  max-width: 560px;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--t-xs);
  letter-spacing: .04em;
  line-height: 1;
  padding: var(--s-4) var(--s-6);
  border-radius: var(--r-sm);
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform .25s cubic-bezier(.2,.7,.2,1), background .2s, border-color .2s, color .2s, box-shadow .25s;
  white-space: nowrap;
}
.btn:active { transform: translateY(1px) scale(.99); }
.btn-primary { background: var(--cta-gradient); color: #fff; box-shadow: var(--shadow-cta); }
.btn-primary:hover { filter: brightness(1.04); transform: translateY(-2px); box-shadow: 0 12px 26px rgba(232, 76, 60,0.34); }
.btn-primary:active { filter: brightness(0.96); }
.btn-ghost { background: transparent; color: var(--ink); border-color: var(--line); }
.btn-ghost:hover { border-color: var(--ink); transform: translateY(-2px); }
.btn-solid { background: var(--ink); color: #fff; }
.btn-solid:hover { background: #000; transform: translateY(-2px); box-shadow: none; }
.btn-dark { background: #fff; color: var(--d-bg); }
.btn-dark:hover { transform: translateY(-2px); box-shadow: none; }
.btn-dark-ghost { background: rgba(255,255,255,.04); color: #fff; border-color: var(--d-line); }
/* The one solid-accent button: Book a call in the closing band, as the
   reviewer's mockup draws it (2026-09-23). */
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:hover { background: #d63f30; transform: translateY(-2px); }
/* Inverts with the page theme rather than picking a side: near-black with white
   text on light sections, white with near-black text on dark ones. Both colours
   are theme tokens, so it rides the same --theme-t curve as the bar around it
   and there is no second variant to keep in step — which is exactly the trap
   the nav's old .navbar--dark class fell into. */
/* Driven by --theme-step, not --theme-t: see the note on that token. The end
   values are the same --page-text / --page-bg pair, restated against the
   stepped driver. The transition is also narrowed to exclude background and
   color - inherited from .btn, a 0.2s ease would have stretched the swap back
   out into a fifth of a second of unreadable label, which is the whole thing
   being avoided. */
.btn-invert {
  background: rgb(calc( 17 + 238 * var(--theme-step)) calc( 17 + 238 * var(--theme-step)) calc( 20 + 235 * var(--theme-step)));
  color:      rgb(calc(255 - 241 * var(--theme-step)) calc(255 - 241 * var(--theme-step)) calc(255 - 238 * var(--theme-step)));
  transition: transform .25s cubic-bezier(.2,.7,.2,1), opacity .2s;
}
.btn-invert:hover { transform: translateY(-2px); opacity: .88; }
.btn-dark-ghost:hover { border-color: rgba(255,255,255,.5); transform: translateY(-2px); }

.btn .arr { transition: transform .25s cubic-bezier(.2,.7,.2,1); }
.btn:hover .arr { transform: translate(3px, -3px); }
/* ============================================================
   Motion
   One attribute drives everything: data-motion names the effect, the
   choreography lives here, and app.js only adds .is-in. Adding a new
   effect costs a CSS block and no JavaScript.

     data-motion="rise|stagger|lines|mask-up|fade|sweep|wipe|band|track|plate|count|none"
     data-motion-delay="120"     ms before this element starts
     data-motion-stagger="90"    ms step between a container's children

   These are ANIMATIONS, not transitions, and deliberately so. The first
   build delivered the stagger through `transition-delay`, which several
   components silently discarded: .eng, .val, .price and .case all declare
   a `transition` shorthand, and a shorthand resets transition-delay to 0.
   The cascade worked on section heads and did nothing on four card grids.
   animation-delay cannot be clobbered that way.
   ============================================================ */

@keyframes motion-rise  { from { opacity: 0; transform: translateY(28px) scale(0.96); }
                          to   { opacity: 1; transform: none; } }
@keyframes motion-label { from { opacity: 0; transform: translateY(12px); }
                          to   { opacity: 1; transform: none; } }
@keyframes motion-up    { from { transform: translateY(108%); } to { transform: none; } }
@keyframes motion-fade  { from { opacity: 0; } to { opacity: 1; } }
@keyframes motion-sweep { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes motion-drop  { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes motion-wipe  { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes motion-inx   { from { opacity: 0; transform: translateX(-16px); }
                          to   { opacity: 1; transform: none; } }
@keyframes motion-pop   { from { transform: scale(0); } 62% { transform: scale(1.18); }
                          to   { transform: scale(1); } }
@keyframes motion-settle{ from { opacity: 0; transform: scale(1.06); }
                          to   { opacity: 1; transform: none; } }

/* shared timing; `both` holds the from-state through the delay, so a
   cascaded child stays hidden until its turn */
[data-motion].is-in,
[data-motion].is-in > * {
  animation-duration: .8s;
  animation-timing-function: var(--ease-reveal);
  animation-fill-mode: both;
}

/* --- rise: the workhorse --- */
[data-motion="rise"]:not(.is-in) { opacity: 0; }
[data-motion="rise"].is-in { animation-name: motion-rise; animation-delay: var(--motion-delay, 0ms); }

/* --- stagger: container triggers, children cascade --- */
[data-motion="stagger"]:not(.is-in) > * { opacity: 0; }
[data-motion="stagger"].is-in > * {
  animation-name: motion-rise;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms));
}
/* The eyebrow is a label, not a heading: it gets a shorter throw (motion-label,
   no scale) AND its own curve. --ease-out decelerates hard and lands early;
   --ease-reveal on the h2 keeps gliding. Two curves is what stops a section
   head reading as one block that happens to be split in two. */
[data-motion="stagger"].is-in > .eyebrow {
  animation-name: motion-label;
  animation-duration: .55s;
  animation-timing-function: var(--ease-out);
}

/* --- lines: each line rises out of ITS OWN box, so the clip has to be on the
   line, not on the block. The markup is <h1><span class="h-line"><span>…
   Custom properties inherit, so --i set on the line reaches the inner span.
   overflow:clip, not hidden - hidden would make each line a scroll container. --- */
[data-motion="lines"] > * {
  display: block;
  overflow: clip;
  /* Display headlines run line-height below 1, so the line BOX is shorter than
     the text's ink: at 54px/0.96 the box is 52px and the glyphs need 70px.
     Clipping to the box would shear 10px off the ascenders and 8px off the
     descenders permanently, not just while animating. The clip margin gives
     the ink room while still hiding the 108% travel. */
  overflow-clip-margin: 0.35em;
}
[data-motion="lines"] > * > * { display: block; }
[data-motion="lines"]:not(.is-in) > * > * { transform: translateY(108%); }
[data-motion="lines"].is-in > * > * {
  animation-name: motion-up;
  animation-duration: .9s;
  animation-timing-function: var(--ease-reveal);
  animation-fill-mode: both;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms));
}

/* --- mask-up: one block rising, slower, no scale. Standfirsts, pull quotes --- */
[data-motion="mask-up"] { overflow: clip; }
[data-motion="mask-up"] > * { display: block; }
[data-motion="mask-up"]:not(.is-in) > * { transform: translateY(105%); }
[data-motion="mask-up"].is-in > * {
  animation-name: motion-up; animation-duration: 1.1s; animation-delay: var(--motion-delay, 0ms);
}

/* --- fade: no movement. Photographs and screenshots, where a rise reads
   as the page settling badly rather than as intent --- */
[data-motion="fade"]:not(.is-in) { opacity: 0; }
[data-motion="fade"].is-in { animation-name: motion-fade; animation-duration: 1s; animation-delay: var(--motion-delay, 0ms); }

/* --- sweep: a rule draws itself left to right --- */
[data-motion="sweep"] { transform-origin: left center; }
[data-motion="sweep"]:not(.is-in) { transform: scaleX(0); }
[data-motion="sweep"].is-in { animation-name: motion-sweep; animation-duration: 1s; animation-delay: var(--motion-delay, 0ms); }

/* --- wipe: a ruled list writes itself. Each row is revealed by a clip that
   travels left to right, so the row's hairline appears to be ruled onto the
   page rather than faded on, and the type drifts in behind the clip edge.
   The cascade runs DOWN the list while every gesture runs ACROSS it - the
   opposite pairing to `band`, which is the point: a stack of rows and a row
   of columns should not reveal the same way. --- */
[data-motion="wipe"]:not(.is-in) > * { clip-path: inset(0 100% 0 0); }
[data-motion="wipe"]:not(.is-in) > * > * { opacity: 0; transform: translateX(-12px); }
[data-motion="wipe"].is-in > * {
  animation-name: motion-wipe;
  animation-duration: .85s;
  /* `backwards`, not `both`: the clip must be released when the row lands, or
     it would keep clipping the :hover padding shift for the life of the page. */
  animation-fill-mode: backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms));
}
[data-motion="wipe"].is-in > * > * {
  animation-name: motion-inx;
  animation-duration: .8s;
  animation-timing-function: var(--ease-reveal);
  animation-fill-mode: backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms) + 90ms);
}

/* --- band: a spec band divided by vertical rules. The division is vertical, so
   the reveal runs horizontally: each rule drops in from the top and the segment
   behind it slides in from the left, left to right across the band. The rule is
   the component's own ::before so the component can move it per breakpoint;
   this block only drives it. --- */
[data-motion="band"]:not(.is-in) > *::before { transform: scaleY(0); }
[data-motion="band"]:not(.is-in) > * > * { opacity: 0; transform: translateX(-16px); }
[data-motion="band"].is-in > *::before {
  animation: motion-drop .55s var(--ease-reveal) backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms));
}
[data-motion="band"].is-in > * > * {
  animation-name: motion-inx;
  animation-duration: .75s;
  animation-timing-function: var(--ease-reveal);
  animation-fill-mode: backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 90ms) + var(--motion-delay, 0ms) + 110ms);
}

/* --- track: a timeline. The rule draws across, each node pops as the rule
   reaches it, and the copy drops in under the node it belongs to. The draw uses
   a near-even curve on purpose - --ease-reveal is so front-loaded that the rule
   would arrive before the first node had moved, and the sequence would read as
   three things fading in after a line. --- */
[data-motion="track"]:not(.is-in)::before { transform: scaleX(0); }
[data-motion="track"]:not(.is-in) > *::before { transform: scale(0); }
[data-motion="track"]:not(.is-in) > * > * { opacity: 0; }
[data-motion="track"].is-in::before {
  animation: motion-sweep .8s cubic-bezier(.4, .1, .3, 1) backwards var(--motion-delay, 0ms);
}
[data-motion="track"].is-in > *::before {
  animation: motion-pop .5s var(--ease-out) backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 240ms) + var(--motion-delay, 0ms) + 100ms);
}
[data-motion="track"].is-in > * > * {
  animation-name: motion-label;
  animation-duration: .7s;
  animation-fill-mode: backwards;
  animation-delay: calc(var(--i, 0) * var(--step, 240ms) + var(--motion-delay, 0ms) + 260ms);
}

/* --- plate: a framed photograph resolves into a frame that is already drawn.
   Deliberately not a rise - a photograph that jumps as you scroll reads as the
   page loading badly, not as intent. The caption lifts once the image lands.
   --plate-dur lets the component carry its own scale: a large plate should
   resolve more slowly than the small one beside it. --- */
[data-motion="plate"]:not(.is-in) img { opacity: 0; transform: scale(1.06); }
[data-motion="plate"]:not(.is-in) figcaption > * { opacity: 0; }
[data-motion="plate"].is-in img {
  /* `backwards` again: `both` would pin transform after the animation and the
     :hover scale on the photograph would silently stop working. */
  animation: motion-settle var(--plate-dur, 1.1s) var(--ease-reveal) backwards var(--motion-delay, 0ms);
}
[data-motion="plate"].is-in figcaption > * {
  animation-name: motion-label;
  animation-duration: .6s;
  animation-timing-function: var(--ease-out);
  animation-fill-mode: backwards;
  animation-delay: calc(var(--motion-delay, 0ms) + 260ms);
}
[data-motion="plate"].is-in figcaption > *:nth-child(2) {
  animation-delay: calc(var(--motion-delay, 0ms) + 340ms);
}

/* --- none: observed and given .is-in, but not styled here. For components
   that own their choreography (the process diagram) --- */

/* Promote only while the element still has somewhere to go. The old system
   set will-change on all 86 .reveal elements for the life of the page. */
[data-motion]:not(.is-in):not([data-motion="none"]) { will-change: opacity, transform; }

/* app.js stands the observer down under reduced motion and adds .is-in to
   everything at init - but it is a deferred script, so the :not(.is-in)
   pre-states would still paint for a frame. These resets cover every selector
   the effects above touch, pre-state and animation both. */
@media (prefers-reduced-motion: reduce) {
  [data-motion], [data-motion] > *, [data-motion] > * > *,
  [data-motion]::before, [data-motion] > *::before,
  [data-motion] img, [data-motion] figcaption > * {
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
    animation: none !important;
  }
}

/* ============================================================
   Global scroll-driven light <-> dark theme
   The whole viewport is painted by one fixed canvas whose color
   (and dotted texture) is interpolated by app.js based on scroll
   position. Sections are transparent and let it show through, so
   the entire page eases from light mode to dark mode and back as
   you scroll between [data-theme] sections — not a localized seam.
   ============================================================ */
/* Two stacked plates. The light ground is painted once and never repaints; the
   dark plate above it carries a plain opacity, which the compositor can animate
   without touching the main thread. Compositing an opaque dark plate at opacity
   t over an opaque white one IS the lerp that used to be recomputed in JS and
   written to --page-bg every frame - same arithmetic, no full-viewport repaint.

   app.js writes plate.style.opacity directly rather than through a custom
   property: a custom property on :root is inherited, so every write would
   invalidate computed style for the whole document, which is the cost this is
   trying to avoid. */
.theme-canvas {
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background: #ffffff;
}
/* Phones, 2026-09-24 (owner's screenshot, Android Chrome): a white band at the
   bottom of the screen while scrolling through a dark section. When the
   address bar slides away the viewport grows before this fixed layer is
   resized, and what shows under it is the root background, which was white.
   Two fixes, either enough on its own:
   - the layer is sized to the LARGE viewport (the height with the toolbar
     gone), so it already covers the area the toolbar gives up;
   - the root takes --page-bg, the same tweened ground, so anything that does
     show through is the right colour. */
@supports (height: 100lvh) {
  .theme-canvas { bottom: auto; height: 100lvh; }
}
html { background-color: var(--page-bg, #fff); }
.tc-dark {
  position: absolute; inset: 0; background: #0e0e11;
  opacity: var(--theme-t);      /* app.js overwrites this inline, per frame */
  will-change: opacity;
}
/* There was a dot texture here (.theme-canvas::after, a 26px grid fading in
   with --theme-t). Removed site-wide on the owner's instruction, 2026-09-23,
   along with the hero and CTA dot-field canvases: "we don't need them". */

/* ---------- Inline CTA link ----------
   Global rather than section-scoped: .bs-link ships on 10 of the 15 pages
   (every service block and every case-study aside). */
.bs-link {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin-top: 15px;
  color: var(--ink);
  font-family: var(--font-head);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.01em;
  transition: color .2s var(--ease), gap .2s var(--ease);
}

.bs-link::before {
  width: 6px;
  height: 6px;
  flex: none;
  border-radius: 50%;
  background: var(--accent);
  content: "";
}

.bs-link span {
  display: inline-block;
  transition: transform .25s var(--ease);
}

.bs-link:hover {
  gap: 11px;
  color: var(--accent);
}

.bs-link:hover span {
  transform: translate(2px, -2px);
}

.bs-link:focus-visible {
  border-radius: 2px;
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* Without overflow-clip-margin there is no way to clip the travel without also
   shearing the glyphs, so drop the mask and use the plain rise instead.
   Better a different effect than permanently clipped headlines. */
@supports not (overflow-clip-margin: 1px) {
  [data-motion="lines"] > * { overflow: visible; }
  [data-motion="lines"]:not(.is-in) > * > * { transform: none; opacity: 0; }
  [data-motion="lines"].is-in > * > * { animation-name: motion-rise; }
}
