
/* ============================================
   Base
   ============================================ */

/* ---- Webfont: Aeonik Pro VF (variable) — active display/body face ----
   One axis only: wght 100–900. No wdth and no opsz, so optical sizing is
   inert here — a heading and a caption render from the same outlines,
   unlike Helvetica Now below. The weight tokens (400/500/600) all sit
   inside the range, so nothing gets clamped.

   Swapping the two faces is a two-line change: reorder --font-heading /
   --font-body in tokens.css and point the preload in index.html at
   whichever file leads the stack. */
@font-face {
  font-family: "Aeonik Pro VF";
  src: url("../fonts/AeonikProVF.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* ---- Webfont: Helvetica Now Var (variable) ----
   One file covering three axes: wght 50–1000, wdth 50–100, opsz 4–30.
   A single face serves every weight the site asks for, so no cut gets
   rounded to a file we don't ship.

   opsz stays on the browser default (font-optical-sizing: auto), which
   feeds the rendered size into the axis: body copy lands near the Text
   master, display headings pull toward Display with its tighter fit.
   font-display: swap keeps text visible during load. */
@font-face {
  font-family: "Helvetica Now Var";
  src: url("../fonts/HelveticaNowVar.woff2") format("woff2");
  font-weight: 50 1000;
  font-stretch: 50% 100%;
  font-style: normal;
  font-display: swap;
}

/* ---- Webfont: Monstera Jam — the handwritten face ----
   One static cut, one job: the signature line above a section heading. It
   is never used for anything a reader has to work through, so a single
   weight is the whole family here and .text-signature asks for 400.

   Not preloaded, unlike Aeonik. It sets a dozen short labels, none of them
   in the fold, so font-display: swap costs a late repaint on a label
   instead of a render-blocking fetch on every visit. */
@font-face {
  font-family: "Monstera Jam";
  src: url("../fonts/MonsteraJam-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

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

html {
  -webkit-text-size-adjust: 100%;
  /* No scroll-behavior here on purpose. Lenis writes scrollTop every frame,
     so a native smooth scroll and Lenis fight each other and the page ends up
     not moving at all — which is what killed every in-page anchor. Smoothing
     is Lenis's job; anchors go through lenis.scrollTo in js/lenis.js. */
  /* A pinned section carries a hard pixel width until ScrollTrigger gets
     round to refreshing, which is a horizontal scrollbar for as long as a
     resize drag lasts. clip rather than hidden: it never becomes a scroll
     container, so it cannot be mistaken for the scroller. */
  overflow-x: clip;

  /* One knob for the whole page. Every size on the site is in rem or in a
     clamp whose ends are, so moving the root moves the type, the spacing
     scale and the container together — the 1440px measure reaches 1620 —
     and a wide screen gets a page built for it rather than a laptop layout
     stranded in the middle of it.

     Fluid between 1600 and 1920 rather than switched at one of them: the
     container would otherwise jump 180px in a single pixel of resize.

     The middle term is 1rem plus a share of the viewport, never a bare vw.
     rem on this element resolves against the initial font size, which is
     the reader's own browser setting, so someone running 20px gets a
     20-to-22.5 band instead of being overruled by a number we picked. The
     multiplier is the slope: 2px of growth over 320px of width. */
  font-size: clamp(1rem, 1rem + (100vw - 1600px) * 0.00625, 1.125rem);
}

body {
  font-family: var(--font-body);
  /* font-size: var(--text-body); */
  font-weight: var(--weight-regular);
  line-height: var(--leading-body);
  color: var(--color-text);
  background: var(--color-bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'ordn' on, 'ss04' on;
  @media (max-width: 767px){
    line-height: 1.3;
  }
  /* font-feature-settings: 'ss04' on; */
}

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

/* Lets width/height transitions run to intrinsic keywords (max-content,
   fit-content, auto). The nav bar's scrolled width is the one place that
   needs it — see `.nav > .container` below. */
:root {
  interpolate-size: allow-keywords;
}

img,
svg,
video {
  display: block;
  max-width: 100%;
  height: auto;
}


/* ============================================
   Surfaces: grain and glass
   ============================================ */

/* ---- Grain overlay ----
   Opt in with .has-noise on anything that already establishes a containing
   block — every <section> and .container does, via the position: relative
   rule further down. A tiled 32px PNG, non-interactive.

   Two knobs, both set on the host element:
     --noise-opacity  strength (default 0.08)
     --noise-z        where the grain sits in its parent's stack (default 1),
                      so it can go over a photo but under the copy.

   Deliberately no `position` here: .hero__media is absolute, and forcing
   relative on the host would move it. */
.has-noise::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: var(--noise-z, 1);
  pointer-events: none;
  background-image: url("../images/bg-noise-pattern.png");
  background-repeat: repeat;
  background-size: 32px 32px;
  opacity: var(--noise-opacity, 0.05);
}

/* ---- Section cover ----
   A picture behind a block's content. An element and a real <img>, not a
   background on a pseudo: the browser can then lazy-load it, pick a source
   and decode it like any other image, and the file is named in the markup
   next to the block it belongs to rather than in a stylesheet.

   Markup, as the first child of the block:
     <div class="section-cover" aria-hidden="true">
       <img src="..." alt="" loading="lazy" />
     </div>

   Add .is-blurred to the same div to throw it out of focus. One knob,
   --cover-blur, default 2rem.

   The block it sits in needs to be a containing block; every <section> and
   .container already is. */
:has(> .section-cover){
  /* z-index -1 below is what puts the picture under the content without
     the content having to say anything about itself. On its own it would
     sink too far — a block that opens no stacking context of its own would
     let the layer fall behind its own background colour, and a dark scheme
     paints one. This gives it that context, and the layer lands between
     the ground and the copy. */
  isolation: isolate;
}

.section-cover{
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.section-cover img{
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: contrast(1) brightness(0.95) saturate(0.75);
}

/* Out of focus. A blur pulls the picture's own edges inward and leaves the
   ground showing as a soft rim all the way round, so the image is grown by
   the radius on every side and pulled back by the same margin; the clip on
   the div above trims it to the block again. */
.section-cover.is-blurred img{
  /* The base sets max-width: 100% on every img, which would clamp this
     back to the block's own width and leave the overhang on one side. */
  max-width: none;
  width: calc(100% + var(--cover-blur, 2rem) * 2);
  height: calc(100% + var(--cover-blur, 2rem) * 2);
  margin: calc(var(--cover-blur, 2rem) * -1);
  filter: blur(var(--cover-blur, 2rem));
}

/* ---- Frosted glass ----
   Opt in with .has-glass on anything meant to read as a pane over what is
   behind it: the nav bar, the hero panels, the card bodies over a
   screenshot, the falling gap tiles, the illustration plates.

   One blur and one saturation for the whole site. They used to be written
   per component and had drifted to seven different pairs — 12px, 0.5rem,
   1rem, 2rem, 1.1em, with saturation at none, 1.1, 1.25, 1.4 and 1.5 — so
   two panes sitting side by side were made of visibly different glass.

   The fill is not here. Glass is the blur and nothing else; what each pane
   is tinted with stays with the component, because that is the part that
   belongs to the scheme it sits in.

   -webkit- alongside the standard one: Safari only dropped the prefix in
   16, and this is the effect the site leans on hardest. */
.has-glass {
  -webkit-backdrop-filter: blur(1rem) saturate(1.5);
  backdrop-filter: blur(1rem) saturate(1.5);
}


/* ============================================
   Layout
   ============================================ */

.container {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--container-pad);
}

.is-full{
  max-width: 100%;
  margin-inline: auto;
  padding-inline: var(--container-pad);
}

/* Between .container and .is-full: runs edge to edge on anything narrower
   than the cap, then stops growing. Same gutter as both, so swapping a
   container between the three shifts nothing but its ceiling. */
.is-wide{
  max-width: var(--container-wide);
  margin-inline: auto;
  padding-inline: var(--container-pad);
}

/* Under .container: a reading measure rather than a layout one, for sections
   that are a column of text and nothing else. The same gutter again, so it
   swaps with the others without shifting anything but the ceiling. It is
   also the measure .section-header and .section-footer are set to, which is
   what lets a narrow section's copy line up with a wide one's heading. */
.is-narrow{
  max-width: var(--container-narrow);
  margin-inline: auto;
  padding-inline: var(--container-pad);
}

.grid {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: var(--grid-gap);
}

.section {
  padding-block: var(--space-section);
  overflow: hidden;
}

/* Whichever section opens the page under the hero. Double padding at the top
   only, so it carries the gap between the fold and the start of the page
   proper; the bottom keeps the normal rhythm.

   Positional, so it lives on the section that happens to be first rather
   than on any one section's own rules — reordering the page is then a matter
   of moving the class, not of moving a padding between two stylesheets. */
.section.is-first {
  padding-top: calc(var(--space-section) * 1.75);
}

.section.is-last {
  padding-bottom: calc(var(--space-section) * 1.75);
}

.ml-6 { margin-left: var(--space-6); }

.p-2 { padding: var(--space-2); }

.p-4 { padding: var(--space-4); }

.p-8 { padding: var(--space-8); }

/* ---- Radius utilities ----
   Reads the --radius-* scale in tokens.css. Usage: <div class="rounded-md"> */
.rounded-none { border-radius: 0; }

.rounded-sm { border-radius: var(--radius-sm); }

.rounded-md { border-radius: var(--radius-md); }

.rounded-lg { border-radius: var(--radius-lg); }

.rounded-full { border-radius: 999px; }

/* Every section and container is a containing block, for .has-noise and
   for anything positioned inside them. */
section,
.container{
  position: relative;
}

.u-text-center {
  text-align: center;
}

/* Announced, not drawn. For a control whose meaning is carried by an icon —
   the label has to exist for a screen reader without taking any space. */
.u-visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---- Section header and footer ----
   The intro above a section's content and the closing line under it. Both
   are a flex column on half the container, centred in it; below 1024px
   they go full width and left, whatever variant they carry. */
.section-header,
.section-footer{
  display: flex;
  flex-direction: column;
  max-width: calc(var(--container-max) / 1.5);
  margin-inline: auto;
  @media (max-width: 1023px){
    max-width: 100%;
    margin-inline: unset;
    text-align: left;
    align-items: start;
  }
}

/* The one thing the two do not share. The header stacks a signature, a
   heading and a lede, three parts of one thought, so they sit close. The
   footer is a closing line with a button under it, two separate things,
   and it keeps the wider step. */
.section-header{
  gap: var(--space-3);
}

.section-footer{
  gap: var(--space-4);
}

/* Centred variant.

   Deliberately inside a min-width query rather than declared flat. Below
   1024px the base rule above already puts every header left and full width,
   and a flat .is-center would beat it on specificity — two classes against
   one — so centred copy would leak onto phones and tablets. Confining the
   rule to the wide breakpoint lets the base take over on its own below it,
   which is the behaviour wanted: centred on desktop, left everywhere else. */
@media (min-width: 1024px){
  .section-header.is-center,
  .section-footer.is-center{
    text-align: center;
    align-items: center;
  }
}

/* ---- Section eyebrow (parked) ----
   Nothing on the page carries .section-eyebrow right now. The eyebrows are
   plain .text-signature lines, and both this block and js/section-eyebrow.js
   are kept whole so the run can come back without being rebuilt.

   The page's contents, repeated above every heading: one 2px tick per
   section, in page order, and the section being read is the tick that has
   opened into its own name.

   The total lives in one place, --eyebrow-of, and each eyebrow only states
   where it sits, --eyebrow-at, on the element itself — position is the one
   thing about it that is data rather than style. The ticks either side are
   drawn, not marked up: one repeating gradient per side, cut to the number
   of sections before and after this one. Adding a section is then a matter
   of bumping the total and writing one line of markup, with nothing to
   renumber by hand.

   Every tick is drawn in currentcolor, so the run takes the ink of whatever
   section it opens and needs no variant per scheme. */
.section-eyebrow{
  --eyebrow-of: 12;
  --eyebrow-at: 1;
  --eyebrow-tick: 2px;
  --eyebrow-gap: 1px;
  /* One tick and the gap after it: the width a section takes in the run. */
  --eyebrow-pitch: calc(var(--eyebrow-tick) + var(--eyebrow-gap));

  display: inline-flex;
  align-items: stretch;
  /* Stated, not left to auto: in a header that does not centre its children
     the run is a stretched flex item, and a stretched item would hand the
     plate a width to grow into before the scroll ever reaches it. */
  width: fit-content;
  margin: 0;
  font-size: var(--text-label);
  font-weight: var(--weight-label);
  line-height: var(--leading-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

/* The sections before this one and the sections after it. Each run is
   n pitches wide, gap included, so the pitch's own transparent tail is what
   separates the run from the name — there is no flex gap to leave a stray
   pixel behind when a run is empty.

   The runs are drawn from opposite ends for the same reason: the gradient
   starts with ink at the edge it is pointed away from, which puts the ticks
   hard against the outside and the transparent tail against the name. */
.section-eyebrow::before,
.section-eyebrow::after{
  content: "";
  flex: none;
  background-image: repeating-linear-gradient(
    to var(--eyebrow-from),
    currentcolor 0 var(--eyebrow-tick),
    transparent var(--eyebrow-tick) var(--eyebrow-pitch)
  );
}

.section-eyebrow::before{
  --eyebrow-from: right;
  width: calc((var(--eyebrow-at) - 1) * var(--eyebrow-pitch));
}

.section-eyebrow::after{
  --eyebrow-from: left;
  width: calc((var(--eyebrow-of) - var(--eyebrow-at)) * var(--eyebrow-pitch));
}

/* The open tick: a plate of the section's own ink with the name knocked out
   of it. Held to a single grid column, because 0fr to 1fr is the one way to
   animate a box between nothing and an intrinsic width — no measuring, and
   the name can be changed without a script being told. Collapsed, the plate
   is floored at a tick's width and is indistinguishable from its neighbours,
   so the name grows out of the run rather than appearing over it. */
.section-eyebrow__label{
  display: grid;
  grid-template-columns: 1fr;
  min-width: var(--eyebrow-tick);
  overflow: clip;
  background-color: currentcolor;
  transition: grid-template-columns var(--duration-slow) var(--easing);
}

/* The resting state is open, so the name is there before js/section-eyebrow.js
   is: the script collapses what it is about to animate and nothing else. */
.section-eyebrow.is-collapsed .section-eyebrow__label{
  grid-template-columns: 0fr;
}

/* The name, and the reason it needs a span of its own: min-width is what
   lets the column take it below its own text width, and the plate above
   cannot carry that itself — it is the box being closed. The clip up there
   is what wipes the name away as the column goes.

   Only the height is padding. Sideways, the inset is two empty inline
   blocks, because a padded box has a floor of its own padding and the plate
   would close to that rather than to a tick; a spacer is content, and
   content is what min-width is allowed to take to nothing. Padding above
   and below never touches a width, so it stays padding. */
.section-eyebrow__text{
  min-width: 0;
  padding-block: 0.4em;
  color: var(--color-bg);
  white-space: nowrap;
}

.section-eyebrow__text::before,
.section-eyebrow__text::after{
  content: "";
  display: inline-block;
  width: 0.5em;
}

@media (prefers-reduced-motion: reduce){
  .section-eyebrow__label{
    transition: none;
  }
}

.section-content{
  display: flex;
  flex-direction: column;
  gap: var(--section-gap);
}

.u-anchor{
  display: block;
  height: 0;
  overflow: hidden;
}

/* A quote. No plate of its own now — no padding and no rule down the edge
   — so it sits straight on the section ground and whatever holds it sets
   the inset. The measure comes from .quote-text below; this block just
   centres it.

   Still a flex column: the block takes a source mark under the quote, and
   the column is what keeps that mark lined up with the text rather than
   hanging off the paragraph. */
.quote-wrapper{
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  align-items: center;
  text-align: center;
  /* Fills whatever holds it, so the centring holds even where the parent
     would otherwise shrink it to content — .section-footer packs its
     children to the start below 1024px. */
  width: 100%;

  /* The same line the section headings go left on. A quote is the one other
     thing on the page set centred, so leaving it centred under a heading
     that has gone left reads as two blocks that were never meant to sit
     together. */
  @media (max-width: 1023px){
    align-items: start;
    text-align: left;
  }
}

/* The mark and caption under a quote. .quote-wrapper centres the block and
   the caption inherits its text-align, but the wordmark is an <svg> with
   its own width — a flex item in a column, so it needs the cross-axis rule
   to come off the start edge. */
.quote-source{
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  align-items: center;
  text-align: center;

  /* Both again, for the same reason they are both stated above: the caption
     follows the text-align, the wordmark is an svg of its own width and
     follows the cross-axis rule. */
  @media (max-width: 1023px){
    align-items: start;
    text-align: left;
  }
}

/* How a quote is set, in one place — the markup carries .quote-text and no
   size class, so every quote on the site moves together.

   The four properties are one step of the scale in tokens.css, kept whole:
   swap the -sm suffix on all four for another step and the quotes resize
   without any of the mismatches that come from taking a size from one step
   and a tracking from another. */
.quote-text{
  font-family: var(--font-heading);
  font-size: var(--text-heading-md);
  font-weight: var(--weight-heading-md);
  line-height: var(--leading-heading-md);
  letter-spacing: var(--tracking-heading-md);
  /* A reading measure of its own. .quote-wrapper carries none, so without
     this a quote runs the full width of whatever holds it — the container on
     three of these, which is far past a comfortable line at this size. */
  max-width: 45ch;
  text-wrap: balance;
  @media (max-width: 767px){
    font-size: var(--text-heading-sm);
    font-weight: var(--weight-heading-sm);
    line-height: var(--leading-heading-sm);
    letter-spacing: var(--tracking-heading-sm);
  }
}

.section-header-2{
  display: grid;
  gap: var(--space-16);
  grid-template-columns: 7fr 5fr;
  align-items: end;
  .section-header__right{
    display: flex;
    flex-direction: column;
    justify-content: end;
  }
  .section-header{
    width: 100%;
    max-width: unset;
  }
  /* One column. The gap was a column gap between a heading and a paragraph
     standing side by side; stacked, the same figure reads as a break in the
     block. --space-inner is the panel-on-a-plate step, and it stays under
     --section-gap at every width below this line, so the two halves of the
     header sit closer to each other than the header sits to the content. */
  @media (max-width: 1023px){
    grid-template-columns: 1fr;
    gap: var(--space-inner);
  }
}


/* ============================================
   Typography
   ============================================ */

/* One class per step. Each pulls the four tokens of its own step and
   nothing else, so retuning a step in tokens.css moves everything set in
   it and nothing set in another. */
.text-display,
.text-heading-xl,
.text-heading-lg,
.text-heading-md,
.text-heading-sm,
.text-heading-xs {
  font-family: var(--font-heading);
  text-wrap: balance;
}

.text-display {
  font-size: var(--text-display);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--tracking-display);
}

.text-heading-xl {
  font-size: var(--text-heading-xl);
  font-weight: var(--weight-heading-xl);
  line-height: var(--leading-heading-xl);
  letter-spacing: var(--tracking-heading-xl);
}

/* Also the large-quote style — same 44/1.15 as Figma's quote. */
.text-heading-lg {
  font-size: var(--text-heading-lg);
  font-weight: var(--weight-heading-lg);
  line-height: var(--leading-heading-lg);
  letter-spacing: var(--tracking-heading-lg);
}

.text-heading-md {
  font-size: var(--text-heading-md);
  font-weight: var(--weight-heading-md);
  line-height: var(--leading-heading-md);
  letter-spacing: var(--tracking-heading-md);
}

.text-heading-sm {
  font-size: var(--text-heading-sm);
  font-weight: var(--weight-heading-sm);
  line-height: var(--leading-heading-sm);
  letter-spacing: var(--tracking-heading-sm);
}

/* Heading at body size — Figma "Heading body". */
.text-heading-xs {
  font-size: var(--text-heading-xs);
  font-weight: var(--weight-heading-xs);
  line-height: var(--leading-heading-xs);
  letter-spacing: var(--tracking-heading-xs);
}

.text-body {
  font-size: var(--text-body);
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  letter-spacing: var(--tracking-body);
}

.text-small {
  font-size: var(--text-small);
  font-weight: var(--weight-small);
  line-height: var(--leading-small);
  letter-spacing: var(--tracking-small);
}

/* Body uppercase — a variant of body, so it keeps body's size and leading. */
.text-uppercase {
  font-size: var(--text-body);
  font-weight: var(--weight-uppercase);
  line-height: var(--leading-body);
  letter-spacing: var(--tracking-uppercase);
  text-transform: uppercase;
}

/* Signature — the hand-written aside above a section heading. The one step
   that sits outside the scale: no token of its own for size or leading,
   because it is not a rung anything else lands on.

   Sized in rem, so it reads the same wherever it is dropped rather than
   taking a multiple of its parent. 28px, growing with the root step to 31.5
   on the widest screens.

   The last two are resets, not choices, and both matter more here than they
   would on a drawn face. Letters in this one are cut to run into each other,
   so any tracking inherited from a parent pulls the joins apart; 0 keeps
   them shut. And the face has no real capitals, so an inherited uppercase
   would land on substitutes. */
.text-signature {
  font-family: var(--font-signature);
  font-size: 1.75rem;
  font-weight: var(--weight-regular);
  line-height: 1.6;
  letter-spacing: 0;
  text-transform: none;
}

.text-muted {
  color: var(--color-text-muted);
}

/* Accent ink, as a utility: one class, one property. The colour is a
   decision about a particular line rather than about a step in the scale,
   so it is said in the markup and not folded into .text-signature. Reaches
   for the role, so it follows the accent wherever that is retuned. */
.text-color-accent {
  color: var(--color-accent);
}

/* Label — functional: section markers, meta, nav. Sans, uppercase. */
.label {
  font-size: var(--text-label);
  font-weight: var(--weight-label);
  line-height: var(--leading-label);
  letter-spacing: var(--tracking-label);
  color: var(--color-text-muted);
  text-transform: uppercase;
}

/* Brand: site title/logo link, set in the heading font */
.brand {
  font-family: var(--font-heading);
  font-size: 1.25em;
  font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-heading-md);
  text-decoration: none;
}

/* Numbers in stats and tables always tabular */
.numeric {
  font-variant-numeric: tabular-nums;
}

.measure {
  max-width: 42em; /* comfortable reading width for body text */
}


/* ============================================
   Links
   ============================================ */

a {
  color: inherit;
}

.link {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
  text-decoration-color: var(--color-border);
  color: var(--link-text, inherit);
  transition: background-color var(--duration-fast) var(--easing),
    color var(--duration-fast) var(--easing),
    text-decoration-color var(--duration-fast) var(--easing);
}

.link:hover {
  background: var(--color-accent);
  color: var(--link-hover-text, #ffffff);
  text-decoration-color: var(--link-hover-text, #ffffff);
}

/* Animated underline links: no <text-decoration>, an ::before/::after bar
   wipes in on hover instead. No sizing of its own — font-size, weight,
   color all inherit from context. Two ways to opt in, pick one:
   - class     .ulink-hover / .ulink-hover.ulink
   - data attr [data-underline-link] / [data-underline-link="alt"]
   Default wipes in from the left. Alt starts drawn, wipes out to the
   right, then back in from the left (a two-step tuck).
   Wrap a group in [data-hover] to trigger the underline on a hover target
   other than the link itself (e.g. hovering a parent card).
   Markup: <a class="ulink-hover" href="#">Label</a> */
.link-group {
  display: flex;
  gap: 1em;
  justify-content: center;
}

.ulink-hover,
[data-underline-link] {
  color: inherit;
  text-decoration: none;
  position: relative;
}

.ulink-hover::before,
.ulink::after,
[data-underline-link]::before,
[data-underline-link="alt"]::before,
[data-underline-link="alt"]::after {
  content: "";
  position: absolute;
  bottom: -0.0625em;
  left: 0;
  width: 100%;
  height: 0.0625em;
  background-color: currentColor;
  transition: transform 0.735s var(--easing);
  transform-origin: right;
  transform: scaleX(0) rotate(0.001deg);
}

.ulink::before,
[data-underline-link="alt"]::before {
  transform-origin: left;
  transform: scaleX(1) rotate(0.001deg);
  transition-delay: 0.3s;
}

.ulink::after,
[data-underline-link="alt"]::after {
  transform-origin: right;
  transform: scaleX(0) rotate(0.001deg);
  transition-delay: 0s;
}

@media (hover: hover) and (pointer: fine) {
  [data-hover]:hover .ulink-hover::before,
  [data-hover]:hover [data-underline-link]::before,
  .ulink-hover:hover::before,
  [data-underline-link]:hover::before {
    transform-origin: left;
    transform: scaleX(1) rotate(0.001deg);
  }

  [data-hover]:hover .ulink::before,
  [data-hover]:hover [data-underline-link="alt"]::before,
  .ulink:hover::before,
  [data-underline-link="alt"]:hover::before {
    transform-origin: right;
    transform: scaleX(0) rotate(0.001deg);
    transition-delay: 0s;
  }

  [data-hover]:hover .ulink::after,
  [data-hover]:hover [data-underline-link="alt"]::after,
  .ulink:hover::after,
  [data-underline-link="alt"]:hover::after {
    transform-origin: left;
    transform: scaleX(1) rotate(0.001deg);
    transition-delay: 0.3s;
  }
}


/* ============================================
   Focus
   ============================================ */

/* Keyboard only. */
:focus-visible {
  outline: 2px solid var(--color-text);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}


/* ============================================
   Component: Tag
   ============================================ */

/* One chip for the whole site. The metric rows in the examples slider, the
   service labels above a case study and anything else that needs a word on a
   plate all run on this: type, padding and both colours are set here and
   nowhere else, so there is a single place to change the look.

   No colour is named. The word is whatever ink it is set in, inherited, and
   the plate is that same ink at 15% — so the chip reads on the page, on a
   dark plate or on an accent footer without a variant per ground. A block
   only has to set a colour and the tags inside follow it. */
.tag {
  display: inline-block;
  padding: 0.375em;
  /* The small step at its own case. Caps at the label step was the earlier
     take, and a row of shouted metric names is a lot of voice for the
     quietest part of a card. */
  font-size: var(--text-small);
  font-weight: var(--weight-regular);
  line-height: 1;
  white-space: nowrap;
  border-radius: var(--radius-sm);
  background-color: color-mix(in srgb, currentcolor 15%, transparent);
  @media(max-width: 767px){
    font-size: var(--text-label);
  }
}

/* A row of them. Wraps, since a tag's width comes from its label and a long
   one should drop rather than squeeze the rest. */
.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}


/* ============================================
   Component: Form
   ============================================ */

.input {
  width: 100%;
  padding: 0.8em 1em;
  font-family: inherit;
  font-size: var(--text-body);
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  transition: border-color var(--duration-fast) var(--easing);
}

.input::placeholder {
  color: var(--color-text-subtle);
}

.input:hover {
  border-color: var(--color-text-subtle);
}

.input:focus {
  outline: none;
  border-color: var(--color-text);
}

/* Field: floating label. The label sits inside the field like a
   placeholder at rest, then floats to a small caption above the value
   on focus or once filled — no extra vertical space wasted on an
   always-visible label above the field.
   Requires: label AFTER the input/textarea in markup (the ~ selector
   needs it) and placeholder=" " on the field so :placeholder-shown works.
   Markup:
   <div class="field">
     <input class="input" id="name" name="name" placeholder=" " />
     <label for="name">Name</label>
   </div> */
.field {
  position: relative;
}

.field .input {
  padding: 1.375em 1em 0.5em;
}

.field textarea.input {
  padding-top: 1.6em;
  resize: vertical;
  min-height: 6em;
}

.field label {
  position: absolute;
  left: 1rem; /* fixed unit — the input's 1em padding is also relative to its own constant font-size, but the label's font-size shrinks on float, so em would drift */
  top: 50%;
  transform: translateY(-50%);
  font-size: var(--text-body);
  color: var(--color-text-subtle);
  pointer-events: none;
  transition: top var(--duration-fast) var(--easing),
    font-size var(--duration-fast) var(--easing),
    color var(--duration-fast) var(--easing);
}

.field textarea.input ~ label {
  top: 1.6em;
  transform: none;
}

.field .input:focus ~ label,
.field .input:not(:placeholder-shown) ~ label {
  top: 0.6em;
  transform: none;
  font-size: var(--text-label);
  color: var(--color-text-muted);
}


/* ============================================
   Component: Check list
   Shared: service cards, the CTA block, anywhere a list of inclusions is
   needed.
   The tick is a CSS mask on ::before, so the list stays a plain <ul>/<li>
   (nothing decorative in the DOM), the glyph is inlined as a data URI
   (zero extra requests, no repeated <img>), and its colour is
   currentColor — it flips automatically on the dark cards.
   Markup: <ul class="check-list"><li>…</li></ul>
   ============================================ */
.check-list {
  display: grid;
  gap: var(--space-1);
  list-style: none;

  > li {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--space-1);
    align-items: start;

    &::before {
      content: "";
      /* Height matches one body line so the tick sits on the first line
         of a wrapping item, not centred against the whole block. */
      height: calc(1em * var(--leading-body));
      width: 1em;
      background-color: currentColor;
      -webkit-mask: var(--icon-check) center / 1em 1em no-repeat;
      mask: var(--icon-check) center / 1em 1em no-repeat;
    }
  }
}


/* ============================================
   Component: Accordion

   One list, shared by the FAQ and by the steps inside a case study. The
   content is always in the DOM inside .accordion__reveal and only the grid
   row track animates between 0fr and 1fr — a height that can actually be
   tweened, which is why this is not <details>. State lives in
   data-accordion-status, written by js/accordion.js, or by
   js/case-studies.js where opening a row also swaps the shot beside it.

   Nothing but hairlines divides the rows: one above each, one under the
   last. No plate, no inset of its own — the list takes the measure and the
   ground of whatever column it is dropped into.
   ============================================ */
.accordion{
  display: grid;
  width: 100%;
}

.accordion__item{
  border-top: 1px solid var(--color-border);
  /* ease-out, not the house curve: that one is built to snap and settle,
     which a row has the travel to sell and a fade does not. */
  transition: color var(--duration-fast) ease-out;

  &:last-child{
    border-bottom: 1px solid var(--color-border);
  }
}

/* Where the row is a question it is a real <h3> (document outline + AEO),
   styled to look like plain text — the button inside owns the interaction. */
.accordion__heading{
  margin: 0;
  font: inherit;
}

.accordion__summary{
  /* button reset so it reads like plain text */
  font: inherit;
  color: inherit;
  width: 100%;
  margin: 0;
  border: 0;
  background: none;
  text-align: left;
  appearance: none;
  -webkit-appearance: none;

  display: flex;
  gap: var(--space-4);
  align-items: center;
  justify-content: space-between;
  padding: 0.5rem var(--space-1);
  cursor: pointer;
}

.accordion__title{
  /* font-size: var(--text-heading-sm); */
  font-weight: var(--weight-button);
  letter-spacing: var(--tracking-heading-sm);
}

/* ---- the chip ----
   The site's animated button in miniature: the glyph rides in a mask in two
   copies and one leaves as the next slides in, while the plate wipes up from
   below. Both run on --duration-slow and the house easing because
   [data-button-anim-target] declares that once for every button on the site,
   so only the colour pair is set here.

   At rest there is no plate at all — on a list whose only furniture is
   hairlines, a disc behind the glyph was one shape too many. The plate is
   what hover brings.

   Not a <button>: the summary around it already is one, and a control inside
   a control is neither valid nor reachable. */
.accordion__toggle{
  --button-bg: transparent;
  --button-text: currentColor;
  --button-bg-hover: var(--color-text);
  --button-text-hover: var(--color-bg);

  flex: none;
  pointer-events: none;   /* the summary takes the click */
}

/* The component's own radius, not a disc: the same shape family as every
   other button on the page.

   The plate is the mask window plus twice the padding: 0.9 + 0.3 x 2 =
   1.5em. The glyph keeps most of that — the square is the quiet part, the
   plus is what is being pointed at. */
.accordion__toggle .btn-animated-content{
  padding: 0.3em;
}

.accordion__toggle .btn-animated-content__mask{
  width: 0.9em;
  height: 0.9em;
  /* The stack starts at the top of the window rather than being centred in
     it, or the mask shows half of each copy. */
  align-items: flex-start;
}

/* Stacked, not side by side — the roll travels up. */
.accordion__icons{
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.accordion__icon{
  flex: none;
  width: 0.9em;
  height: 0.9em;
  background-color: currentColor;
  /* Drawn to the full window: the SVG carries its own margin inside the
     viewBox, so the strokes still stop short of the edge. */
  -webkit-mask: var(--icon-plus) center / 0.9em 0.9em no-repeat;
  mask: var(--icon-plus) center / 0.9em 0.9em no-repeat;
  transition: transform var(--duration-slow) var(--easing);
}

/* The copy on screen after the swap already wears the hover colour, so it
   arrives onto the rising plate rather than changing on it. The lower one,
   since the stack travels up. */
.accordion__icon:last-child{
  color: var(--button-text-hover);
}

/* Open: the chip is already wearing the hover pair, and the plus has turned
   into a cross — the same control, now the one that shuts the row. */
.accordion__item[data-accordion-status="active"] .accordion__toggle{
  --button-bg: var(--color-text);
  --button-text: var(--color-bg);
}

.accordion__item[data-accordion-status="active"] .accordion__icon{
  transform: rotate(45deg);
}

/* Hover is on the summary, not the chip: the whole row is the control, and
   the chip does not take pointer events at all. */
@media (hover: hover) and (pointer: fine){
  .accordion__summary:hover .accordion__icons{
    transform: translateY(-50%);
  }

  .accordion__summary:hover .btn-animated-content__bg{
    transform: translate(0, 0);
  }

  /* Pointing at one row steps the rest back. :has puts the condition on the
     list, so the rule can reach siblings the hovered row has no selector
     for. Fading the whole row, not just its colour, takes its hairline with
     it — which is the point: the row being read is the only one at full
     strength.

     An open row is exempt. Its panel is on screen and being read, so
     dimming it while the pointer wanders over a neighbouring question would
     mute the one thing the list is currently showing. */
  .accordion:has(.accordion__item:hover)
    .accordion__item:not(:hover):not([data-accordion-status="active"]){
    color: color-mix(in srgb, currentcolor 50%, transparent);
  }
}

/* ---- the panel ----
   The travelling row track: 0fr collapsed, 1fr open. */
.accordion__reveal{
  display: grid;
  grid-template-rows: 0fr;
  overflow: hidden;
  transition: grid-template-rows var(--duration-base) var(--easing);
}

.accordion__item[data-accordion-status="active"] > .accordion__reveal{
  grid-template-rows: 1fr;
}

/* Nothing but the clip. A padded grid item is not zero-height at 0fr — the
   padding still counts — so a closed panel would keep a band of itself on
   screen. The inset lives one level in, on a box the row can collapse to
   nothing. */
.accordion__content{
  overflow: hidden;
  min-height: 0;
}

.accordion__inner{
  padding: var(--space-2) var(--space-1) var(--space-6);
  max-width: 62ch;
}

.accordion__inner > :first-child{ margin-top: 0; }

.accordion__inner > :last-child{ margin-bottom: 0; }

.accordion__inner p{
  margin: 0;
  line-height: var(--leading-body);
  color: var(--color-text-muted);
}

.accordion__inner ol{
  margin: var(--space-3) 0 0;
  padding-left: 1.4em;
  display: grid;
  gap: var(--space-2);
}

.accordion__inner li{
  line-height: var(--leading-body);
}


/* ============================================
   Component: Stat slider (hero, case studies)
   ============================================ */

/* Lifted out of .hero so the component can be used anywhere. Every
   selector here is .hero__-prefixed and unique to it, so nothing else
   picks the rules up; the only change is that they no longer need a
   .hero ancestor. The homepage puts one of these inside a case study.

   Everything below is mixed off currentColor, so the component takes the
   colour of whatever it is dropped into: the hero rail on the page's ink,
   a case study's accent plate. Nothing here names a palette token. */
.hero__slider {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  width: 100%;
  /* At rest the type is held back by .text-muted on the element; hover
     returns it to the full strength of whatever it inherits. */
  transition: color var(--duration-base) ease-out;
  &:hover{
    color: inherit;
  }
}

.hero__slides {
  display: grid;
}

.hero__slide {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  gap: 0;
  justify-content: flex-end;
  visibility: hidden;

}

/* Logos ride inside each slide now, one set per stat. A touch more air
   above them separates the wall from the description line. */
.hero__slide .hero__logos {
  margin-top: var(--space-4);
}

.hero__slide[data-active] {
  visibility: visible;
}

/* Line masks — GSAP rolls the inner element through these. Helvetica
   Now's ascent + descent exceed one em, so at line-height 1 the
   ink spills past the line box and a flat overflow:hidden shaves the
   tops of the digits. The padding / negative-margin pair widens the
   clip box on both sides without moving anything in layout. */
.hero__line {
  display: block;
  overflow: hidden;
  /* padding-block: 0.18em; */
  /* margin-block: -0.18em; */
}

.hero__line-in {
  display: block;
  will-change: transform;
}

/* The whole slider carries .text-muted, so the stat, description and
   counter inherit the tint — no per-element colour. .hero__desc is a hook
   for the script only. */
/* The figure itself, set in the display step. It carries its own type
   rather than a .text-* utility in the markup: every stat on the page is
   this one element, so the step is decided here once instead of being
   repeated on twenty spans. */
.hero__stat {
  font-family: var(--font-heading);
  font-size: var(--text-display);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--tracking-display);
}

/* In the nav row now, between the counter and the arrows, so it is the bar
   itself that centres rather than a rule spanning the whole rail. It takes
   whatever the two ends leave: flex 1 with a zero basis, or a long counter
   would push it off balance. */
.hero__track {
  flex: 1 1 0;
  min-width: 0;
  height: 1px;
  overflow: hidden;
  background: color-mix(in srgb, currentColor 25%, transparent);
}

.hero__progress {
  width: 100%;
  height: 100%;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left center;
}

.hero__slider-nav {
  display: flex;
  gap: var(--space-6);
  align-items: center;
}

.hero__arrows {
  display: flex;
  /* No longer the element that eats the free space — the track is. It holds
     its own width instead, so the two buttons never get squeezed into each
     other when the bar grows. */
  flex: none;
  min-width: max-content;
  gap: 2px;
  align-items: center;
  justify-content: flex-end;
}

.hero__arrow {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.5em;
  border: 0;
  border-radius: 4.5px;
  background: transparent;
  color: inherit; /* <button> won't inherit the slider's colour on its own */
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--easing);
}

.hero__arrow svg {
  width: 1rem;
  height: 1rem;
  transition: all var(--duration-fast) var(--easing);
}

.hero__arrow:hover svg{
  transform: scale(1.1);
}

/* Glyph follows the slider's text colour (secondary) at rest; the hover
   rule lifts it to the heading colour. */
.hero__arrow svg path {
  stroke: currentColor;
  transition: stroke var(--duration-fast) var(--easing);
}

/* One exported glyph serves both directions — Figma mirrors it for
   "previous", so we do the same rather than shipping a second file. */
.hero__arrow[data-prev] svg {
  transform: scaleX(-1);
}

/* Hover: the logo-tile fill appears (same currentColor at 15%, so it
   matches the tiles exactly) and the glyph brightens to the heading
   colour. */
.hero__arrow:hover {
  background: color-mix(in srgb, currentColor 15%, transparent);
}

.hero__arrow:hover svg path {
  stroke: currentColor;
}

/* Counter — the index rolls like a stopwatch digit, the total is fixed.
   Colour and type inherit from the slider. */
.hero__counter {
  display: flex;
  flex: none;
  justify-content: flex-start;
}

.hero__roll {
  display: inline-grid;
  min-width: 2ch; /* holds the slot steady while digits swap */
  overflow: hidden;
  padding-block: 0.18em;
  margin-block: -0.18em;
  text-align: right;
}

.hero__roll-in {
  grid-area: 1 / 1;
  will-change: transform;
}


/* ============================================
   Component: Step timeline (approach, about)
   ============================================ */

.step-timeline {
  width: 100%;
  max-width: 50em;
  margin-left: auto;
  margin-right: auto;
}

.step-timeline__p {
  color: var(--color-text-muted);
}

.step-timeline__wrapper {
  position: relative;
}

.step-timeline__list {
  grid-column-gap: calc(var(--space-section) / 2);
  grid-row-gap: calc(var(--space-section) / 2);
  flex-flow: column;
  justify-content: flex-start;
  align-items: stretch;
  display: flex;
}

.step-timeline__item {
  grid-column-gap: 3em;
  grid-row-gap: 3em;
  justify-content: flex-start;
  align-items: flex-start;
  display: flex;
  position: relative;
}

.step-timeline__line {
  pointer-events: none;
  background-color: color-mix(in srgb, currentcolor 14%, transparent);
  width: 2px;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 1.5em;
}

.step-timeline__fill {
  transform-origin: 50% 0;
  background-color: currentcolor;
  width: 100%;
  height: 100%;
  position: absolute;
  inset: 0%;
}

.step-timeline__marker {
  color: inherit;
  /* The surface token is a tint, not a solid, so the line would read
     straight through the marker sitting on it. */
  background-color: var(--color-surface);
  /* border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); */
  border-radius: var(--radius-md);
  flex: none;
  justify-content: center;
  align-items: center;
  width: 3em;
  height: 3em;
  transition: all .4s cubic-bezier(.645, .045, .355, 1);
  display: flex;
  position: relative;
}

/* Marker inside 'active' step item */
[data-step-timeline-item][data-status="active"] [data-step-timeline-marker] {
  background: var(--color-text);
  color: var(--color-bg);
  border-color: var(--color-text);
}

/* Marker inside 'current' step item */
[data-step-timeline-item][data-current] [data-step-timeline-marker] {
  box-shadow: 0 0 0 0.5em color-mix(in srgb, currentcolor 16%, transparent);
}

.step-timeline__content {
  grid-column-gap: 1em;
  grid-row-gap: 1em;
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-start;
  max-width: 28em;
  padding-top: .4em;
  transition: opacity .25s;
  display: flex;
}

/* Full opacity in the current step */
[data-step-timeline-item][data-current] .step-timeline__content {
  opacity: 1;
}

.step-timeline__content-h {
  margin-top: 0;
  margin-bottom: 0;
  font-size: var(--text-heading-sm);
  font-weight: var(--weight-heading-sm);
  letter-spacing: var(--tracking-heading-sm);
  line-height: var(--leading-heading-sm);
}

@media screen and (max-width: 767px) {
  .step-timeline {
    max-width: none;
  }

  .step-timeline__item {
    grid-column-gap: 1.5em;
    grid-row-gap: 1.5em;
  }
}


/* ============================================
   Component: Gap drop (examples, use cases)
   ============================================ */

/* js/gap-drop.js + Matter.js. Matter runs the simulation on invisible bodies; every pill here is real
   text moved by transform. container-type makes this box the container the
   cqw below is measured against, so the labels scale with the media, not
   with the viewport. */
.gap-drop{
  container-type: inline-size;
  position: relative;
  width: 100%;
  aspect-ratio: 364 / 235;
  overflow: hidden;
  background-color: var(--color-bg);
  border-radius: calc(var(--radius-sm) + 2px);
}

/* Same plate as the marquee cursor: the glass fill, the light ink on it and
   the blur behind. They are the same kind of object — a label riding over a
   picture — so they should be cut from the same material. */
.gap-drop__pill{
  position: absolute;
  top: 0;
  left: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* A floor under the inset. Both this and the type are in em off a cqw
     font-size, so in a narrow frame — the examples card on a phone — they
     shrink together and the label ends up against the pill's own edges. */
  padding: max(0.75rem, 1.25em);
  /* The tile follows the frame it falls into: a square where the box is one,
     flatter where the box is wide and shallow — squares run out of headroom
     in a low frame before they run out of floor. The default is the wide
     one, since the box this component ships with is 364/235; the use-cases
     square overrides it back.

     Width still comes from the label, so a long one makes a bigger tile than
     a short one, which the physics is happy with — it measures each pill's
     box when it builds the body for it. */
  aspect-ratio: 16 / 9;
  /* Stated rather than left to shrink-to-fit. An absolutely positioned box
     with a ratio has two ways to find its width, from its content or from
     its height through the ratio, and WebKit resolves the shrink-to-fit one
     against the content box while applying it to the border box. The inset
     is swallowed: every tile comes out exactly as wide as its label, the
     nowrap line is centred, and the text sits edge to edge with no
     horizontal padding at all. Safari on every width, not only phones.

     max-content is the same width the browser was already trying to use, so
     Blink measures identically — checked against both engines. min-width
     does not do it; the keyword goes through the same broken path there. */
  width: max-content;
  /* The shape family's corner, not a capsule. Nothing else on the page is
     fully rounded. */
  border-radius: 0.5em;
  color: currentColor;
  background-color: color-mix(in srgb, currentcolor 12%, transparent);
  /* An inner highlight rather than a border: it sits inside the box, so it
     costs the tile no width and the physics body stays exactly the size of
     what is drawn. */
  box-shadow: inset 0 0 4px color-mix(in srgb, currentcolor 25%, transparent);
  /* The same blur the cursor card carries, so the two read as one material.
     Worth knowing what it costs here: these are nine plates, moved every
     frame by the physics, and they overlap as they pile up. Each samples
     and blurs the ones behind it, which are themselves blurred, and the
     edge of every sample is a hard rectangle. Where the stack is deepest
     those edges can show as seams. */
  font-size: 2.6cqw;
  line-height: 1.25;
  white-space: nowrap;
  opacity: 0;
  will-change: transform;
}

/* nothing is visible until its body is released */
.gap-drop__pill.is-dropped{
  opacity: 1;
}

/* Reduced motion: no physics, the pills just stack in the box. */
[data-gap-drop-status="static"]{
  display: flex;
  flex-wrap: wrap;
  align-content: flex-end;
  gap: 0.4em;
}

[data-gap-drop-status="static"] .gap-drop__pill{
  position: static;
  opacity: 1;
}


/* ============================================
   Component: Cursor marquee (logo wall, case studies)
   ============================================ */

/* Osmo Supply's Cursor with Marquee Effect (js/cursor-marquee.js). Global:
   the script targets it by data attribute and any element carrying
   data-cursor-marquee-text summons it. Structure, the clip-path reveal and
   the CSS keyframe marquee are the component's. Re-pointed at our tokens:
   colour and type. The card is ours in shape — a circle of a stated
   diameter rather than the vendor pill. The z-index sits above .nav (100),
   and the cursor is hidden where it has no business being. */

.cursor-marquee {
  /* Above .nav, which owns z-index 100. */
  z-index: 200;
  pointer-events: none;
  justify-content: center;
  align-items: center;
  display: flex;
  position: fixed;
  /* Integration: pin the origin to the viewport corner. Without these, a
     fixed box with auto offsets sits at its static position — and this
     markup is the last thing in <body>, so that is thousands of pixels
     down the page. GSAP then translates by clientX/clientY from there and
     the cursor never reaches the screen. */
  top: 0;
  left: 0;
  transform: translate(-50%, -50%);
  /* Integration: the diameter of the round card, stated once. */
  --cursor-marquee-size: 5rem;
}

.cursor-marquee__card {
  color: var(--color-bg);
  background-color: var(--illustration-glass);
  /* Integration: the two copies form a row that starts at the left edge —
     centring it would leave the window mid-copy and the loop would jump. */
  justify-content: flex-start;
  align-items: center;
  display: flex;
  position: absolute;
  overflow: hidden;
  transition: all 0.4s cubic-bezier(.75, 0, .25, 1);
  /* Integration: round. A fixed square box clipped to a circle, so the
     badge holds its shape whatever the label says — the text scrolls
     inside it instead of stretching it into a pill. The component's slit
     reveal becomes an iris: the disc opens from the centre while the
     text inside stays at size. */
  width: var(--cursor-marquee-size);
  height: var(--cursor-marquee-size);
  border-radius: 50%;
  transform: scale(0.8) rotate(0.001deg);
  clip-path: circle(0% at 50% 50%);
  will-change: clip-path, transform;
  opacity: 0;
}

[data-cursor-marquee-status="active"] .cursor-marquee__card {
  clip-path: circle(50% at 50% 50%);
  transform: scale(1) rotate(0.001deg);
}

/* Integration: the component ships a resting dot that trails the pointer
   everywhere once the script loads — the "not-active" half of this rule.
   Dropped, so the card only exists over a [data-cursor-marquee-text]
   element. Leaving one fades it out as the clip-path collapses, both on
   the same 0.4s, so it closes rather than blinking off. */
[data-cursor-marquee-status="active"] .cursor-marquee__card {
  opacity: 1;
}

/* CSS Keyframe Animation */
@keyframes translateXCursor {
  to {
    transform: translateX(-100%);
  }
}

.cursor-marquee__text-span {
  white-space: nowrap;
  transform-origin: 0;
  /* Integration: the card no longer sizes to the text, so every copy is
     at least as wide as the circle and centred in it. That, plus the two
     copies sitting flush in a row, is what keeps the loop seamless: the
     keyframe travels exactly one copy width, and the copy behind lands
     where the first began — at any label length. */
  flex: none;
  min-width: 100%;
  text-align: center;
  padding: 0.325em 0.75em;
  font-size: var(--text-body);
  line-height: 1;
  display: block;
  position: relative;
  letter-spacing: 0.01em;
}

.cursor-marquee__text-span {
  animation: translateXCursor 10s linear infinite paused;
  transition: opacity 0.15s ease-in-out 0.25s;
  opacity: 0;
}

[data-cursor-marquee-status="active"] .cursor-marquee__text-span {
  transition: opacity 0.15s ease-in-out 0s;
  opacity: 1;
}

.cursor-marquee__text-span.is--duplicate {
  /* Integration: the component parks the copy at left:100% of the card,
     which only lands right while the card is as wide as its text. The
     card is a fixed circle now, so let the copy sit in the flow instead —
     it follows the first by exactly one copy width at any label length,
     which is the distance the keyframe travels. */
  position: static;
}

/* ---- Integration guards ----
   Touch has no cursor to decorate, and the global reduced-motion rule
   clamps every animation to 0.01ms — which would leave the marquee
   thrashing rather than scrolling. Hide it outright in both cases. */
@media (hover: none), (pointer: coarse) {
  .cursor-marquee {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cursor-marquee {
    display: none;
  }
}


/* ============================================
   Component: Window (examples, case studies)
   ============================================ */

/* A screenshot framed as a window: a translucent plate with the
   illustrations' hairline, a title bar with the three dots at the left, and
   the picture under it. The dots are drawn, not markup — three radial
   gradients on the bar, in the plate's own ink at a low alpha.

   Ink is the illustrations' own rather than the section's, so the hairline
   and the dots read light on every ground the plate is put on. The picture
   is whatever the block holds — <picture>, <img> or <video> — and takes the
   inner radius, one step in from the plate's. Outer radius one step up from
   the inner so the two corners stay concentric across the padding. */
.window{
  --window-bar: 0.875rem;   /* the title bar's height */
  --window-dot: 0.5rem;     /* one dot */
  --window-dot-gap: 0.25rem;
  /* The row starts a step in from the padding edge, so it clears the curve
     of the picture's corner below it rather than hanging past it. */
  --window-dot-inset: var(--space-1);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-2);
  color: var(--illustration-fg);
  border-radius: var(--radius-lg);
  border: 1px solid var(--illustration-stroke);
  /* A white veil rather than the illustrations' dark glass, for now: the
     plate has to hold on a photograph as well as on ink. */
  background-color: #ffffff25;
  overflow: hidden;

  /* Phone. Every figure the plate is built from steps down together, so the
     frame keeps its proportions in a column a third of the width: at the
     desktop sizes the bar and its three lights eat a visible share of the
     card, and the screenshot they are framing is what should have it. */
  @media (max-width: 767px){
    --window-bar: 0.625rem;
    --window-dot: 0.375rem;
    --window-dot-gap: 0.1875rem;
    gap: var(--space-1);
    padding: var(--space-1);
  }
}

/* The three lights, in one ink at three strengths — the difference in
   colour macOS draws is played as a difference in opacity here, so the row
   still reads as close, minimise, zoom without a second hue on the plate. */
.window::before{
  --pitch: calc(var(--window-dot) + var(--window-dot-gap));
  --edge: calc(var(--window-dot) / 2);
  content: "";
  flex: none;
  height: var(--window-bar);
  background-image:
    radial-gradient(circle, color-mix(in srgb, currentColor 70%, transparent) calc(var(--edge) - 0.5px), transparent calc(var(--edge) + 0.5px)),
    radial-gradient(circle, color-mix(in srgb, currentColor 45%, transparent) calc(var(--edge) - 0.5px), transparent calc(var(--edge) + 0.5px)),
    radial-gradient(circle, color-mix(in srgb, currentColor 25%, transparent) calc(var(--edge) - 0.5px), transparent calc(var(--edge) + 0.5px));
  background-size: var(--window-dot) var(--window-dot);
  background-repeat: no-repeat;
  /* Left to right, one pitch apart, centred on the bar's height. */
  background-position:
    var(--window-dot-inset) 50%,
    calc(var(--window-dot-inset) + var(--pitch)) 50%,
    calc(var(--window-dot-inset) + var(--pitch) * 2) 50%;
}

/* auto, not 0, as the basis: a picture in a plate that takes its height
   from content has to contribute that height, and a 0 basis would collapse
   it. In a plate of fixed height the basis is the picture's own 100%, and
   min-height lets it shrink under the bar. */
.window > *{
  flex: 1 1 auto;
  min-height: 0;
  border-radius: var(--radius-md);
  overflow: hidden;
}

.window img, .window video{
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-md);
}


/* ============================================
   Component: Fill row (gap, problems)
   ============================================ */

/* A row in a list that paints its plate in from the top as it comes up
   the screen. The plate is the row's ::before, a solid block the full size
   of the row scaled on Y from its top edge by --fill, and --fill is the
   one thing js/fill-rows.js writes — it tweens it from 0 to 1 once the row
   has come far enough up, and back if the row drops below the line again.
   Colour, edges and everything else about the plate live here.

   isolate keeps the plate's z-index -1 inside the row, under the row's
   own content but over whatever ground the row sits on. Rows that carry
   their own hairlines and padding keep them; this only adds the plate. */
.fill-row{
  --fill: 0;
  position: relative;
  isolation: isolate;

  &::before{
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-color: var(--color-surface);
    transform-origin: 50% 0;
    transform: scaleY(var(--fill));
  }
}

/* The filled state. A row that carries it in the markup simply has its
   plate and the script leaves it alone; on every other row the script
   adds it as the plate sweeps in and takes it back if the row drops below
   the line again, so a section can hang its own filled styling off it.
   The tween writes --fill inline on those rows, which wins over this, so
   the plate still sweeps rather than snapping. Under reduced motion the
   script creates nothing and every row has its plate. */
.fill-row.is-filled{
  --fill: 1;
}

@media (prefers-reduced-motion: reduce){
  .fill-row{
    --fill: 1;
  }
}


/* ============================================
   Reduced motion
   ============================================ */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}


/* ============================================
   Section: Nav
   ============================================ */

/* Fixed top bar. Lives outside every section so it stays pinned above the
   page on scroll. A dark translucent pill on its own colour context: color
   is the cream (--color-bg), so currentColor-based light tints and the link
   colour (--color-text-muted) resolve light on the dark bar. Wrapped in the
   site-wide .container for its horizontal gutter. .nav__bar has exactly 3
   flex children: logo wrapper, menu (grows), button group. */
.nav{
  --h-nav-top: 1rem;
  position: fixed;
  z-index: 100;
  top: 0;
  right: 0;
  left: 0;
  padding-top: var(--h-nav-top);
  font-size: var(--text-small);

  /* The bar sits edge to edge at the top of the page and pulls in to the site
     container on the first scroll — js/nav-shrink.js takes .is-full off, and the
     width eases the rest of the way from here. Only the width changes: .container
     and .is-full declare the same padding, so nothing else shifts.

     Desktop only. Below 1200px the class is never removed, and below the
     --container-max breakpoint both rules resolve to the viewport width anyway,
     so there would be nothing to see. */
  @media (min-width: 1200px) {
    > .container{
      transition: width var(--duration-slow) var(--easing),
        max-width var(--duration-slow) var(--easing);
    }

    /* Scrolled: the bar is only as wide as its own content, not the full
       container rail. --container-max stays as the ceiling so a long menu
       can never push past the site width. Animating width to an intrinsic
       keyword needs `interpolate-size: allow-keywords` — it's on :root in
       main.css. Where that isn't supported the width still lands in the
       right place, it just snaps instead of easing. */
    & > .container:not(.is-full){
      width: max-content;
      max-width: var(--container-max);
    }
  }

  /* The bar is a dark block, and says so with .scheme-dark in the markup
     rather than by mixing its own ink: the scheme paints the ground and
     the text, and everything inside reads its colours off that pair like
     the rest of the site. Solid, and with no glass behind it — the page
     under it is busy enough that a blurred pane read as a smear. */
  .nav__bar{
    display: flex;
    gap: var(--space-4);
    align-items: center;
    padding: var(--space-1) var(--space-1) var(--space-1) var(--space-4);
    border-radius: var(--radius-md);
    margin-inline: auto;
    transition: background-color var(--duration-base) var(--easing);
  }

  /* An untouched page: no fill at all, so the hero runs unbroken under
     the bar. js/nav-shrink.js holds .is-nav-top on the body until the
     first scroll — the same moment the bar pulls in to the container —
     and the ground fades in from here. */
  body.is-nav-top & .nav__bar{
    background-color: transparent;
  }

  /* Desktop: a roomier bar that overhangs the container gutter on both sides.
     1200px is the nav's existing desktop threshold — the same width where the
     container pull-in transition above turns on.

     The overhang has to stay inside --container-pad or the bar spills off the
     viewport. At 1200px the gutter has already hit its 3rem cap, so a 2rem pull
     leaves 1rem of clearance and every wider viewport keeps it. Raising the pull
     past --space-12 would need --container-pad to grow with it. */
  @media (min-width: 1200px) {
    .nav__bar{
      padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
    }
  }

  .nav__logo{
    flex: none;
    line-height: 1;
  }

  /* .brand hard-codes --color-text, which is the scheme's own ink here
     anyway; inherit keeps it on the bar's colour whatever the scheme. */
  .nav__brand{
    color: inherit;
  }

  .nav__list{
    display: flex;
    flex: 1;
    gap: 2px;
    align-items: center;
    min-width: 0;
    list-style: none;
    /* Links don't wrap, so once the bar is too narrow to fit all of them
       — anywhere below desktop-wide, not just past the phone breakpoint —
       this scrolls instead of spilling over .nav__actions. Unconditional
       on purpose: the exact width where 7 links stop fitting depends on
       copy length and can shift, and a scrollbar is always the safe
       fallback, never a broken layout. */
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }

  .nav__list::-webkit-scrollbar{
    display: none;
  }

  /* Links borrow the label mechanism from .btn-animated-link — the mask plus the
     text-shadow duplicate — but keep the plain background fade rather than
     the diagonal wipe. Line-height 1.5 makes the line box exactly
     --text-duplicate-distance tall, so the duplicate lands flush. */
  .nav__link{
    --button-text-hover: currentColor;
    position: relative;
    display: block;
    padding: 0.5em 0.5em; /* same padding as .btn-sm — one compact size, site-wide */
    color: var(--color-text-muted); /* a tint of the bar's own ink */
    text-decoration: none;
    white-space: nowrap;
    border-radius: var(--radius-sm);
    background-color: transparent;
    border: none;
    /* Clips the progress fill to the rounded corners. The label's own mask
       carries overflow: hidden too, so the text swap is unaffected. */
    overflow: hidden;
    transition: color var(--duration-fast) var(--easing),
      background-color var(--duration-base) var(--easing);
  }

  /* Section progress. The bar grows left to right while its section crosses
     the viewport middle — js/nav-progress.js scrubs --nav-progress from 0 to 1.
     Sits above the link's own background and below the label, which is
     .btn-animated-content__mask at z-index 1. */
  .nav__link::before{
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    background-color: color-mix(in srgb, currentColor 15%, transparent);
    transform: scaleX(var(--nav-progress, 0));
    transform-origin: left center;
  }

  .nav__link:hover{
    color: var(--color-text);
    background-color: color-mix(in srgb, currentColor 15%, transparent);
  }

  /* The section you are currently in. Text only — the plate stays at its
     resting 8% so hover is still a distinct state on top of this. Note that
     both the plate and the progress fill are currentColor mixes, so lifting
     the text to the scheme's full ink brightens them along with it. */
  .nav__link.is-active{
    color: var(--color-text);
  }

  @media (hover: hover) and (pointer: fine) {
    .nav__link:hover .btn-animated-content__text{
      transform: translate(0, calc(-1 * var(--text-duplicate-distance)));
    }
  }

  .nav__actions{
    display: flex;
    flex: none;
    gap: var(--space-1); /* 4px */
    align-items: center;
  }

  /* Phone: bar wraps — the logo+buttons row stays on top, the link list
     drops to its own full-width row below and scrolls sideways. */
  @media (max-width: 1199px) {
    &{
      padding-top: 0;
    }

    /* The nav's own .container goes edge-to-edge here — no site gutter, no max-width cap. The logo/buttons/list each carry their own inset
       below instead, so the menu row can scroll flush with the screen
       edge instead of stopping short at a container padding. */
    & .container{
      max-width: 100%;
      padding-inline: 0;
    }

    .nav__bar{
      flex-wrap: wrap;
      /* Logo and buttons share the first line — .nav__list wraps below it
         because it's ordered last and forced to width:100% — so this
         spreads just those two to opposite ends of that line. */
      justify-content: space-between;
      gap: var(--space-2);
      padding: 0.75em 0;
      border-radius: 0;
    }

    .nav__logo{
      padding-left: var(--container-pad);
    }
    .nav__actions{
      padding-right: var(--container-pad);
    }

    /* Drops to its own full-width row below the logo/buttons line, flush
       with the screen edge (the container has no padding to fight here
       anymore). Its own padding-inline just keeps the first/last item
       visually lined up with the logo/buttons above at rest. */
    .nav__list{
      order: 3;
      width: 100%;
      flex: none;
      gap: 2px;
      padding-inline: var(--container-pad);
    }
    .nav__link{
      padding: 0.5em 0.75em;
    }
  }

  @media (max-width: 767px){
    &{
      font-size: min(var(--text-small), 3.25vw);
    }
  }
}


/* ============================================
   Section: Hero
   ============================================ */

/* The card's recording light. A light, not a blink: it breathes down and
   back rather than cutting out, and never goes all the way dark.

   Out here rather than beside .hero__rec, because @keyframes is not an
   at-rule CSS nesting allows inside a style rule — nested, the name still
   resolves and the element still claims to be animating, but there are no
   frames behind it and nothing moves. */
@keyframes hero-rec {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.25; }
}

/* Fullscreen. Figma: node 149:1120 (desktop 1728×1024) / 134:6223 (phone).

   Colour: a dark section — ink background, cream text. currentColor is the
   cream (--color-bg), so translucent light shades come straight from
   color-mix(currentColor …). Accent is the global --color-accent; the few
   ink shades read --color-text directly. Type and size come entirely from
   the global tokens — no local overrides. The stat slider in the right rail
   is the shared component above. */
.hero{
  /* --- layout rail ---
     1600px content at 1728 viewport → 64px gutters, matching the nav
     bar width in the mockup. --container-max (100rem) caps it. */
  --h-gutter: clamp(1rem, 3.704vw, 4rem);
  --h-nav-top: 1.75rem;
  --grid-gap: 1rem; /* re-point .grid — the mockup's 12-col gap */

  position: relative;
  isolation: isolate;
  min-height: 100svh;
  overflow: hidden;

  /* ---- Background photograph ---- */

  .hero__media {
    --noise-z: 10;
    position: absolute;
    inset: 0;
    z-index: -1;
    &::after{
      --noise-opacity: 0.1;
    }
  }

  .hero__media img,
  .hero__media video {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
  }

  /* Same angle/stops as the Figma fill — lifts the bottom third to
     solid so the headline and right rail sit on flat ink. */
  .hero__scrim {
    position: absolute;
    inset: 0;
    background: linear-gradient(
      175.417deg,
      color-mix(in srgb, var(--color-bg) 0%, transparent) 55.225%,
      var(--color-bg) 94.728%
    );
  }

  /* ---- Body grid ---- */

  .hero__body {
    display: flex;
    align-items: flex-end;
    min-height: 100svh;
    padding-top: calc(var(--h-nav-top) * 2 + 5rem);
    padding-bottom: var(--space-section);
  }

  .hero__grid {
    align-items: end;
    margin-inline: auto;
  }

  /* ---- Column, wrapper, inner ----
     The same three-part shape the Context row runs
     (.tab-layout__col / .tab-content__wrap / .tab-content__inner): the
     column is its share of the row, the wrapper fills it, and the inner is
     what the content is actually drawn in.

     The wrapper and the inner are both edge to edge now — the gutter and
     the measures the two used to carry are gone, so a column's width is
     the grid ratio and nothing else. The shape is kept because the card on
     the right still needs an element inside the column to draw its plate
     on, and because the two halves stay symmetrical. */
  .hero__left,
  .hero__right {
    min-width: 0;
  }

  /* Source order is right column then left, so the page's only h1 — the name
     and positioning line inside the card — is the first heading in the
     document. These put the composition back: the headline leads on screen at
     every width.

     Declared outside any query on purpose. Above 1001px the row is a grid and
     order picks the track; below it the row is a flex column and order picks
     the stacking position. One pair of rules covers both, so the stacked hero
     still opens with the headline rather than the card. */
  .hero__left {
    order: 1;
  }

  .hero__right {
    order: 2;
  }

  .hero__wrap {
    display: flex;
    justify-content: center;
    width: 100%;
  }

  .hero__inner {
    display: flex;
    flex-direction: column;
    gap: var(--space-8); /* 39 */
    align-items: flex-start;
    width: 100%;
  }

  /* Right rail — the card. It is the inner and not the column, so the plate
     is drawn around the measure rather than around the whole half. */
  .hero__right .hero__inner {
    border: 1px solid color-mix(in srgb, currentcolor 12%, transparent);
    border-radius: var(--radius-md);
    padding: var(--space-12);
    @media (max-width: 767px){
      padding: var(--space-8);
    }
  }

  /* The card's top line: the name at the start, the recording light at the
     far end. The inner lays its children out at flex-start, so the row has
     to ask for the full width to have an far end at all. */
  .hero__id {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-4);
    width: 100%;
  }

  /* The light. Sized in its own em off the heading step the name is set in,
     which is what lets the offset below be stated once and hold at every
     width the step clamps through.

     The row aligns on the top edge, so the square would sit on the ceiling
     of the first line rather than across the name. Half the line box less
     half the square drops it onto the middle of that line: the line box is
     the leading, and both are em of the same size. */
  .hero__rec {
    flex: none;
    font-size: var(--text-heading-sm);
    width: 0.5em;
    height: 0.5em;
    margin-top: calc(var(--leading-heading-sm) * 0.5em - 0.25em);
    background-color: var(--color-accent);
    animation: hero-rec 2s var(--easing) infinite;
  }

  @media (prefers-reduced-motion: reduce) {
    .hero__rec {
      animation: none;
    }
  }

  /* ---- Headline ---- */

  /* Type is entirely .text-display. The measure is what forces the
     three-line break the frame draws — no <br> in the markup. */
  .hero__title {
    max-width: 15ch;
  }

  .hero__title-accent {
    color: var(--color-accent);
    font-family: var(--font-hand);
    letter-spacing: 0;
    display: inline-block;
    transform: rotate(-2deg);
    margin-bottom: 0.25em;
  }

  /* The masks SplitText wraps the headline's lines in for the intro
     (js/hero-intro.js). Each clips to its own line box, and at leading 1
     the descenders hang about 0.06em below that box, so the tails of the
     g's and y's were being cut.

     The clip happens at the padding edge, so the mask is given that much
     padding at the bottom — the tails then sit inside the clip — and the
     same amount is taken back off its own margin, so the next line still
     starts exactly one line box down and nothing in the layout moves.
     The margin has to be the mask's rather than the line's: a negative
     margin on the line would shrink the mask's content box and the
     padding edge would land right back where it was.

     Done this way rather than with overflow-clip-margin, which Safari
     does not support — which is why the tails still went on phones. The
     headline is a flex item, so it keeps the last mask's negative margin
     to itself. Gone with the split once the intro ends. */
  .hero-intro__line-mask {
    padding-bottom: 0.12em;
    margin-bottom: -0.12em;
  }

  /* ---- Checklist + subtitle ---- */

  .hero__support {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    align-items: flex-start;
    width: 100%;
  }

  .hero__checklist {
    display: flex;
    gap: 4px;
    list-style: none;
    flex-wrap: wrap;
  }

  /* Top, not centre. Centre is the same thing while an item fits on one
     line, and it is wrong the moment one does not: the longest item wraps
     to two lines on a phone, and a centred tick floats into the gap between
     them instead of marking the item. */
  .hero__checklist li {
    display: flex;
    gap: var(--space-2);
    align-items: flex-start;
    padding: 4px var(--space-2);
    border-radius: 3px;
    background: color-mix(in srgb, currentColor 10%, transparent);
  }

  /* Half the line box less half the tick, which lands it across the middle
     of the first line — exactly where centring already put it on the items
     that fit. 1lh is the line box itself, so this follows whatever leading
     the hero is set in rather than restating the number. */
  .hero__checklist img {
    width: 16px;
    height: 16px;
    margin-top: calc((1lh - 16px) / 2);
  }

  /* Checklist labels and the subtitle carry .text-muted in the
     markup; size, weight, leading and tracking are inherited. */

  .hero__actions {
    display: flex;
    gap: var(--space-1); /* 4px */
    align-items: center;
  }

  /* ---- Right rail: logo wall ---- */

  /* Three per row at every width — flex + min-width orphaned the third
     tile on intermediate viewports (2 + 1), the grid can't. */
  .hero__logos {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 4px;
    width: 100%;
    list-style: none;
  }

  .hero__logos li {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 2.5em;
    padding-inline: 0.75em;
    border-radius: var(--radius-sm);
    background-color: color-mix(in srgb, currentColor 10%, transparent);
    /* No hover state: these are proof, not navigation, and a reaction with
       nothing behind it reads as a link that failed. If one is ever added,
       keep it off transform and opacity — the slide swap runs autoAlpha + y
       on these <li> (hero slider script at the foot of index.html) and a
       CSS transition on either would fight the tween frame by frame. */
  }

  /* Capped by height, not width: the logos run from 49 to 125px wide at a
     shared 20px height, so a fixed width would blow the narrow ones up and
     squash the wide ones. max-width keeps the widest marks inside their
     cell — that one shrinks a little below the cap, which is the trade for
     three logos in a rail this narrow. */
  .hero__logos img {
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: 0.875em;
    opacity: 0.6;
  }

  /* ---- Row geometry ----
     Two thirds to the headline, one to the card. The row runs on a plain
     .container, so both halves are measured off the capped page width
     rather than off the viewport.

     Nothing else here: neither half carries a gutter or a measure of its
     own any more, so each column is set to exactly its share of the grid.
     The only inset left in the row is the card's own padding, on
     .hero__right .hero__inner above.

     Only from 1001px. Below that the row stacks and the ratio does not
     apply — the stacked hero is unchanged. */
  @media (min-width: 1001px) {
    .hero__grid {
      grid-template-columns: 7fr 5fr;
      gap: var(--space-4);
    }
  }

  /* ---- Phone / tablet ----
     The photo stops being a full-bleed background and becomes a band
     under the nav; the rail collapses to one column. Stacks at 1000,
     matching where the (now standalone) nav folds — above that the
     two-column composition holds. */

  @media (max-width: 1000px) {
    --h-gutter: 1rem;
    min-height: 0;

    /* In flow rather than absolute, so the band can never outrun the
       content that overlaps it. Explicit height, not aspect-ratio +
       max-height: clamping one axis of a ratio box re-derives the other,
       which collapses the width. */
    .hero__media {
      position: relative;
      inset: auto;
      width: 100%;
      height: clamp(18rem, 98vw, 34rem); /* 394px at the 402 frame */
      margin-top: 5.625rem; /* clears the nav */
    }

    .hero__media img,
    .hero__media video {
      object-position: 42% center;
    }

    /* The grain overlay is clipped to the band, so it ends in a hard texture
       edge against the flat ink below — that was the real seam. Fade it out
       before the band's bottom so there's no noise/tone step. The scrim
       itself keeps its original (desktop) gradient. */
    .hero__media::after {
      -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 88%);
      mask-image: linear-gradient(to bottom, #000 55%, transparent 88%);
    }

    .hero__body {
      display: block;
      min-height: 0;
      margin-top: -10.875rem; /* headline rides over the bottom of the band */
      padding-top: 0;
    }

    .hero__grid {
      display: flex;
      flex-direction: column;
      gap: var(--space-section);
    }

    .hero__left,
    .hero__right {
      width: 100%;
    }

    .hero__inner {
      gap: var(--space-6);
    }

  }

  /* ---- Phone ----
     Only the treatments that genuinely need a narrow screen live here. */
  @media (max-width: 1000px) {
    line-height: 1.3;

    /* Measure comes off; the column is already narrow enough to break
       into the frame's three lines. Leading stays --leading-display. */
    .hero__title {
      max-width: none;
    }

    .hero__checklist {
      flex-direction: column;
      gap: 4px;
      align-items: flex-start;
    }

    .hero__checklist li {
      padding: 0;
      background: none;
    }

    .hero__checklist img {
      padding: 1.6px;
      border: 0.8px solid color-mix(in srgb, currentColor 15%, transparent);
      border-radius: 2.4px;
      background: color-mix(in srgb, currentColor 15%, transparent);
    }

    .hero__actions .btn-animated-content {
      flex: 1 0 0;
      justify-content: center;
      /* padding: 0.75em; */
    }

    /* The wipe rests below the button, tilted 15°. On a full-width button
       that tilt lifts its corner by roughly half-the-width x sin15°, which
       is far more than the 175% drop clears — so the parked plate pokes
       back in as a yellow wedge. These two are full-width here, so they
       wipe straight up. The hover end state is rotate(0) either way. */
    .hero__actions .btn-animated-content__bg {
      transform: translate(0, 175%);
    }

    .hero__actions {
      gap: 4px;
      width: 100%;
    }

  }

  /* Reduced motion: the slider script skips every tween and swaps state
     directly, so the progress bar just stays hidden rather than crawling. */
  @media (prefers-reduced-motion: reduce) {
    .hero__track {
      opacity: 0.35;
    }
  }

  @media (max-width: 767px){
    .hero__actions{
      flex-direction: column;
      align-items: stretch;
      .btn-animated-link{
        flex-grow: 1;
      }
    }
  }
}


/* ============================================
   Section: Context
   ============================================ */

.section-context{
  .section-footer{
    margin-top: var(--space-12); 
    @media(max-width: 767px){
      padding: var(--space-12) 0 0 0
    }
  }

  .section-context__grid{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-12);
    container-type: inline-size;
    @media (max-width: 1023px){
      gap: var(--space-6);
    }
    /* Phone: swap the grid for a horizontal scroll-snap carousel.
       Cards lay out in one row, each ~80% wide so the next one peeks,
       and swiping right reveals the rest. */
    @media (max-width: 767px){
      grid-template-columns: none;
      grid-auto-flow: column;
      grid-auto-columns: 65%;
      gap: var(--space-1);
      /* Break out of the container padding so cards can scroll to the screen
         edges, then re-add it as padding + scroll-padding so the resting
         inset (and every snap point) matches the rest of the layout. */
      margin-inline: calc(var(--container-pad) * -1);
      padding-inline: var(--container-pad);
      scroll-padding-inline: var(--container-pad);
      overflow-x: auto;
      overscroll-behavior-x: contain;
      scroll-snap-type: x mandatory;
      -webkit-overflow-scrolling: touch;
      scrollbar-width: none;
      &::-webkit-scrollbar{
        display: none;
      }
    }
  }

  .section-context__card-content{
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-6);
  }

  .section-context__columnn{

    /* Snap each card to the start as it scrolls into the carousel viewport */
    @media (max-width: 767px){
      scroll-snap-align: start;
    }

    @media (min-width: 1200px){
      &.is-small{
        padding: 5cqw;
      }
      &:not(.is-small){
        .section-context__card-footer{
          padding-left: 5cqw;
        }
      }
    }
  }

  .section-context__card-stage{
    padding: 10cqw;
  }
  @media (min-width: 1200px){
    .section-context__card-stage{
      padding: 10cqw;
    }
     .section-context__columnn.is-small{
      .section-context__card-stage{
          padding: 5cqw;
      }
    }
  }

  .section-context__content{
    .section-context__card-header{
      width: 100%;
      aspect-ratio: 16 / 12;
      display: flex;
      justify-content: center;
      align-items: center;
      @media (max-width: 1199px){
        flex-basis: 100%;
        aspect-ratio: 1;
      }
    }
    .section-context__card-footer{
      display: flex;
      flex-direction: column;
      gap: var(--space-4);
      flex-basis: 50%;
      flex-grow: 1;
      text-wrap: balance;
      padding-right: 4cqw;
      width: 100%;
      @media (max-width: 767px){
        padding-right: 7%;
      }
      p{
        color: var(--color-text-muted);
        max-width: 40ch;
        margin-left: 0;
      }
    }
  }

  /* Illustrations live inside the context cards. The header clips the stage
     (background image + illustration) to its rounded corners. */
  .section-context__card-header{
    overflow: hidden;
  }

  /* The stage layers the background and the illustration. Its padding is set
     per-card and can vary; it insets the illustration only — the background
     still fills the whole stage. */
  .section-context__card-stage{
    position: relative;
    width: 100%;
    height: 100%;
    display: flex;
    overflow: hidden;
    justify-content: center;
    align-items: center;
  }

  /* Background sits as its own layer, filling the whole stage (ignores the stage
     padding). It's a <div> wrapper so the pull-in animates on the layer while the
     <img> inside just covers it — controllable separately. The photo runs at its
     own colour; nothing grades it.

     The corner is drawn by the clip, not by border-radius, and the resting shape
     already carries its `round` clause. That is what makes the hover ease: a
     transition between two inset() shapes runs only when both are built the same
     way, so the pair moves offset-to-offset and radius-to-radius with nothing to
     match up at the start. The resting radius is --radius-md, the same corner
     .section-context__card-header clips to, so the frame at rest is unchanged. */
  .section-context__card-bg{
    position: absolute;
    inset: 0;
    z-index: 0;
    clip-path: inset(0px round var(--radius-md));
    overflow: hidden;
    transition: clip-path var(--duration-slow) var(--easing);
  }

  .section-context__card-bg img{
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  /* Grain overlay — off by default, opt in per card with .has-noise (many assets
     already carry their own grain). Same tiled noise as the hero, graded from
     lighter at the top to ~0.16 at the bottom: a mask can only reduce alpha, so
     the layer runs at full opacity and the gradient masks the top back down. */
  .section-context__card-bg.has-noise{
    --noise-opacity: 0.1;
  }

  .section-context__card-bg.has-noise::after{
    -webkit-mask-image: linear-gradient(to bottom 30deg, rgba(0, 0, 0, 0.53) 25%, rgba(0, 0, 0, 1) 75%);
    mask-image: linear-gradient(to bottom 30deg, rgba(0, 0, 0, 0.53) 25%, rgba(0, 0, 0, 1) 75%);
  }

  /* The illustration container fills the area left by the stage padding, and is
     the query container the em-built illustration scales against — so the
     illustration always spans the full available width, whatever the padding. */
  .section-context__card-illustration{
    position: relative;
    z-index: 1;
    flex: 1;
    align-self: stretch;
    min-width: 0;
    container-type: inline-size;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .section-context__card .section-context__card-illustration > div{
    transition: transform calc(var(--duration-slow)*1.5) var(--easing);
  }

  /* Hover is the card, not the grid cell. The cell is the larger of the two —
     .is-small pads it by 5cqw — so hovering the padding used to fire the pair
     while the pointer was still outside the card, with nothing under it to
     explain the movement. Guarded on (hover: hover) so a touch device does not
     latch the state after a tap. */
  @media (hover: hover){
    /* Promoted up front, not on hover. The first hover on a card used to snap and
       the ones after it eased, which is a warm-up signature: the layer did not
       exist yet, so that first run paid for building it and dropped its frames.
       Asking for the layer at the moment the pointer arrives would keep paying
       the same cost, so the hint is declared at rest. Only on hover-capable
       pointers, so phones never hold the extra layers. */
    .section-context__card-bg{
      will-change: clip-path;
    }

    .section-context__card-illustration > div{
      will-change: transform;
    }

    .section-context__card:hover .section-context__card-illustration > div{
      transform: scale(1.05);
    }

    /* Both ends of the pair deepen together: the inset pulls the photo in and the
       corner opens up with it. --radius-lg is the top of the radius scale, one
       step above the resting --radius-md. */
    .section-context__card:hover .section-context__card-bg{
      clip-path: inset(8px round var(--radius-lg));
    }
  }

  /* 100cqw of the container maps to the illustration's design width (--ilx-w, in
     em), so it fills the container width exactly. */
  .section-context__card-illustration .ilx{
    font-size: calc(100cqw / var(--ilx-w, 17.5));
  }

  /* ---- illustrations ----
     Four self-contained frosted-glass micro-scenes, one per card. Animation
     is driven by js/context-illustrations.js.

     Shared building blocks first; each scene then only declares what is
     unique to it. Colour comes entirely from the --illustration-* tokens
     (see tokens.css). Type hierarchy is carried by size and colour/opacity
     only — every weight here is 400 or 500, never bold. */

  /* ---- Shared foundation ------------------------------------------- */

  .ilx {
    --ilx-ease: cubic-bezier(.2, .7, .2, 1);
    --ilx-glow-size: 14em;
    --ilx-glow-opacity: 19%;
    --ilx-glow-blur: .55em;
    /* Design width in em: 100cqw of the illustration container maps to this, so
       the scene fills the available width. Includes any overhang (e.g. nodes). */
    --ilx-w: 17.5;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 17.5em;
    font-size: 1em;
    font-family: inherit;
    color: var(--illustration-fg);
    isolation: isolate;
  }

  /* Ambient glow behind every scene. */
  .ilx::before {
    content: "";
    position: absolute;
    z-index: 0;
    width: var(--ilx-glow-size);
    height: var(--ilx-glow-size);
    background: radial-gradient(circle,
      color-mix(in srgb, var(--illustration-fg) var(--ilx-glow-opacity), transparent),
      transparent 65%);
    filter: blur(var(--ilx-glow-blur));
  }

  /* Frosted-glass surface shared by every raised card/node. Depth is
     tunable per element via --ilx-glass-shadow. */
  .ilx-glass {
    background: var(--illustration-glass);
    border: 0.0625em solid var(--illustration-stroke);
    box-shadow:
      var(--ilx-glass-shadow, 0 1.5em 2.8em -1.1em rgba(0, 0, 0, .5)),
      inset 0 0.06em 0 color-mix(in srgb, var(--illustration-fg) 45%, transparent);
  }

  /* Per-scene glow tuning + design width (--ilx-w, em) used for the fill. The
     stake scene is 20em but its persona nodes overhang ~1.4em each side, so its
     full extent is wider. */
  .ilx2-funnel { --ilx-w: 17.5; --ilx-glow-size: 13.75em; --ilx-glow-opacity: 20%; --ilx-glow-blur: .5em; }
  .ilx2-stake  { --ilx-w: 22.8; --ilx-glow-size: 15em;    --ilx-glow-opacity: 18%; --ilx-glow-blur: .6em; }
  .ilx2-ab     { --ilx-w: 17.5; --ilx-glow-size: 15em;    --ilx-glow-opacity: 18%; --ilx-glow-blur: .6em; }
  .ilx2-ship   { --ilx-w: 18.5; --ilx-glow-size: 14em;    --ilx-glow-opacity: 20%; --ilx-glow-blur: .55em; }


  /* ---- 1. Funnel — timeline marquee -------------------------------- */

  .ilx2-funnel .fn-card {
    position: relative;
    z-index: 1;
    width: 17.5em;
    padding: 1.05em 1.05em 1.15em;
    border-radius: 0.5em;
    overflow: hidden;
    transition: transform .6s var(--ilx-ease);
  }
  .ilx2-funnel .fn-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 0.85em;
  }
  .ilx2-funnel .fn-head .t { font-size: 0.66em; font-weight: 500; color: var(--illustration-fg); }
  .ilx2-funnel .fn-head .d {
    font-size: 0.56em;
    font-weight: 500;
    letter-spacing: .02em;
    text-transform: uppercase;
    color: var(--illustration-soft);
  }
  .ilx2-funnel .fn-lanes {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.22em;
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 13%, #000 87%, transparent);
    mask-image: linear-gradient(90deg, transparent, #000 13%, #000 87%, transparent);
  }
  .ilx2-funnel .fn-playhead {
    position: absolute;
    top: -0.35em;
    bottom: -0.35em;
    left: 50%;
    width: 0.16em;
    transform: translateX(-50%);
    z-index: 4;
    pointer-events: none;
    border-radius: 0.1em;
    background: linear-gradient(180deg, transparent,
      color-mix(in srgb, var(--illustration-fg) 78%, transparent) 30%,
      color-mix(in srgb, var(--illustration-fg) 78%, transparent) 70%, transparent);
    box-shadow: 0 0 0.85em color-mix(in srgb, var(--illustration-fg) 42%, transparent);
  }
  .ilx2-funnel .fn-lane { position: relative; height: 2em; overflow: hidden; }
  .ilx2-funnel .fn-strip {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    display: flex;
    align-items: center;
    gap: 10em;
    will-change: transform;
  }
  .ilx2-funnel .fn-bar {
    flex: 0 0 auto;
    height: 2em;
    width: 13em;
    border-radius: 0.25em;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    padding: 0 0.85em;
    font-size: 0.7em;
    font-weight: 500;
    letter-spacing: -0.01em;
    white-space: nowrap;
  }
  .ilx2-funnel .fn-bar .dot {
    width: 0.42em;
    height: 0.42em;
    border-radius: 50%;
    margin-right: 0.55em;
    flex: 0 0 auto;
    background: currentColor;
    opacity: .7;
  }
  .ilx2-funnel .fn-l1 .fn-bar {
    background: color-mix(in srgb, var(--illustration-fg) 10%, transparent);
    color: var(--illustration-soft);
  }
  .ilx2-funnel .fn-l2 .fn-bar {
    background: color-mix(in srgb, var(--illustration-fg) 16%, transparent);
    color: color-mix(in srgb, var(--illustration-fg) 85%, transparent);
  }
  .ilx2-funnel .fn-l3 .fn-bar {
    background: color-mix(in srgb, var(--illustration-fg) 24%, transparent);
    color: var(--illustration-fg);
    border: 0.0625em solid var(--illustration-stroke);
    box-shadow: 0 0 1.2em color-mix(in srgb, var(--illustration-fg) 16%, transparent);
  }


  /* ---- 2. Stake — persona windows ---------------------------------- */

  .ilx2-stake .dm-scene { position: relative; z-index: 1; width: 20em; height: 15em; }
  .ilx2-stake .dm-hub {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 17.5em;
    aspect-ratio: 16 / 11;
    transform: translate(-50%, -50%);
    z-index: 3;
    border-radius: 0.5em;
    padding: 0.85em;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 0.5em;
    overflow: hidden;
  }
  .ilx2-stake .dm-el { position: relative; border-radius: 0.2em; }
  .ilx2-stake .dm-ln {
    height: 0.42em;
    border-radius: 0.125em;
    background: color-mix(in srgb, var(--illustration-fg) 16%, transparent);
  }
  .ilx2-stake .dm-ln.w1 { width: 100%; background: color-mix(in srgb, var(--illustration-fg) 60%, transparent); }
  .ilx2-stake .dm-ln.w2 { width: 78%; }
  .ilx2-stake .dm-ln.w3 { width: 90%; }
  .ilx2-stake .dm-ln.w4 { width: 62%; }
  .ilx2-stake .dm-media {
    height: 2.9em;
    border-radius: 0.3em;
    background: color-mix(in srgb, var(--illustration-fg) 9%, transparent);
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0.1em 0;
    border: none;
  }
  .ilx2-stake .dm-media svg { width: 1.5em; height: 1.5em; color: color-mix(in srgb, var(--illustration-fg) 34%, transparent); }
  /* The tint and the glow; the edge is not here. It is drawn clockwise onto
     this box by the .dm-trace rect below, which js/context-illustrations.js
     injects — a static border would have nothing left to draw. The radius is
     declared here and read from here: the script copies it onto the rect, so
     the line and the tint it sits on cannot end up with different corners. */
  .ilx2-stake .dm-hl {
    position: absolute;
    inset: -0.32em -0.4em;
    border-radius: 0.28em;
    z-index: 2;
    pointer-events: none;
    opacity: 0;
    box-shadow: 0 0 1.3em color-mix(in srgb, var(--illustration-fg) 30%, transparent);
    background: color-mix(in srgb, var(--illustration-fg) 8%, transparent);
  }
  .ilx2-stake .dm-trace {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  /* Only the paint is set here; x, y, width, height and rx are written by the
     script in px once per pass. stroke-width stays in em so the line keeps
     its weight against the illustration's scale, and the script reads the
     computed value back to inset the rect by half of it. */
  .ilx2-stake .dm-trace rect {
    fill: none;
    stroke: var(--illustration-fg);
    stroke-width: 0.0925em;
  }
  /* This scene is drawn at a 22.8em design width against the funnel's 17.5em,
     so at the same container width its em is only ~0.77 of that scene's and
     every label came out visibly smaller than the funnel's. The type here is
     sized back up by that ratio — .nm and .gl now match .fn-head's .t and .d
     optically — and the avatar, gap and padding follow so the chip does not
     turn cramped around bigger text. The nodes are pinned by their outer
     edge, so the extra size grows inward and the scene's own width, and with
     it --ilx-w, is unchanged. Only one node is ever on screen, so there is
     nothing for it to collide with. */
  .ilx2-stake .dm-node {
    position: absolute;
    z-index: 4;
    display: flex;
    align-items: center;
    gap: 0.6em;
    padding: 0.6em 0.85em;
    border-radius: 0.25em;
    --ilx-glass-shadow: 0 0.8em 1.6em -0.7em rgba(0, 0, 0, .5);
    transition: border-color .3s, box-shadow .3s;
  }
  .ilx2-stake .dm-node .ic {
    flex: 0 0 auto;
    width: 1.9em;
    height: 1.9em;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--illustration-hair);
    color: var(--illustration-fg);
  }
  .ilx2-stake .dm-node .ic svg { width: 1.05em; height: 1.05em; }
  .ilx2-stake .dm-node .tx { display: flex; flex-direction: column; line-height: 1.1; }
  .ilx2-stake .dm-node .nm { font-size: 0.86em; font-weight: 500; color: var(--illustration-fg); white-space: nowrap; }
  .ilx2-stake .dm-node .gl { font-size: 0.73em; color: var(--illustration-soft); white-space: nowrap; margin-top: 0.15em; }
  .ilx2-stake .dn1 { top: -0.3em; left: -1em; }
  .ilx2-stake .dn2 { top: 2.2em; right: -1.4em; }
  /* Clear of the row it draws on, not sitting across it — the chip's bottom
     edge lands just above the outline, the same way dn1 and dn2 stand over
     theirs. dn4 is the one exception: it is the bottom node and hangs below
     the last line instead. */
  .ilx2-stake .dn3 { bottom: 5.6em; left: -1.4em; }
  .ilx2-stake .dn4 { bottom: -0.3em; right: -1em; }


  /* ---- 3. A/B — layout + letter switch ----------------------------- */

  .ilx2-ab .ab-card {
    position: relative;
    z-index: 1;
    width: 17.5em;
    padding: 1.05em;
    border-radius: 0.5em;
    transition: transform .6s var(--ilx-ease);
  }
  .ilx2-ab .ab-tab {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 0.85em;
  }
  .ilx2-ab .ab-seg {
    position: relative;
    display: flex;
    gap: 0.22em;
    padding: 0.22em;
    border-radius: 0.3em;
    background: var(--illustration-hair);
  }
  .ilx2-ab .ab-seg b {
    position: relative;
    z-index: 2;
    box-sizing: border-box;
    width: 1.55em;
    height: 1.55em;
    border-radius: 0.2em;
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
    padding-top: 0.14em;
    font-size: 0.78em;
    font-weight: 500;
    color: var(--illustration-soft);
    transition: color .3s;
  }
  .ilx2-ab .ab-seg b.on { color: var(--illustration-fg); }
  .ilx2-ab .ab-seg-pill {
    position: absolute;
    top: 0.22em;
    left: 0;
    z-index: 1;
    height: 1.55em;
    width: 1.55em;
    border-radius: 0.2em;
    /* Outlined, not filled. The solid plate needed a dark --illustration-surface
       on top of it to stay legible; with that token off the active letter now
       carries the fg colour and the plate is reduced to its edge. */
    background: transparent;
    border: 0.0625em solid var(--illustration-fg);
  }
  .ilx2-ab .ab-uplift {
    font-size: 0.72em;
    font-weight: 500;
    color: var(--illustration-fg);
    display: flex;
    align-items: center;
    gap: 0.22em;
  }
  /* The reading is collected, not asserted: a row of ticks fills left to
     right and only then does the number land. This replaces a trend arrow
     whose head was drawn as a corner hanging below the line's tip instead of
     closing on it — at this size it read as a glitch. The dim state is the
     base so the row still looks like a meter when nothing is animating. */
  .ilx2-ab .ab-meter {
    display: flex;
    align-items: center;
    gap: 0.16em;
  }
  .ilx2-ab .ab-meter i {
    width: 0.7em;
    height: 0.11em;
    border-radius: 0.055em;
    background: var(--illustration-fg);
    opacity: 0.22;
  }
  .ilx2-ab .ab-site {
    border-radius: 0.4em;
    overflow: hidden;
    aspect-ratio: 16/9;
    display: flex;
    flex-direction: column;
    background: color-mix(in srgb, var(--illustration-fg) 5%, transparent);
    border: 0.0625em solid color-mix(in srgb, var(--illustration-fg) 12%, transparent);
  }
  .ilx2-ab .ab-bar {
    flex: 0 0 auto;
    height: 1.5em;
    display: flex;
    align-items: center;
    gap: 0.3em;
    padding: 0 0.7em;
    border-bottom: 0.0625em solid color-mix(in srgb, var(--illustration-fg) 10%, transparent);
  }
  .ilx2-ab .ab-bar i {
    width: 0.36em;
    height: 0.36em;
    border-radius: 50%;
    background: color-mix(in srgb, var(--illustration-fg) 24%, transparent);
  }
  .ilx2-ab .ab-bar .ab-url {
    margin-left: 0.3em;
    height: 0.6em;
    flex: 1;
    max-width: 8em;
    border-radius: 0.3em;
    background: color-mix(in srgb, var(--illustration-fg) 9%, transparent);
  }
  .ilx2-ab .ab-stage { position: relative; flex: 1; }
  .ilx2-ab .ab-variant {
    position: absolute;
    inset: 0;
    padding: 0.7em;
    display: flex;
    flex-direction: column;
    gap: 0.4em;
  }
  .ilx2-ab .ab-hero {
    height: 0.52em;
    border-radius: 0.125em;
    background: color-mix(in srgb, var(--illustration-fg) 18%, transparent);
  }
  .ilx2-ab .ab-hero.w { width: 80%; }
  .ilx2-ab .ab-hero.s { width: 55%; }
  .ilx2-ab .ab-hero.big { height: 0.72em; }
  .ilx2-ab .ab-line {
    height: 0.34em;
    border-radius: 0.1em;
    background: color-mix(in srgb, var(--illustration-fg) 11%, transparent);
  }
  .ilx2-ab .ab-line.w { width: 70%; }
  .ilx2-ab .ab-media {
    border-radius: 0.25em;
    background: color-mix(in srgb, var(--illustration-fg) 9%, transparent);
    border: 0.0625em solid color-mix(in srgb, var(--illustration-fg) 14%, transparent);
  }
  .ilx2-ab .ab-media.is-thumb { flex: 0 0 42%; }
  .ilx2-ab .ab-blocks { display: flex; gap: 0.45em; flex: 1; }
  .ilx2-ab .ab-blocks i { flex: 1; border-radius: 0.2em; background: color-mix(in srgb, var(--illustration-fg) 12%, transparent); }
  .ilx2-ab .ab-cta {
    height: 0.95em;
    border-radius: 0.2em;
    background: color-mix(in srgb, var(--illustration-fg) 90%, transparent);
    box-shadow: 0 0.4em 1em -0.3em rgba(0, 0, 0, .4);
  }
  .ilx2-ab .ab-cta.w { width: 42%; }
  .ilx2-ab .ab-cta.c { width: 42%; align-self: center; }
  .ilx2-ab .ab-row { display: flex; gap: 0.55em; flex: 1; }
  .ilx2-ab .ab-row .col { display: flex; flex-direction: column; gap: 0.34em; flex: 1; }
  .ilx2-ab .ab-row .col .ab-cta { margin-top: auto; }
  .ilx2-ab .ab-center { align-items: center; text-align: center; justify-content: center; }
  .ilx2-ab .ab-center .ab-hero,
  .ilx2-ab .ab-center .ab-line { align-self: center; }


  /* ---- 4. Ship — weekly list stagger ------------------------------- */

  /* The card is what clips, so a row stays visible all the way out to the
     glass edge. The list used to do it, and being inset by the card's own
     padding it cut every leaving row a full 0.95em early — the row vanished
     while there was still panel to cross. Vertically nothing moves: the card
     has no bottom padding, so the list's bottom and the card's are the same
     line and the last row is cropped exactly where it was. */
  .ilx2-ship .sp-card {
    position: relative;
    z-index: 1;
    /* Wider in em than the other scenes, so the em itself comes out smaller:
       the card still fills the stage, the type and rows inside it are set a
       size down. */
    width: 18.5em;
    padding: 0.95em;
    border-radius: 0.5em;
    overflow: hidden;
    transition: transform .6s var(--ilx-ease);
  }
  .ilx2-ship .sp-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 0.75em;
  }
  .ilx2-ship .sp-title { font-size: 0.72em; font-weight: 500; color: var(--illustration-fg); }
  /* The rows stay in flow through the loop — they fade, they do not move —
     so the list takes its height from them and the card's own padding
     closes it at the foot. */
  .ilx2-ship .sp-list { position: relative; }
  .ilx2-ship .sp-row {
    display: flex;
    align-items: center;
    gap: 0.6em;
    padding: 0.45em 0.6em;
    border-radius: 0.25em;
    background: color-mix(in srgb, var(--illustration-fg) 10%, transparent);
    /* border: 0.0625em solid color-mix(in srgb, var(--illustration-fg) 16%, transparent); */
    /* ~3px at the size the scene is normally read at, kept in em so it holds
       that proportion in the small homepage card and the large tab square
       alike — a literal 3px would read as glued at one size and loose at the
       other. */
    margin-bottom: 0.14em;
    will-change: transform, opacity;
    &:last-child { margin-bottom: 0; }
  }
  .ilx2-ship .sp-row .ic {
    flex: 0 0 auto;
    width: 1.6em;
    height: 1.6em;
    border-radius: 0.25em;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--illustration-hair);
    color: var(--illustration-fg);
  }
  .ilx2-ship .sp-row .ic svg { width: 0.85em; height: 0.85em; }
  .ilx2-ship .sp-row .tx {
    flex: 1;
    display: flex;
    flex-direction: column;
    line-height: 1.15;
    min-width: 0;
  }
  .ilx2-ship .sp-row .nm {
    font-size: 0.66em;
    font-weight: 500;
    color: var(--illustration-fg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .ilx2-ship .sp-row .sg { font-size: 0.58em; color: var(--illustration-soft); white-space: nowrap; margin-top: 0.15em; }
  .ilx2-ship .sp-status {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: 0.3em;
    font-size: 0.56em;
    font-weight: 500;
    padding: 0.22em 0.55em;
    border-radius: 0.25em;
  }
  .ilx2-ship .sp-status .dot { width: 0.5em; height: 0.5em; border-radius: 50%; }
  /* Same move as the A/B pill: the filled chip is now an outlined one. It
     still outranks .queue and .pending — full-strength fg edge against their
     15% solid and 30% dashed, plus the glow it always had. */
  .ilx2-ship .sp-status.live {
    color: var(--illustration-fg);
    background: transparent;
    border: 0.0625em solid var(--illustration-fg);
    box-shadow: 0 0 0.9em color-mix(in srgb, var(--illustration-fg) 35%, transparent);
  }
  /* Left solid on purpose: it is the status light, and it is what separates
     live from .queue, whose dot is a ring. */
  .ilx2-ship .sp-status.live .dot { background: var(--illustration-fg); }
  .ilx2-ship .sp-status.queue {
    color: var(--illustration-soft);
    background: transparent;
    border: 0.0625em solid var(--illustration-stroke);
  }
  .ilx2-ship .sp-status.queue .dot { background: transparent; border: 0.09em solid color-mix(in srgb, var(--illustration-fg) 40%, transparent); }
  .ilx2-ship .sp-status.pending {
    color: var(--illustration-faint);
    background: transparent;
    border: 0.0625em dashed color-mix(in srgb, var(--illustration-fg) 30%, transparent);
  }
  .ilx2-ship .sp-status.pending .dot { background: transparent; border: 0.09em solid color-mix(in srgb, var(--illustration-fg) 30%, transparent); }


  /* ---- Reduced motion ---------------------------------------------- */

  @media (prefers-reduced-motion: reduce) {
    .ilx2-funnel .fn-strip { transform: none !important; }
    .ilx2-stake .dm-node { opacity: 1 !important; }
    .ilx2-stake .dm-hl { opacity: 0 !important; }
    .ilx2-ab .ab-hero,
    .ilx2-ab .ab-line,
    .ilx2-ab .ab-media,
    .ilx2-ab .ab-cta,
    .ilx2-ab .ab-blocks i { opacity: 1 !important; transform: none !important; }
    .ilx2-ab .ab-variant { opacity: 1 !important; }
    .ilx2-ab .ab-meter i { opacity: 1 !important; }
    .ilx2-ship .sp-row { opacity: 1 !important; transform: none !important; }
  }
}


/* ============================================
   Section: Gap
   ============================================ */

/* Two columns of opposites — design on the left, marketing on the
     right — separated by a narrow gutter. A copy of the word "gap" is
     parked on the one in the headline, gets marked, then rides down
     that gutter wedging each pair apart. The headline keeps its own
     copy the whole way, unmarked and unmoved.

     The closing line is a section of its own below, so it is still off
     screen while this one is pinned. The copy has to reach it across
     that boundary, which is why it is fixed to the viewport and lives
     outside both sections rather than inside the pinned stage.

   Every moving part is transform-only: the two sides of a row slide
   on x, so nothing reflows while the scroll scrubs. */
.home__section-gap{
  --gap-gutter: clamp(1.75rem, 3.5vw, 4rem);
  background-size: cover;
  background-position: bottom right;
  overflow: hidden;
  padding-bottom: 0;
  @media (min-width: 1024px){
    .section-header{
      text-align: center;
    }
  }

  /* No longer pinned: the stage is just the box the travelling word is
     positioned inside, and its natural height is the descent's length. Grow
     the padding or the row spacing to give the descent more scroll. */
  .gap__stage{
    position: relative;
    width: 100%;
    margin-inline: auto;
    overflow: hidden;
    /* Runs the full width of the section now — only the heading above it is
       held to .container — so the frame is a pair of rules top and bottom
       rather than a box. --color-border is the site's hairline and adapts to
       either ground on its own. */
    border-block: 1px solid var(--color-border);
  }

  /* The typographic box the travelling copy is built in. It used to be
     shared with two anchor words inside the headline, which is why it
     restated a whole step by hand — all three had to be built the same way
     or Flip.fit would scale the copy to cover the difference. Those anchors
     are gone from the markup, so the step comes from .text-heading-sm on
     .gap__word-inner and this only keeps what the class does not say. */
  .gap__shape{
    display: inline-block;
    padding-inline: 0.06em;
  }

  /* Narrow screens and reduced motion never move anything, so the
     headline's own word takes the mark and the copy stays away. */
  [data-gap-static] [data-gap-start] .gap__shape{
    background: var(--color-accent);
    color: var(--color-on-accent);
  }

  /* ---- the travelling word ----
     Absolutely positioned inside the stage rather than fixed to the
     viewport. Whenever it is meant to be sitting still on a real word its
     transform is a constant, so the browser scrolls it natively along with
     everything else. A viewport-fixed element re-placed by script on every
     frame cannot keep up with compositor scrolling, and ghosts a second
     copy of the word a few pixels off the real one. */
  .gap__word{
    position: absolute;
    top: 0;
    left: 0;
    z-index: 5;               /* under the grain layer and the nav */
    pointer-events: none;
    will-change: transform;
  }

  /* One stroke, swept left to right: it fills in on the approach and, at
     the far end, empties in the same direction. It clips the mark AND the
     glyphs, so outside its own animation the copy is not on screen at all
     and never double-draws over the real word it is parked on. Insets run
     slightly negative so the mark keeps its bleed past the text box. */
  .gap__word-inner{
    position: relative;
    display: inline-block;
    /* The step comes from .text-heading-sm in the markup, the same class the
       head row's two sides carry, so the travelling mark is cut from the same
       type as the row it rides over. It has to sit on this element and not on
       the text inside it: every em below — the mark's crop, the sweep's crop,
       the marquee window's crop — resolves against this font-size, and on the
       text alone they would have measured the inherited 16px instead.
       line-height comes with the class for the same reason. */
    /* Exactly the mark's own box: the stroke has to sweep the yellow, not
       a taller band around it, or the letters show up outside it. */
    --mask-l: -5%;
    --mask-r: 100%;
    clip-path: inset(0.05em var(--mask-r) -0em var(--mask-l));
    will-change: transform, clip-path;
  }

  /* Cropped to the letters rather than to the line box. Measured: the ink
     of "gap" starts 0.324em below the top of the line box and runs 0.054em
     past the bottom of it, so a symmetric inset leaves a band of dead space
     above and clips the descenders below. These two numbers are that
     measurement plus 0.06em of air, and the window below matches them —
     which is also what lets the copies stack this close together. */
  .gap__word-bg{
    position: absolute;
    inset: 0.05em 0 0;
    /* Cleared rather than painted: on the page's own ground the row the
       chip rides over shows through it instead of being hidden. */
    background: transparent;
    border-radius: var(--radius-sm);
  }

  /* The marquee window. One copy tall, cut to the same edges as the mark
     behind it, so copies appear and leave under its top and bottom. Sides
     are left open — nothing runs off that way and the glyphs need the room.
     clip-path rather than overflow: it takes no part in layout, so the box
     still measures the same as the two real words it has to sit on. */
  .gap__word-text{
    position: relative;
    color: var(--color-text);
    clip-path: inset(0.05em -0.4em 0);
  }

  /* --marquee-gap: a copy above and below, both out of flow, so the window
     keeps the size of a single word while the loop always has something to
     show whichever way it is running. The space between them is set here and
     read back by the script as the lap length. With the window cropped to
     the ink the neighbours are already clear of it at zero, so this is free
     to be small and the stack reads as one dense column. */
  .gap__word-track{
    --marquee-gap: 0.04em;
    position: relative;
    display: inline-block;
    white-space: nowrap;
    will-change: transform;
  }

  .gap__word-copy--prev, .gap__word-copy--next{
    position: absolute;
    left: 0;
  }

  /* Both offsets are driven off top, so both resolve against the same
     height: anchoring one by bottom instead measures from the copy's own
     inline box and the two laps come out different lengths. */
  .gap__word-copy--prev{ top: calc(-100% - var(--marquee-gap)); }

  .gap__word-copy--next{ top: calc( 100% + var(--marquee-gap)); }

  /* ---- the list of opposites ---- */
  .gap__list{
    position: relative;
  }

  .gap__row{
    display: grid;
    grid-template-columns: 1fr var(--gap-gutter) 1fr;
    align-items: baseline;
    padding-block: 0.75em;
    &:not(.gap__row--head){
      border-top: 1px solid color-mix(in srgb, currentcolor 10%, transparent);
    }

  }

  /* The row owns the weight, not the side. The gap script writes a
     font-weight on .gap__row as the copy passes and the sides inherit it, so
     nothing in here may declare one of its own — a weight on the side would
     win over the inherited value and the pair would never thicken. The
     header row is the intended exception: its sides carry .text-heading-sm. */
  .gap__side{
    will-change: transform, opacity;
  }

  /* justify-self, not just text-align: the box has to shrink to its text so
     the script can measure the slack each row has left before the wedge
     would push a word past the edge of the screen. */
  .gap__side--left{
    grid-column: 1;
    justify-self: end;
    text-align: right;
  }

  .gap__side--right{
    grid-column: 3;
    justify-self: start;
    text-align: left;
  }

  .gap__row--head{
    padding-block: 1.25em;
    @media(max-width: 767px){
      .gap__side{
        font-size: 1em;
      }
    }
  }

  .gap__row:not(.gap__row--head):not(.gap__row--quote){
    @media (max-width: 767px){
      font-size: 0.875em;
    }
  }

  /* The script hands `gap-active` to whichever row the travelling word is
     currently over, one row at a time, and leaves `gap-filled` on every row
     it has already passed — the quote row included, the moment the last
     pair fills. Neither paints anything here: the plates come from
     .fill-row on each row (js/fill-rows.js), on their own scroll trigger,
     and the classes only drive the text opacity from the script. They are
     free hooks for a wave, e.g.

       .gap-active { ... }
       .gap-active + .gap__row,
       .gap__row:has(+ .gap-active) { ... }

     Prefer transform/opacity/colour so nothing reflows mid-descent. Note
     the script already writes inline transform and opacity on .gap__side
     every frame, so target .gap__row (or other properties on .gap__side)
     rather than fighting those. */

  /* The quote as the last row: a plain block rather than the three-column
     grid, on the container's inset so its measure lines up with the
     heading above. The rule on top comes from .gap__row, the same hairline
     every pair sits under. */
  .gap__row--quote{
    display: block;
    padding-block: var(--space-section);
  }

  @media (max-width: 767px){
    &{
      --gap-gutter: clamp(1.5rem, 12vw, 4rem);
    }
  }

}


/* ============================================
   Section: Examples
   ============================================ */

/* A light section with a dark plate inside it. The header row sits on the
   page ground; the slider of example cards runs on the ink inside
   .examples__stage. Slider mechanics are Osmo's basic GSAP slider
   (js/gsap-slider.js): the track is dragged by GSAP Draggable, the item
   width comes from --slider-spv / --slider-gap, and the script writes a
   status on every slide — active, inview, not-active — which is what every
   reveal below keys off. */
.home__section-examples-flat{

  /* ---- slider settings, read by the script ---- */
  [data-gsap-slider-init]{
    --slider-status: on;    /* slider on/off */
    --slider-spv: 2.15;     /* slides per view */
    --slider-gap: var(--space-1);

    @media (max-width: 1023px){
      --slider-spv: 1.8;
    }
    @media (max-width: 767px){
      --slider-spv: 1.2;
    }
    @media (max-width: 499px){
      --slider-spv: 1.1;
    }
  }

  /* ---- the plate ----
     The ink used to be painted on the section itself, edge to edge. It is a
     box inside the container now: the section runs on the page ground so
     the header above reads as the page's own, and only the slider sits on
     the dark. The ground and the ink come from .scheme-dark in the markup;
     the cards take --color-surface off that pair on their own. */
  .examples__stage{
    display: flex;
    flex-direction: column;
    gap: var(--space-inner);
    padding: var(--space-card) var(--space-card) 0 var(--space-card);
    border-radius: var(--radius-lg);
    /* The track shows 2.15 slides, so the third is always cut — at the
       plate's edge rather than the screen's. */
    overflow: hidden;
    /* The grain is on the cover now, not on the plate, so it rides the
       photograph at the cover's own depth and needs nothing said about it
       here. The containing block stays: the cover is absolute against it. */
    position: relative;

    /* Edge to edge from the same width the track drops to fewer slides:
       below it the slides are narrow enough that the container gutter costs
       a visible slice of the one peeking in.

       Once it bleeds it stops reading as a plate on the page and starts
       reading as a band of its own, so the ink above and below it is a
       section's worth rather than a card's. Sideways it keeps the card
       step, which is the inset the slides are measured against. */
    @media (max-width: 1023px){
      margin-inline: calc(-1 * var(--container-pad));
      padding-top: var(--space-section);
      border-radius: 0;
    }
  }

  /* ---- header inside the plate: heading + arrows, one row ----
     Left, not centred: the block starts on the plate's edge, in line with
     the track below it. From 1024 the flex column turns on its side so the
     arrows sit at the end of the heading's line, full width, with the
     measure handed down to the heading itself. */
  .examples__header{
    margin-inline: 0;

    @media (min-width: 1024px){
      flex-direction: row;
      align-items: flex-end;
      justify-content: space-between;
      gap: var(--space-12);
      max-width: none;

      > :first-child{
        max-width: 40rem;
      }
    }
  }

  /* ---- the slider ---- */
  .gsap-slider{
    display: flex;
    flex-flow: column;
    align-items: center;
    gap: var(--space-6);
    width: 100%;
    position: relative;
    /* Visible, not hidden: the plate clips, and a card scaled up past its
       slide should not be cut at the track. */
    overflow: visible;
  }

  .gsap-slider__collection{
    width: 100%;
  }

  .gsap-slider__list{
    display: flex;
    user-select: none;
    -webkit-user-select: none;
    will-change: transform;
    touch-action: pan-y;
    backface-visibility: hidden;
  }

  .gsap-slider__item{
    flex: none;
    width: calc(((100% - 1px) - (var(--slider-spv) - 1) * var(--slider-gap)) / var(--slider-spv));
    margin-right: var(--slider-gap);
  }

  [data-gsap-slider-item]:last-child{
    margin-right: 0;
  }

  /* grab cursor while the track is draggable */
  [data-gsap-slider-list-status="grab"]{ cursor: grab; }
  [data-gsap-slider-list-status="grabbing"]{ cursor: grabbing; }

  /* ---- arrows ----
     The pair sits at the far end of the header row, so it takes only the
     width of the two buttons. Gone below the desktop breakpoint: the track
     is swipeable there, and a pair of arrows under a heading that has gone
     back to full width reads as a stray control rather than as navigation. */
  .gsap-slider__controls{
    display: flex;
    align-items: center;
    gap: var(--space-2);

    @media (max-width: 1023px){
      display: none;
    }
  }

  /* The button carries the animated component's own layout (inline-flex from
     .btn-animated-link); only the bare <button> chrome is stripped here. */
  .gsap-slider__control{
    padding: 0;
    background: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    transition: opacity var(--duration-base) var(--easing);

    /* A plate at rest, not an outline on nothing: .is-secondary ships the
       arrows transparent, which is wrong for two controls floating on a dark
       stage. And the wipe goes the scheme's way — inside .scheme-dark the
       hover plate is the ink and the arrow on it comes back to the ground.
       Two classes, because .btn-animated-link.is-secondary weighs two. */
    &.is-secondary{
      --button-bg: var(--color-surface);
      --button-bg-hover: var(--color-text);
      --button-text-hover: var(--color-bg);
    }

    /* Square, sized to what a nav button comes to: .is-sm pads a
       --leading-body line box by 0.5em top and bottom. Written as a box
       rather than as padding so the glyph keeps its own 1rem. */
    > .btn-animated-content{
      padding: 0;
      justify-content: center;
      width: calc(1em * var(--leading-body) + 1em);
      height: calc(1em * var(--leading-body) + 1em);
    }

    /* The window is one arrow wide on purpose: the pair inside is translated
       by exactly -50% of itself, so window and icon have to stay the same
       number or the swap lands mid-glyph. */
    .btn-animated-content__mask{
      width: 1rem;
      height: 1rem;
    }
  }

  /* The arrow sits in the button's mask in two copies, side by side, so the
     visible one can leave while its twin slides in. Next moves right, prev
     moves left. */
  .gsap-slider__icons{
    flex: none;
    display: flex;
    align-items: center;
  }

  .gsap-slider__icon{
    flex: none;
    width: 1rem;
    height: 1rem;
    /* inherited by the paths, which set no stroke-width of their own */
    stroke-width: 3;
  }

  .gsap-slider__control--prev .gsap-slider__icon{
    transform: scaleX(-1);
  }

  /* whichever copy is on screen after the swap sits on the hover plate */
  .gsap-slider__control--prev .gsap-slider__icon:nth-child(2),
  .gsap-slider__control--next .gsap-slider__icon:nth-child(1){
    color: var(--button-text-hover);
  }

  .gsap-slider__control--next .gsap-slider__icons{
    transform: translateX(-50%);
  }

  @media (hover: hover) and (pointer: fine){
    .gsap-slider__control--prev:hover .gsap-slider__icons{
      transform: translateX(-50%);
    }
    .gsap-slider__control--next:hover .gsap-slider__icons{
      transform: translateX(0);
    }
  }

  /* hidden when the slider is off (all slides fit), dimmed at either end */
  [data-gsap-slider-status="not-active"] [data-gsap-slider-controls]{
    display: none;
  }

  [data-gsap-slider-control-status="not-active"]{
    opacity: 0.2;
    pointer-events: none;
  }

  /* ---- the cards ----
     A plate on the scheme's surface: screenshot, title, note, then an impact
     gauge, a frequency read-out and the CRO metrics the mistake moves, as
     tags. Every card the height of the tallest: .gsap-slider__list is a
     flex row, so the slide already stretches; align-content puts the free
     space between the card's rows. */
  .ce-card{
    position: relative;
    display: grid;
    grid-template-columns: 1fr;
    align-content: space-between;
    gap: var(--space-4);
    height: 100%;
    padding: var(--space-inner);
    overflow: hidden;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    /* Each card is painted in its own ink, named on the card in the markup
       as --example-accent. The scheme's surface stays as the fallback, so a
       card that is added without a colour still reads as a card.

       Laid on at a share of itself rather than solid, so the cover behind
       the plate keeps coming through and the card's own .has-glass has
       something left to blur. The share is a variable of its own because it
       is the only part of the colour that moves: hover takes it up, and the
       card firms up without changing hue. */
    --example-fill: 50%;
    background-color: color-mix(in srgb, var(--example-accent, var(--color-surface)) var(--example-fill), transparent);

    /* background-color leads the list: it is the one a pointer asks for, and
       ease-out puts the whole of the change at the start of the move. */
    transition: background-color var(--duration-base) ease-out, opacity var(--duration-base) var(--easing), transform 1s var(--easing), color 0.4s ease-out;

    &:hover{
      --example-fill: 75%;
    }
    perspective: 1000px;
    @media (max-width: 767px){
      padding: var(--space-card) var(--space-card) calc(var(--space-card) * 2);
    }

    @media (min-width: 1024px){
      /* Room for the pair of read-outs to breathe on a wide card. */
      .ce-card__footer .ce-stats{
        gap: var(--space-8);
      }
    }
  }

  /* The screenshot, on the shared .window plate. The transform is the slide
     status's: an active or in-view card draws it at full scale, a card the
     reader has not reached yet steps it back and dims it — the picture
     alone, so the copy and the read-outs keep their weight at every
     position. transition: inherit rides the card's own tween. */
  .ce-card__media{
    width: 100%;
    margin-inline: auto;
    transition: inherit;
    transform-origin: 50% 0%;
    order: -1;
    @media (max-width: 767px){
      width: 100%;
    }

    /* The card's height comes from the picture here, so it is not asked to
       fill a box the way the case study's is. */
    img{
      height: auto;
    }
  }

  /* The script writes one of three statuses and removes the attribute
     entirely when the slider is off. Only the slides that are genuinely out
     of frame fall back, so this pairs an explicit not-active against
     active-or-inview rather than negating active, which would have caught
     the peeking slide and the no-attribute case with it. */
  .gsap-slider__item:is([data-gsap-slider-item-status="active"], [data-gsap-slider-item-status="inview"]){
    .ce-card__media{
      transform: scale(1);
    }
    .ce-card__body{
      transform: none;
    }
  }

  .gsap-slider__item[data-gsap-slider-item-status="not-active"] .ce-card__media{
    opacity: 0.5;
    transform: scale(0.9);
  }

  /* The closing card's media sits at full scale and stays there: the pills
     are a physics scene measured against the box they fall in, so animating
     that box would move the floor while they are still bouncing on it.
     Three classes on purpose, to outweigh the status rules above. */
  .gsap-slider__item .ce-card.ce-card--cta .ce-card__media{
    transform: scale(1);
    transition: none;
  }

  .ce-card__body{
    display: flex;
    flex-direction: column;
    gap: var(--space-6);
    transition: inherit;
  }

  .ce-card__text{
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
  }

  /* An inline lead-in, not a heading. The card opens with the name of the
     mistake at full strength, then a full stop, then the note in the muted
     ink — one paragraph, one reading line. The step comes from
     .text-heading-xs in the markup; only the colour is set here, named
     rather than inherited, because the paragraph around it is muted. */
  .ce-card__title{
    color: var(--color-text);
  }

  .ce-card__desc{
    color: var(--color-text-muted);
    text-wrap: balance;
  }

  /* The gauges and the tag row, under the screenshot. This is the only
     place in a card where the accent is named. Everything under it — the
     read-out labels, the chips, the filled part of each bar — is a
     currentColor mix, so the whole block turns together off this one
     declaration. At rest it takes the card's own ink; the accent arrives
     with the numbers, on hover.

     Held in a variable as well as in color, because one thing inside cannot
     read it off currentcolor: the figure's chip paints the colour as a plate
     while its own type is something else. */
  .ce-card__footer{
    display: flex;
    flex-direction: column;
    gap: var(--space-6);
    --footer-ink: var(--color-text);
    color: var(--footer-ink);
    transition: color var(--duration-base) var(--easing);
  }

  :is([data-gsap-slider-item-status="active"], [data-gsap-slider-item-status="inview"]) .ce-card:hover .ce-card__footer{
    --footer-ink: var(--color-accent);
  }

  /* Closing card: the two CTAs stand where the tag row does on the others,
     stacked, each across the whole column. */
  .ce-card__cta{
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin-top: var(--space-2);
    @media (min-width: 1024px){
      margin-top: auto;
    }
    .btn-animated-link{
      width: 100%;
    }
    .btn-animated-content{
      flex: 1 0 0;
      justify-content: center;
    }
  }

  /* ---- read-outs: impact + frequency, side by side ---- */
  .ce-stats{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-6);
    margin-top: var(--space-1);
    /* The labels keep the medium weight the metric chips give up: the figure
       beside them is the thing being read. */
    font-size: var(--text-small);
    font-weight: var(--weight-medium);
    line-height: 1;

    /* The figure itself, as a filled chip: the plate is whatever the footer
       is currently set to, so it goes accent with everything else. The type
       on it is the scheme's ground. */
    .ce-tag{
      color: var(--color-bg);
      background-color: var(--footer-ink);
      font-weight: var(--weight-medium);
    }
  }

  /* The label and its figure sit together at the start of the bar. */
  .ce-bar__row{
    display: flex;
    justify-content: flex-start;
    align-items: baseline;
    gap: 1em;
    margin-bottom: 0.5rem;
  }

  .ce-pips{
    display: flex;
    gap: 4px;
  }

  .ce-pip{
    position: relative;
    flex: 1;
    height: 2px;
    border-radius: 999px;
    background: color-mix(in srgb, currentColor 14%, transparent);
  }

  /* The empty track is the pip itself, so it never moves. The lit part is a
     ::after that wipes out from the left, one segment after the next, on the
     slides that are on screen. Delays live in the active state only:
     leaving the slide pulls everything back at once instead of
     un-staggering. */
  .ce-pip.is-on::after{
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: color-mix(in srgb, currentColor 70%, transparent);
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 0.35s var(--easing);
  }

  :is([data-gsap-slider-item-status="active"], [data-gsap-slider-item-status="inview"]){
    .ce-pip.is-on::after{ transform: scaleX(1); }
    .ce-pip:nth-child(1).is-on::after{ transition-delay: 0.10s; }
    .ce-pip:nth-child(2).is-on::after{ transition-delay: 0.13s; }
    .ce-pip:nth-child(3).is-on::after{ transition-delay: 0.16s; }
    .ce-pip:nth-child(4).is-on::after{ transition-delay: 0.19s; }
    .ce-pip:nth-child(5).is-on::after{ transition-delay: 0.22s; }
    .ce-pip:nth-child(6).is-on::after{ transition-delay: 0.25s; }
    .ce-pip:nth-child(7).is-on::after{ transition-delay: 0.28s; }
    .ce-pip:nth-child(8).is-on::after{ transition-delay: 0.31s; }
    .ce-pip:nth-child(9).is-on::after{ transition-delay: 0.34s; }
    .ce-pip:nth-child(10).is-on::after{ transition-delay: 0.37s; }
  }

  /* ---- reveals: the read-out chips and the metric tags ----
     The two read-outs come in just ahead of their bars, impact first.
     'inview' as well as 'active': a read-out belongs to a slide the reader
     can see, and the slider only ever marks one slide active however many
     of them fit. */
  .ce-tag{
    transition: background-color var(--duration-base) var(--easing);
  }

  .ce-reveal{
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 0.35s var(--easing), transform 0.35s var(--easing);
  }

  /* The metric chips are not marked with .ce-reveal in the markup: the order
     they come in is the order they are written, which nth-child already
     knows. The transition list is restated rather than inherited from
     .ce-tag — a second list on a heavier selector replaces it, so the
     plate's colour change has to be carried over with the two new
     properties. */
  .ce-tags .ce-tag{
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 0.4s ease-out,
      transform 0.4s ease-in-out,
      background-color 0.4s ease-out;
  }

  :is([data-gsap-slider-item-status="active"], [data-gsap-slider-item-status="inview"]){
    .ce-reveal{
      opacity: 1;
      transform: none;
    }
    .ce-tag--impact.ce-reveal{ transition-delay: 0s; }
    .ce-tag.ce-reveal:not(.ce-tag--impact){ transition-delay: 0.05s; }

    /* The tag row overlaps the bars rather than queueing behind them: it
       starts while the bars are still filling, so the two read as one move.
       The step is slightly wider than the pips' — these are words to read,
       not segments to watch fill. */
    .ce-tags .ce-tag{
      opacity: 1;
      transform: none;
    }
    .ce-tags .ce-tag:nth-child(1){ transition-delay: 0.22s; }
    .ce-tags .ce-tag:nth-child(2){ transition-delay: 0.28s; }
    .ce-tags .ce-tag:nth-child(3){ transition-delay: 0.34s; }
    .ce-tags .ce-tag:nth-child(4){ transition-delay: 0.40s; }
    .ce-tags .ce-tag:nth-child(5){ transition-delay: 0.46s; }
  }

  @media (prefers-reduced-motion: reduce){
    .ce-reveal, .ce-tags .ce-tag{ opacity: 1; transform: none; transition: none; }
    .ce-pip.is-on::after{ transform: scaleX(1); transition: none; }
  }
}


/* ============================================
   Section: Problems
   ============================================ */

/* Full-width rows, built the way the gap list is: a hairline above each
   band, the band running edge to edge, and inside it a .container in three
   columns: number, title, copy. Each row is a .fill-row and paints its
   plate in as it comes up the screen (js/fill-rows.js); the quote closes
   the list as the last of them. */
.home__section-problems{
  padding-bottom: 0;
  .problems__list{
    display: grid;
  }

  /* The plate itself comes from .fill-row, carried in the markup. */
  .problem{
    padding-block: var(--space-8);
    /* A hairline above every band is all that separates one from the next.
       The last one closes the list. */
    border-top: 1px solid var(--color-border);
    &:last-child{
      border-bottom: 1px solid var(--color-border);
    }
  }

  /* Three columns: number, title, copy. */
  .problem__body{
    display: grid;
    grid-template-columns: 2fr 4fr 6fr;
    gap: var(--grid-gap);
    align-items: start;
  }

  /* A step down from the .text-heading-md the markup carries. */
  .problem__title{
    font-size: var(--text-heading-sm);
    font-weight: var(--weight-semibold);
    letter-spacing: var(--tracking-heading-sm);
    line-height: var(--leading-heading-sm);
  }

  /* The number: no chip, no plate, nothing that moves. Set quiet and kept
     quiet, so it reads as the row's marker and not as a control. It sits
     on the title's line height so the two centre on each other. */
  .problem__num{
    font-size: var(--text-body);
    line-height: calc(var(--text-heading-sm) * var(--leading-heading-sm));
    color: var(--color-text-subtle);
  }

  .problem__text{
    max-width: 30rem;
    /* A currentcolor tint, so it follows the row's ink. */
    color: var(--color-text-muted);
    /* A shorter measure once the row is one column: the copy would
       otherwise run the full width of the phone. */
    @media (max-width: 1023px){
      padding-right: var(--space-12);
    }
  }

  /* The quote as the last row: no columns, the wider container, and more
     air than a problem takes. It is a .problem so it shares the hairline,
     the fade and the plate. */
  .problem--quote{
    padding-top: var(--space-section);
    padding-bottom: calc(var(--space-section) * 1.75);
  }

  /* Three columns need three columns' worth of width. Below that the number
     keeps the title beside it and the copy drops under the title, so the
     row goes back to being read top to bottom. */
  @media (max-width: 767px){
    .problem__body{
      grid-template-columns: auto 1fr;
      column-gap: var(--space-4);
    }
    .problem__text{
      grid-column: 2;
    }
  }
}


/* ============================================
   Section: Services
   ============================================ */

@keyframes availability-pulse {
  0%   { opacity: 0.4; transform: scale(1); }
  70%  { opacity: 0;   transform: scale(2.75); }
  100% { opacity: 0;   transform: scale(2.75); }
}

/* Two columns. The audit is the offer the section is built around, so it
   takes the whole left column, is the one card in the brand scheme, and is
   the one card with nothing folded away. The other two stack on the right
   and open on demand — same accordion mechanics as the FAQ and the case
   studies, so they run on js/accordion.js and on the grid-row trick, not on
   a height animation. js/scheme-feature.js moves .scheme-brand to whichever
   card is open. */
.home__section-services{
  /* ---- Availability row ----
     One shared shape for all three cards: a strip of dots, then a label.
     A dot is a seat. .is-open means still bookable, plain means taken —
     so a single dot card reads "open" and the support card reads
     "3 taken, 2 left" without the label having to carry it alone.
     js/availability.js keeps the dates in the label current. */
  .service-card__availability{
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    text-align: center;
    text-wrap: balance;
  }

  .availability__dots{
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    flex: none;
  }

  .availability__dot{
    position: relative;
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: color-mix(in srgb, currentcolor 25%, transparent);
  }

  .availability__dot.is-open{
    background: var(--color-success);
  }

  /* The pulse is a second circle scaling out from under the dot, so the
     animation stays on transform/opacity instead of a box-shadow colour.
     Only the first open dot breathes: two rings pulsing side by side in the
     support card reads as a loading state, not as availability. */
  .availability__dot.is-open::after{
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: var(--color-success);
    animation: availability-pulse 2.6s var(--easing) infinite;
  }

  .availability__dot.is-open ~ .availability__dot.is-open::after{
    content: none;
  }

  .service-card__cta{
    display: flex;
    flex-direction: column;
    align-items: stretch;
    a, .btn-animated-content{
      width: 100%;
      justify-content: center;
    }

    /* Both footers pack their children to the start, which leaves the
       button at the width of its own label. That is right beside a price on
       a wide card and wrong on a phone, where the card is the screen and a
       button the width of its words reads as an afterthought. align-self,
       because the rule to beat is the parent's align-items. */
    @media (max-width: 767px){
      align-self: stretch;
    }
  }

  .services{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-1);
    /* Stretch, so the ink card is as tall as the two cards beside it. */
    align-items: stretch;

    @media (max-width: 1023px){
      grid-template-columns: 1fr;
    }
  }

  .services__col{
    display: grid;
    gap: var(--space-1);
    align-content: start;
  }

  /* ---- the card ---- */

  .service{
    --service-pad: var(--space-card);
    display: flex;
    flex-direction: column;
    gap: var(--space-8);
    border-radius: var(--radius-lg);
    border: 1px solid color-mix(in srgb, currentcolor 15%, transparent);
    /* A plate on the section's ground, nothing more. It used to set its own
       ink back to dark and paint itself 70% of the section's ink, which made
       the card a colour of its own that every tint inside had to be tuned
       against. The scheme on the section owns both now: the ink comes down
       untouched and the plate is the scheme's surface.

       Hover lifts the same tint rather than switching to a second colour, so
       the two states are one number apart and cannot drift.

       Both the copy and the plate are mixed off --color-text rather than off
       currentcolor, and for the same reason twice over. --color-text-muted
       and --color-surface are both currentcolor recipes, and currentcolor on
       a card is whatever it inherits from the section — which is the wrong
       ink on the lead card, whose own scheme the component outweighs, and a
       tint of a tint on the others, whose copy is muted already. Read off
       the scheme's ink they land where they should in both. */
    color: color-mix(in srgb, var(--color-text) 60%, transparent);
    transition: all 0.4s ease-out;
    will-change: background-color, transform, color, border-color, border;
    @media (max-width: 767px){
      --service-pad: var(--space-8);
      gap: var(--space-6);
    }
  }

  /* Keyed on the scheme, not on which card it is. js/scheme-feature.js moves
     .scheme-brand to whichever card is open, so a card is a plate whenever it
     is not the featured one — including the audit card, once one of the
     folded two takes the accent from it. */
  .service:not(.scheme-brand){
    background-color: color-mix(in srgb, var(--color-text) 5%, transparent);
  }

  .service:not(.scheme-brand):hover{
    background-color: color-mix(in srgb, var(--color-text) 12%, transparent);
    color: var(--color-text);
    transform: translateY(2px);
  }

  /* Nothing folds in the audit card, so its padding sits on the card itself.
     Structure, not colour — it keeps this whether or not it is the featured
     one. The other two hand their padding to the summary and the panel. */
  .service--lead{
    padding: var(--service-pad);
  }

  /* ---- the two controls, keyed on the scheme ----
     Both used to name a variant in the markup, which worked while the accent
     never moved. It moves now, so a class on the button would be right in one
     state and wrong in the other — an accent button on the accent card is
     invisible, and an ink button on an ink plate is nearly so.

     The section decides instead. On a plate the call to action is the accent;
     on the accent it is the ink, and only the label travels, which is the
     is-ink move written in the scheme's own terms. */
  /* :not(.service__toggle) because the plus is a .btn-animated-link too, and
     it is not a call to action — it keeps the glass set further down. */
  .service .btn-animated-link:not(.service__toggle){
    --button-bg: var(--color-accent);
    --button-text: var(--color-on-accent);
    --button-bg-hover: color-mix(in srgb, var(--color-accent) 78%, white);
    --button-text-hover: var(--color-on-accent);
  }

  .service.scheme-brand .btn-animated-link:not(.service__toggle){
    --button-bg: var(--color-text);
    --button-text: var(--color-on-dark);
    --button-bg-hover: var(--color-text);
    --button-text-hover: var(--color-accent);
  }

  /* The plus keeps its glass on either ground — the recipe is mixed off
     --color-text, which the scheme has already swapped for it. Only the wipe
     needs saying: the component's accent would land on an accent card and
     nothing would happen, so there it wipes to the ink instead. */
  .service.scheme-brand .service__toggle{
    --button-bg-hover: var(--color-text);
    --button-text-hover: var(--color-bg);
  }

  /* The featured card, whichever it currently is. The ground is the scheme's
     own; restated here because every selector in this block weighs three
     classes and the scheme's paint rule weighs two, so the component would
     win otherwise.

     No grading on it either. The other cards are plates in a dark section
     and the muted step is what keeps their copy off the ink; on the accent
     the ground is doing all the shouting a card needs, and 60% dark over it
     reads as faded rather than as secondary. */
  .service.scheme-brand{
    background-color: var(--color-bg);
    color: var(--color-text);

    .price-label{
      color: inherit;
    }
  }

  .service__main{
    display: flex;
    flex-direction: column;
    gap: var(--space-8);
    @media (max-width: 767px){
      gap: var(--space-6);
    }
  }

  /* The one block at full strength. Everything else in the card is graded
     down, so the name and the figure are what the eye lands on first. */
  .service__header{
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    align-items: start;
    color: var(--color-text);
  }

  .service__title{
    font-size: var(--text-heading-sm);
    font-weight: var(--weight-heading-sm);
    letter-spacing: var(--tracking-heading-sm);
    @media (max-width: 767px){
      font-size: var(--text-heading-md);
      font-weight: var(--weight-heading-md);
      letter-spacing: var(--tracking-heading-md);
      margin-bottom: var(--space-3);
    }
  }

  .service__price{
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
  }

  /* Back down, inside the one block that is not. It sits under the figure
     as its unit, not as a second headline. The mix reads the header's full
     ink above it, so this lands at the same 60% as the rest of the card
     rather than compounding on an already muted value. */
  .price-label{
    color: var(--color-text-muted);
    font-size: var(--text-label);
    text-transform: uppercase;
    letter-spacing: 0.02em;
    font-weight: 500;
    line-height: 1;
  }

  .service__desc{
    max-width: 46ch;
  }

  /* The footer sits on the bottom edge, which is what lines the audit's
     button up with the foot of the column beside it. */
  .service__footer{
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    margin-top: auto;
  }

  .service--lead .service__footer{
    align-items: start;
  }

  /* ---- the folding pair ----
     The padding belongs to the two halves, not to the card around them. On
     the card it would have sat outside the summary's box, so the band of
     space around the copy would not have opened anything when clicked — the
     hit area can only ever be as big as the element it is stretched over. */
  .service--sub{
    gap: 0;
    padding: 0;
  }

  /* ---- the summary ----
     The top of the card, and the only part of it that toggles — the same
     shape as the FAQ and the case studies, where the summary row is the
     control and the panel below it is not. The button is the real control
     and carries aria-expanded; its hit area is stretched over the whole
     summary by the ::before below, which is what keeps the markup legal —
     a heading and paragraphs cannot live inside a <button>. */
  /* Half the padding top and bottom, the full measure at the sides — the same
     shape .quote-wrapper uses. These two cards stack in one column, so the
     block padding is paid twice at every seam between them; halving it keeps
     the pair from reading looser than the single card beside it. */
  .service__summary{
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-8);
    padding: var(--service-pad);
    cursor: pointer;
  }

  /* Description and plus share a row, and the plus sits on its bottom edge.
     Deliberately not a column beside the whole head: a title that runs to
     two lines should push the description down and take the plus with it, not leave the plus floating against the price. */
  .service__row{
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--space-8);
  }

  .service--sub .service__desc{
    margin: 0;
  }

  /* ---- the toggle ----
     Built from the same component as the slider arrows, and stripped the
     same way: the bare <button> chrome comes off and .btn-animated-content
     supplies the plate, the mask and the wipe. That is where the feel comes
     from — one glyph leaves the mask as its twin slides in, the plate rises
     from below, and both run on --duration-slow and the house easing, because [data-button-anim-target] declares that once for every button
     on the site.

     The same glass the slider arrows are cut from: a plate of the scheme's
     ink at 10% with a hairline on it, rather than the filled chip the
     component ships. A control that only unfolds a panel should not be the
     brightest thing on the card, and the shipped pair made it exactly that.

     Mixed off --color-text, not off --color-surface, for the reason the card
     itself is: surface is a currentcolor recipe and currentcolor here is the
     card's muted copy, so the plate would have come out as a tint of a tint.

     The glyph is named for the same reason — left on currentColor it would
     inherit that muted ink and go quieter than the plate it sits on. Hover
     is the component's own: accent fill, accent-ink glyph, and the outline
     follows the fill the way it does on the arrows. */
  .service__toggle{
    --button-bg: color-mix(in srgb, var(--color-text) 10%, transparent);
    --button-text: var(--color-text);

    position: static;   /* so ::before resolves against .service__summary */
    flex: none;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    appearance: none;

    /* The hit area is the whole summary, not the button's own square. One
       control, so one aria-expanded and one tab stop, and clicking the
       title or the price still opens the card. */
    &::before{
      content: "";
      position: absolute;
      inset: 0;
      z-index: 1;
    }
  }

  /* The arrows' proportions, and now their box too. */
  .service__toggle > .btn-animated-content{
    /* Square, sized to what a nav button comes to: .is-sm pads a --leading-body
       line box by 0.5em top and bottom, so 1.4em + 1em = 2.4em. Written as a box
       rather than as padding so the glyph keeps its own 1.25em and the surround
       takes up the difference — padding alone would have had to be 0.575em, and
       the icon would have been left as the only thing explaining the height. */
    padding: 0;
    justify-content: center;
    width: calc(1em * var(--leading-body) + 1em);
    height: calc(1em * var(--leading-body) + 1em);
  }

  .service__toggle .btn-animated-content__mask{
    width: 1.25em;
    height: 1.25em;
    /* The stack has to start at the top of the window, not be centred in it,
       or the mask shows half of each copy. */
    align-items: flex-start;
  }

  /* Stacked, not side by side. Sideways is the slider arrows' move, and it
     is right there because the arrow points that way; a plus points nowhere, so it leaves upward — the same direction the label rolls in every other
     button on the site. */
  .service__icons{
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: center;
  }

  .service__icon{
    flex: none;
    width: 1.25em;
    height: 1.25em;
    background-color: currentColor;
    -webkit-mask: var(--icon-plus) center / 1em 1em no-repeat;
    mask: var(--icon-plus) center / 1em 1em no-repeat;
    /* The only turn is the open state, where the plus becomes a cross. The
       hover move is the slide the whole component already does — a spin on
       top of it would be one motion more than the slider arrows have. */
    transform: rotate(var(--toggle-open, 0deg));
    transition: transform var(--duration-slow) var(--easing);
  }

  /* The copy that is on screen after the swap is already wearing the hover
     colour, so it arrives onto the rising plate rather than changing on it.
     The lower one, since the stack travels up. */
  .service__icon:last-child{
    color: var(--button-text-hover);
  }

  [data-accordion-status="active"] .service__icon{
    --toggle-open: 45deg;
  }

  /* Hover is on the summary, not the card or the button: the panel below is
     not part of the control, and the button's square is not all of it. */
  @media (hover: hover) and (pointer: fine){
    .service__summary:hover .service__icons{
      transform: translateY(-50%);
    }

    .service__summary:hover .btn-animated-content__text, .service__summary:hover .btn-animated-content__bg{
      transform: translate(0, 0);
    }
  }

  /* ---- the reveal ----
     The FAQ's mechanism exactly: only the grid row track animates, so a
     panel of unknown height opens on one transition and no measurement. */
  .service__reveal{
    display: grid;
    grid-template-rows: 0fr;
    overflow: hidden;
    transition: grid-template-rows var(--duration-base) var(--easing);
  }

  [data-accordion-status="active"] > .service__reveal{
    grid-template-rows: 1fr;
  }

  .service__reveal-inner{
    overflow: hidden;
    min-height: 0;
  }

  /* The panel carries the sides and the foot; the space above it is the
     summary's own bottom padding, which is already there whether the card is
     open or shut. All of it lives on the inner block rather than on the
     panel itself, or a closed card would keep the padding as a visible gap
     under a row that has collapsed to nothing. */
  /* Matching the summary above it: no top padding, since the summary's own
     bottom already spaces the two apart. */
  .service__reveal-body{
    display: flex;
    flex-direction: column;
    gap: var(--space-12);
    padding: 0 var(--service-pad) calc(var(--service-pad) / 2);
  }

  .service--sub .service__footer{
    margin-top: 0;
    align-items: start;
  }

  /* ---- closing CTA ----
     A .section-footer, so it closes the section the way the quotes close
     theirs. One centred column — heading, the line under it, the button,
     the audit note — and nothing behind it: the section's own dark ground
     is the background, and measure plus space do the holding that a plate
     used to do. */
  .services__cta{
    display: flex;
    flex-direction: column;
    justify-content: center;
    /* The final CTA's measure, so the two closings sit on the same width. */
    max-width: 46rem;
    margin-inline: auto;
    margin-top: var(--space-inner);
    /* The parts carry their own offsets, the way .section-start's do, so the
       column takes no gap of its own. */
    gap: 0;
    /* Centred on desktop, left below 1024 like every other header. Stated
       here because this selector outranks .section-footer.is-center. */
    align-items: start;
    text-align: left;
    @media (min-width: 1024px){
      align-items: center;
      text-align: center;
    }
  }

  /* .section-start__lede, to the letter. */
  .services__cta-lede{
    margin-top: var(--space-4);
    max-width: 52ch;
    margin-inline: auto;
    text-wrap: balance;
    color: var(--color-text-muted);
  }

  /* Where .section-start__actions sits under its lede, and the audit note
     under the button at the service cards' own --space-3. */
  .services__cta-actions{
    display: flex;
    flex-direction: column;
    align-items: start;
    gap: var(--space-3);
    margin-top: var(--space-6);

    @media (min-width: 1024px){
      align-items: center;
    }
  }
}


/* ============================================
   Section: Case studies
   ============================================ */

/* Two columns per case. The left one is a photograph with the client, the
   step screenshot, the numbers and the link out sitting on it; the right one
   is the title and the process. js/case-studies.js drives it off the data-
   hooks.

   The cases used to carry a colour of their own, set on the article in the
   markup, and every plate on the left was painted in it. They run on the
   site's one accent now, like everything else that lights up on hover.

   Opening a step blurs the cover back and brings the screenshot forward. The
   screenshot's box is in flow rather than floated over the copy, so the
   space is reserved whether a step is open or not and the numbers under it
   never jump. */
.home__section-cases{
  /* clip, not the .section default of hidden. Both crop the same way, but
     hidden makes the section a scroll container, and a scroll container is
     what the sticky slide below would then stick inside — which, since the
     section scrolls with the page, looks exactly like sticky not working.
     clip crops without creating one. */
  overflow: clip;

  .cases{
    display: grid;
    gap: var(--space-section);
  }

  /* Two plates side by side, not one plate around both: the article carries
     no ground, corner or inset of its own, and each column is its own
     rounded block, flush against the other. */
  .case__frame{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0;
    /* Stretch, so the two halves are one height and each can push its own
       content apart from the inside. */
    align-items: stretch;

    @media (max-width: 1023px){
      grid-template-columns: 1fr;
    }
  }

  /* ---- left: the slide ---- */
  .case__slide{
    position: relative;
    /* Over the copy column: the media slides in from the side on scroll and
       has to cover the copy's plate on the way, not pass under it. */
    z-index: 1;
    /* overflow is what clips the cover and the shots to the box, and the
       corner with them. */
    overflow: hidden;
    isolation: isolate;
    border-radius: var(--radius-lg);
  }

  /* The cover fills the slide behind everything else. .is-active is its sharp,
     resting state; the script takes it off when a step opens, and that is what
     pushes it back — the same blur-and-scale the homepage uses. */
  .case__cover{
    position: absolute;
    inset: 0;
    z-index: 0;
    /* It used to carry brightness(0.55), which dropped the cover a stop and a
       half the moment a step opened — a second darkening on top of the wash in
       ::after, and the one that made the card look like it had changed image
       rather than stepped back. The wash is the only thing that darkens
       anything here now.

       Saturate in its place. Pushing the colour up as the picture goes soft
       reads as depth of field rather than as a dimmer: what is out of focus is
       still the same photograph, just no longer the thing being looked at. */
    transform: scale(1.06);
    filter: blur(2rem) saturate(1.5);
    transition: transform var(--duration-slow) var(--easing),
      filter var(--duration-slow) var(--easing);
  }

  .case__cover.is-active{
    transform: scale(1);
    /* Both functions restated, in the same order. A filter list only
       interpolates against a list of the same shape — drop saturate here and
       the transition has nothing to walk to, so it would snap. */
    filter: blur(0) saturate(1);
  }

  /* Whatever the cover turns out to be. The three cases ship a photograph, but
     the script already plays and pauses a cover video with the open and closed
     state, and it needs the same box to fill. */
  .case__cover picture, .case__cover img, .case__cover video{
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* A wash under the copy so the type holds whatever the photograph is doing
     behind it. */
  .case__slide::after{
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    background: linear-gradient(42deg,
      color-mix(in srgb, var(--color-text) 20%, transparent),
      color-mix(in srgb, var(--color-text) 0%, transparent));
  }

  .case__slide-body{
    position: relative;
    z-index: 2;
    height: 100%;
    display: flex;
    flex-direction: column;
    /* The client at the head, the screenshot in the middle, the numbers and the
       link at the foot. As the card grows the space goes between them, and the
       footer stays on the bottom edge — the same job .service__footer does in
       the services section. */
    justify-content: space-between;
    gap: var(--space-8);
    padding: clamp(var(--space-6), 3vw, var(--space-16));
  }

  .case__slide-footer{
    display: flex;
    flex-direction: column;
    align-items: start;
    gap: var(--space-6);
  }

  /* The hero's stat slider, on the case's own numbers. The only colour
     declared is the ink: the slider inside mixes everything it needs off
     currentColor, so the bar, the arrows and the counter all follow. The
     slider itself needs a width because in the hero it is a grid column
     and in here it is a flex child that would shrink to its content. */
  .case__stats{
    position: relative;
    z-index: 3;   /* back over the slide's link */
    display: flex;
    flex-direction: column;
    align-items: start;
    gap: var(--space-8);
    width: 100%;
    color: var(--color-bg);
  }

  .case__stats .hero__slider{
    width: 100%;
    /* Full strength, and no hover shift. In the hero the slider is held back
       by .text-muted and comes forward on hover, which reads against the
       photograph behind it; on a filled accent plate there is nothing to come
       forward from, and the change just looks like a flicker. */
    transition: none;

    &:hover{
      color: inherit;
    }
  }

  .case__stats .hero__stat{
    font-size: calc(var(--text-heading-xl) * 2);
  }

  /* The caption is a line of its own here rather than small print, the same as
     on the large result tile — and the counter is set to match it, so the two
     things framing the figure read as one pair. */
  .case__stats .hero__desc, .case__stats .hero__counter{
    font-weight: var(--weight-medium);
  }

  /* The client's own mark, as a file. It used to be the name set in
     heading-sm, which is why the type properties were here.

     An <img>, not an inline <svg>, and the same call the hero logo strip makes
     for its eighteen: nothing here recolours the mark, so there is nothing to
     gain from putting several hundred path characters in the document. The
     marks carry their brand colour and a white wordmark, and the wash under
     them is dark on every case, so they are legible as authored. Inline SVG
     on this site is for icons that have to follow currentcolor.

     Height is what is held: the three are drawn to a common 32 and differ in
     width, so sizing on height is what lines them up. The width and height
     attributes in the markup are the intrinsic pair, which is what reserves the
     box before the file lands. */
  .case__client{
    display: block;
    width: auto;
    height: clamp(1.5rem, 2vw, 2rem);
  }

  /* ---- the step screenshot ----
     Holds its box open at all times; only what is inside it comes and goes. */
  /* The two shot layers share one grid cell, so the stage takes its height
     from a shot rather than from a ratio of its own — the ratio lives on the
     picture, and the window's bar and inset add to it instead of eating
     into it. */
  .case__stage{
    display: grid;
  }

  /* The plate itself is the shared .window component; only its place in
     the stage and the swap are the section's. */
  .case__shot{
    grid-area: 1 / 1;
    opacity: 0;
    transform: scale(1.08);
    transition: opacity var(--duration-base) var(--easing),
      transform var(--duration-slow) var(--easing);
  }

  /* Comes in from oversized and settles; on the way out it carries on
     shrinking, so two consecutive steps read as one continuous move. */
  .case__shot.is-active{
    opacity: 1;
    transform: scale(1);
  }

  .case__shot.is-leaving{
    opacity: 0;
    transform: scale(0.94);
  }

  /* The reset back to oversized happens with transitions off, so the jump is
     never seen. */
  .case__shot.is-instant{
    transition: none;
    opacity: 0;
    transform: scale(1.08);
  }

  /* The shape the screenshots are exported at: every capture is 1600x1000,
     so nothing is trimmed to fit. The brand film on the third case is 16/9
     and is the one thing that does get trimmed — a few percent off each
     side, which its titles sit well clear of. */
  .case__shot img, .case__shot video{
    aspect-ratio: 16 / 10;
    height: auto;
  }

  /* The slide's own link, filling it. Inside the body rather than beside it:
     .case__slide-body carries a z-index and so opens a stacking context, which
     a sibling could not be layered into.

     Over the content, not under it. Under it, .case__stage — positioned, and
     later in the markup — painted on top and swallowed the hover across the
     middle of the card, leaving only the gaps around it live. */
  .case__link{
    position: absolute;
    inset: 0;
    z-index: 2;
  }

  /* And the controls back over the link. The head is transparent to the
     pointer so the client name is still part of the surface; only the reset
     button inside it takes its own clicks, which it already governs through
     [data-cs-active]. */
  .case__slide-head{
    position: relative;
    z-index: 3;
    pointer-events: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    min-height: 2.5em;
  }

  /* The step toggles' button, in the same shape and with the same motion: a
     cross riding in a mask in two copies, and the accent wiping up behind it.
     Square-cornered like the rest — the round chip was the odd one out. */
  .case__close{
    /* Only the resting pair is named. The hover is .btn-animated-link's own
       default, which is the site accent — the same wipe every other button
       on the page makes. */
    --button-bg: var(--color-bg);
    --button-text: var(--color-text);

    flex: none;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    appearance: none;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--duration-base) var(--easing);
  }

  .case__close .btn-animated-content{
    padding: 0.5em;
  }

  .case__close .btn-animated-content__mask{
    width: 0.9em;
    height: 0.9em;
    align-items: flex-start;
  }

  .case__close-icons{
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: center;
  }

  /* A cross is the plus already in the set, turned. */
  .case__close-icon{
    flex: none;
    width: 0.9em;
    height: 0.9em;
    background-color: currentColor;
    -webkit-mask: var(--icon-plus) center / 0.7em 0.7em no-repeat;
    mask: var(--icon-plus) center / 0.7em 0.7em no-repeat;
    transform: rotate(45deg);
  }

  .case__close-icon:last-child{
    color: var(--button-text-hover);
  }

  /* Only reachable while a step is showing — data-cs-active is written by the
     script on the frame. */
  [data-cs-active="true"] .case__close{
    opacity: 1;
    pointer-events: auto;
  }

  @media (hover: hover) and (pointer: fine){
    .case__close:hover .case__close-icons{
      transform: translateY(-50%);
    }

    .case__close:hover .btn-animated-content__bg{
      transform: translate(0, 0);
    }
  }

  /* ---- right: title and process ---- */
  .case__col{
    display: flex;
    flex-direction: column;
    /* The same inset the slide body carries on the other side. */
    padding: clamp(var(--space-6), 3vw, var(--space-16));
    background-color: var(--color-surface);
    border-radius: var(--radius-lg);

    /* Once the frame stacks, the copy is no longer a panel beside the
       picture — it is the text under it, and a plate with its own padding
       around it only builds a second card inside the card. The plate goes,
       the inset goes with it, and the space it used to hold is one margin
       between the picture and the first line. */
    @media (max-width: 1023px){
      padding: 0;
      background-color: transparent;
      border-radius: 0;
      margin-top: var(--space-8);
    }
  }

  /* The column is half the frame and grows with it; the copy inside should not.
     Everything the column holds sits in here instead, held to a measure, so the
     title and the steps keep their line length however wide the card gets. */
  .case__col-inner{
    height: 100%;
    display: flex;
    flex-direction: column;
    /* The same job .case__slide-body does on the other side: the head sits on
       the top edge, the steps on the bottom one, and whatever height the card
       ends up with goes between them. The gap is the floor under that. */
    justify-content: space-between;
    gap: var(--space-12);
    width: 100%;
    max-width: 30rem;
    /* Centred in the column, not started in it: past 30rem the column keeps
       growing and the block would otherwise drift away from the slide beside
       it. */
    margin-inline: auto;

    /* Stacked, there is no slide beside it to stay near. The measure and
       the centring would stand the copy in the middle of a column as wide
       as the picture above it, with neither edge lining up. */
    @media (max-width: 1023px){
      max-width: none;
      margin-inline: 0;
      gap: var(--space-8);
    }
  }

  /* The steps close the column, pinned to its bottom edge by the space-between
     above, so the rule under the last one would be drawn along the foot of the
     plate with nothing beneath it. Scoped rather than changed on .accordion__item
     itself: the FAQ and the services cards run the same component in the middle
     of a block, where that rule still closes the list. */
  .case__col-inner .accordion__item:last-child{
    border-bottom: none;
  }

  .case__head{
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  .case__title{
    text-wrap: balance;
  }
}


/* ============================================
   Section: Clients (logo wall)
   ============================================ */

@keyframes logo-wall-shot-in {
  from { transform: scale(1.15); }
  to { transform: scale(1); }
}

@keyframes logo-wall-tag-in {
  from {
    opacity: 0;
    transform: translateY(0.4em);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* A third and two thirds: a yellow plate carrying the count, and the wall
   of client marks beside it, three across and two down. The marks keep
   their square, which is what sets the height of the row, and the plate
   takes that height as a grid item stretching to it. Nothing here is told
   a pixel or a ratio.

   The wall itself is Osmo's Logo Wall Cycle (js/logo-wall.js): the script
   moves [data-logo-wall-target] between cells, so each brand carries its
   own picture and caption wherever it lands. Every data- hook is the
   component's; the layout of a cell is ours. */
.home__section-logos{
  /* The marks are flattened to one tint, and on the page's light ground that
     tint is the black end. Inside a cell, on the photograph, it flips. */
  --logo-wall-invert: 0;

  /* One seam for the whole section. The outer grid and the wall inside it are
     two separate grids, and the eye reads their gaps as one set of lines, so
     they are held to one number. */
  --logos-gap: var(--space-1);

  .logos{
    display: grid;
    grid-template-columns: 1fr 2fr;
    gap: var(--logos-gap);
  }

  /* ---- the count plate ----
     It has no shape of its own: the width is its column and the height is
     the row, which the two rows of squares next to it settle. */
  .logos__count{
    /* The figure below is capped against this plate's own width, not the
       viewport's. */
    container-type: inline-size;
    display: flex;
    flex-direction: column;
    /* The figure at the top, the line at the foot. As the plate grows the
       space goes between them rather than around them. */
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-card);
    border-radius: var(--radius-md);
    overflow: hidden;
  }

  /* The case studies' figure size, because the number here is the same kind
     of claim. The cap is what keeps it inside a plate that is only ever a
     third of the row. */
  .logos__figure{
    display: block;
    font-family: var(--font-heading);
    font-weight: var(--weight-display);
    line-height: var(--leading-display);
    letter-spacing: var(--tracking-display);
    font-size: min(calc(var(--text-heading-xl) * 2), 42cqw);
  }

  /* The step comes from .text-heading-sm in the markup. */
  .logos__note{
    margin: 0;
    text-wrap: balance;
  }

  /* Under the two-column width the plate goes full width and the wall drops
     below it. There is no tall box to hold them apart any more, so the figure
     and the line sit side by side on one band instead. */
  @media (max-width: 1023px){
    .logos{
      grid-template-columns: 1fr;
    }

    .logos__count{
      flex-direction: row;
      /* Centre, not bottom: the line wraps to two or three words depending
         on the width, and sat on a common bottom edge it would swing up and
         down the figure as it rewraps. */
      align-items: center;
      gap: var(--space-8);
    }

    .logos__note{
      max-width: 28ch;
    }
  }

  @media (max-width: 640px){
    .logos__count{
      flex-direction: column;
      align-items: start;
    }
  }

  /* ---- the wall ---- */
  .logo-wall{
    display: flex;
    justify-content: center;
    width: 100%;
  }

  .logo-wall__collection{
    width: 100%;
  }

  /* Columns only. The rows are left to size themselves, which means the
     square each mark keeps is what decides how tall the wall is, and the
     plate in the other column stretches to match. Two across on a phone, so
     a mark is never set smaller than its own wordmark. */
  .logo-wall__list{
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--logos-gap);
    overflow: hidden;

    @media (max-width: 640px){
      grid-template-columns: repeat(2, 1fr);
    }
  }

  /* How many cells the wall shows: six on the desk, four on a phone. The
     script reads `display: none` to decide which ones take part, so this
     drives the animation too — the rest of the logos are the pool it cycles
     through. */
  [data-logo-wall-item]:nth-child(n + 7){
    display: none;
  }

  @media (max-width: 640px){
    [data-logo-wall-item]:nth-child(n + 5){
      display: none;
    }
  }

  .logo-wall__item{
    position: relative;
    overflow: hidden;
    /* The picture inside runs a transform animation on every turn of the
       wall, which the browser lifts onto its own compositor layer — and a
       raised layer can paint outside the box that clips it for a frame
       before the clip catches up. That is the flash of a whole photograph
       in a cell nobody touched. A stacking context here is what pins the
       layer inside the box. The corners come from .logo-wall__logo, which
       carries its own radius. */
    isolation: isolate;
    /* Both directions are timed from here, so leaving the wall eases the
       cards back. The hover rule restates it with a delay on the front. */
    transition: transform var(--duration-slow) var(--easing),
      opacity var(--duration-slow) var(--easing);
  }

  /* The cell. Everything in it sits on the picture, so it runs on the light
     end of the palette: .scheme-dark in the markup carries the ink and the
     veil over the photograph. The logos are flattened images and cannot
     inherit a colour, so the mark is handed the other end of its own invert
     instead. The surface is restated over the scheme's paint because there
     is a photograph underneath that has to stay visible. */
  .logo-wall__logo{
    --logo-wall-invert: 1;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    overflow: hidden;
    background-color: var(--color-surface);
    border-radius: var(--radius-lg);
    aspect-ratio: 1;
  }

  /* The link is the whole cell, built like the plate beside it: mark at the
     top left, caption at the bottom left, the space between them. The link
     is what carries the layout rather than the cell around it, because the
     link is the node the script moves from cell to cell.

     Absolute, and it has to be: on every turn the script appends the
     incoming link before it removes the outgoing one, so for the length of
     the swap the cell holds two of these. Out of the flow they simply lie
     on top of one another, which is what the crossfade is. */
  .logo-wall__logo-target{
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    align-items: start;
    gap: var(--space-2);
    /* Roughly the share of its own width the yellow plate gives to
       --space-card, so a cell is inset like the plate. */
    padding: var(--space-6);
    text-decoration: none;

    /* The hit area, over the picture and the caption. */
    &::after{
      content: "";
      position: absolute;
      inset: 0;
      z-index: 3;
      border-radius: var(--radius-md);
    }

    /* Ring goes on the hit area, not the link box. */
    &:focus-visible{
      outline: none;
    }

    &:focus-visible::after{
      outline: 2px solid currentcolor;
      outline-offset: -2px;
    }

    @media (max-width: 1023px){
      padding: var(--space-4);
    }
  }

  /* ---- the picture ----
     The cell's surface, nothing more. Arrives over-scale and eases down to
     its own size once the logo has turned over — the same delay the caption
     waits out, so the picture and the line settle together behind it. Much
     slower than anything else in the cell on purpose: it is the largest
     thing moving.

     An animation for the same reason the caption uses one: the script moves
     this node out of the DOM and back in on every turn, and re-attaching a
     node restarts its animations. `backwards` holds the over-scale through
     the delay. */
  .logo-wall__hover{
    position: absolute;
    inset: 0;
    z-index: 0;
  }

  .logo-wall__hover-img{
    width: 100%;
    height: 100%;
    object-fit: cover;
    transform: scale(1);
    animation: logo-wall-shot-in calc(var(--duration-slow) * 4) ease-out
      0.85s backwards;
  }

  /* ---- the mark ----
     Both sides are ceilings and neither is a size, so the box shrink-wraps
     the mark: a tall glyph stops at the height, a long wordmark stops at the
     width. Half the cell is the width ceiling, so the longest wordmarks do
     not read as the loud ones. brightness(0) + invert flattens every mark to
     one tint; invert(0) is a no-op, which a bare `none` in a filter list
     would not be. */
  .logo-wall__logo-img{
    position: relative;
    z-index: 1;
    width: auto;
    height: auto;
    max-width: 50%;
    max-height: 1.25rem;
    object-fit: contain;
    filter: brightness(0) invert(var(--logo-wall-invert, 1));
  }

  /* ---- the caption ----
     What was done for the brand, the second thing in the link's column.
     Relative rather than static: the picture behind it is an absolute box at
     z-index 0, and a positioned element at level 0 paints over in-flow
     content at the same level.

     It arrives just behind the card it belongs to. The card's own move is
     0.1s of delay plus 0.9s of travel, both in js/logo-wall.js, and this
     starts a little inside the end of that. An animation rather than a
     transition, because there is no state to transition between: the script
     re-attaches this node on every turn, which restarts it. */
  .logo-wall__tag{
    position: relative;
    z-index: 2;
    font-size: var(--text-small);
    line-height: 1.2;
    text-align: left;
    text-wrap: pretty;
    color: inherit;
    animation: logo-wall-tag-in var(--duration-slow) ease-out 0.85s backwards;
  }

  @media (hover: hover) and (pointer: fine) {
    /* Whichever cell is hovered stays put; the rest of the wall pulls back
       so the active one reads as the only thing in focus.

       Keyed off the list being hovered, not off it containing a hovered
       cell. There is a gap between cells, and a pointer crossing it is over
       neither of them for a frame — with :has() the whole wall would fall
       out of the pulled-back state and start opening back up. The list is
       under the pointer the whole way across.

       Same pair as the base rule, with a delay on the front. That is what
       stops a card the pointer only passed over from pulling back and
       immediately returning: leaving a card and leaving the wall are two
       events a fraction of a second apart. The delay is on the way in only;
       the base rule returns straight away. */
    .logo-wall__list:hover .logo-wall__item:not(:hover){
      transform: scale(0.93);
      opacity: 0.75;
      transition: transform var(--duration-slow) var(--easing) 0.15s,
        opacity var(--duration-slow) var(--easing) 0.15s;
    }
  }

  /* Keyboard gets the same treatment. Kept out of the hover media query so
     it applies on any input, and keyed off the link rather than the cell. */
  .logo-wall__list:has(.logo-wall__logo-target:focus-visible)
    .logo-wall__item:not(:has(.logo-wall__logo-target:focus-visible)){
    transform: scale(0.95);
    opacity: 0.4;
  }
}


/* ============================================
   Section: Approach
   ============================================ */

/* The heading holds the left column and the timeline runs down the right.
   The timeline is the shared step-timeline component above. */
.home__section-approach-v2{
  .approach{
    /* The offset the heading sticks at. The first step is pushed down by the
       same amount, so the two columns start level instead of the heading
       hanging below a timeline that began at the top of the section. */
    --approach-top: 5rem;

    display: grid;
    grid-template-columns: 5fr 6fr;
    gap: var(--space-16);
    align-items: start;

    @media (max-width: 1023px) {
      grid-template-columns: 1fr;
      gap: var(--space-12);
    }
  }

  /* Held in place while the steps pass it, so the claim and the process that
     backs it are on screen together. */
  @media (min-width: 1024px) {
    .approach__intro{
      position: sticky;
      top: var(--approach-top);
    }

    .approach .step-timeline{
      padding-top: var(--approach-top);
    }
  }
}


/* ============================================
   Section: Use cases
   ============================================ */

/* Osmo Supply's Bouncy Content Tabs (js/bouncy-tabs.js). Structure, every
   data- hook and all of the motion are the component's. Re-pointed here:
   colour, radius and type onto our tokens. The pill radius is the one
   deliberate shape change — the component ships 100em pills, our shape
   family is the sharp 4/6/16 set. */
.home__section-use-cases{
  padding-bottom: calc(var(--space-section) * 2);

  .bouncy-tabs {
    grid-column-gap: var(--space-2);
    grid-row-gap: var(--space-2);
    flex-flow: column;
    justify-content: flex-start;
    /* stretch, not centre: the nav is as wide as the panel below it now. */
    align-items: stretch;
    width: 100%;
    display: flex;
  }

  /* The rail is the container and the three things riding in it — the ghost,
     the indicator and the buttons — are one step down, so the inner corners
     nest inside the outer ones instead of matching them. */
  .bouncy-tabs__nav {
    background-color: var(--color-surface);
    border-radius: var(--radius-md);
    flex-flow: wrap;
    justify-content: center;
    align-items: stretch;
    padding: var(--space-2);
    /* Full width, and the tabs share it evenly. The component ships an
       inline row that shrinks to its labels; here it lines up with the
       panel's edges. */
    display: flex;
    width: 100%;
    position: relative;
    @media (max-width: 767px){
      padding: var(--space-1);
    }
  }

  .bouncy-tabs__ghost {
    z-index: 1;
    opacity: 0;
    pointer-events: none;
    visibility: hidden;
    /* The rail is already --color-surface, so the hover plate has to be the
       page's own ground to read against it. */
    background-color: var(--color-bg);
    border-radius: var(--radius-sm);
    width: 2em;
    position: absolute;
    top: 0;
    left: 0;
  }

  .bouncy-tabs__indicator {
    z-index: 2;
    pointer-events: none;
    background-color: var(--color-text);
    border-radius: var(--radius-sm);
    width: 3em;
    position: absolute;
    top: 0;
    left: 0;
  }

  /* The tabs share the rail evenly. */
  .bouncy-tabs__button {
    flex: 1 1 0;
    min-width: max-content;
    z-index: 3;
    color: var(--color-text-muted);
    appearance: none;
    background-color: transparent;
    border: none;
    border-radius: var(--radius-sm);
    padding: 0.75em 1.25em;
    font-family: inherit;
    font-size: var(--text-body);
    font-weight: var(--weight-button);
    cursor: pointer;
    transition: color var(--duration-base) var(--easing);
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
    @media (max-width: 767px){
      font-size: var(--text-small);
    }
  }

  /* ---- The label rolls, the way every button's does ----
     Same mechanism as .btn-animated-link: the label carries a copy of itself
     one --text-duplicate-distance below, drawn as a text-shadow in the
     colour it is heading for, and the mask crops to a single line. Moving
     the label up by that distance swaps one for the other.

     Written out rather than borrowed, because the component's classes are
     nested inside .btn-animated-link and do nothing outside it. What is
     shared is the part that matters for it feeling the same: the timing
     comes from [data-button-anim-target], which buttons.css declares once
     for the whole site. No plate rises behind it — the nav has the ghost
     for that. */
  .bouncy-tabs__button-mask {
    display: flex;
    overflow: hidden;
  }

  .bouncy-tabs__button-text {
    --text-duplicate-distance: 1.5em;
    text-shadow: 0 var(--text-duplicate-distance) var(--color-text);
  }

  /* Only on a tab that is not already open: the active one sits on the ink
     plate, where the copy waiting below would arrive in the wrong colour. */
  [data-bouncy-tabs-button]:not([data-active]):hover .bouncy-tabs__button-text {
    transform: translate(0, calc(-1 * var(--text-duplicate-distance)));
  }

  [data-bouncy-tabs-button][data-active] {
    color: var(--color-bg);
  }

  /* The inset moved off the panel and onto the copy column alone, so the
     square on the right runs to the card's own edges instead of floating
     inside a border of surface. */
  .bouncy-tabs__collection {
    background-color: var(--color-surface);
    border-radius: var(--radius-lg);
    width: 100%;
    overflow: hidden;
    padding: var(--space-2);
  }

  .bouncy-tabs__list {
    width: 100%;
    position: relative;
  }

  .bouncy-tabs__item {
    /* No gutter between the two: the copy column carries its own padding and
       the square starts where that ends. */
    grid-column-gap: 0;
    grid-row-gap: 0;
    justify-content: flex-start;
    /* stretch, so the copy column is as tall as the square beside it and
       has a height to space its two halves against. */
    align-items: stretch;
    display: flex;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
  }

  [data-bouncy-tabs-panel]:not([data-active]) {
    opacity: 0;
    visibility: hidden;
  }

  .bouncy-tabs__item-visual {
    /* Square, and it is the square that sets the panel's height — flex: none
       stops it being pulled taller by the copy beside it.

       Half the card, so the two halves of the panel read as a pair. The copy
       column is the flexible one, so fixing this side at 50% is what makes
       it 50% too. */
    flex: none;
    aspect-ratio: 1;
    width: 50%;
    overflow: hidden;
    border-radius: var(--radius-md);
  }

  /* Head and foot, held apart. Same arrangement as the audit card in the
     services section: title, note and list at the top, the two buttons on
     the bottom edge. The gap is the floor, space-between takes whatever the
     square leaves over. */
  .bouncy-tabs__item-text {
    grid-column-gap: var(--space-16);
    grid-row-gap: var(--space-16);
    flex-flow: column;
    flex: 1;
    justify-content: space-between;
    align-items: flex-start;
    display: flex;
    padding: var(--space-12);
  }

  .bouncy-tabs__item-main {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    align-items: flex-start;
    max-width: 30rem;
  }

  /* The title stands apart from what follows it: twice the column's own gap,
     so it reads as the panel's heading rather than as the first line of the
     copy under it. */
  .bouncy-tabs__item-main > h3 {
    margin-bottom: var(--space-4);
  }

  .bouncy-tabs__item-footer {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    align-items: center;

    /* Phone. The same call the service cards make: the panel is the whole
       screen here, and a pair of buttons each the width of its own label
       reads as an afterthought under a column that runs edge to edge. They
       stack and each takes the row. The inner box is what carries the
       plate, so it has to be told as well — stretching the link alone would
       leave the button inside it at its label's width. */
    @media (max-width: 767px){
      /* The column above packs its children to the start, so the footer
         itself has to claim the width before anything inside it can. */
      align-self: stretch;
      flex-direction: column;
      align-items: stretch;

      .btn-animated-content{
        width: 100%;
        justify-content: center;
      }
    }
  }

  /* The falling-gaps box is the shared component; the examples card gives
     it a wide ratio. Here the square around it is what sets the shape, and
     each panel names its own picture inline — the box only says how to lay
     one in. */
  .gap-drop {
    aspect-ratio: auto;
    height: 100%;
    .gap-drop__pill{
      color: var(--color-bg);
    }
    /* The frame is a square here, so the tiles in it are too. Only while it
       is: below the breakpoint the frame goes wide and the base ratio takes
       over again. The query has to be here rather than a plain override in
       the section's mobile block, because this selector is a level deeper
       and would outrank it. */
    @media screen and (min-width: 768px) {
      .gap-drop__pill {
        aspect-ratio: 1;
      }
    }

    /* Square corners: the card's own radius and its overflow are what round
       this now, on the two sides that actually touch an edge. */
    border-radius: 0;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
  }

  @media screen and (max-width: 767px) {
    /* Two per row. Six tabs in one line have nowhere to go on a phone, and
       wrapping them freely leaves a ragged last row — a grid keeps every
       cell the same width whatever the labels are. */
    .bouncy-tabs__nav {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: var(--space-1);
    }

    .bouncy-tabs__button {
      /* The flex sizing above has nothing to size here, and max-content
         would push a long label out of its cell. */
      flex: none;
      min-width: 0;
      /* A plate of its own, so each cell reads as a control rather than as
         a label floating on the rail. On one line the row itself carries
         that job; broken into three, it does not. */
      background-color: var(--color-bg);
      transition: color var(--duration-base) var(--easing),
        background-color var(--duration-base) var(--easing);
    }

    /* The open one steps back out of the way: the indicator sits under the
       buttons, so an opaque plate here would hide the very thing that marks
       which tab is open. */
    [data-bouncy-tabs-button][data-active] {
      background-color: transparent;
    }

    .bouncy-tabs__button-text {
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .bouncy-tabs__item-text {
      padding: var(--space-8);
      gap: var(--space-8);
    }

    .bouncy-tabs__item {
      flex-flow: column;
      justify-content: flex-start;
      align-items: stretch;
    }

    /* Stacked, the picture leads. It is what the tab is recognised by, and
       putting it under a column of copy would bury it below the fold on a
       phone. Order rather than a change of markup, so the reading order
       stays title-first for a screen reader. */
    .bouncy-tabs__item-visual {
      order: -1;
      width: 100%;
      /* A square is too tall once it is the full width of the screen. */
      aspect-ratio: 16 / 9;
    }
  }
}


/* ============================================
   Section: About me
   ============================================ */

/* Artwork on the left half, the copy on the right. The artwork is Osmo
   Supply's 3D Cards Tornado (js/cards-tornado.js), pinned to the section
   rather than placed in a grid cell; the copy runs the shared step-timeline
   component as a list of years. */
.home__section-about{
  /* Double the standard block padding, top and bottom. The ink plate runs
     edge to edge, so the padding is the only thing holding the copy off the
     seam where the section meets the page — at the normal --space-section it
     reads as cramped in a way it does not on white ground. */
  padding-block: calc(var(--space-section) * 2);

  /* The containing block for .about__decor, which is pinned to the section
     rather than to its grid cell. isolate keeps the tornado's own z-indexes —
     the script writes up to 1000 on the cards — sealed inside this section
     and away from the fixed nav. */
  position: relative;
  isolation: isolate;

  .about{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-16);
    align-items: start;

    @media (max-width: 1023px) {
      grid-template-columns: 1fr;
    }
  }

  /* The same vertical rhythm .section-content runs, since this is the same kind
     of stack: the blocks that make up a section, one under the other. */
  .about__content{
    /* The second column. It is the only thing in the grid — the artwork beside
       it is pinned to the section, not placed in a cell — so without this it
       would fall into the first one and land under the cards. */
    grid-column: 2;
    display: flex;
    flex-direction: column;
    gap: var(--section-gap);

    /* One column below the breakpoint, so there is no second one to ask for. */
    @media (max-width: 1023px){
      grid-column: auto;
    }
  }

  /* The decoration half. A sibling of .container in the markup, so the
     section is its containing block: it bleeds to the viewport edge and runs
     the section's full height, padding included. .about keeps two columns
     even though only .about__content is placed in one — the first column is
     what holds the copy off the cards.

     Above the copy, not behind it, and covering the whole section rather
     than its half: the tornado is what a pointer has to land on for a drag
     or a wheel to reach it. The scene itself is still a half — the width is
     on .cards-tornado__collection below. The copy carries no links and no
     controls, so nothing is being covered that has anything to answer;
     selecting the text is the one thing given up. */
  .about__decor{
    position: absolute;
    inset: 0;
    z-index: 2;
  }

  .about__content{
    position: relative;
    z-index: 1;
  }

  /* ---- the tornado ---- */
  .cards-tornado{
    cursor: grab;
    touch-action: none;
    width: 100%;
    height: 100%;
    position: relative;
    overflow: clip;
  }

  /* The half the tornado is actually drawn in. Everything inside is placed
     against this element, so holding it to half the width and letting it
     sit at the start keeps the cards orbiting the left side. It takes the
     clip too: a card at the near side of the orbit swings wider than the
     half, and unclipped it would reach into the copy. Not on the list
     itself, which declares perspective and preserve-3d — a clip on a
     preserve-3d element flattens the scene. */
  .cards-tornado__collection{
    width: 50%;
    height: 100%;
    position: relative;
    overflow: clip;
  }

  .cards-tornado__list{
    font-size: clamp(.5em, .75vw, 1.5em);
    width: 100%;
    height: 100%;
    perspective: 75em;
    transform-style: preserve-3d;
    position: relative;
  }

  .cards-tornado__item{
    transform-style: preserve-3d;
    backface-visibility: visible;
    will-change: transform, filter;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .demo-card{
    aspect-ratio: 4 / 5;
    pointer-events: none;
    -webkit-user-select: none;
    user-select: none;
    background-color: var(--color-text);
    border-radius: 1em;
    width: 18em;
    position: relative;
  }

  .cover-image{
    object-fit: cover;
    border-radius: inherit;
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
  }

  /* ---- below the breakpoint: a strip, not a scene ----
     The tornado is gone — js/cards-tornado.js gates itself on the same width
     — and the block drops out of the section's right half and into the flow
     under the copy, where the same photographs run as a swipeable strip.
     Everything here is undoing the 3D scene: the absolute stacking, the
     perspective, and the touch-action the tornado needs and a scroller
     cannot have. */
  @media (max-width: 1023px) {
    .about__decor{
      position: static;
      /* It comes first in the markup, so the strip leads and the gap it
         needs is under it, not over it. */
      margin-bottom: var(--space-16);
    }

    .cards-tornado{
      height: auto;
      overflow: visible;
      cursor: auto;
      touch-action: auto;
    }

    .cards-tornado__collection{
      width: 100%;
      height: auto;
      overflow: visible;
    }

    /* The strip. A card can scroll to the screen edge, and the gutter comes
       back as padding and scroll-padding, which keeps the resting inset and
       every snap point in line with the rest of the page. No negative margin:
       .about__decor is a sibling of the container, so it starts at the
       section's edge already. */
    .cards-tornado__list{
      height: auto;
      perspective: none;
      transform-style: flat;
      display: flex;
      gap: var(--space-1);
      padding-inline: var(--container-pad);
      scroll-padding-inline: var(--container-pad);
      overflow-x: auto;
      overscroll-behavior-x: contain;
      scroll-snap-type: x mandatory;
      -webkit-overflow-scrolling: touch;
      scrollbar-width: none;

      &::-webkit-scrollbar{
        display: none;
      }
    }

    .cards-tornado__item{
      position: static;
      transform: none;
      flex: none;
      scroll-snap-align: start;
      /* The script's hint, for properties nothing animates here. */
      will-change: auto;
    }

    /* Stated in vw rather than the component's 18em: that em comes off the
       list's own clamped font-size, which is tuned for the 3D scene. */
    .demo-card{
      width: 48vw;
      max-width: 20rem;
    }
  }

  /* The heading and the timeline both take the column's whole width here,
     not a centred measure inside a wider one.

     The header carries a reading measure and auto side margins, which is
     what centres it under the page's own container. This column is already
     that narrow and is a flex column besides, so those margins had nothing
     to centre against but the column itself: they ate the free space either
     side and stood the heading in the middle of a block whose every other
     line starts at the left edge. */
  .about__content .section-header,
  .about .step-timeline{
    max-width: none;
    margin-inline: 0;
  }

  /* Years, not step numbers. The marker box is 3em of its own font size, so it
     cannot be shrunk by setting a smaller size on the marker itself — that
     would take the box down with it. The span inside carries the smaller type
     instead, which keeps the chip 3em and the line centred on it at 1.5em. */
  .about .step-timeline__marker span{
    font-size: var(--text-small);
  }

  /* No ring on the leading step. The shared rule spreads a currentcolor glow
     around it, which on a dark plate reads as a halo rather than as emphasis —
     the light chip is already doing that job on its own. */
  .about [data-step-timeline-item][data-current] [data-step-timeline-marker]{
    box-shadow: none;
  }
}


/* ============================================
   Section: Why me
   ============================================ */

/* The heading centred above, the six reasons in a three-up grid under it.
   Three fixed columns rather than auto-fit: six items divide evenly into two
   rows of three, and auto-fit against the full .container would have fitted
   five across and left a row of one. */
.home__section-why{
  padding-top: calc(var(--space-section) * 2);

  .why__list{
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-12);
    margin: 0;
    padding: 0;
    list-style: none;

    @media (max-width: 1023px) {
      grid-template-columns: repeat(2, 1fr);
    }

    @media (max-width: 639px) {
      grid-template-columns: 1fr;
    }

    @media (min-width: 1024px){
      gap: 0;
      border-right: 1px solid var(--color-border);
      border-bottom: 1px solid var(--color-border);
      .why__item{
        padding: var(--space-inner);
        /* background-color: var(--color-surface); */
        /* border-radius: var(--radius-lg) */
        border-left: 1px solid var(--color-border);
        border-top: 1px solid var(--color-border);
      }
    }
  }

  .why__item{
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
  }

  /* Icon and label on one line, the icon holding its own size against a label
     that may run to two. */
  .why__title{
    display: flex;
    align-items: center;
    gap: var(--space-4);
    font-size: var(--text-heading-sm);
    font-weight: var(--weight-heading-sm);
    letter-spacing: var(--tracking-heading-sm);
    line-height: var(--leading-heading-sm);
  }

  /* The marks are drawn in currentColor, so they take the heading's ink and
     follow it anywhere the section is re-coloured. The nudge sits them on the
     label's first line rather than its box. */
  .why__icon{
    flex: none;
    width: 1em;
    height: 1em;
  }

  .why__text{
    color: var(--color-text-muted);
    text-wrap: balance;
  }
}

.home__section-faq{
  .accordion.faq{
    max-width: 40rem;
    margin-inline: auto;
    text-wrap: balance;
  }
}


/* ============================================
   Section: First step (final CTA)
   ============================================ */

/* The one ground under the CTA and the footer. The scheme and the glow are
   on this wrapper rather than on either child, since a scheme paints its own
   background-color and would sit on top of the picture; the children only
   inherit the tokens, and the glow spans both as one image. */
.page-end{
  position: relative;
  .section-cover{
    img{
      object-position: 50% 50%;
    }
  }
}

/* Dark. A centred header, a lede and the cal.com booking widget under
   them; the pair of buttons the hero runs is still in the markup, hidden.
   It shares its ground with the footer below it. */
.section-start{
  scroll-margin-top: 5rem;
  padding-bottom: 0;

  .section-header{
    max-width: 46rem;
    margin-inline: auto;
  }

  .section-start__lede{
    margin-top: var(--space-4);
    margin-inline: auto;
  }

  /* The same pair the hero runs, centred under the lede. The gap is the
     hero's, so the two blocks sit at the same distance from each other
     wherever they appear. Below the centred-header breakpoint the header goes
     left on its own and the buttons follow it. */
  .section-start__actions{
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    align-items: center;
    margin-top: var(--space-8);
    /* display: flex would beat the hidden attribute's own display: none,
       so it is restated here for the markup that carries it. */
    &[hidden]{
      display: none;
    }
  }

  @media (min-width: 1024px){
    .section-header.is-center .section-start__actions{
      justify-content: center;
    }
  }

  /* The booking widget. cal.com's embed drops an iframe in here and sizes
     it to its own content, so the box only sets a width and holds a
     minimum height while that loads. overflow: auto is from their snippet,
     for the months that run taller than the frame. */
  .section-start__cal{
    width: 100%;
    max-width: 64rem;
    /* min-height: 32rem; */
    margin-top: var(--space-12);
    margin-inline: auto;
    overflow: hidden;
    margin-bottom: -3em;
    /* cal-inline{
      margin-bottom: -5em;
      overflow: hidden;
    } */
     @media (max-width: 767px){
      margin-bottom: 0;
     }
  }
}


/* ============================================
   Site footer
   ============================================ */

/* The quiet end of the page: the one-line credo signed off with the
   copyright on the left half and, on the right, the wordmark over a column
   of contact links. Two halves on desktop, stacked below it — and there the
   markup order takes over: links first, the credo and the small print close
   the page.

   It carries .scheme-dark in the markup, the same ground the CTA above it
   runs on, so the two read as one dark block closing the page. Nothing
   separates them but the hairline on .site-footer__inner — which is why
   there is no padding on top here: the CTA's own bottom padding is the space
   above that rule, and a second one would double it. */
.site-footer{
  .site-footer__inner{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-12) var(--grid-gap);
    align-items: start;
    border: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
    background-color: var(--illustration-glass);
    padding: var(--space-card);
    border-radius: var(--radius-lg);

  }

  /* Second in the markup, first on the wide layout. */
  .site-footer__contact{
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    order: 1;
  }

  .site-footer__brand{
    color: inherit;
    margin-bottom: var(--space-3);
  }

  .site-footer__nav{
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-1);
  }

  /* Muted until pointed at, so the column reads as a list of ways out
     rather than as a second navigation competing with the bar at the top. */
  .site-footer__link{
    color: var(--color-text-muted);
    text-decoration: none;
    transition: color var(--duration-base) ease-out;
  }

  .site-footer__link:hover, .site-footer__link:focus-visible{
    color: var(--color-text);
  }

  /* Set flush left, unlike .quote-wrapper, and as tall as the contact column
     across the gutter: the credo sits on its top edge and the small print on
     its bottom, level with the last link. The measure keeps the credo to two
     lines. */
  .site-footer__quote{
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-6);
    height: 100%;
  }

  /* The opening quote hangs into the margin so the letters, not the mark,
     sit on the left edge. Safari does it natively; elsewhere the first line
     is pulled back by the mark's own advance in Aeonik (0.417em, measured),
     which comes to the same thing for a quote that always starts with one. */
  .site-footer__quote-text{
    max-width: 18ch;
    text-wrap: balance;
    hanging-punctuation: first;
  }

  @supports not (hanging-punctuation: first){
    .site-footer__quote-text{
      text-indent: -0.417em;
    }
  }

  .site-footer__meta{
    color: var(--color-text-muted);
  }

  /* One column, back in markup order: the wordmark and links first, the
     credo and the small print close the page under them. */
  @media (max-width: 767px){
    .site-footer__inner{
      grid-template-columns: 1fr;
    }

    .site-footer__contact{
      order: 0;
    }
  }
}


/* ============================================
   Page: briefs (audit-brief.html, and the web design brief to come)
   A page of its own, not a homepage section: the client lands here from
   the Stripe payment link. One narrow column, the shared .field inputs,
   nothing that moves.
   ============================================ */
.brief{
  min-height: 100svh;

  /* js/audit-brief.js switches blocks with the hidden attribute, and the grids
     below would otherwise outrank the browser's display: none. */
  [hidden]{
    display: none !important;
  }

  .brief__bar{
    padding-block: var(--space-6);
  }

  .brief__main{
    display: grid;
    gap: var(--space-12);
    padding-top: var(--space-8);
    padding-bottom: var(--space-section);
  }

  .brief__intro{
    display: grid;
    gap: var(--space-4);
  }

  .brief__form{
    display: grid;
    gap: var(--space-10);
  }

  /* Fieldsets drop their browser chrome and become a plain stack. */
  .brief__group{
    display: grid;
    gap: var(--space-3);
    min-width: 0;
    border: 0;

    > legend{
      margin-bottom: var(--space-4);
      padding: 0;
    }
  }

  .brief__item{
    display: grid;
    gap: var(--space-1);
  }

  .brief__hint{
    padding-inline: 1rem;
    font-size: var(--text-small);
    line-height: var(--leading-small);
    color: var(--color-text-muted);
  }

  /* Long labels on narrow screens: let them wrap instead of running under
     the field's edge. Floated, they are one line of caption anyway. */
  .field label{
    right: 1rem;
  }

  /* The palette has no error red, so the message leans on weight. */
  .brief__error{
    font-weight: var(--weight-medium);
  }

  .brief__done{
    display: grid;
    gap: var(--space-4);
    justify-items: start;
    outline: none;
  }

  .brief__home{
    padding-inline: 0;
  }

  /* The bot trap: off screen, not display:none, which some bots skip. */
  .brief__trap{
    position: absolute;
    left: -10000px;
    width: 1px;
    height: 1px;
    overflow: hidden;
  }
}
