/* =============================================================
   ANCHOR — Design System
   Step 1: tokens, reset, nav, hero
   ============================================================= */

/* ---------- 1. Tokens ---------- */
:root {
  color-scheme: dark;

  /* ---- Palette (dark) ----
     Eight values. Everything else in the file reaches them through the
     legacy names below, which are kept so component CSS keeps working. */
  --bg:         #0A0C0E;  /* page */
  --surface:    #14181C;  /* raised cards, drawers, panels */
  --surface-2:  #1C2126;  /* inputs, hover states */
  --line:       #2A3138;  /* borders */
  --text:       #F8FAFC;  /* primary */
  --text-2:     #94A3B8;  /* secondary */
  --teal:       #2DD4BF;  /* primary accent */
  --violet:     #A78BFA;  /* compliance accent — replaces amber */

  /* ---- Form controls ----
     Translucent white rather than a solid grey, so one value holds on both
     form grounds: --surface in the CTA band and --bg on /contact/. --danger is
     the error red for dark grounds. Two of these are repeated as literal hexes
     inside data-URI icons in 10f3, which cannot read var() — change them there
     too. */
  --field-line:       rgba(255, 255, 255, 0.38);  /* control boundaries, rest */
  --field-line-hover: rgba(255, 255, 255, 0.55);
  --field-fill:       rgba(255, 255, 255, 0.05);
  --field-fill-focus: rgba(255, 255, 255, 0.09);
  --danger:           #FCA5A5;
  --danger-fill:      rgba(252, 165, 165, 0.08);
  --danger-line:      rgba(252, 165, 165, 0.45);

  /* ---- Legacy names ----
     The light theme's roles, pointed at the dark palette. Measured on --bg:
     --text 17.9:1, --text-2 7.6:1, --teal 10.5:1, --violet 7.2:1 — so the
     "-ink" variants that once existed to clear 4.5:1 on white are simply the
     accents themselves now. --ink-strong was the one ground darker than the
     old page; here it is the one lighter than it. */
  --page:       var(--bg);
  --ink:        var(--text);
  --ink-strong: var(--surface);
  --slate:      var(--text-2);
  --slate-dim:  var(--text-2);
  --slate-meta: var(--text-2);
  --teal-bright:var(--teal);
  --teal-ink:   var(--teal);
  --amber:      var(--violet);
  --amber-ink:  var(--violet);

  --radius:     14px;
  --radius-sm:  10px;
  --radius-lg:  20px;  /* large surfaces — the showcase image frames */
  --radius-pill:999px;
  --maxw:       1180px;

  /* Typography — see the metric-matched fallbacks in 1b, which is why each
     stack has a "… Fallback" entry between the webfont and the generics. */
  --font-display: "Space Grotesk", "Space Grotesk Fallback", ui-sans-serif, system-ui, sans-serif;
  --font-body:    "Inter", "Inter Fallback", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-mono:    "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, "SFMono-Regular", Menlo, monospace;

  /* Display type — one scale for every page heading, so the h1s and the
     section h2s across all six pages agree with each other by construction.
     48–110px for an h1 and 34–68px for an h2, tracked at -0.035em and set
     solid, and every heading capped at ~14ch so it stacks over a few short
     lines instead of running to the column edge.

     --display-measure is in em, not ch, on purpose: 1ch is the width of "0"
     and Space Grotesk's is exactly 0.500em (measured at 100px), so 7em IS
     14ch — but a centred block sized in ch changes width when the webfont
     swaps in and slides sideways to stay centred; em does not depend on the
     font at all. See .page-intro__title for the CLS numbers behind that. */
  --display-h1:      clamp(3rem, 8vw, 6.875rem);     /* 48px – 110px */
  /* the homepage hero alone runs a size up: three stacked phrases filling the
     first viewport. The cap is set by "your business" in the 1180px container
     (~7.2em of glyphs); the floor keeps it whole in a 350px column at 390. */
  --display-hero:    clamp(3.375rem, 11vw, 9.5rem);  /* 54px – 152px */
  /* the two paired mask-reveal h1s (/about/, /compliance/) run a notch
     smaller so both authored lines hold as one line each at every width — at
     the shared scale the longer pair folded at 390 and again at 1440. The
     floor is set by "We make sure you pass." in a 350px column: it needs 430px
     at 40px, so 32px is the largest size that keeps it whole at 390. */
  --display-h1-pair: clamp(2rem, 7vw, 6rem);         /* 32px – 96px  */
  --display-h2:      clamp(2.125rem, 5vw, 4.25rem);  /* 34px – 68px  */
  --display-track:   -0.035em;
  --display-lh:      1;
  --display-measure: 7em;                             /* ~14ch */
  /* for the few headings that are a sentence, or two authored mask lines —
     at 14ch those set as a column, not a headline. See .display--wide. */
  --display-measure-wide: 11em;                       /* ~22ch */

  /* The reveal bands — see 10k. Near-black, and the only dark ground on the
     site apart from the CTA and band sections; the lit word colours are
     measured against it there. */
  --band-bg:    #0C0E10;
  --band-dim:   rgba(255, 255, 255, 0.16);

  /* Card edges — see 10l. A 1px gradient border, full colour at the top-left
     fading to a quarter at the bottom-right, painted with a two-layer
     background under a transparent border so no wrapper or pseudo-element is
     needed. Teal for the service surfaces, amber for compliance. */
  --border-teal:  linear-gradient(135deg, var(--teal) 0%, rgba(45, 212, 191, 0.55) 55%, rgba(45, 212, 191, 0.28) 100%);
  --border-amber: linear-gradient(135deg, var(--violet) 0%, rgba(167, 139, 250, 0.55) 55%, rgba(167, 139, 250, 0.28) 100%);

  /* Spacing scale — intentional steps, used everywhere */
  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.5rem;
  --s-6: 2rem;
  --s-7: 2.5rem;
  --s-8: 3rem;
  --s-9: 4rem;
  --s-10: 5.5rem;
  --s-11: 7rem;

  /* Gutter */
  --gutter: clamp(1.25rem, 5vw, 3rem);

  /* Elevation — layered, low opacity. Black-based now: the teal tint the
     light theme used is invisible on a near-black page, and depth on dark
     comes from the surface step as much as from the shadow. */
  --shadow-base:     0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-elevated: 0 1px 2px rgba(0, 0, 0, 0.4),
                     0 8px 20px -6px rgba(0, 0, 0, 0.55);
  --shadow-floating: 0 1px 2px rgba(0, 0, 0, 0.45),
                     0 6px 12px -4px rgba(0, 0, 0, 0.5),
                     0 22px 44px -16px rgba(0, 0, 0, 0.65);

  /* How far a project image drifts from its layout box, in each direction.
     MIRRORS PARALLAX_MAX in initWorkMotion() (assets/js/main.js) — change both
     together. The drift is a transform, so it paints outside the element's box
     without reserving any space; anything sitting directly under a
     .project__frame must clear this much or the image will paint over it. */
  --parallax-max: 12px;

  /* Aspect ratio of the client-site captures in assets/img/work/: a 1440x900
     viewport at 2x is 2880x1800, which is 8/5. The frames that hold them are
     set to this exactly so object-fit:cover has nothing to crop — a screenshot
     is content, and cropping it arbitrarily cuts through the very thing it is
     meant to show. Re-capture at a different viewport and this moves with it. */
  --shot-ratio: 8 / 5;

  /* Motion — restrained, spring-ish */
  --ease-spring: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out:    cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast:    140ms;
  --dur-base:    240ms;

  /* The nav's real painted height (brand + 2 × padding-block), measured:
     76px below 900px, 86.8px above. It was 4.5rem before the full-viewport
     hero, which subtracts it, made the 15px shortfall visible — the anchor
     offsets that use it were landing that much under the bar on desktop. */
  --nav-h: 4.75rem;
}

@media (min-width: 900px) {
  :root { --nav-h: 5.425rem; }
}

/* The display floors cannot survive a 195px-wide viewport, which is what a
   390px phone becomes at 200% zoom: "Everything" at 48px is 175px of glyphs in
   a 155px column, and "Accessibility" at 34px is 161px. Under 22rem the scale
   drops to the floors the headings had before, which were measured to fit.
   Media-query rem is the initial 16px, unaffected by zoom, so this is the
   width the visitor actually has. */
@media (max-width: 22rem) {
  :root {
    --display-h1: 2.35rem;
    --display-hero: 2.2rem; /* "business" is 156px at 2.25rem, 1px over the 195px viewport's 155px column */
    --display-h1-pair: 2.35rem;
    --display-h2: 1.9rem;
  }
}

/* ---------- 1a. Webfonts, self-hosted ----------
   The same files Google Fonts served (Inter v20, Space Grotesk v22, JetBrains
   Mono v24), fetched 2026-09-19 with a Chrome UA so they are the woff2
   unicode-range subsets, and kept in assets/fonts/. Only latin, latin-ext and
   greek are shipped; Space Grotesk has no greek subset upstream, so Greek
   display text falls to its metric-matched fallback below. Inter and Space
   Grotesk are variable fonts — one file per subset carries every weight the
   site uses, hence the weight ranges. local() first, so a visitor with the
   face installed never downloads it. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: local("Inter"), url("../fonts/inter/inter-v20-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: local("Inter"), url("../fonts/inter/inter-v20-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; /* latin-ext */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: local("Inter"), url("../fonts/inter/inter-v20-greek.woff2") format("woff2");
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; /* greek */
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: local("Space Grotesk"), url("../fonts/space-grotesk/space-grotesk-v22-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; /* latin */
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: local("Space Grotesk"), url("../fonts/space-grotesk/space-grotesk-v22-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; /* latin-ext */
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: local("JetBrains Mono"), url("../fonts/jetbrains-mono/jetbrains-mono-v24-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; /* latin */
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: local("JetBrains Mono"), url("../fonts/jetbrains-mono/jetbrains-mono-v24-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; /* latin-ext */
}
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: local("JetBrains Mono"), url("../fonts/jetbrains-mono/jetbrains-mono-v24-greek.woff2") format("woff2");
  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; /* greek */
}

/* ---------- 1b. Metric-matched fallback fonts ----------
   The three webfonts load with font-display: swap, so the page paints in a
   local font first and re-lays out when they arrive. Left alone that re-layout
   is a layout shift on every page: the nav links and the "Start a project"
   button move ~5px horizontally (0.0003 CLS), and on /contact/ the chip rows
   re-wrap and it reaches 0.0272 — a quarter of the "good" budget from nothing
   but a font arriving.

   These three faces are the same local fonts the browser would have picked
   anyway, scaled so their text occupies the SAME box as the webfont. Nothing
   is downloaded: src is local() only, so if none of the named fonts exist the
   face simply fails and the next family in the stack is used unadjusted —
   exactly the behaviour we have today.

   Every number was measured, not guessed: a long sample string was rendered at
   1000px in each webfont and in Arial through a canvas, and

     size-adjust      = webfont advance width / Arial advance width
     ascent-override  = (webfont fontBoundingBoxAscent / em) / size-adjust
     descent-override = same, for the descent

   The divisions are there because the overrides are expressed against the
   ALREADY-ADJUSTED em, not the original one. Re-run assets/../_metrics probe
   logic if a font is ever swapped out.

   Liberation Sans is metric-compatible with Arial by design; Helvetica Neue is
   close enough that the residual error is well under a pixel. */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Liberation Sans");
  size-adjust: 106.4%;       /* measured 105.93% at 400, 106.86% at 500 */
  ascent-override: 91.07%;
  descent-override: 22.65%;
  line-gap-override: 0%;
}

@font-face {
  font-family: "Space Grotesk Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Liberation Sans");
  size-adjust: 100.71%;
  ascent-override: 97.71%;
  descent-override: 28.99%;
  line-gap-override: 0%;
}

/* The mono is the big one — JetBrains Mono sets ~30% wider than Arial, which is
   why the mono labels and the nav were the loudest part of the swap. */
@font-face {
  font-family: "JetBrains Mono Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Liberation Sans");
  size-adjust: 129.84%;
  ascent-override: 78.56%;
  descent-override: 23.1%;
  line-gap-override: 0%;
}

/* ---------- 2. Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }

* { margin: 0; }

/* The UA rule for this is `[hidden] { display: none }` — a plain attribute
   selector, so any author rule that sets `display` at the same specificity
   silently beats it. Measured: `.lead { display: grid }` left the form fully
   visible after `form.hidden = true`. Nothing here ever wants a hidden element
   laid out, so the exception is made once, at the reset. */
[hidden] { display: none !important; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-padding-top: calc(var(--nav-h) + var(--s-4));
}

body {
  position: relative;
  min-height: 100svh;
  isolation: isolate;
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

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

input, button, textarea, select { font: inherit; color: inherit; }

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.1;
  text-wrap: balance;
}

/* The wide measure, opted into per heading in the markup: the two paired
   mask-reveal h1s whose authored lines are 22ch each (/about/, /compliance/),
   and the two sentence-length h2s (the homepage compliance title, the services
   closer). Element-qualified so it outranks the component rules regardless of
   where they sit in the file. */
h1.display--wide, h2.display--wide { max-width: var(--display-measure-wide); }
h1.display--wide { font-size: var(--display-h1-pair); }

p { text-wrap: pretty; }

a { color: inherit; }

::selection { background: rgba(45, 212, 191, 0.35); color: var(--text); }

/* ---------- 3. A11y utilities ---------- */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

:focus-visible {
  outline: 2px solid var(--teal);
  outline-offset: 3px;
  border-radius: 4px;
}

.skip-link {
  position: absolute;
  left: var(--s-4);
  top: var(--s-4);
  z-index: 100;
  transform: translateY(-200%);
  padding: var(--s-3) var(--s-5);
  background: var(--page);
  color: var(--teal-ink);
  font-weight: 500;
  text-decoration: none;
  border: 1px solid var(--teal);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-floating);
  transition: transform var(--dur-base) var(--ease-spring);
}
.skip-link:focus-visible { transform: translateY(0); }

/* ---------- 3b. Lenis (smooth scroll) ----------
   Inlined from lenis.css so there is no second network request. Only applies
   once main.js has actually instantiated Lenis and it has added .lenis. */
html.lenis,
html.lenis body { height: auto; }

.lenis.lenis-stopped { overflow: clip; }
.lenis [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }

/* ---------- 3c. Scroll reveal ----------
   Scoped to .js so the content stays visible if scripts fail or are blocked.
   transform + opacity only — never anything that reflows. */
.js .reveal {
  opacity: 0;
  transform: translate3d(0, 16px, 0);
  transition:
    opacity 600ms var(--ease-out),
    transform 600ms var(--ease-spring);
  transition-delay: var(--reveal-delay, 0ms);
  will-change: transform, opacity;
}

.js .reveal.is-revealed {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

/* once it has played, drop the compositor hint and the stagger delay so it
   cannot slow down a child's hover transition */
.js .reveal.is-done {
  will-change: auto;
  transition-delay: 0ms;
}

/* ---------- 3d. Hero intro — the 4-act cold open ----------
   Decorative only. Fixed and out of flow so it cannot shift layout,
   pointer-events none so it cannot block scrolling or clicks, and display:none
   until main.js opts in — so no-JS, reduced-motion and repeat visits never see
   it. The real hero sits underneath in the DOM the entire time.

   THE TIMELINE, in one place. Every delay below is an absolute offset from the
   frame .intro-on lands, so this table is the whole spec:

     ACT 1  the mark lands                                     1900ms
        0 →  400   < a > type in           (3 x 240ms, 80ms apart)
      400 → 1900   HOLD, 1500ms            nothing animates

     ACT 2  the code build                                      1955ms
     1900 → 2130   line 1 types            230ms / 28 steps
     2130 → 2270   line 2 types            140ms / 20 steps
     2270 → 2355   line 3 types             85ms /  5 steps
     2355 → 3855   HOLD, 1500ms            the built block sits readable with the
                                           caret blinking — the same weight Act 1
                                           gives the mark
     3805 → 4005   the block clears        200ms, starting 50ms before the flip so
                                           the flip is the clear final beat

     ACT 3  the logo flip                                        600ms
     3855 → 4455   rotateY 0 -> 360deg     the mark stays put and turns once
     4255 → 4455   the mark lifts and fades  over the flip's last 200ms

     ACT 4  resolve to hero                                      800ms
     4055 → 4655   eyebrow                 each element transitions for 600ms
     4105 → 4705   H1
     4155 → 4755   sub
     4205 → 4805   chip
     4255 → 4855   CTAs                    <- last thing to settle

   TOTAL 4855ms. main.js holds .intro-on until 5000ms, then removes the whole
   overlay, so no transition-delay is ever withdrawn mid-flight.

   Note the mark is on screen and completely still from 400ms right through to
   the flip at 3855ms. Act 1's hold is only the stretch where it is ALONE. */
.intro {
  display: none;
  position: fixed; /* viewport-centred, so it never lands on the hero body text */
  inset: 0;
  z-index: 5;
  place-items: center;
  pointer-events: none;
}

html.intro-on .intro { display: grid; }

/* Only the mark is in flow here, so the stage box is the mark's box — which is
   what lets the code block hang off its bottom edge without moving it.
   The perspective is what gives Act 3's flip its depth; see that act below for
   why it has to live on the parent. */
.intro__stage {
  position: relative;
  perspective: 900px;
}

.intro__mark {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: clamp(2.5rem, 1.5rem + 4vw, 4.5rem);
  line-height: 1;
  letter-spacing: -0.02em;
  text-align: center;
  color: var(--ink);
  will-change: transform, opacity;
}

.intro__br { color: var(--teal); }

/* ACT 1 — the glyphs type in (0 → 400ms) */
html.intro-on .intro__mark > span {
  opacity: 0;
  animation: intro-glyph 240ms var(--ease-spring) both;
}
html.intro-on .intro__mark > span:nth-child(1) { animation-delay: 0ms; }
html.intro-on .intro__mark > span:nth-child(2) { animation-delay: 80ms; }
html.intro-on .intro__mark > span:nth-child(3) { animation-delay: 160ms; }

@keyframes intro-glyph {
  from { opacity: 0; transform: translate3d(0, 6px, 0) scale(0.96); }
  to   { opacity: 1; transform: none; }
}

/* ACT 1 — the HOLD, 400 → 1900ms. Nothing animates. The mark sits fully
   visible and completely still so it registers as the logo. */

/* ---------- ACT 2 — the code build ----------
   Out of flow, hanging below the mark: the mark never moves to make room. The
   column is a fixed 27ch (the longest line) so the block cannot resize as the
   lines fill in, which would otherwise slide the whole thing sideways. */
.intro__code {
  position: absolute;
  top: calc(100% + var(--s-6));
  left: 50%;
  translate: -50% 0;        /* centring only — `transform` is left free for the close */
  transform-origin: top center;
  width: 28ch; /* longest line + the caret's parking cell */
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: clamp(0.8125rem, 0.6rem + 0.65vw, 1.0625rem);
  line-height: 1.55;
  letter-spacing: 0;
  white-space: pre;
  will-change: transform, opacity;
}

/* Each line is a clip that widens in whole-character steps. Its right border is
   the caret, so it trails the text with no extra element and no JS.

   content-box on purpose: the page-wide border-box reset would take the 2px
   caret out of the Nch width and clip the last character of every line. */
.intro__line {
  box-sizing: content-box;
  display: block;
  width: 0;
  overflow: hidden;
  border-right: 2px solid transparent;
}

/* The caret is solid while its own line is typing — at these speeds a blink
   would just read as a flicker — and only the last one blinks, right through
   Act 2's hold, which is what keeps the held block feeling live rather than
   frozen. No fill-mode on the caret animations, so each line's border goes back
   to transparent when its turn ends and there is never more than one caret on
   screen. */
/* Each line runs one MORE step than it has characters, to a width one cell
   wider. steps(n, end) only ever reaches (n-1)/n during the active phase, so
   animating a 27-character line over 27 steps clips the last character forever.
   The extra cell is where the caret parks — exactly where a terminal puts it. */
html.intro-on .intro__line--1 {
  animation:
    intro-type-1 230ms steps(28, end) 1900ms both,
    intro-caret-on 230ms linear 1900ms;
}

html.intro-on .intro__line--2 {
  animation:
    intro-type-2 140ms steps(20, end) 2130ms both,
    intro-caret-on 140ms linear 2130ms;
}

/* 5 steps over 85ms keeps every step on its own frame, so </a> visibly snaps
   shut instead of popping — the two long lines are deliberately faster than a
   frame, which is what makes them sweep in like a compile. */
html.intro-on .intro__line--3 {
  animation:
    intro-type-3 85ms steps(5, end) 2270ms both,
    intro-caret-on 85ms linear 2270ms,
    intro-caret-blink 500ms steps(2, jump-none) 2355ms infinite;
}

@keyframes intro-type-1 { from { width: 0; } to { width: 28ch; } }
@keyframes intro-type-2 { from { width: 0; } to { width: 20ch; } }
@keyframes intro-type-3 { from { width: 0; } to { width: 5ch; } }

@keyframes intro-caret-on {
  from, to { border-right-color: var(--amber); }
}

@keyframes intro-caret-blink {
  from { border-right-color: var(--amber); }
  to   { border-right-color: transparent; }
}

/* ACT 2 clear, 3805 → 4005ms — the block collapses toward the tag above it and
   fades. It starts 50ms before the flip and is gone a third of the way in, so
   the mark has the ta-da to itself. The mark no longer leaves with it. */
html.intro-on .intro__code {
  animation: intro-code-out 200ms var(--ease-out) 3805ms both;
}

@keyframes intro-code-out {
  from { opacity: 1; transform: scale(1) translate3d(0, 0, 0); }
  to   { opacity: 0; transform: scale(0.9) translate3d(0, -10px, 0); }
}

/* ---------- ACT 3 — the logo flip ----------
   One full turn on the spot, then it dissolves.

   The rotation rides the `rotate` property and the fade rides `opacity`, so the
   two are independent animations that compose. Putting both in one keyframe set
   would have meant two animations touching `transform`, where the later one in
   the list simply wins and the other is discarded.

   3D needs perspective from the mark's PARENT — the `rotate` property cannot
   carry a perspective() of its own the way `transform` can, and perspective()
   inside `transform` would be composited after `rotate` and never apply to it.

   The easing is a local ease-in-out rather than one of the site's tokens on
   purpose. --ease-spring and --ease-out are both heavily front-loaded, which is
   right for a short slide but wrong for a full turn: measured, --ease-spring put
   the mark through 180deg in the first 100ms and then crawled the last 60deg
   over 350ms, reading as a glitch followed by a stall. This curve winds up,
   whips through the middle of the rotation, and decelerates onto the landing.
   (The perspective itself is declared with .intro__stage further up.) */
html.intro-on .intro__mark {
  animation:
    intro-flip 600ms cubic-bezier(0.7, 0, 0.2, 1) 3855ms both,
    intro-mark-out 200ms linear 4255ms both;
}

@keyframes intro-flip {
  from { rotate: y 0deg; }
  to   { rotate: y 360deg; }
}

/* It lifts as it goes rather than dissolving in place. The mark is
   viewport-centred, which at common desktop heights is exactly where the hero's
   sub paragraph lands, so a straight fade reads as two things occupying one
   spot. Rising out of frame turns the same 200ms into a hand-off.
   translate3d rides `transform`, which the flip leaves free — the rotation is
   on `rotate` and the fade on `opacity`, so all three compose. */
@keyframes intro-mark-out {
  from { opacity: 1; transform: translate3d(0, 0, 0); }
  to   { opacity: 0; transform: translate3d(0, -18px, 0); }
}

/* ---------- ACT 4 — resolve to hero ----------
   Starts 200ms into the flip, so the hero is arriving as the mark comes round
   and lands. Set explicitly rather than as an offset on --reveal-delay, because
   these steps are 50ms while the authored data-delay ladder is 80ms. */
html.intro-on .hero .reveal      { transition-delay: 4055ms; }
html.intro-on .hero .hero__title { transition-delay: 4105ms; }
html.intro-on .hero .hero__ctas  { transition-delay: 4155ms; }

/* Repeat visit within the session: the hero is simply there. Scoped to
   .hero .reveal only — scroll-triggered reveals in every other section are
   untouched and still animate normally. */
html.intro-seen .hero .reveal {
  transition: none;
  opacity: 1;
  transform: none;
}

/* ---------- 4. Layout ---------- */

/* Page atmosphere. Anchored to the top of the document — i.e. behind the
   transparent nav as well as the hero — so there is no seam at the nav edge.
   The wrapper is clipped to the body box so the layers can never add scroll,
   and both fade out at the bottom rather than ending on a hard cut. */
.page-fx {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.page-fx__glow,
.page-fx__grain {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: clamp(40rem, 112vh, 68rem);
}

.page-fx__glow {
  background:
    radial-gradient(52rem 32rem at 4% 2%,   rgba(45, 212, 191, 0.10), transparent 64%),
    radial-gradient(42rem 28rem at 98% 8%,  rgba(167, 139, 250, 0.08), transparent 62%),
    radial-gradient(56rem 42rem at 62% 94%, rgba(45, 212, 191, 0.04), transparent 66%);
  -webkit-mask-image: linear-gradient(to bottom, #000 58%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 58%, transparent 100%);
}

.page-fx__grain {
  opacity: 0.42;
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.09'/%3E%3C/svg%3E");
  background-size: 160px 160px;
  -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
}

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

/* ---------- 5. Nav ---------- */
.nav {
  position: sticky;
  top: 0;
  z-index: 50;
  /* transparent over the hero — no tint band at the nav/hero seam */
  background: transparent;
}

/* Once the page scrolls, content would otherwise pass under the transparent
   bar. Fade in a blurred backdrop instead. Kept on a pseudo-element so only
   opacity animates, and it stays invisible at the top of the page. */
.nav::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  /* --bg at 90%. At 82% the nav links measured 4.74:1 over the lightest
     capture on the page and 4.57:1 over pure white — passing, but too close.
     The rgba line is the fallback for browsers without color-mix; it is --bg's
     own rgb and must change with the token. */
  background: rgba(10, 12, 14, 0.9);
  background: color-mix(in srgb, var(--bg) 90%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(14px);
  backdrop-filter: saturate(180%) blur(14px);
  border-bottom: 1px solid var(--line);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}

.nav.is-stuck::before { opacity: 1; }

.nav__inner {
  display: grid;
  grid-template-columns: auto auto;
  grid-template-areas:
    "brand cta"
    "links links";
  align-items: center;
  justify-content: space-between;
  row-gap: var(--s-3);
  padding-block: var(--s-4);
}

.brand {
  grid-area: brand;
  display: inline-flex;
  align-items: center;
  gap: 0.5ch;
  text-decoration: none;
  line-height: 1;
  transition: transform var(--dur-base) var(--ease-spring);
}
.brand:hover { transform: translateY(-1px); }
.brand:active { transform: translateY(0) scale(0.985); }

.brand__mark {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 1.375rem;
  line-height: 1;
  color: var(--text);
  letter-spacing: -0.02em;
}
.brand__br { color: var(--teal); }

.brand__word {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.0625rem;
  line-height: 1;
  letter-spacing: 0.14em;
  color: var(--text);
  /* nudge: Space Grotesk caps sit slightly high next to the mono glyphs */
  padding-top: 0.05em;
}

.nav__links {
  grid-area: links;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: var(--s-5);
  font-size: 0.9375rem;
  font-weight: 500;
}

.nav__links a {
  position: relative;
  color: var(--text-2);
  text-decoration: none;
  padding-block: var(--s-1);
  transition: color var(--dur-fast) var(--ease-out);
}
.nav__links a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  border-radius: 2px;
  background: var(--teal);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-base) var(--ease-spring);
}
.nav__links a:hover { color: var(--text); }
.nav__links a:hover::after,
.nav__links a:focus-visible::after { transform: scaleX(1); }
.nav__links a:active { color: var(--teal); }

/* ---------- 5a2. Nav label roll ----------
   Hover slides the label up out of a clip box while an identical copy rolls up
   from below to take its place — one line-height of travel, so the incoming
   baseline lands exactly where the outgoing one was.

   The copy is a ::after pulling from data-text, never a second text node — but
   that alone is NOT enough to keep it out of the accessibility tree. Chrome
   folds ::before/::after generated content into name-from-contents (accname
   §2F), and measured "Work Work" as the link's accessible name when the pseudo
   sat directly on .nav__roll. So the pseudo lives on its own empty span carrying
   aria-hidden="true": the generated text has no node of its own to be read from,
   and the host it hangs off is pruned from the tree outright. Verified: every
   link exposes its name exactly once, at rest and mid-roll.

   (content: attr(data-text) / "" is the purpose-built fix and works in current
   Chrome, but where the alt-text syntax is unsupported the whole declaration is
   invalid, the duplicate never renders, and the label rolls up into an empty
   box. The aria-hidden host degrades to a plain static label instead.)

   The teal underline lives on the <a>'s OWN ::after, one level up — the two
   never collide.

   It is a transition, not an animation, so leaving mid-roll reverses from
   wherever it got to rather than snapping or completing.

   Pointer-only. Outside this query .nav__roll is a bare inline box with no
   clipping, no pseudo and no transition, so touch — and the no-JS wrapped nav
   below 900px — render exactly as they did before. */
.nav__roll,
.nav__roll__in { display: inline; }
.nav__roll__dup { display: none; }

@media (hover: hover) and (pointer: fine) {
  .nav__roll {
    display: block;
    position: relative;
    /* clips to the line box. Body line-height is 1.7, well past the 1.211em
       Inter needs for ascenders + descenders, so the "p" in "Compliance" is
       never cut. */
    overflow: clip;
  }
  .nav__roll__in { display: block; }

  .nav__roll__dup {
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    translate: 0 100%;   /* parked one full line below, outside the clip */
  }
  .nav__roll__dup::after { content: attr(data-text); }

  .nav__roll__in,
  .nav__roll__dup { transition: translate 470ms var(--ease-out); }

  .nav__links a:hover .nav__roll__in,
  .nav__links a:focus-visible .nav__roll__in { translate: 0 -100%; }

  .nav__links a:hover .nav__roll__dup,
  .nav__links a:focus-visible .nav__roll__dup { translate: 0 0; }
}

.nav__cta { grid-area: cta; justify-self: end; }

@media (min-width: 900px) {
  .nav__inner {
    grid-template-columns: auto 1fr auto;
    grid-template-areas: "brand links cta";
    row-gap: 0;
    padding-block: var(--s-5);
  }
  .nav__links { justify-content: center; gap: var(--s-7); }
}

/* ---------- 5b. Mobile nav — hamburger + top dropdown ----------
   Everything here is gated on .js. Without it the toggle stays hidden, the
   dropdown is display:none, and the wrapping inline links above are still the
   nav — which is also what the head failsafe falls back to if main.js never
   runs, so a broken script can never strand a visitor with no navigation. */
.nav__toggle {
  grid-area: cta;
  justify-self: end;
  display: none;
  align-items: center;
  justify-content: center;
  width: 2.75rem;   /* 44px — full touch target, no padding games */
  height: 2.75rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}

.nav__bars {
  position: relative;
  display: block;
  width: 1.375rem;
  height: 0.75rem;
}

.nav__bars i {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform var(--dur-base) var(--ease-spring);
}

.nav__bars i:nth-child(1) { top: 0; }
.nav__bars i:nth-child(2) { bottom: 0; }

/* the two bars meet in the middle and cross */
.nav__toggle[aria-expanded="true"] .nav__bars i:nth-child(1) { transform: translateY(5px) rotate(45deg); }
.nav__toggle[aria-expanded="true"] .nav__bars i:nth-child(2) { transform: translateY(-5px) rotate(-45deg); }

/* 899.98px rather than 900px so this and the desktop query above can never both
   match on a fractional viewport width. */
@media (max-width: 899.98px) {
  .js .nav__inner {
    grid-template-areas: "brand cta";
    row-gap: 0;
  }
  .js .nav__links,
  .js .nav__cta { display: none; }
  .js .nav__toggle { display: inline-flex; }
}

/* ---------- the dropdown ----------
   Absolutely positioned at top:100% of the sticky header, so it hangs off the
   nav bar's bottom edge without a single measured height in CSS or JS. It is
   display:none until .js confirms the script is alive AND the viewport is below
   the nav breakpoint, which keeps its four links out of the desktop tab order
   where the inline nav already provides them.

   --menu-exit is the open/close duration in one place: the row-track animation
   uses it, and so does the delay that holds visibility until the panel has
   finished collapsing. The reduced-motion block sets it to 0s and both follow. */
.menu {
  --menu-exit: 340ms;

  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;

  /* visibility, not opacity, does the hiding: it takes the panel out of the tab
     order and the accessibility tree, so collapsed links can never be focused.
     Held until the exit animation has finished, then flipped in one step. */
  visibility: hidden;
  transition: visibility 0s linear var(--menu-exit);
}

@media (max-width: 899.98px) {
  .js .menu { display: block; }
}

.menu.is-open {
  visibility: visible;
  transition-delay: 0s;
}

/* Starts at the nav's bottom edge, which is why the bar itself never dims —
   no z-index games against .nav::before, it is simply never covered. */
.menu__scrim {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100vh;
  height: 100dvh; /* mobile URL bar: dvh tracks the visible viewport */
  background: rgba(10, 12, 14, 0.5); /* fallback: --bg's rgb */
  background: color-mix(in srgb, var(--bg) 50%, transparent);
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-out);
}

.menu.is-open .menu__scrim { opacity: 1; }

/* The expand. grid-template-rows 0fr -> 1fr animates to the content's natural
   height without anyone measuring it — no scrollHeight read, nothing to go
   stale when the viewport changes. contain keeps the row-track relayout inside
   this box; combined with position:absolute the page behind cannot shift. */
.menu__panel {
  position: relative;
  display: grid;
  grid-template-rows: 0fr;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  box-shadow: var(--shadow-floating);
  /* layout only, not paint: .menu__clip already does the clipping, and paint
     containment would risk taking the panel's own drop shadow with it */
  contain: layout;
  opacity: 0;
  transition:
    grid-template-rows var(--menu-exit) var(--ease-spring),
    opacity 180ms var(--ease-out);
}

.menu.is-open .menu__panel {
  grid-template-rows: 1fr;
  opacity: 1;
}

/* min-height:0 is what lets a grid item shrink below its content; overflow is
   what clips it while it does. */
.menu__clip {
  min-height: 0;
  overflow: hidden;
}

.menu__inner {
  display: block;
  padding-block: var(--s-5) var(--s-7);
}

.menu__nav {
  margin: 0;
  padding: 0;
  list-style: none;
}

.menu__nav a {
  display: block;
  padding-block: var(--s-3);   /* 44px+ rows without padding games */
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.5rem;
  line-height: 1.35;
  letter-spacing: -0.02em;
  color: var(--text);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out);
}

.menu__nav a:hover,
.menu__nav a:focus-visible { color: var(--teal); }
.menu__nav a:active { color: var(--teal); }

/* A wrapper, so the stagger below lands on this and never on .btn — .btn sets a
   transition shorthand of its own, and at equal specificity and later in the
   source it would replace this one's property list, silently dropping opacity
   and leaving the button to pop in at full strength on the first frame. */
.menu__cta { margin-top: var(--s-5); }

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

/* The stagger, deliberately the same shape as .js .reveal: rise on translateY,
   fade on opacity, --ease-spring on the movement and --ease-out on the fade.
   Both properties are cleared by .is-open, so closing mid-stagger leaves
   nothing stranded half-in. */
.menu__nav li,
.menu__cta {
  opacity: 0;
  transform: translate3d(0, 10px, 0);
  transition:
    opacity 260ms var(--ease-out),
    transform 260ms var(--ease-spring);
}

.menu.is-open .menu__nav li,
.menu.is-open .menu__cta {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

.menu.is-open .menu__nav li:nth-child(1) { transition-delay:  80ms; }
.menu.is-open .menu__nav li:nth-child(2) { transition-delay: 132ms; }
.menu.is-open .menu__nav li:nth-child(3) { transition-delay: 184ms; }
.menu.is-open .menu__nav li:nth-child(4) { transition-delay: 236ms; }
.menu.is-open .menu__nav li:nth-child(5) { transition-delay: 288ms; }
.menu.is-open .menu__cta                 { transition-delay: 340ms; }

/* Scroll lock. overflow:clip keeps the scroll offset exactly where it was, so
   closing restores the position with no jump and nothing to save or replay.
   The gutter is set by JS to whatever the scrollbar was occupying, so removing
   it cannot shift the page sideways. */
html.nav-open {
  overflow: clip;
  padding-right: var(--nav-lock-gutter, 0px);
}

/* Over the hero the bar is transparent by design. With the panel hanging off it
   that would read as a floating slab under a see-through strip, so the
   nav's own backdrop is brought in for as long as the menu is open. */
html.nav-open .nav::before { opacity: 1; }

/* ---------- 6. Buttons ---------- */
.btn {
  --btn-y: 0.875rem;
  --btn-x: 1.625rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  padding: var(--btn-y) var(--btn-x);
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  font-family: var(--font-body);
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.005em;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  will-change: transform;
  transition:
    transform var(--dur-base) var(--ease-spring),
    box-shadow var(--dur-base) var(--ease-spring),
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    color 280ms var(--ease-out);
}

.btn--sm { --btn-y: 0.625rem; --btn-x: 1.125rem; font-size: 0.875rem; }

/* Above the wipe. z-index alone would not be enough for .btn--outline — see the
   note there — but the arrow is a real element and can be lifted properly.
   [aria-hidden] picks out the arrow without picking out the submit button's
   label span, which is the only other child any button has. */
/* :not(.sr-only) matters: a visually-hidden span is position:absolute, and
   giving it position:relative would drop it back into the flex flow — clipped
   to 1px, but still taking a slot and a gap. */
.btn > span:not(.sr-only) { position: relative; z-index: 1; }

.btn > span[aria-hidden] {
  translate: 0 0;
  transition: translate 300ms var(--ease-out);
}

.btn:focus-visible > span[aria-hidden] { translate: 4px 0; }

@media (hover: hover) {
  .btn:hover > span[aria-hidden] { translate: 4px 0; }
}

.btn:active > span[aria-hidden] { translate: 2px 0; }

.btn--solid {
  background: var(--teal-ink);
  color: var(--page);
  box-shadow: var(--shadow-elevated);
}
/* Hover darkens the fill; the label flips to white with it, because the
   near-black resting label measured 2.62:1 on #0B5F59. White measures ~6.5:1.
   No wipe here — that belongs to the outline variant. */
.btn--solid:hover {
  background: #0B5F59;
  color: #FFFFFF;
  transform: translateY(-2px);
  box-shadow: var(--shadow-floating);
}
/* the lift is withdrawn on press; the scale is the shared pop below */
.btn--solid:active { transform: translateY(0); box-shadow: var(--shadow-base); }

/* The outline button carries the wipe. It arrived here from .pill, which had
   one consumer; every outline button on the site now gets it — the nav CTA on
   all six pages, the hero's "See our work" and the CTA at the foot of
   /services/.

   --btn-wipe is a single point of change: the fill is --teal-ink because white
   on it measures 5.47:1, where white on --teal (the brand fill) is 3.74:1 and
   fails AA for a 15px label. If that fill colour is ever unified, this one
   declaration is the whole edit.

   -101%, not -100%: at some widths a 100% translate leaves a sub-pixel seam of
   background down the leading edge.

   z-index -1 under isolation, rather than z-index 1 on the label. Three of the
   four outline buttons label themselves with a bare text node, and a text node
   cannot take a z-index — an absolutely positioned ::before at z-index auto
   paints ABOVE inline text, so the fill would cover the words. Negative
   z-index puts it behind the text and in front of the button's background;
   isolation stops it escaping into an ancestor's stacking context. */
.btn--outline {
  position: relative;
  isolation: isolate;
  overflow: hidden; /* clips the fill to the 999px radius */
  background: transparent;
  color: var(--teal-ink);
  border-color: var(--teal);
}

.btn--outline::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--btn-wipe, var(--teal-ink));
  transform: translateX(-101%);
  transition: transform 380ms var(--ease-out); /* cubic-bezier(.16,1,.3,1) */
}

/* The filled state is written twice deliberately: hover is gated behind
   (hover: hover) so a tap does not leave a button stuck filled, and
   :focus-visible must not be gated — it is the keyboard path, on the same
   devices. */
.btn--outline:focus-visible {
  color: var(--page);
  border-color: var(--teal-ink);
}
.btn--outline:focus-visible::before { transform: translateX(0); }

@media (hover: hover) {
  .btn--outline:hover {
    color: var(--page);
    border-color: var(--teal-ink);
    transform: translateY(-2px);
    box-shadow: var(--shadow-elevated);
  }
  .btn--outline:hover::before { transform: translateX(0); }
}

.btn--outline:active { transform: translateY(0); box-shadow: none; }

/* ---------- the press ----------
   A one-shot pop rather than a held deformation: the scale rises past 1 and
   settles back, so the button acknowledges the click instead of just sitting
   squashed until the mouse comes up. --ease-spring BETWEEN the keyframes is
   what makes it elastic — it leaves each keyframe fast and arrives at the next
   slowly, so the rise snaps and the return eases.

   scale, not transform: every variant's hover lift already lives on transform
   or translate, and independent properties compose instead of overwriting each
   other. That is the same reason the hover rules above never mention scale.

   240ms is chosen so a fast click is already past the peak and on the way down
   when :active is withdrawn — a cancelled animation then snaps back by about a
   percent, which is not visible. */
.btn:active {
  animation: press-pop 240ms var(--ease-spring);
}

@keyframes press-pop {
  0%   { scale: 1; }
  40%  { scale: 1.03; }
  100% { scale: 1; }
}

/* Keyboard focus: the global ring plus a soft teal halo, so the button reads as
   focused against both the white page and the tinted hero wash. */
.btn:focus-visible {
  outline: 2px solid var(--teal);
  outline-offset: 3px;
  box-shadow: var(--shadow-elevated), 0 0 0 6px rgba(13, 148, 136, 0.12);
}

/* Touch devices keep :hover applied after a tap — don't let the lift stick.
   The wipe and the white label are already inside (hover: hover) and never
   reach here; the ::before is restated anyway so a future edit that moves them
   out cannot leave a tapped button filled for the rest of the page's life. */
@media (hover: none) {
  .btn:hover,
  .brand:hover,
  .nav__links a:hover { transform: none; }
  .nav__links a:hover::after { transform: scaleX(0); }
  .btn--outline:hover::before { transform: translateX(-101%); }
}

/* ---------- 7. Hero ---------- */
/* Fills the first viewport: the nav is sticky and in flow above it, so the
   hero's own box is the viewport minus the nav, and its bottom edge lands on
   the fold. svh, not vh — on phones the URL bar would otherwise push the CTA
   below the fold on first paint. */
.hero {
  position: relative;
  display: grid;
  align-items: center;
  min-height: calc(100svh - var(--nav-h));
  /* Modest: the centring supplies the whitespace whenever the viewport is
     taller than the content, so this padding only ever decides the minimum.
     Sized so three 79px lines plus the CTA still fit a 720×450 viewport
     (1440 at 200% zoom). */
  padding-block: clamp(2rem, 4vw, var(--s-8));
}

.hero__inner {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(var(--s-6), 4vw, var(--s-8));
  min-width: 0; /* grid item: never let a child's min-content widen the container */
  /* no extra max-width: the hero block shares the container's --maxw so the H1
     lines up with the nav logo */
}

.eyebrow {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: clamp(0.75rem, 0.5rem + 0.5vw, 0.875rem);
  line-height: 1.5;
  letter-spacing: 0.06em;
  color: var(--teal-ink);
  margin-bottom: var(--s-1);
}

/* No measure: the three phrases are authored as lines (.hero__line), and the
   scale is what keeps each one whole. text-wrap: balance from the h1 base rule
   would fight the explicit lines, so it's off here. */
.hero__title {
  max-width: none;
  font-size: var(--display-hero);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  text-wrap: initial;
}

.hero__line { display: block; }

/* Underline is drawn with text-decoration rather than a positioned bar: it is
   offset from the baseline (not the inline content box, which sits a descender
   below it), so it hugs the glyphs and matches the word's width exactly. */
.accent {
  color: var(--teal);
  white-space: nowrap;
  text-decoration: underline;
  text-decoration-color: var(--amber);
  text-decoration-thickness: 2px;
  text-underline-offset: 0.06em;
  text-decoration-skip-ink: none;
}

/* ---------- 8. Code token colours ----------
   Used by the intro overlay's code lines. The hero code chip that also used
   them is in _archive/2026-09-06-home-hero-chip-and-sub.css. */
.tk-bracket { color: var(--teal-ink); }
.tk-tag     { color: var(--ink); }
.tk-attr    { color: var(--slate); }
/* --slate-meta, not --slate-dim. This is real text in a code snippet a reader
   is meant to read, so 1.4.3 applies: --slate-dim measured 2.56:1 on --page.
   --slate-meta is 4.77:1 and still the quietest token in the set — --tk-attr
   above it is --slate at 7.62:1. */
.tk-punc    { color: var(--slate-meta); }
.tk-value   { color: var(--amber-ink); }
.tk-text    { color: var(--ink); }

/* ---------- 9. Hero CTAs ---------- */
.hero__ctas {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-4);
}

@media (max-width: 26.25rem) {
  .hero__ctas { width: 100%; }
  /* Full width, and the label may wrap: "Start a project" is 156px with its
     padding, 1px more than the column a 390px phone has at 200% zoom. */
  .hero__ctas .btn { flex: 1 1 100%; white-space: normal; }
}

/* ---------- 10. Sections ---------- */
.section { padding-block: clamp(var(--s-9), 9vw, var(--s-11)); }

.services {
  background: var(--surface);
  border-top: 1px solid var(--line);
}

/* Centred head over a centred grid of cards. Scoped to .services so the Work
   section's head, which sits above left-aligned content, is untouched. */
.services .section__head {
  margin-inline: auto;
  text-align: center;
}

.services .section__title,
.services .section__sub { margin-inline: auto; }

.section__head {
  max-width: 46rem;
  margin-bottom: clamp(var(--s-7), 5vw, var(--s-9));
}

.section__title {
  margin-top: var(--s-3);
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
}

.section__sub {
  margin-top: var(--s-4);
  max-width: 56ch;
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.125rem);
  line-height: 1.7;
  color: var(--ink);
}

.section__note {
  margin-top: var(--s-3);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.6;
  letter-spacing: 0.02em;
  color: var(--slate-meta);
}

/* ---------- 10b. Services cards ----------
   Nine gradient-bordered cards on the homepage, one per service. Same border
   technique as the
   compliance cards and the /services/ list: a transparent 1px border painted
   by a two-layer background, page colour in the padding box and the accent
   gradient in the border box, so no extra markup. The forced-colors block at
   the end of the sheet gives the edge back as CanvasText. */
.scards {
  display: grid;
  gap: var(--s-4);
  margin: clamp(var(--s-7), 5vw, var(--s-9)) 0 0;
  padding: 0;
  list-style: none;
}

@media (min-width: 40rem) {
  .scards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (min-width: 64rem) {
  .scards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.scard {
  display: flex;
  flex-direction: column;
  padding: clamp(var(--s-5), 3vw, var(--s-7));
  border: 1px solid transparent;
  border-radius: var(--radius-lg);
  background:
    linear-gradient(var(--page), var(--page)) padding-box,
    var(--border-teal) border-box;
  /* deep-link landing: clear of the sticky nav, same offset the panels had */
  scroll-margin-top: calc(var(--nav-h) + var(--s-6));
}

/* Accessibility compliance is the one violet card, as its panel was. Must
   follow the base rule: same specificity, so source order decides. */
.scard--accent {
  background:
    linear-gradient(var(--page), var(--page)) padding-box,
    var(--border-amber) border-box;
}

.scard__num {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.06em;
  color: var(--text-2);
}

.scard__title {
  margin-top: var(--s-4);
  font-size: clamp(1.25rem, 1.05rem + 0.8vw, 1.625rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.scard__body {
  flex: 1 1 auto; /* pushes every Read more to the same baseline across a row */
  margin-top: var(--s-3);
  font-size: 1rem;
  line-height: 1.65;
  color: var(--text-2);
}

/* The link is the shared .arrow-link (44px target, hover/focus arrow nudge);
   this only sets the gap above it, allowing for the link's own 9px handback. */
.scard__more { margin-top: var(--s-5); }
.scard__more .arrow-link { margin-top: calc(var(--s-2) - 0.5625rem); }

/* ---------- 10c. Process ----------
   Three numbered stages in one row (Stage 4 of the Diginuo restructure).
   Page ground between the surface-coloured services and compliance bands,
   so the section reads as its own beat. Stacked below 64rem, three columns
   from it; the hairline between stages is a top rule when stacked and a
   left rule in the row, drawn with --line like every other hairline. */
.process {
  background: var(--page);
  border-top: 1px solid var(--line);
}

.stages {
  display: grid;
  gap: clamp(var(--s-6), 4vw, var(--s-8));
  margin: 0;
  padding: 0;
  list-style: none;
}

.stage {
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}

@media (min-width: 64rem) {
  .stages {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    column-gap: clamp(var(--s-7), 4vw, var(--s-9));
  }

  .stage {
    padding-top: 0;
    padding-left: var(--s-5);
    border-top: 0;
    border-left: 1px solid var(--line);
  }
}

.stage__num {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.06em;
  color: var(--teal);
}

.stage__name {
  margin-top: var(--s-4);
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2.125rem);
  line-height: 1.1;
  letter-spacing: -0.025em;
  text-wrap: balance;
}

.stage__body {
  margin-top: var(--s-4);
  max-width: 44ch;
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.7;
  color: var(--text-2);
}

/* ---------- 10d. Work showcase — layout ---------- */
.showcase {
  background: var(--page);
  border-top: 1px solid var(--line);
  /* the work is the loudest thing on the page, so it gets the most air */
  padding-block: clamp(var(--s-10), 11vw, calc(var(--s-11) + var(--s-7)));
}

.showcase__head {
  max-width: 46rem;
  margin-bottom: clamp(var(--s-8), 7vw, var(--s-10));
}

.showcase__title {
  margin-top: var(--s-3);
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
}

.showcase__sub {
  margin-top: var(--s-5);
  max-width: 48ch;
  font-size: clamp(1.0625rem, 0.98rem + 0.4vw, 1.25rem);
  line-height: 1.65;
  color: var(--ink);
}

/* The counter is what numbers the blocks, so restoring a held-back project
   renumbers everything after it with no edit. */
.showcase__list {
  counter-reset: project;
  display: grid;
  gap: clamp(var(--s-9), 10vw, calc(var(--s-11) + var(--s-6)));
  margin: 0;
  padding: 0;
  list-style: none;
}

.project { counter-increment: project; }

/* The homepage teaser's way out to /work/. Sits below the list with the same
   air the list uses between blocks, so it reads as the end of the section
   rather than a stray link. */
.showcase__more { margin-top: clamp(var(--s-8), 7vw, var(--s-10)); }

/* Stacked below 64rem: image, then copy. */
.project__body {
  margin-top: var(--s-6);
}

@media (min-width: 64rem) {
  .project {
    display: grid;
    grid-template-columns: 1.35fr 1fr;
    column-gap: clamp(var(--s-7), 5vw, var(--s-10));
    align-items: center;
  }

  .project__body { margin-top: 0; }

  /* Alternate which side the work sits on, all the way down the page. The
     column ratio mirrors along with the order, so the image always lands in
     the wide track — swapping only the order would shrink every other image. */
  .project:nth-child(even) {
    grid-template-columns: 1fr 1.35fr;
  }

  .project:nth-child(even) .project__media { order: 2; }
}

.project__meta {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.02em;
}

.project__index::before {
  content: counter(project, decimal-leading-zero);
  color: var(--slate-meta);
}

.project__cat { color: var(--teal-ink); }

.project__title {
  margin-top: var(--s-3);
  font-size: clamp(1.875rem, 1.15rem + 3vw, 3rem);
  line-height: 1.08;
  letter-spacing: -0.03em;
}

.project__line {
  margin-top: var(--s-4);
  max-width: 40ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
  line-height: 1.7;
  color: var(--slate);
}
/* tech chips (distinct from the hero's .chip) */
.tech {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-5);
  padding: 0;
  list-style: none;
}

.tech li {
  padding: 0.25rem 0.5625rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 1.5;
  color: var(--slate);
}

/* The target. Its text box is 26px, which was the one control on the site
   under the 44px everything else holds. The 9px of padding either side takes
   it to 44 without a visible change: an inline-flex is an atomic inline, so
   its MARGIN box is what sets the line box height, and negative margins of the
   same 9px hand the space straight back. Measured before and after on all four
   instances — every rect on /, /work/ and the pages' full heights are
   unchanged.

   The padding is in rem so the target grows with the text rather than pinning
   itself to 44px while the words get bigger. */
.arrow-link {
  display: inline-flex;
  align-items: center;
  gap: 0.5ch;
  padding-block: 0.5625rem;
  margin-block: 0 -0.5625rem;
  margin-top: calc(var(--s-4) - 0.5625rem);
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--teal-ink);
  text-decoration-color: rgba(15, 118, 110, 0.35);
  text-underline-offset: 0.25em;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}

.arrow-link__arrow {
  translate: 0 0;
  transition: translate 300ms var(--ease-out);
}

@media (hover: hover) {
  .arrow-link:hover { text-decoration-color: currentColor; }
  .arrow-link:hover .arrow-link__arrow { translate: 4px 0; }
}

/* outside the hover query: the keyboard gets the same acknowledgement */
.arrow-link:focus-visible { text-decoration-color: currentColor; }
.arrow-link:focus-visible .arrow-link__arrow { translate: 4px 0; }

.arrow-link:active .arrow-link__arrow { translate: 2px 0; }

/* ---------- 10e. Project media, magnetic bubble, image motion ---------- */

/* The link itself. Everything visual lives inside it, so the whole image area
   is one tab stop with one accessible name. */
.project__media {
  position: relative;
  display: block;
  border-radius: var(--radius-lg);
  text-decoration: none;
}

/* The image box. It reserves its own height from aspect-ratio alone, so a real
   render or screenshot dropping in later cannot shift anything.

   Three separate properties move it, so they compose instead of fighting:
     translate  reveal rise      (CSS transition, one-time)
     scale      hover            (CSS transition)
     transform  scroll parallax  (JS, per frame, deliberately not transitioned) */
.project__frame {
  position: relative;
  display: block;
  overflow: hidden;
  aspect-ratio: 3 / 2;
  border-radius: var(--radius-lg);
  background: linear-gradient(155deg, var(--teal-bright) 0%, var(--teal) 34%, var(--ink) 100%);
  box-shadow: var(--shadow-elevated);

  translate: 0 0;
  scale: 1;
  transform: translate3d(0, var(--project-parallax, 0px), 0);

  transition:
    translate 800ms var(--ease-spring) 80ms,
    opacity 800ms var(--ease-out) 80ms,
    scale 420ms var(--ease-spring),
    box-shadow 420ms var(--ease-spring);
}

@media (hover: hover) {
  .project__media:hover .project__frame {
    scale: 1.03;
    box-shadow:
      0 2px 4px rgba(15, 23, 42, 0.05),
      0 14px 28px -8px rgba(13, 148, 136, 0.22),
      0 40px 72px -24px rgba(13, 148, 136, 0.32);
  }
}

/* A frame holding a real capture carries the capture's own ratio, so the
   screenshot is shown whole; a frame still holding a placeholder keeps the 3/2
   the gradient was composed for. Both states are selected from the content, so
   swapping a placeholder for an <img> is still the one-line edit the SWAP
   comments promise — the box re-shapes itself.

   This replaced a 16/9 case frame and a 3/2 card frame. Those were chosen when
   every frame held a gradient and the only question was height. Once real 8/5
   captures went in, 16/9 cropped 10% off the top and bottom and sliced through
   the Memora site's own header, and 3/2 shaved the sides — which reads as a
   rendering fault rather than a crop. A screenshot IS the content; cropping it
   cuts through the very thing it exists to show. */
.project__frame:has(.project__img) { aspect-ratio: var(--shot-ratio); }

/* Scrim exists only while the placeholder is present, so dropping in a real
   image in its place removes the darkening with no other edit. */
.project__frame:has(.project__placeholder)::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(15, 23, 42, 0.62), rgba(15, 23, 42, 0.42));
}

.project__placeholder {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: grid;
  place-items: center;
  padding-inline: var(--s-5);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.5rem, 0.85rem + 2.6vw, 2.75rem);
  line-height: 1.15;
  letter-spacing: -0.025em;
  text-align: center;
  color: #FFFFFF; /* 5.58:1 against the lightest point of the scrimmed gradient */
}

.project__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---------- device frames (homepage showcase only) ----------
   The desktop capture inside a browser window — title bar with three dots and
   the site's domain — and the same site on a phone overhanging the window's
   bottom-left corner. Opt-in via .project__frame--device; /work/ keeps the
   bare frame and its 8/5 aspect rule. The frame's own height now comes from
   the bar plus the screen, so the ratio moves down onto .device__screen. */
.project__frame--device,
.project__frame--device:has(.project__img) {
  aspect-ratio: auto;
  background: var(--surface);
  /* the window's edge. Decorative — the link is identified by the image and
     the copy beside it, not by this hairline. */
  border: 1px solid var(--line);
}

.device__bar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-width: 0;
  height: 2.25rem;
  padding-inline: var(--s-3);
  background: var(--surface-2);
  border-bottom: 1px solid var(--line);
}

.device__dots {
  display: flex;
  gap: 0.375rem;
}

.device__dots i {
  width: 0.625rem;
  height: 0.625rem;
  border-radius: 50%;
  background: rgba(148, 163, 184, 0.35); /* --text-2 at 35%: window buttons, decorative */
}

/* The address pill. Real text, so it is measured like any other: --text-2 on
   --surface is 6.4:1 (computed), checked from pixels in the Stage 2 audit. */
/* width: 0 matters — a flex item contributes its min-content width to the
   container regardless of min-width: 0, so the no-wrap domain became the
   bar's min-content, and through the grid item the whole track's: at 195px
   (390 at 200%) that pushed the frame to 258px and the page into horizontal
   scroll. With a definite zero width it contributes only its padding and
   grows to fill the bar; the domain ellipsises if it must. */
.device__url {
  flex: 1 1 0;
  width: 0;      /* a definite zero: its min-content contribution is then padding only */
  min-width: 0;
  max-width: 22rem;
  margin-inline: auto;
  padding: 0.2rem 0.75rem;
  border-radius: var(--radius-pill);
  background: var(--surface);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.01em;
  color: var(--text-2);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.device__screen {
  display: block;
  aspect-ratio: var(--shot-ratio);
  overflow: hidden;
}

/* Overhangs the window: to the left by a share of its width, capped so it
   always stays inside the page gutter (no horizontal scroll at any width);
   below by --phone-over-y, which the link also takes as bottom margin so the
   copy under it never collides. One viewport-based length for both, because
   a % inset and a % margin resolve against different boxes. */
.device__phone {
  --phone-w: 26%;
  --phone-over-x: min(4%, calc(var(--gutter) - 4px));
  position: absolute;
  z-index: 1;
  inset-block-end: calc(-1 * var(--phone-over-y));
  inset-inline-start: calc(-1 * var(--phone-over-x));
  width: var(--phone-w);
  border: clamp(3px, 0.5vw, 6px) solid var(--surface-2);
  border-radius: clamp(0.75rem, 2.2vw, 1.75rem);
  background: var(--surface-2);
  overflow: hidden;
  box-shadow: var(--shadow-floating);
}

.device__phone img {
  display: block;
  width: 100%;
  height: auto;
}

.project__media:has(.device__phone) {
  --phone-over-y: clamp(0.75rem, 2.8vw, 2.5rem);
  margin-bottom: var(--phone-over-y);
}

/* Rises with the frame, a beat behind it (the reveal choreography below drives
   the frame; the phone follows the same class). Reduced motion flattens both
   in the block at the end of the sheet. */
.js .project.reveal .device__phone {
  opacity: 0;
  translate: 0 24px;
  transition:
    translate 800ms var(--ease-spring) 240ms,
    opacity 800ms var(--ease-out) 240ms;
}

.js .project.reveal.is-revealed .device__phone {
  opacity: 1;
  translate: 0 0;
}

/* ---------- the "Explore" button ----------
   One element, two behaviours, so touch and desktop get the same object rather
   than two different affordances.

   The base state below is the touch treatment: a static circle parked in the
   bottom-right corner, clear of the centred title, always visible. The image
   itself is the tap target — this is only the visible cue, which is why it is
   pointer-events:none and a tap on it falls through to the <a>.

   It is also the fallback for a hover-capable device where the script never
   runs, which is what the .js gate on the block below buys: without it, a
   desktop visitor with JS broken would get an affordance permanently hidden,
   waiting for a class nothing is left to add. */
.project__bubble {
  --bubble-size: clamp(4.75rem, 3.5rem + 4.8vw, 6.875rem); /* 76px -> 110px */
  --bubble-inset: clamp(0.75rem, 0.4rem + 1vw, 1rem);      /* 12px -> 16px */

  position: absolute;
  z-index: 2;
  inset-block-end: var(--bubble-inset);
  inset-inline-end: var(--bubble-inset);
  /* Stacked, not "Explore →" on one line: a single line is ~75px of monospace,
     which crowds the rim at the small end of the clamp and would force the
     label down to an unreadable size to fit. */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.25em;
  width: var(--bubble-size);
  height: var(--bubble-size);
  border-radius: 50%;
  /* --bg, not --ink: this fill has to stay dark under the white label and
     rim, and --ink flipped to near-white in the dark rebuild. */
  background: var(--bg);
  /* The white rim is not decoration. This circle floats over artwork nobody has
     chosen yet — a real screenshot goes in behind it later — so it cannot
     borrow contrast from its background. The rim is 16.9:1 against the fill it
     encloses, which keeps the component's boundary perceivable whatever ends up
     underneath: on a dark image the rim carries it, on a light one the fill
     does. Inset rather than a border so the --bubble-size maths is untouched. */
  box-shadow:
    inset 0 0 0 2px #FFFFFF,
    0 2px 6px rgba(15, 23, 42, 0.18),
    0 14px 30px -10px rgba(15, 23, 42, 0.45);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.8125rem;
  line-height: 1;
  letter-spacing: 0.04em;
  color: #FFFFFF; /* 18.7:1 on --bg */
  pointer-events: none;
}

.project__bubble > span {
  font-size: 1.15em;
  line-height: 1;
}

/* On a pointer that can actually hover, the same circle becomes the magnetic
   bubble: re-anchored to the image's centre by its own margins so JS only ever
   writes a translate, and hidden until the cursor is inside the image. */
@media (hover: hover) {
  .js .project__bubble {
    inset-block-end: auto;
    inset-inline-end: auto;
    top: 50%;
    left: 50%;
    margin: calc(var(--bubble-size) / -2) 0 0 calc(var(--bubble-size) / -2);
    opacity: 0;
    scale: 0.6;
    transition:
      opacity 220ms var(--ease-out),
      scale 360ms var(--ease-spring);
    will-change: transform;
  }

  /* JS holds .is-live only while the cursor is inside the image AND moving. It
     comes off on leave and again after a short rest, so a cursor parked mid
     image never leaves the circle sitting on top of the artwork. */
  .js .project__bubble.is-live {
    opacity: 1;
    scale: 1;
  }

  /* Keyboard focus gets the same affordance, but in the corner: there is no
     cursor to trail, and the centre is exactly where it must not be. transform
     needs !important only because the trailing loop writes it inline. */
  .js .project__media:focus-visible .project__bubble {
    inset-block-end: var(--bubble-inset);
    inset-inline-end: var(--bubble-inset);
    top: auto;
    left: auto;
    margin: 0;
    transform: none !important;
    opacity: 1;
    scale: 1;
  }
}

/* ---------- reveal choreography ----------
   Driven by the .is-revealed class the existing IntersectionObserver already
   sets on .project — no second observer, no extra JS. The frame rises further
   than the block itself; the chips follow the copy 60ms apart. */
.js .project.reveal .project__frame {
  opacity: 0;
  translate: 0 32px;
}

.js .project.reveal.is-revealed .project__frame {
  opacity: 1;
  translate: 0 0;
}

.js .project.reveal .tech li {
  opacity: 0;
  translate: 0 8px;
  transition:
    opacity 320ms var(--ease-out),
    translate 320ms var(--ease-spring);
}

.js .project.reveal.is-revealed .tech li {
  opacity: 1;
  translate: 0 0;
}

.js .project.reveal.is-revealed .tech li:nth-child(1) { transition-delay: 340ms; }
.js .project.reveal.is-revealed .tech li:nth-child(2) { transition-delay: 400ms; }
.js .project.reveal.is-revealed .tech li:nth-child(3) { transition-delay: 460ms; }
.js .project.reveal.is-revealed .tech li:nth-child(4) { transition-delay: 520ms; }
/* ---------- 10f. Compliance ---------- */
.compliance {
  background: var(--surface);
  border-top: 1px solid var(--line);
}

.compliance__inner {
  display: grid;
  gap: clamp(var(--s-7), 5vw, var(--s-9));
}

@media (min-width: 56rem) {
  .compliance__inner {
    grid-template-columns: 1.05fr 1fr;
    align-items: start;
  }
}

.compliance__body {
  margin-top: var(--s-4);
  max-width: 54ch;
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.75;
  color: var(--ink);
}

/* same 9px handback as the base rule, on this section's larger top margin */
.compliance__pitch .arrow-link { margin-top: calc(var(--s-5) - 0.5625rem); }

/* the single amber moment in this section */
.arrow-link--amber {
  color: var(--amber-ink);
  text-decoration-color: rgba(180, 83, 9, 0.35);
}

.compliance__card {
  padding: clamp(var(--s-5), 3vw, var(--s-7));
  /* amber edge, same two-layer technique as the service cards — see 10l */
  border: 1px solid transparent;
  background:
    linear-gradient(var(--page), var(--page)) padding-box,
    var(--border-amber) border-box;
  border-radius: var(--radius);
  box-shadow: var(--shadow-elevated);
}

.compliance__card-head {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.06em;
  color: var(--teal-ink);
}

.steps {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  margin-top: var(--s-5);
  padding: 0;
  list-style: none;
}

.step {
  display: flex;
  align-items: flex-start;
  gap: var(--s-4);
}

.step__num {
  flex: none;
  padding-top: 0.1em;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--teal-ink);
}

.step__label {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.0625rem;
  line-height: 1.3;
  letter-spacing: -0.01em;
}

.step__desc {
  margin-top: var(--s-1);
  font-size: 0.9375rem;
  line-height: 1.6;
  color: var(--slate);
}

.compliance__proof {
  display: flex;
  gap: 0.5ch;
  margin-top: var(--s-6);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.6;
  color: var(--teal-ink);
}

/* ---------- 10f2. Contact / CTA ----------
   The one dark moment on the page. Every value here was checked against
   --ink: white 17.85:1, --teal-bright 7.17:1, --slate-dim 6.96:1.
   White on solid --teal is only 3.74:1, so the primary button inverts to a
   bright-teal fill with an ink label instead. */
.cta {
  background: var(--surface);
  color: #FFFFFF;
}

/* Was a centred column. A centred form reads as a landing-page pop-up rather
   than a piece of the page, so the band splits: pitch on the left, form on the
   right, both left-aligned, stacked below 64rem. */
.cta__inner {
  display: grid;
  gap: var(--s-8);
}

@media (min-width: 64rem) {
  .cta__inner {
    grid-template-columns: 1fr 1.05fr;
    gap: clamp(var(--s-8), 6vw, var(--s-10));
    align-items: start;
  }

  /* the pitch tracks the form down the band on tall viewports */
  .cta__intro {
    position: sticky;
    top: calc(var(--nav-h) + var(--s-7));
  }
}

.eyebrow--bright { color: var(--teal-bright); }

.cta__title {
  margin-top: var(--s-3);
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: #FFFFFF;
}

.cta__sub {
  margin-top: var(--s-4);
  max-width: 46ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
  line-height: 1.7;
  color: var(--slate-dim); /* 6.96:1 on --ink */
}

.cta__alt {
  margin-top: var(--s-6);
  font-size: 0.9375rem;
  line-height: 1.7;
  color: var(--slate-dim); /* 6.96:1 on --ink */
}

.cta__alt a {
  color: var(--teal-bright); /* 7.17:1 on --ink */
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color var(--dur-fast) var(--ease-out);
}
.cta__alt a:hover { color: #5EEAD4; }

.btn--bright {
  background: var(--teal-bright);
  color: var(--bg); /* was --ink, which the dark rebuild turned white: 1.78:1 on teal */
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.22),
    0 10px 24px -8px rgba(20, 184, 166, 0.45);
}

.btn--ghost {
  background: transparent;
  color: var(--teal-bright);
  border-color: var(--teal-bright);
}

@media (hover: hover) {
  /* Darkens rather than brightens. It used to go UP to #2DD4BF, which on a
     filled button reads as the surface catching light rather than as a press
     target responding; every other filled button here darkens. --teal is the
     darker neighbour already in the palette, and the ink label measures 4.77:1
     on it against 7.17:1 at rest, so the state costs contrast but keeps AA.
     Note for the fill-unification decision: if the resting fill ever becomes
     --teal, this hover needs a darker tone or it will match the rest state. */
  .btn--bright:hover {
    background: #0B5F59; /* --teal and --teal-bright are one colour now; darken, as .btn--solid does */
    color: #FFFFFF;
    translate: 0 -2px;
    box-shadow:
      0 1px 2px rgba(0, 0, 0, 0.22),
      0 16px 32px -10px rgba(20, 184, 166, 0.55);
  }
  .btn--ghost:hover {
    background: rgba(20, 184, 166, 0.12);
    translate: 0 -2px;
  }
}

.btn--bright:active,
.btn--ghost:active { translate: 0 0; }

/* teal-bright ring reads far better than --teal against the dark band */
.cta :focus-visible {
  outline-color: var(--teal-bright);
  box-shadow: none;
}

/* ---------- 10f3. Lead form ----------
   The shared form component, dark. Five forms run it: the homepage funnel and
   the flat forms on /work/, /about/ and /compliance/ sit in the CTA band on
   --surface; /contact/ sits on the page, --bg, with the layout variants in
   10j. Every colour below is a token, so both grounds are painted by the same
   rules. Measured from painted pixels, --surface / --bg:

     --text        labels, field and chip text       17.05 / 18.73  (needs 4.5)
     --text-2      legend, hints, optional markers    6.96 /  7.64  (needs 4.5)
     --teal        required marks, links, focus ring  9.58 / 10.53  (needs 4.5 / 3)
     --danger      error text, icon and boundary      9.40 / 10.32  (needs 4.5 / 3)
     --field-line  control boundaries at rest         3.54 /  3.51  (needs 3)
     --bg on --teal   checked chip label             10.53          (needs 4.5)

   --field-line is the tight one. It reads 3.54 on a chip and 3.96 on a field
   (the field's own fill sits behind its border); do not lower its alpha.

   The status boxes are message containers, not controls, so their borders
   (--line, --danger-line, teal at 45%) are not held to 3:1; the text inside is.
   The submit button is .btn--bright: a teal fill with a --bg label. */

.lead {
  display: grid;
  gap: var(--s-5);
}

.lead__legend {
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--text-2);
}

.lead__row { display: grid; gap: var(--s-5); }

@media (min-width: 30rem) {
  .lead__row { grid-template-columns: 1fr 1fr; }
}

.field {
  display: grid;
  gap: var(--s-2);
  /* Two fields share a row. When one shows an error its .field gains a row and
     the row grows; the default (normal = stretch) then shares that spare height
     out among the OTHER field's auto rows, so its input grew from 46.5px to 61px
     and its underline sat 14.5px below its neighbour's. start keeps each row at
     its own height and leaves the spare space under the field. */
  align-content: start;
  min-width: 0; /* stops a long value forcing the grid track wider than the band */
}

/* fieldset carries browser chrome that has to be undone before it can be a
   plain layout box */
.field--group {
  margin: 0;
  padding: 0;
  border: 0;
}

.field__label {
  display: block;
  padding: 0; /* legend */
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.5;
  color: var(--text);
}

.field--group .field__label { margin-bottom: var(--s-3); }

.field__req {
  color: var(--teal);
  font-weight: 600;
}

.field__opt {
  font-weight: 400;
  color: var(--text-2);
}

/* Helper text under a control (the message and attachment hints on /contact/). */
.field__hint {
  font-size: 0.8125rem;
  line-height: 1.6;
  color: var(--text-2);
}

.field__input {
  width: 100%;
  padding: 0.75rem 0.9375rem;
  border: 1px solid var(--field-line);
  border-radius: var(--radius-sm);
  background: var(--field-fill);
  font-family: inherit;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--text);
  transition:
    border-color var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out);
}

.field__input:hover { border-color: var(--field-line-hover); }

/* Deliberately no `outline: none` here. At (0,2,0) it would outrank the global
   :focus-visible rule at (0,1,0) and silently delete the keyboard focus ring;
   the ring is already absent for mouse focus because that rule is
   :focus-visible, not :focus. This adds to it rather than replacing it. */
.field__input:focus {
  border-color: var(--teal);
  background: var(--field-fill-focus);
}

.field__textarea {
  min-height: 8.5rem;
  resize: vertical;
}

/* Native select chrome cannot be styled, so the arrow is drawn here instead.
   Its stroke is --text-2's hex, literal because a data URI cannot read var().
   The option list is still OS-rendered, so <option> gets an explicit opaque
   pair — the field's own translucent fill would otherwise be composited over
   whatever the OS paints behind the popup. */
.field__select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 2.75rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' stroke='%2394A3B8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 1rem center;
  cursor: pointer;
}

.field__select option {
  background: var(--surface);
  color: var(--text);
}

/* ---------- chip radios ----------
   The input stays in the DOM, focusable and keyboard-driven; only its painting
   is replaced. sr-only clipping rather than display:none, which would take it
   out of the tab order and break the arrow-key group. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.chip-opt { display: inline-flex; }

/* Clipped, not display:none — the input has to stay focusable, keep reporting
   its own checked/selected state, and stay in the radio group's arrow-key
   cycle. The styled <label> beside it is the only thing painted. The file
   input on /contact/ is the same trick with a different control. */
.chip-opt input,
.filepick input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

.chip-opt label {
  display: inline-flex;
  align-items: center;
  min-height: 2.75rem; /* 44px — a full touch target, not just the 24px minimum */
  padding: 0.5rem 1rem;
  border: 1px solid var(--field-line);
  border-radius: var(--radius-pill);
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.5;
  color: var(--text);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out);
}

.chip-opt label:hover {
  border-color: var(--teal);
  background: color-mix(in srgb, var(--teal) 14%, transparent);
}

/* checked is not colour-only: the fill, the border and the label colour all
   change together, and the radio underneath still reports its own state */
.chip-opt input:checked + label {
  border-color: var(--teal);
  background: var(--teal);
  color: var(--bg); /* 10.5:1 on --teal; --text on teal is 1.78:1 */
}

.chip-opt input:focus-visible + label {
  outline: 2px solid var(--teal);
  outline-offset: 3px;
}

/* ---------- errors ----------
   Text, an icon and a border colour all move together, so the state never
   depends on hue alone. :empty is what hides it — the element is always in the
   DOM so aria-describedby can point at a node that exists, and JS only has to
   write or clear its text. The icon is a data URI, which cannot read var():
   its disc is --danger's hex and the mark on it is --bg's hex. */
.field__error {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--danger);
}

.field__error:empty { display: none; }

.field__error::before {
  content: "";
  flex: 0 0 auto;
  width: 1rem;
  height: 1rem;
  margin-top: 0.15rem;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23FCA5A5'/%3E%3Cpath d='M8 4.2v4.6' stroke='%230A0C0E' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='8' cy='11.6' r='1.1' fill='%230A0C0E'/%3E%3C/svg%3E") center / contain no-repeat;
}

.field__input[aria-invalid="true"] {
  border-color: var(--danger);
  background: var(--danger-fill);
}

.lead__submit {
  justify-self: start;
  margin-top: var(--s-2);
}

.lead__submit[disabled] {
  cursor: progress;
  opacity: 0.75;
}

@media (max-width: 26.25rem) {
  .lead__submit { justify-self: stretch; }
}

/* The privacy line above every submit. Same measure as the required-fields
   legend; the link is the band's link colour and is underlined, so it does
   not rely on colour alone. */
.form__notice {
  max-width: 52ch;
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--text-2);
}

.form__notice a {
  color: var(--teal);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

.form__notice a:hover { color: var(--text); }

/* ---------- the status region ----------
   Empty and invisible until JS writes to it. It is the same node for the error
   summary, the sending state and the success message. The three icons are data
   URIs with literal hexes: --danger, --text and --teal for the shapes, --bg
   for the marks on them. */
.lead__status {
  display: none;
  gap: 0.625rem;
  margin-bottom: var(--s-5);
  padding: var(--s-4) var(--s-5);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  font-size: 1rem;
  line-height: 1.6;
}

.lead__status.is-shown { display: flex; align-items: flex-start; }

.lead__status::before {
  content: "";
  flex: 0 0 auto;
  width: 1.25rem;
  height: 1.25rem;
  margin-top: 0.15rem;
  background: center / contain no-repeat;
}

.lead__status--error {
  border-color: var(--danger-line);
  background: var(--danger-fill);
  color: var(--danger);
}
.lead__status--error::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='9' fill='%23FCA5A5'/%3E%3Cpath d='M10 5.2v6' stroke='%230A0C0E' stroke-width='2.2' stroke-linecap='round'/%3E%3Ccircle cx='10' cy='14.6' r='1.3' fill='%230A0C0E'/%3E%3C/svg%3E");
}

.lead__status--busy {
  border-color: var(--line);
  background: var(--field-fill);
  color: var(--text);
}
.lead__status--busy::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='8' fill='none' stroke='%23F8FAFC' stroke-opacity='.3' stroke-width='2.4'/%3E%3Cpath d='M10 2a8 8 0 0 1 8 8' fill='none' stroke='%23F8FAFC' stroke-width='2.4' stroke-linecap='round'/%3E%3C/svg%3E");
  animation: lead-spin 700ms linear infinite;
}

@keyframes lead-spin { to { rotate: 360deg; } }

/* The success state is a panel rather than a line — it replaces the form, so it
   has to carry the weight the form was carrying. */
.lead__status--done {
  padding: var(--s-6);
  border-color: color-mix(in srgb, var(--teal) 45%, transparent);
  background: color-mix(in srgb, var(--teal) 12%, transparent);
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 1rem + 0.6vw, 1.375rem);
  font-weight: 500;
  letter-spacing: -0.015em;
  color: var(--text);
}
.lead__status--done::before {
  width: 1.5rem;
  height: 1.5rem;
  margin-top: 0.05rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='11' fill='%232DD4BF'/%3E%3Cpath d='m7 12.3 3.2 3.2L17 8.7' fill='none' stroke='%230A0C0E' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

/* ---------- honeypot ----------
   Off-screen rather than display:none: a bot that checks computed styles skips
   display:none fields, and this one has to look like an ordinary input. It is
   aria-hidden and tabindex=-1 in the markup, so nothing but a script ever
   reaches it. */
.lead__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

@media (max-width: 26.25rem) {
  .cta__form .btn { width: 100%; }
}

/* ---------- 10f3b. Funnel steps (homepage) ----------
   One fieldset per step. JS toggles the hidden attribute; without JS the
   three stack and the step controls are not shown, so the form is the flat
   form it replaced. The legend is the question, sized as a small display
   line, with the count in mono above it. Colours are 10f3's: --text for the
   question, --text-2 for the optional marker, --teal for the count and the
   skip link. */
.lead--steps { gap: 0; }

.step { display: grid; gap: var(--s-5); }

/* A legend is not a grid item, so the step's gap never reaches it: measured
   0px from question to first chip at every width. The legend carries the
   step gap as its own margin instead, so question→options and field→field
   are one rhythm on all three steps. */
.step > .step__title { margin-bottom: var(--s-5); }

.step__title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.375rem, 1.1rem + 1.1vw, 1.875rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--text);
  text-wrap: balance;
}

.step__title:focus { outline: none; } /* focus lands here programmatically only; the count announces the move */
.step__title:focus-visible { outline: 2px solid var(--teal); outline-offset: 4px; border-radius: 2px; }

.step__count {
  display: block;
  margin-bottom: var(--s-2);
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.75rem;
  line-height: 1.5;
  letter-spacing: 0.06em;
  color: var(--teal);
}

.step__title .field__opt { font-family: var(--font-body); font-size: 0.6em; letter-spacing: 0; vertical-align: 0.15em; }

.step__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--s-4);
  margin-top: var(--s-2);
}

/* Skip: a text button, styled as the band's links are. 44px tall through
   padding, like .arrow-link. */
.step__skip {
  display: inline-flex;
  align-items: center;
  min-height: 2.75rem;
  padding: 0 0.25rem;
  border: 0;
  background: none;
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--teal);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--teal) 40%, transparent);
  text-underline-offset: 0.25em;
  cursor: pointer;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}

.step__skip:hover,
.step__skip:focus-visible { text-decoration-color: currentColor; }

/* the submit sits in the step nav now; the old top margin would double the gap */
.step__nav .lead__submit { margin-top: 0; }

@media (max-width: 26.25rem) {
  .step__nav .btn { flex: 1 1 100%; }
  .step__nav .step__skip { flex: 1 1 100%; justify-content: center; }
}

/* step entry: a short rise, off under reduced motion (10n covers .step too) */
.js .step:not([hidden]) { animation: step-in 260ms var(--ease-out) both; }

@keyframes step-in {
  from { opacity: 0; translate: 0 8px; }
  to   { opacity: 1; translate: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .js .step:not([hidden]) { animation: none; }
}

/* Steps 2 and 3 are hidden by JS, not by a hidden attribute in the markup,
   so a script-less visitor gets all three. Between the head's .js flag and
   the deferred script running, this rule holds them back so the form does not
   paint stacked and then collapse; initLeadSteps adds .is-ready and takes
   over with the hidden attribute. The 2s failsafe that drops .js if main.js
   never runs lifts this too. */
.js .lead--steps:not(.is-ready) .step + .step { display: none; }

/* No JS: the steps stack as one form. The counts and the step buttons only
   make sense once something is turning pages, so they go. */
html:not(.js) .step__count,
html:not(.js) [data-step-back],
html:not(.js) [data-step-next],
html:not(.js) [data-step-skip] { display: none; }
html:not(.js) .step + .step { margin-top: var(--s-7); }

/* ---------- 10f4. Reflow at 200% text zoom (WCAG 1.4.4 / 1.4.10) ----------
   At 390px with the browser font size at 200%, the page scrolled sideways:
   scrollWidth 454 against clientWidth 390. Neither .compliance__* nor .cta__*
   has a fixed width — the cause is that .compliance__inner and .cta__inner are
   both grids, and a grid item defaults to min-width:auto, which floors its
   track at the item's MIN-CONTENT width. One unshrinkable item therefore drags
   its whole track, its container and the document's scrollWidth with it:

     .cta__form        min-content 414  <- the submit button, white-space:nowrap
                                           on "Send project brief" plus 104px of
                                           rem padding that doubles under zoom
     .compliance__card min-content 365  <- rem padding (96px of a 310px box)
                                           around text that cannot break
     .compliance__pitch min-content 358 <- the single word "Accessibility" at
                                           heading size
     .cta__intro       min-content 231  <- not a cause at all; it was widened to
                                           414 by .cta__form sharing its track

   min-width:0 lets the track shrink to the container; overflow-wrap lets the
   content reflow inside it once it has. Both are inert while content fits, so
   nothing moves at 100% at any width. The button additionally needs its nowrap
   lifted — with the box constrained but the label still unbreakable, the text
   would spill past the button instead, and overflowing inline content extends
   scrollWidth just the same. Scoped to the CTA submit, not to .btn.

   Not a cause, but worth recording since it measures wider than any of them:
   .lead__hp's honeypot input has a min-content of 429px (an <input> is sized
   from its default size=20). It contributes nothing because .lead__hp is
   position:absolute with overflow:hidden, so it is out of flow. */
.compliance__inner > *,
.cta__inner > * { min-width: 0; }

.compliance__pitch,
.compliance__card,
.cta__intro,
.cta__form { overflow-wrap: break-word; }

.cta__form .lead__submit { white-space: normal; }

/* Fixing the four above took scrollWidth from 454 to 395, which exposed the
   same unbreakable-word problem underneath: a single word wider than the 310px
   content box at 200% — "projects." in the showcase title, 355px.

   This rule used to also carry .card__title, and two companions (.card__badge
   white-space, .banner__id > * max-width) sat below it for the amber banner's
   "Accessibility" and "In force since 2025". All three went with the cards. */
.showcase__title { overflow-wrap: break-word; }

/* .step body: 267px inside 212px — a flex item at min-width:auto cannot go
   below its min-content, and "Remediation" alone is 202px. overflow-wrap was
   already inherited from .compliance__card, but break-word (unlike anywhere)
   does not lower an item's min-content, so the floor had to be removed for the
   wrapping to take effect. No step wraps at 100% at any width — measured. */
.step > * { min-width: 0; }

/* ---------- 10g. Footer ---------- */
.footer {
  background: var(--surface);
  border-top: 1px solid var(--line);
  padding-block: clamp(var(--s-8), 6vw, var(--s-9)) var(--s-6);
}

.footer__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--s-7);
}

.footer__tagline {
  margin-top: var(--s-4);
  max-width: 34ch;
  font-size: 0.9375rem;
  line-height: 1.7;
  color: var(--slate);
}

.footer__nav {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(var(--s-6), 6vw, var(--s-9));
}

.footer__heading {
  font-family: var(--font-body);
  font-size: 0.75rem;
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--slate-meta);
}

.footer__col ul {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-top: var(--s-4);
  padding: 0;
  list-style: none;
}

.footer__col a,
.footer__contact a {
  color: var(--slate);
  font-size: 0.9375rem;
  text-decoration-color: transparent;
  text-underline-offset: 0.2em;
  transition:
    color var(--dur-fast) var(--ease-out),
    text-decoration-color var(--dur-fast) var(--ease-out);
}

@media (hover: hover) {
  .footer__col a:hover,
  .footer__contact a:hover {
    color: var(--ink);
    text-decoration-color: currentColor;
  }
}

.footer__contact {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2) var(--s-4);
  margin-top: var(--s-8);
  padding: 0;
  list-style: none;
  font-family: var(--font-mono);
}

.footer__contact a { font-family: var(--font-mono); font-size: 0.8125rem; }

/* separator trails its item so a wrapped line never starts with a stray dot */
.footer__contact li:not(:last-child)::after {
  content: "·";
  margin-left: var(--s-4);
  color: var(--slate-meta);
}

.footer__bottom {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2) var(--s-5);
  margin-top: var(--s-7);
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
}

.footer__copy {
  font-size: 0.8125rem;
  color: var(--slate-meta);
}

.footer__note {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
  color: var(--teal-ink);
}

/* ---------- 10g2. Curved elastic dividers (shared) ----------
   Every divider on the site — /services/ and /about/ — is the same inline SVG
   carrying .rule-curve: one shallow arc on a 1200x28 viewBox, stretched to the
   container with preserveAspectRatio="none" and held to a true 1px by
   vector-effect="non-scaling-stroke".

   .rule-curve is the hook main.js queries. Pages style the wrapper (colour,
   height, offset) under their own class names; this block owns only what the
   physics touches, so a new page gets the elastic behaviour by adding one
   class and nothing else. */
.rule-curve { overflow: visible; }

/* The pull-point dot. Created by JS, so it exists only where the physics does —
   touch devices and reduced-motion users never get the element at all. It
   starts at rx/ry 0 (drawn, but zero-sized, so it costs nothing and cannot be
   seen) and JS sizes it while a line is being held. Teal rather than --line:
   the line is a hairline in #E2E8F0 and a dot that colour would be invisible.
   Decorative — it lives inside an aria-hidden svg. */
.rule-curve__dot {
  fill: var(--teal);
  pointer-events: none;
}

/* ---------- 10h. Services page ----------
   Only markup on /services/ uses any of this. Everything reuses the same
   tokens, the same .reveal system and the same easings as the homepage, so the
   two pages move identically. */

/* ---------- current page marker ----------
   aria-current is what a screen reader announces; these give it a visible
   counterpart so the state is not sound-only. */
.nav__links a[aria-current="page"] {
  color: var(--text);
}
.nav__links a[aria-current="page"]::after { transform: scaleX(1); }

.menu__nav a[aria-current="page"] { color: var(--teal); }

/* ---------- 1. intro ---------- */
.page-intro {
  padding-block: clamp(var(--s-9), 4rem + 8vw, var(--s-11)) clamp(var(--s-8), 3rem + 5vw, var(--s-10));
  text-align: center;
}

.page-intro__title {
  margin-top: var(--s-4);
  margin-inline: auto;
  /* em, NOT ch. `ch` is the width of a "0" in the current font, so a centred
     block sized in ch changes width when the webfont swaps in and slides
     sideways to stay centred — measured here as 851px -> 985px, an x shift of
     66px. With the old markup the heading's only child was a text node, which
     stays centred and never reports a box move, so the shift was invisible to
     the layout-shift observer; giving each line its own block child made the
     same movement visible and CLS jumped from 0.0003 to 0.0074. The shared
     --display-measure is 7em for the same reason — Space Grotesk's ch is
     exactly 0.5em, so it is the ~14ch every heading gets, minus the font
     dependency. */
  max-width: var(--display-measure);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--display-h1);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  text-wrap: balance; /* progressive: ignored where unsupported, wraps as normal */
  color: var(--ink);
}

.page-intro__sub {
  margin-top: var(--s-5);
  margin-inline: auto;
  max-width: 52ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
  line-height: 1.7;
  color: var(--ink); /* 17.4:1 on --page */
}

.page-intro .eyebrow { justify-content: center; }

/* ---------- the per-line mask reveal ----------
   The two lines of each subpage <h1> are authored as mask spans in the markup,
   NOT split at runtime. Splitting text with JS after paint would undo three
   things at once: text-wrap:balance stops applying, the authored break that
   locks the line count against the font swap is gone, and DOM surgery on the
   largest element on the page is a CLS risk on pages currently measuring
   0.0001.

   display:block, not inline-block, so the two lines stack exactly the way the
   <br> used to and the heading's box height is unchanged.

   The clip box needs room for descenders — the "y" in "Everything you", the "p"
   in "Real products" — or overflow:clip shaves them off. padding-bottom opens
   the box and an equal negative margin takes the same amount straight back out
   of layout, so nothing moves.

   Pure CSS, keyed off the .js flag the head script sets synchronously. That
   means it starts at first paint: no IntersectionObserver, no loader gate, and
   no dependency on main.js having loaded. Without .js — script disabled, or the
   head failsafe having fired — none of it applies and the heading is just
   there, in place, at full opacity.

   Opacity is never touched. This is a mask reveal, not a fade. */
.title-line {
  display: block;
  overflow: clip;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}

.title-line__in { display: block; }

/* 125% of a 1.04 line-height is 1.30em, clear of the 1.20em padded clip box */
.js .title-line__in {
  animation: title-line-rise 700ms var(--ease-out) both;
}

.js .title-line:nth-child(2) .title-line__in { animation-delay: 90ms; }

@keyframes title-line-rise {
  from { translate: 0 125%; }
  to   { translate: 0 0; }
}

/* ---------- 2. brand visual panel ----------
   A dark code field, not a teal rectangle: near-black, a faint rule grid masked
   to fade at the edges, and one soft teal bloom sitting behind the mark. The
   whole thing is decorative and aria-hidden.

   The build-up is driven entirely by the .reveal class the existing
   IntersectionObserver already sets — no second observer, no JS of its own, and
   the once-only behaviour is inherited rather than reimplemented. */
.panel-wrap { padding-block: 0 clamp(var(--s-9), 8vw, var(--s-11)); }

.brand-panel {
  position: relative;
  display: grid;
  place-items: center;
  min-height: min(60vh, 34rem);
  padding: var(--s-8);
  overflow: hidden;
  border-radius: var(--radius-lg);
  background: var(--ink-strong);
  box-shadow:
    0 2px 6px rgba(15, 23, 42, 0.14),
    0 30px 70px -30px rgba(10, 14, 22, 0.5);
  /* The flip's perspective has to come from here. The `rotate` property cannot
     carry a perspective() of its own, and a perspective() written into the
     mark's own `transform` would composite after `rotate` and never apply to
     it — the same reason the hero intro puts it on .intro__stage. */
  perspective: 1100px;
}

/* 60vh of a phone viewport is taller than the panel is wide, which reads as a
   portrait card rather than a panel. Capped so it stays roughly square. */
@media (max-width: 48rem) {
  .brand-panel { min-height: min(46vh, 22rem); }
}

/* The rule grid. Masked radially so it is strongest behind the mark and gone
   before it reaches the rounded corners, which stops it reading as graph paper
   and keeps the panel's edge clean. */
.brand-panel::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background-image:
    repeating-linear-gradient(to right,  rgba(148, 163, 184, 0.09) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.09) 0 1px, transparent 1px 56px);
  -webkit-mask-image: radial-gradient(78% 80% at 50% 50%, #000 24%, transparent 76%);
  mask-image: radial-gradient(78% 80% at 50% 50%, #000 24%, transparent 76%);
}

@media (max-width: 48rem) {
  .brand-panel::before {
    background-image:
      repeating-linear-gradient(to right,  rgba(148, 163, 184, 0.09) 0 1px, transparent 1px 36px),
      repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.09) 0 1px, transparent 1px 36px);
  }
}

/* The bloom. Resting state is what it settles to after the pulse, so a page
   without JS — where .is-revealed never lands — shows the finished panel. */
.brand-panel__glow {
  position: absolute;
  z-index: 1;
  width: 72%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(20, 184, 166, 0.42), transparent 72%);
  opacity: 0.5;
  scale: 1;
}

.brand-panel__mark {
  position: relative;
  z-index: 2;
  display: inline-flex;
  align-items: baseline;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: clamp(4.5rem, 1.5rem + 17vw, 13rem);
  line-height: 1;
  letter-spacing: -0.02em;
  color: #FFFFFF; /* 18.9:1 on --ink-strong */
}

/* #5EEAD4 rather than --teal-bright: measured from real pixels against the
   brightest state of the bloom behind it, --teal-bright lands under the 3:1
   that large text needs and this clears it with room. */
.brand-panel__br { color: #5EEAD4; }

/* The caret echoes the hero intro's amber cursor. Purely decorative — it is the
   only thing still moving once the build has settled. */
.brand-panel__caret {
  align-self: center;
  width: 0.075em;
  height: 0.58em;
  margin-left: 0.14em;
  border-radius: 1px;
  background: var(--amber);
  opacity: 0;
}

/* ---------- the build-up, ~1.6s ----------
   Scoped to .js so the mark renders complete if the script never runs, and hung
   off .is-built — NOT .is-revealed. The panel's own fade and rise still come
   from the shared .reveal system, but the mark inside it waits for the visitor's
   first scroll (or a 3.5s fallback); see initBrandPanel() in main.js. The two
   gates are deliberately separate classes so the panel can be up and looking
   finished while the mark is still holding.

   Each property has exactly one job — translate carries the fly-in, scale the
   pop, opacity the fade, rotate the flip — so none of them overwrite each other
   and `transform` is left free throughout.

  Timed from the moment .is-built lands, not from load:
     0 →  520ms   < flies in from the left, > from the right
   200 →  580ms   a scales up in the middle
   620 → 1220ms   the whole mark flips once, rotateY 0 -> 360deg
  1020 → 1540ms   the bloom pulses and settles, landing under the flip
  1440ms          the caret starts blinking                                 */
.js .brand-panel .brand-panel__br,
.js .brand-panel .brand-panel__a {
  opacity: 0;
  transition:
    opacity 520ms var(--ease-out),
    translate 520ms var(--ease-spring),
    scale 380ms var(--ease-spring) 200ms;
}

.js .brand-panel .brand-panel__br--in  { translate: -0.7em 0; }
.js .brand-panel .brand-panel__br--out { translate:  0.7em 0; }
.js .brand-panel .brand-panel__a {
  scale: 0.35;
  transition:
    opacity 380ms var(--ease-out) 200ms,
    scale 380ms var(--ease-spring) 200ms;
}

.js .brand-panel.is-built .brand-panel__br,
.js .brand-panel.is-built .brand-panel__a {
  opacity: 1;
  translate: 0 0;
  scale: 1;
}

/* The finale — the same single turn the hero intro ends on, so the two pages
   share one signature move. The easing is the hero's local curve rather than
   --ease-spring: measured on a 360deg turn, --ease-spring covers 180deg in the
   first 100ms and then crawls the last 60deg over 350ms, which reads as a
   glitch followed by a stall. This whips and lands. */
.js .brand-panel.is-built .brand-panel__mark {
  animation: panel-flip 600ms cubic-bezier(0.7, 0, 0.2, 1) 620ms both;
}

@keyframes panel-flip {
  from { rotate: y 0deg; }
  to   { rotate: y 360deg; }
}

/* one pulse, then it holds the 100% keyframe — which is the resting state
   declared above, so nothing snaps when the animation hands back */
.js .brand-panel .brand-panel__glow { opacity: 0.1; scale: 0.7; }

.js .brand-panel.is-built .brand-panel__glow {
  animation: panel-pulse 520ms var(--ease-out) 1020ms both;
}

@keyframes panel-pulse {
  0%   { opacity: 0.1;  scale: 0.7; }
  55%  { opacity: 0.85; scale: 1.1; }
  100% { opacity: 0.5;  scale: 1; }
}

.js .brand-panel.is-built .brand-panel__caret {
  animation: panel-caret 1s steps(2, jump-none) 1440ms infinite;
}

@keyframes panel-caret {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* ---------- 3. what you get ----------
   The detail view: all nine services on screen at once, as one list.

   The band is now actually tinted. It always claimed to be, but the only teal
   in it was a radial centred at 50% -12% that had faded to nothing by the time
   it reached the rows — sampled at the list, the surface read rgb(248,250,252),
   i.e. --surface untouched. A flat wash under the radial gives the whole band
   one even tint instead of a glow at the top and plain surface below.

   0.035 is measured, not chosen. The binding constraint is --amber-ink on
   item 09, which starts at only 5.05:1 on white, so every point of tint comes
   off that budget first — see the ratios recorded on .slist__num below.

   padding-block is the standard .section rhythm. A nine-item list brings its
   own vertical mass, and a heavier clamp only made a long page longer. */
.solutions {
  background:
    radial-gradient(90rem 44rem at 50% -12%, rgba(13, 148, 136, 0.075), transparent 62%),
    linear-gradient(rgba(13, 148, 136, 0.035), rgba(13, 148, 136, 0.035)),
    var(--surface);
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  padding-block: clamp(var(--s-8), 7vw, var(--s-10));
}

.solutions__title {
  max-width: var(--display-measure);
  margin-inline: auto;
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  text-align: center;
  color: var(--ink);
}

/* ---------- the nine services ----------
   Two columns from 56rem up: number over name on the left, the sentence on the
   right. Both sides run to roughly the same height, so the row is as tall as
   one paragraph rather than number + name + paragraph stacked — which is what
   keeps nine services off the scrollbar.

   The left column is a fixed 150px, not a minmax track. At 17px, "Accessibility
   compliance" and "3D visuals and renders" both wrap to two lines inside it and
   every other name fits on one, so the column edge is a straight line down the
   page instead of drifting with the longest name.

   The <name/> bracket treatment that used to sit above each name is gone: at
   this size in a 150px column the angle brackets read as clutter around the
   name rather than as a tag beside it. The number stays and still comes from a
   counter, so reordering the list renumbers it for free. */
.slist {
  counter-reset: slist;
  margin: clamp(var(--s-7), 5vw, var(--s-8)) 0 0;
  padding: 0;
  list-style: none;
}

.slist__item {
  counter-increment: slist;
  display: grid;
  gap: var(--s-2) var(--s-5);
  /* The rule sits BETWEEN rows, so the space either side of it is symmetric:
     1.1rem of padding below the content, then the rule, then 1.1rem of margin
     before the next row starts. No padding-top — the previous row's margin is
     already the gap above. */
  padding-bottom: 1.1rem;
  margin-bottom: 1.1rem;
  border-bottom: 1px solid var(--line);
}

/* no rule under the last row, and no trailing space to push the CTA down */
.slist__item:last-child {
  padding-bottom: 0;
  margin-bottom: 0;
  border-bottom: 0;
}

@media (min-width: 56rem) {
  .slist__item {
    grid-template-columns: 150px minmax(0, 1fr);
    column-gap: 24px;
  }
  /* explicit placement, so the body sits beside BOTH lines of the left column
     rather than under whichever one auto-flow reached first */
  .slist__num   { grid-area: 1 / 1; }
  .slist__name  { grid-area: 2 / 1; }
  .slist__body  { grid-area: 1 / 2 / 3 / 3; }
  .slist__link  { grid-area: 3 / 2; }
}

/* Empty in the markup on purpose: the number is generated content, so the
   element is a placement hook and nothing else. aria-hidden because an <ol>
   already conveys the ordinal — announcing "01" as well would be noise.

   Contrast measured from painted pixels, and the two views have different
   backdrops, so both are recorded. With JS the text sits on the white card;
   without it, on the tinted band — which is a flat wash plus a radial centred
   above the section, so it is not uniform and samples rgb(238,245,247) at the
   top where the items are.

                     on the card    on the band
     number, teal       5.47:1         4.96:1
     number, amber      5.02:1         4.55:1     <- tightest on the page
     name (48px)       17.85:1        16.33:1
     body (23px)        7.58:1         6.99:1
     "Read more"        5.02:1         4.59:1
     prev / next        5.47:1            n/a

   The band case is the binding one, and it is why the wash is 0.035 rather
   than anything heavier: --amber-ink starts at only 5.05:1 on white, so every
   point of tint comes off that budget first. */
.slist__num {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 0.6875rem; /* 11px */
  line-height: 1.5;
  letter-spacing: 0.12em;
  color: var(--teal-ink);
}

.slist__num::before { content: counter(slist, decimal-leading-zero); }

/* A CSS counter cannot survive this page's one-at-a-time view: [hidden] is
   display:none, and a display:none element does not increment a counter. With
   eight of the nine removed from the count, whichever service was visible was
   always the first one counted, so every service rendered "01" — E-commerce
   included. Verified: counter-increment was seen by exactly 1 element on all
   nine deep links.

   So under .js the number is written into the element by initServiceRouter()
   from the item's index, and the counter is switched off to stop the two
   printing one after the other. The counter stays as the base rule because the
   no-JS view renders all nine, where it is correct and renumbers for free. */
.js .slist__num::before { content: none; }

.slist__name {
  font-family: var(--font-display);
  font-weight: 500;
  /* 17px to 18px. It used to run to 22px, which a 150px column cannot hold:
     "Accessibility compliance" went to three lines and set the row height on
     its own. */
  font-size: clamp(1.0625rem, 1.03rem + 0.15vw, 1.125rem);
  line-height: 1.25;
  letter-spacing: -0.02em;
  color: var(--ink);

  /* 1.4.10 Reflow. At 390px with the text at 200% the card's inner column is
     182px while "development" alone measures 337px, so the auto-sized grid
     track could not shrink to the card and the PAGE scrolled sideways — 441px
     on #app-development, 430px on #accessibility.

     THREE properties, and each one is doing a different job. Getting this wrong
     is easy, so the measurements are recorded:

     min-width: 0 is the actual fix. A grid item's automatic minimum size is its
     min-content, and min-content here is 325-337px whatever the wrapping mode —
     measured: hyphens:auto 325, break-word 325, no wrapping at all 325. Chrome
     does not count hyphenation opportunities when it computes min-content. That
     floor, not the text, is what held the track open.

     break-word, NOT anywhere, and this is the whole difference between a break
     that reads as hyphenation and one that reads as a mistake. `anywhere` puts
     a soft wrap opportunity between every pair of characters, and the line
     breaker takes the LAST opportunity that fits — always a raw cut, later in
     the word than any hyphenation point. Measured with `anywhere`: eight of the
     nine headings cut mid-word with no hyphen — "Websit/es", "Brandi/ng",
     "render/s". `break-word` only introduces a break when the word would
     otherwise overflow, so hyphenation is tried first and the raw cut is left
     as the last resort for a word no dictionary can split.

     hyphens: auto needs a language to pick a dictionary; <html lang="en"> is
     already on every page. hyphenate-limit-chars: 6 3 3 keeps it readable —
     nothing under six characters is hyphenated, and at least three characters
     stay on each side, so no line ends "Brandi-" or begins "-s".

     No media query, deliberately. All three are inert while the text fits: a
     break-word break only happens to a word with nowhere to go, hyphenation
     only when a word would not otherwise fit the line, and min-width: 0 lowers
     a floor rather than setting a size. Verified on all nine headings at
     390/768/1024/1440 at 100% — every one still wraps on spaces alone. A width
     query would have to guess which viewport-and-zoom combination first
     outgrows the column, and would guess wrong the day a service is renamed. */
  min-width: 0;
  overflow-wrap: break-word;
  hyphens: auto;
  hyphenate-limit-chars: 6 3 3;
}

.slist__body {
  max-width: 62ch;
  font-size: clamp(1rem, 0.97rem + 0.2vw, 1.0625rem);
  line-height: 1.65;
  color: var(--slate); /* 7.62:1 */
}

/* item 09 — the one amber moment on the page. Its "Read more" is amber too,
   via .slist__link a below. */
.slist__item--accent .slist__num { color: var(--amber-ink); }

.slist__link {
  margin-top: var(--s-1);
  font-size: 0.9375rem;
  font-weight: 500;
}

.slist__link a {
  display: inline-flex;
  align-items: center;
  /* 2.75rem of hit area without 2.75rem of visible box: the padding is
     vertical only and the line box stays where the type sets it */
  min-height: 2.75rem;
  color: var(--amber-ink);
  text-decoration-color: rgba(180, 83, 9, 0.35);
  text-underline-offset: 0.25em;
  transition: text-decoration-color var(--dur-fast) var(--ease-out);
}

.slist__arrow {
  margin-left: 0.5ch;
  translate: 0 0;
  transition: translate 300ms var(--ease-out);
}

.slist__link a:focus-visible { text-decoration-color: currentColor; }
.slist__link a:focus-visible .slist__arrow { translate: 4px 0; }

@media (hover: hover) {
  .slist__link a:hover { text-decoration-color: currentColor; }
  .slist__link a:hover .slist__arrow { translate: 4px 0; }
}

.slist__link a:active .slist__arrow { translate: 2px 0; }

/* ---------- one service at a time ----------
   Everything above this point is the NO-JS layout, and it is deliberately the
   base rather than an override: with no script, all nine services are in the
   document with no hidden attribute on any of them, so they stack and the rule
   between rows is doing real work. initServiceRouter() is what collapses that
   to one at a time, and it can only run where .js is set — so the solo view
   hangs off .js and the stacked view needs no undoing.

   Solo, the two-column row is wrong: one row of content beside a 150px name
   column leaves the column empty below the name, and the name has no reason to
   be small any more. It becomes a single stacked column at display size — the
   one thing on the page, sized like it. */
/* The card. It is on EVERY item, not on :first-child and not on any static
   wrapper, so it belongs to whichever one is currently not [hidden] — there is
   never more than one.

   There was previously no card rule here at all. What looked like one was the
   :focus-visible ring below, which is why it came and went: the browser focuses
   a fragment target, and fragment navigation counts as non-pointer, so landing
   on /services/#branding matched :focus-visible and drew a rounded teal box —
   then clicking "next" with a mouse moved focus programmatically, :focus-visible
   stopped matching, and the "card" vanished. Measured both ways: arrival and
   keyboard match, real pointer does not.

   The border is TEAL and it is permanent — not a state. Every route into this
   page puts a card on screen and they must all look the same: a nav link, a
   deep link from the homepage chips, and a prev/next click now differ in
   nothing at all, because the only thing that ever varied (the focus ring) is
   no longer what draws the card's edge. It stays 1px so the change is colour
   only and nothing reflows.

   It is a gradient now (--border-teal, full teal at the top-left fading down
   the diagonal), painted as the border-box layer of a two-layer background
   under a transparent border. Same 1px, same box — the --page fill simply
   moved from `background` to the padding-box layer.

   1px --teal against the ring's 2px --teal-ink, ten pixels apart: see the
   offset note below for why those three numbers and not others. */
.js .slist__item {
  padding: clamp(var(--s-6), 4vw, var(--s-8));
  margin-bottom: 0;
  gap: 0;
  /* see the narrow-width padding override below §"room for the words" */
  border: 1px solid transparent;
  background:
    linear-gradient(var(--page), var(--page)) padding-box,
    var(--border-teal) border-box;
  border-radius: var(--radius-lg);
  box-shadow:
    0 1px 2px rgba(15, 23, 42, 0.04),
    0 18px 40px -28px rgba(15, 23, 42, 0.18);
}

/* Amber for the compliance card. Same specificity as the rule above, so it has
   to FOLLOW it — placed next to the other --accent rules further up it lost on
   source order and the card painted teal, caught from the border pixels. */
.js .slist__item--accent {
  background:
    linear-gradient(var(--page), var(--page)) padding-box,
    var(--border-amber) border-box;
}

/* ---------- room for the words ----------
   The padding above is tuned for the desktop card and it is a clamp whose FLOOR
   wins at narrow widths: at 390px the 4vw middle term is 15.6px, far below
   --s-6, so the floor applies — and --s-6 is 2rem, which at 200% text is 64px a
   side. A 350px card then holds a 182px column while "Accessibility" alone
   measures 325px, so the longest words could only ever be cut, never wrapped.

   Inline padding in vw, not rem, and that is the whole point of the rule. A rem
   floor doubles at exactly the moment the column can least afford it; 5vw is
   19.5px at 390 whatever the text size, so the column goes 182px -> 271px at
   200% and the words that fit whole are no longer broken at all.

   Block padding stays on the rem scale — vertical space costs the words
   nothing, and the card would look starved without it.

   Below 30rem only (480px), so nothing at 768px and above moves. */
@media (max-width: 30rem) {
  .js .slist__item {
    padding-block: clamp(var(--s-5), 4vw, var(--s-6));
    padding-inline: 5vw;
  }
}

/* The router focuses the item after a switch; it is not a tab stop, so the ring
   only appears when a keyboard or a fragment actually put it there.

   No border-radius here on purpose: an outline already follows the element's
   own radius, and setting one would have reshaped the card's corners for as
   long as it held focus.

   The offset is 10px, and it went 3 -> 6 -> 10 for one reason each time. At 3px
   the ring sat close enough to the card's own edge to read as the edge being
   teal, which is the confusion this pair of bugs started with; 6px put a
   visible band between the two while the edge was still grey. The edge is teal
   now, so same hue, and 6px of gap between a 1px line and a 2px line of the
   same family reads as one doubled ring rather than a ring around a card.

   Three things separate them at 10px, measured on the painted pixels: the gap
   is ten times the border's own weight, the ring is twice as thick, and it is
   the darker tone (--teal-ink #0F766E against --teal #0D9488), which also
   keeps the indicator itself at 5.5:1 on the band it sits on. */

   TWO selectors, because :focus-visible alone made the ring inconsistent. It
   is a heuristic on INPUT MODALITY, not on whether focus moved: the router
   focuses the panel on every switch (instrumented — focus() is called and
   document.activeElement is the <li>), but after a pointer interaction Chrome
   decides the focus was mouse-originated and suppresses the indicator. So the
   ring painted on arrival, where no click preceded it, and not on a clicked
   prev/next. .is-routed is set by the router only when it deliberately moves
   focus as part of a navigation, which restores the ring in that case without
   widening the rule to plain :focus — clicking the card's paragraph also
   focuses the <li> (tabindex="-1" elements take focus on click, measured), and
   a ring around the whole card every time someone selects a word would be
   noise. The class is cleared when focus leaves the card.

   :focus-within, not :focus, and that is the flicker fix. prev/next live
   INSIDE the card, so pressing one moves focus off the <li> and onto a link —
   :focus stops matching at mousedown and does not match again until the new
   card is focused on click. Held for a human 120ms that is seven frames with
   no ring, on a card that has not even swapped yet. :focus-within keeps the
   card lit while focus is anywhere inside it, so the ring simply stays put and
   then moves with the navigation.

   The router now marks the ARRIVAL item too, so .is-routed is what lights the
   card from the very first paint and :focus-visible is only a fallback. That
   is deliberate: while the two mechanisms shared the job, the one click where
   they changed hands — the first prev/next after a deep link — was the one
   place the ring still went out, for eight frames at a 130ms press. Arrival is
   marked only for a hash that names a service, so a bare /services/ still
   paints nothing until something navigates. */
.js .slist__item:focus { outline: none; }
.js .slist__item:focus-visible,
.js .slist__item.is-routed:focus-within {
  outline: 2px solid var(--teal-ink);
  outline-offset: 10px;
}

@media (min-width: 56rem) {
  .js .slist__item { grid-template-columns: minmax(0, 1fr); column-gap: 0; }
  .js .slist__num,
  .js .slist__name,
  .js .slist__body,
  .js .slist__link { grid-area: auto; }
}

.js .slist__num { font-size: 0.8125rem; }

.js .slist__name {
  margin-top: var(--s-3);
  font-size: clamp(1.75rem, 1.3rem + 2.1vw, 3rem);
  line-height: 1.1;
  letter-spacing: -0.03em;
}

.js .slist__body {
  margin-top: var(--s-5);
  max-width: 40ch;
  font-size: clamp(1.125rem, 1.02rem + 0.5vw, 1.4375rem);
  line-height: 1.6;
}

.js .slist__link { margin-top: var(--s-4); }

/* ---------- prev / next ----------
   Built by initServiceRouter(), so it exists only where it can work. Wraps to
   two stacked rows on narrow screens — space-between would otherwise squeeze
   two service names into one line. */
.snav {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--s-3) var(--s-6);
  margin-top: clamp(var(--s-7), 5vw, var(--s-9));
  padding-top: clamp(var(--s-5), 3vw, var(--s-6));
  border-top: 1px solid var(--line);
}

/* The two paging controls ARE .btn.btn--outline — makeLink() puts both classes
   on them — so the border, the 999px radius, the wipe, the white label, the
   focus behaviour and the reduced-motion handling all come from one place. This
   rule only retunes the two padding custom properties .btn exposes for a
   control that sits inside a card rather than at the foot of a page.

   They stopped being text links when they stopped being the only way through
   the list: nine services paged one at a time makes prev/next the primary
   controls on the page, and an underline is a weak affordance for that. The
   page's single ASK is still the one CTA at the foot — these are navigation.

   white-space: normal because .btn sets nowrap and a service name is up to
   three words: at a 32px root "Accessibility compliance" would drive the pill
   past the card rather than wrapping inside it. */
.snav__link {
  --btn-y: 0.6875rem; /* 11px */
  --btn-x: 1.125rem;  /* 18px */
  min-height: 2.75rem; /* 44px, WCAG 2.5.8 */
  min-width: 0;
  white-space: normal;
  /* anywhere, not break-word: at 390px with a 32px root the card's inner column
     is 182px and "Accessibility" alone is 205px, so wrapping between words is
     not enough — measured, the pills drove the page to 441px wide. break-word
     does not lower an element's min-content and would not have helped. */
  overflow-wrap: anywhere;
}

/* .btn already slides any [aria-hidden] child 4px right on hover and on
   :focus-visible. Prev points the other way, so only the direction is
   overridden — the transition, the timing and the reduced-motion suppression
   are inherited rather than restated. Same specificity as .btn's rule, later in
   the file, so it wins. */
.snav__link--prev:focus-visible > span[aria-hidden] { translate: -4px 0; }

@media (hover: hover) {
  .snav__link--prev:hover > span[aria-hidden] { translate: -4px 0; }
}

.snav__link--prev:active > span[aria-hidden] { translate: -2px 0; }

/* Narrow: two pills of different widths wrap into a ragged left-and-right pair
   that reads as a mistake rather than as a pair. Stacked, full width, prev
   above next — which is also their DOM order, so nothing is reordered for the
   keyboard. Measured: the widest pair needs 422px of the card's inner column,
   and 36rem is the last viewport that still gives it that. */
@media (max-width: 36rem) {
  .snav {
    flex-direction: column;
    align-items: stretch;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* The .btn block already stops the lift, the wipe's travel and the 4px slide
     for every button, these two included — the fill and the white label still
     arrive, they just arrive instantly. Only prev's leftward slide needs saying
     again, because its direction is set by a selector .btn's override does not
     match. */
  .snav__link--prev:hover > span[aria-hidden],
  .snav__link--prev:focus-visible > span[aria-hidden],
  .snav__link--prev:active > span[aria-hidden] { translate: none !important; }
}

/* The list had a "Start a project" pill of its own here. It went when the page
   lost its lead form: it pointed at #contact, an id that left with the form,
   and a second CTA 100px above the closing one read as a stutter. The ask now
   lives once, at the foot, in .closer. */

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

/* .pill lived here. It was a second button component with one consumer — the
   "Start a project" at the foot of /services/ — and geometrically it was
   .btn--outline with different padding: same 999px radius, same 1px --teal
   border, same --teal-ink label, 165x45 against 156x48. Two components that
   close to each other are a drift risk, not a choice, so the instance became
   .btn.btn--outline and the class went. Its wipe-fill treatment was not lost:
   it moved onto .btn--outline, where every outline button gets it.

   .pill--accent went with it. Nothing ever carried it — the amber links on this
   page and on the homepage chips are .slist__link and .svc__more, text links
   rather than pills. */

/* ---------- 4. closing line ----------
   Replaces the duplicate lead form. It does NOT reuse the dark .cta band that
   form sat in: measured, .solutions and .footer are both rgb(248,250,252), so
   this strip is the only thing between two identical surfaces. Painting it
   --page (255,255,255) is what stops the band and the footer reading as one
   continuous block — a dark band here would have done the separating too, but
   at the cost of a heavy full-width slab on a page whose whole point is to be
   short. */
.closer {
  background: var(--page);
  border-top: 1px solid var(--line);
  padding-block: clamp(var(--s-8), 6vw, var(--s-10));
  text-align: center;
}

.closer__title {
  max-width: var(--display-measure);
  margin-inline: auto;
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: var(--ink); /* 17.4:1 on --page */
}

.closer__cta { margin-top: clamp(var(--s-5), 3vw, var(--s-6)); }

/* No margin reset needed on the CTA any more: .pill carried a margin-top of
   its own for the layout it was first built in, .btn does not.

   The one thing .btn brings that .pill did not is white-space: nowrap, and at
   390px with a 32px root that pushed "Start a project →" to 359px inside a
   310px column — 9px of horizontal page scroll, measured. Lifted here exactly
   as .cta__form does it for the submit button, and for the same reason. */
.closer__cta .btn { white-space: normal; }

/* =============================================================
   10i. About page
   Shares every token, the .reveal system, the nav, the footer and the lead
   form with the rest of the site. It deliberately does NOT share the services
   page's row component: /about/ is meant to read calmer — a wider label
   column, no pill links, no brand panel, and one straight hairline before the
   curved ones start, so the elastic dividers arrive as an event.
   ============================================================= */

/* An authored line break in a balanced heading — the break is the design, not
   the browser's guess.

   It is kept at EVERY width on purpose. Letting it drop at 390 and the heading
   wrap on its own measured a 0.0249 CLS: the fallback font set
   "Small studio. Real products." in two lines and Space Grotesk in three, so
   the swap grew the heading by exactly one line (97px -> 146px) and pushed
   everything under it down 49px. With the break in place the line count is 2
   in either font and the shift is 0 — the modifier below is what buys the room
   for that, trimming the floor of the shared clamp so the longer of the two
   lines still fits on one line at 390. */
.page-intro__title--pair {
  /* the trimmed floor now lives in the display tokens' narrow-viewport
     override, where it applies to every heading at once; the class stays in
     the markup because the mask-line reveal is keyed off it */
  font-size: var(--display-h1);
}

.section--tight { padding-block: clamp(var(--s-8), 6vw, var(--s-10)); }

/* ---------- 2. statement row ---------- */
.stmt {
  padding-top: clamp(var(--s-6), 4vw, var(--s-8));
  border-top: 1px solid var(--line);
}

@media (min-width: 48rem) {
  .stmt {
    display: grid;
    /* wider than the services page's 17rem: fewer words, more air */
    grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
    gap: clamp(var(--s-6), 5vw, var(--s-9));
    align-items: start;
  }
}

.stmt__label,
.arow__label,
.how__title-label {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  font-weight: 500;
  line-height: 1.6;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.stmt__label { color: var(--teal-ink); } /* 5.50:1 on --page */

.stmt__body {
  margin-top: var(--s-5);
  display: grid;
  gap: var(--s-5);
  max-width: 60ch;
}

@media (min-width: 48rem) {
  .stmt__body { margin-top: 0; }
}

.stmt__body p {
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  line-height: 1.75;
  color: var(--ink);
}

/* ---------- a .stmt directly after a .how list ----------
   Both components correctly own their own edge, and back to back the edges add
   up. A .how list closes with .how__rule-end (an arc), then .stmt opens with
   its own 1px border-top — two rules 198px apart at 1440 with nothing between
   them, which reads as a row that failed to render rather than as a section
   break. On top of that the two sections each pad the same boundary.

   Measured at 1440, arc bottom to label top was 247px:
     112.0  section.how padding-bottom
      86.4  section.statement padding-top   <- removed here
      48.0  .stmt padding-top               <- removed here
       1.0  .stmt border-top                <- removed here
   leaving the 112px the .how section already spends closing itself, which is
   the same rhythm as every other section change on the site.

   Scoped by real adjacency, so it cannot reach .stmt anywhere else — /about/
   has the two in the opposite order and is untouched. Today this matches
   exactly one place: the "On overlays" block on /compliance/. */
.how + .statement { padding-top: 0; }

.how + .statement .stmt {
  border-top: 0;
  padding-top: 0;
}

/* ---------- 3. how we work ---------- */
.how__title {
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: var(--ink);
}

.how__list {
  counter-reset: arow;
  margin: clamp(var(--s-6), 4vw, var(--s-8)) 0 0;
  padding: 0;
  list-style: none;
}

.arow {
  position: relative;
  counter-increment: arow;
  padding-block: clamp(var(--s-7), 5vw, var(--s-9));
}

@media (min-width: 48rem) {
  .arow {
    display: grid;
    grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
    gap: clamp(var(--s-6), 5vw, var(--s-9));
    align-items: start;
  }
}

/* The divider sits in padding that already exists, absolutely positioned so it
   stays out of the row's grid and cannot change its height — which is why the
   sag has nothing to shift. translate centres the arc on the row boundary and
   transform flips alternate rows: two properties, two jobs, so the flip never
   undoes the centring. */
.arow__rule {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: clamp(0.75rem, 1.6vw, 1.75rem);
  translate: 0 -50%;
  color: var(--line);
}

.arow:nth-child(even) .arow__rule,
.arow__rule--flip { transform: scaleY(-1); }

.how__rule-end {
  display: block;
  width: 100%;
  height: clamp(0.75rem, 1.6vw, 1.75rem);
  color: var(--line);
}

.arow__label {
  position: relative;
  z-index: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 1.1ch;
  color: var(--slate); /* 7.62:1 on --page */
}

/* a CSS counter, so restoring or reordering a row renumbers the rest on its own */
.arow__num {
  color: var(--teal-ink); /* 5.50:1 */
  letter-spacing: 0.06em;
}
.arow__num::before { content: counter(arow, decimal-leading-zero); }

.arow__text {
  position: relative;
  z-index: 1;
  margin-top: var(--s-4);
  max-width: 56ch;
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  line-height: 1.7;
  color: var(--ink);
}

@media (min-width: 48rem) {
  .arow__text { margin-top: 0; }
}

/* ---------- 4. the dark band ----------
   --ink-strong, one step below the --ink the closing form band uses, so the
   page steps down into near-black and back up rather than showing two
   identical slabs. Contrast on #0A0E16 is higher than the audited values on
   --ink in every case: white 19.4:1, --slate-dim 7.6:1, --teal-bright 7.8:1,
   --amber 9.2:1. */
.band {
  background: var(--ink-strong);
  color: #FFFFFF;
}

.band__title {
  margin-top: var(--s-3);
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: #FFFFFF;
}

.band__list {
  margin: clamp(var(--s-7), 5vw, var(--s-9)) 0 0;
  padding: 0;
  list-style: none;
}

.brow {
  position: relative;
  padding-block: clamp(var(--s-6), 4vw, var(--s-8));
}

@media (min-width: 48rem) {
  .brow {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: clamp(var(--s-5), 4vw, var(--s-7));
    align-items: start;
  }
}

/* the same elastic divider, re-coloured for the dark ground. Decorative, so it
   is held to a visible hairline rather than a contrast ratio. */
.brow__rule {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: clamp(0.75rem, 1.6vw, 1.75rem);
  translate: 0 -50%;
  color: rgba(255, 255, 255, 0.17);
}

.brow:nth-child(even) .brow__rule { transform: scaleY(-1); }

/* the pull-point dot needs the brighter teal to read on near-black */
.band .rule-curve__dot { fill: var(--teal-bright); }

/* Stroke-only geometry on a 24x24 viewBox. The box is fixed in both axes so a
   font-size change can never resize it and shift the row. */
.brow__icon {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: var(--radius-sm);
  color: var(--teal-bright); /* 7.8:1 on --ink-strong, decorative anyway */
}

.brow__icon svg {
  width: 1.5rem;
  height: 1.5rem;
}

/* compliance is the differentiator, so it gets the page's only amber */
.brow--accent .brow__icon {
  color: var(--amber);
  border-color: rgba(245, 158, 11, 0.34);
}

.brow__body {
  position: relative;
  z-index: 1;
  margin-top: var(--s-4);
}

@media (min-width: 48rem) {
  .brow__body { margin-top: 0; }
}

.brow__label {
  font-size: clamp(1.25rem, 1.05rem + 0.8vw, 1.6rem);
  line-height: 1.2;
  letter-spacing: -0.02em;
  color: #FFFFFF;
}

.brow__text {
  margin-top: var(--s-3);
  max-width: 60ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
  line-height: 1.75;
  color: var(--slate-dim); /* 7.6:1 on --ink-strong */
}

/* teal-bright ring, like the contact band — --teal is too dark on this ground */
.band :focus-visible {
  outline-color: var(--teal-bright);
  box-shadow: none;
}

/* =============================================================
   10j. Contact page

   Layout variants only. /contact/ runs the shared form component from 10f3 on
   the page ground (--bg) instead of the CTA band (--surface): same markup,
   same JS hooks, same error plumbing and status region, and every colour,
   boundary, fill and focus ring comes from 10f3's tokens. What is here is what
   makes this form "airy" — a single centred column, wider gaps, mono labels,
   underline-style fields, chips whose fill rises — plus the file picker, which
   exists on this page only and takes its colours from the same tokens.

   The light-ground overrides this block used to carry, with the first version
   of the rising fill, are kept in
   _archive/2026-09-21-contact-form-light-overrides.css.
   ============================================================= */

.contact-form-block { padding-block: clamp(var(--s-8), 6vw, var(--s-10)); }

/* A single centred column. Wider than a reading measure because two fields sit
   side by side inside it, narrower than the page so the form never sprawls. */
.contact-form-block .lead,
.contact-form-block .lead__status {
  max-width: 46rem;
  margin-inline: auto;
}

.lead--airy { gap: clamp(var(--s-6), 4vw, var(--s-8)); }

/* ---------- labels: the site's mono idiom ----------
   Deliberately NOT text-transform: uppercase, even though every other mono
   label on the site is. Chrome applies text-transform when it computes an
   accessible name, so an uppercased <label> reaches a screen reader as "NAME"
   — and short all-caps strings are exactly what NVDA and VoiceOver are prone
   to spell out letter by letter. Mono plus tracking carries the same idiom
   without handing assistive tech a word it might not read as a word. */
.lead--airy .field__label {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.06em;
}

.lead--airy .field__opt { letter-spacing: 0.02em; }

/* ---------- fields: a hairline underneath, nothing else ----------
   Only the border WIDTHS change. The boundary's style and colour still come
   from 10f3's .field__input, so rest, hover, focus and invalid follow the
   shared tokens (--field-line, --field-line-hover, --teal, --danger) with no
   colour declared here. An underline has no box to fill, hence the transparent
   ground in every state — a keyword, not a colour. */
.lead--airy .field__input {
  padding: 0.625rem 0;
  border-width: 0 0 1px;
  border-radius: 0;
  font-size: 1.0625rem;
}

.lead--airy .field__input,
.lead--airy .field__input:focus,
.lead--airy .field__input[aria-invalid="true"] { background: transparent; }

.lead--airy .field__textarea {
  min-height: 7.5rem;
  padding-top: var(--s-2);
}

/* ---------- chips: the fill rises, and the label turns with it ----------
   /contact/ only (the interest checkboxes and the budget radios). The border,
   the hover tint and the focus ring are 10f3's, untouched; this replaces how
   the checked FILL arrives, not what it ends up as.

   Three layers inside the label, which is its own stacking context:
     ::before  z -1  the fill, --teal, pivoted on the bottom edge, scaleY 0 → 1
     the text        --text at rest
     ::after   z  1  a second copy of the text in --bg, unclipped from the
                     bottom edge up: inset(100% 0 0 0) → inset(0)
   Both pseudo-elements fill the same padding box and share one duration and
   easing, so the top of the fill and the top of the dark copy are the same
   line at every frame: light text on the ground above it, dark text on teal
   below it. Transform and clip-path only, so nothing lays out while it runs.
   Unchecking runs both backwards. The timing is the archived animation's
   (_archive/2026-09-21-contact-form-light-overrides.css).

   The copy's text comes from data-label, which must equal the label's visible
   text exactly. It is written with the alt-text form, content: … / "", because
   Chrome otherwise folds generated content into name-from-content and the
   checkbox is announced as "Website Website" (measured; see also 5a2). Where
   that syntax is unsupported the declaration is dropped and no copy renders —
   which is why the real text does not stay --text: once the fill has finished
   rising it takes 10f3's checked colour, --bg, through a colour transition
   delayed by exactly the fill's duration. With the copy that swap is invisible
   (it is already covered, and it removes the light fringe the copy would
   otherwise sit on); without it the chip still ends up dark on teal, 10.53:1.
   Unchecking uses the label's ordinary fast colour transition, so the light
   text is back before the fill has dropped past it. */
.lead--airy .chip-opt label {
  --chip-fill-dur: 460ms;
  --chip-fill-ease: cubic-bezier(0.4, 0, 0, 1);
  position: relative;
  overflow: hidden;
  isolation: isolate;
}

/* 10f3's checked background and colour both still apply, held back until the
   fill has finished rising. The background matters as much as the colour: the
   ::before stops at the padding box, so on its own it leaves a hairline seam
   inside the border that the band chips, whose teal runs under the border, do
   not have. Once it lands the two end states are the same pixels. */
.lead--airy .chip-opt input:checked + label {
  transition:
    border-color var(--dur-fast) var(--ease-out),
    background-color 0s linear var(--chip-fill-dur),
    color 0s linear var(--chip-fill-dur);
}

.lead--airy .chip-opt label::before,
.lead--airy .chip-opt label::after {
  position: absolute;
  inset: 0;
}

.lead--airy .chip-opt label::before {
  content: "";
  z-index: -1;
  border-radius: inherit; /* squashed by scaleY into the dome the fill rises with */
  background: var(--teal);
  transform-origin: 50% 100%;
  transform: scaleY(0);
  transition: transform var(--chip-fill-dur) var(--chip-fill-ease);
}

.lead--airy .chip-opt label::after {
  content: attr(data-label) / "";
  z-index: 1;
  /* the label's own box model, so the copy lands on the real glyphs and wraps
     where they wrap */
  display: flex;
  align-items: center;
  padding: inherit;
  color: var(--bg);
  clip-path: inset(100% 0 0 0);
  transition: clip-path var(--chip-fill-dur) var(--chip-fill-ease);
}

.lead--airy .chip-opt input:checked + label::before { transform: scaleY(1); }
.lead--airy .chip-opt input:checked + label::after  { clip-path: inset(0); }

/* ---------- the attachment ---------- */
.filepick {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  position: relative;
}

.filepick__btn {
  display: inline-flex;
  /* Reflow (1.4.10): as one unwrappable row the icon, "reference" and
     "(optional)" floor the button at 184px, 29px more than a 390px screen at
     200% has, and the page scrolled sideways. Inert wherever the label fits. */
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.75rem; /* 44px touch target */
  padding: 0.5rem 1.125rem;
  border: 1px dashed var(--field-line);
  border-radius: var(--radius-pill);
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
  transition:
    border-color var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out);
}

.filepick__btn svg {
  width: 1rem;
  height: 1rem;
  color: var(--teal);
}

/* same hover as the chips in 10f3 */
.filepick__btn:hover {
  border-color: var(--teal);
  background: color-mix(in srgb, var(--teal) 14%, transparent);
}

/* The input is clipped, so its focus ring has to be drawn on the label. */
.filepick input:focus-visible + .filepick__btn {
  outline: 2px solid var(--teal);
  outline-offset: 3px;
}

.filepick__name {
  min-width: 0;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--text-2);
  overflow-wrap: anywhere; /* a long filename must not push the page sideways */
}

.filepick__name:not(:empty) { flex: 1 1 12rem; }

.filepick__clear {
  padding: 0.375rem 0.75rem;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--text-2);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

.filepick__clear:hover { color: var(--text); background: var(--surface); }

/* invalid: solid rather than dashed, so the state is not colour alone */
.filepick input[aria-invalid="true"] + .filepick__btn {
  border-color: var(--danger);
  border-style: solid;
}

/* ---------- the divider between blocks ---------- */
.contact-rule {
  display: block;
  width: 100%;
  height: clamp(0.75rem, 1.6vw, 1.75rem);
  color: var(--line);
}

.contact-rule--flip { transform: scaleY(-1); }

/* ---------- the direct-contact block ---------- */
.contact-direct { padding-block: clamp(var(--s-8), 6vw, var(--s-10)); }

.contact-direct__title {
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: var(--ink);
}

.cinfo {
  display: grid;
  gap: clamp(var(--s-6), 4vw, var(--s-8));
  margin: clamp(var(--s-6), 4vw, var(--s-8)) 0 0;
  padding: 0;
  list-style: none;
}

@media (min-width: 46rem) {
  .cinfo { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.cinfo__key {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--teal-ink); /* 5.50:1 */
}

.cinfo__val {
  display: block;
  margin-top: var(--s-3);
  font-family: var(--font-mono);
  font-size: clamp(0.9375rem, 0.9rem + 0.25vw, 1.0625rem);
  line-height: 1.6;
  color: var(--ink); /* 17.85:1 */
  overflow-wrap: anywhere;
}

a.cinfo__val {
  text-decoration-color: var(--slate-meta);
  text-underline-offset: 4px;
  transition: color var(--dur-fast) var(--ease-out);
}

a.cinfo__val:hover { color: var(--teal-ink); text-decoration-color: currentColor; }

/* =============================================================
   10k. Compliance page

   Almost nothing new: the intro, the statement rows, the numbered rows, the
   elastic dividers, the dark band and the closing form are all the components
   /about/ and /services/ already use. Only three things here have no home
   elsewhere — the legal disclaimer, the proof band's checklist, and the FAQ
   disclosure.
   ============================================================= */

/* ---------- the "we are not lawyers" note ----------
   Deliberately NOT amber. Amber is this site's compliance accent and the proof
   band below spends the page's one allowance on it; a second amber edge here
   would read as a warning about the law rather than a caveat about us. A grey
   rule and the surface tint say "aside" without raising the temperature.
   --slate on --surface measures 7.30:1. */
.legal-note {
  margin-top: clamp(var(--s-6), 4vw, var(--s-7));
  max-width: 64ch;
  padding: var(--s-5);
  border-left: 3px solid var(--slate-meta);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: var(--surface);
  font-size: 0.9375rem;
  line-height: 1.75;
  color: var(--slate);
}

.legal-note__label {
  display: block;
  margin-bottom: var(--s-2);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--slate-meta); /* 4.55:1 on --surface */
}

/* ---------- proof band ----------
   Rides on .band (--ink-strong), so the type colours are the audited ones
   already in 10i: #FFFFFF 19.4:1, --slate-dim 7.6:1, --teal-bright 7.8:1,
   --amber 9.2:1. */
.proof__body {
  margin-top: var(--s-5);
  max-width: 60ch;
  font-size: clamp(1rem, 0.95rem + 0.35vw, 1.125rem);
  line-height: 1.75;
  color: var(--ink);
}

.proof__list {
  margin: clamp(var(--s-6), 4vw, var(--s-7)) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--s-3);
}

@media (min-width: 48rem) {
  .proof__list {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: clamp(var(--s-6), 5vw, var(--s-9));
  }
}

.proof__list li {
  display: grid;
  grid-template-columns: 1.1rem minmax(0, 1fr);
  gap: var(--s-3);
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  line-height: 1.7;
  color: var(--slate-dim);
}

.proof__tick { color: var(--teal-bright); }

/* The one amber moment on this page. It marks the honesty caveat rather than a
   selling point, which is the whole reason it earns the accent. */
.proof__caveat {
  margin-top: clamp(var(--s-6), 4vw, var(--s-7));
  max-width: 60ch;
  padding-top: var(--s-5);
  border-top: 1px solid rgba(255, 255, 255, 0.14);
  font-size: 0.9375rem;
  line-height: 1.7;
  color: var(--amber); /* 9.2:1 on --ink-strong */
}

/* ---------- FAQ ----------
   Native <details>/<summary>. No JS, no ARIA of our own: the browser exposes
   the expanded state and the keyboard behaviour (Enter and Space toggle,
   Tab reaches the summary) for free, and it all still works with JS disabled —
   which a hand-rolled button-and-region version would not.

   Nothing here animates height. Growing a disclosure open is a layout shift by
   definition; the only motion is the marker rotating and the answer easing in
   over its own space. */
.faq__list {
  margin: clamp(var(--s-6), 4vw, var(--s-8)) 0 0;
  padding: 0;
  list-style: none;
  max-width: 62rem;
}

.faq__item {
  border-top: 1px solid var(--line);
}
.faq__item:last-child { border-bottom: 1px solid var(--line); }

.faq__q {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-5);
  padding-block: clamp(var(--s-5), 3vw, var(--s-6));
  cursor: pointer;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.0625rem, 1rem + 0.5vw, 1.35rem);
  line-height: 1.35;
  letter-spacing: -0.015em;
  color: var(--ink);
  transition: color var(--dur-fast) var(--ease-out);
}

/* kill the native triangle in both engines without touching the semantics */
.faq__q { list-style: none; }
.faq__q::-webkit-details-marker { display: none; }
.faq__q::marker { content: ""; }

.faq__q:hover { color: var(--teal-ink); }
.faq__q:focus-visible { outline-offset: 2px; }

/* A plus that becomes a minus: two bars, and only the upright one rotates.
   Fixed px box in both axes so a font-size change cannot resize it. */
.faq__sign {
  position: relative;
  flex: none;
  align-self: center;
  width: 1.125rem;
  height: 1.125rem;
  color: var(--teal-ink);
}

.faq__sign::before,
.faq__sign::after {
  content: "";
  position: absolute;
  left: 0;
  top: calc(50% - 1px);
  width: 100%;
  height: 2px;
  border-radius: 1px;
  background: currentColor;
}

.faq__sign::after {
  rotate: 90deg;
  transition: rotate 320ms var(--ease-out);
}

.faq__item[open] .faq__sign::after { rotate: 0deg; }

.faq__a {
  padding-bottom: clamp(var(--s-5), 3vw, var(--s-6));
  max-width: 68ch;
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.75;
  color: var(--slate); /* 7.62:1 on --page */
}

.faq__a p + p { margin-top: var(--s-4); }
.faq__a a { color: var(--teal-ink); }

.js .faq__item[open] .faq__a { animation: faq-in 300ms var(--ease-out) both; }

@keyframes faq-in {
  from { opacity: 0; translate: 0 -6px; }
  to   { opacity: 1; translate: 0 0; }
}

/* =============================================================
   10L. Work page

   The case study borrows the showcase's media component wholesale — .project,
   .project__media, .project__frame, .project__bubble — because that is what
   binds it to initWorkMotion(): the script finds work by `.project`, and the
   magnetic bubble is opacity:0 until JS adds .is-live, so a .project__media
   with no .project ancestor would hover a permanently invisible affordance.
   Carrying the class and overriding the layout is the honest way round.
   ============================================================= */

/* The showcase's .project turns into a 2-up grid at 64rem so image and copy sit
   side by side. Here the media IS the section, full bleed to the container, so
   that grid is switched back off. */
@media (min-width: 64rem) {
  .case__hero.project { display: block; }
}

/* The case hero had NO bottom spacing at all — the eyebrow below it started at
   exactly 0px from the hero's bottom edge. That was survivable for a static
   image and fatal for this one: initWorkMotion() drifts .project__frame by up
   to ±--parallax-max with a transform, which paints outside the layout box
   without reserving a pixel of space. Scrolled past the midpoint the drift goes
   POSITIVE, and the panel painted 12px below its own box, straight over an
   18-21px line of eyebrow text — about half of it, which is exactly what was
   reported. At scroll 0 the drift is negative, which is why it never showed up
   in a screenshot taken at the top of the section.

   max() rather than a bare clamp so the clearance is enforced, not just
   commented: the editorial spacing wins at every width today (40px floor, 64px
   at 1440), and if --parallax-max is ever raised the second term takes over
   instead of the overlap silently coming back. The hover scale (1.03) also
   paints below the box — ~9px at this frame's height — and is covered by the
   same margin. */
.case__hero {
  margin-bottom: max(
    clamp(var(--s-7), 5vw, var(--s-9)),
    calc(var(--parallax-max) + var(--s-5))
  );
}

.case__media { display: block; }

/* Frame ratio is no longer set here. It moved to 10e as
   .project__frame:has(.project__img), because the homepage showcase holds the
   same captures now and was cropping them against a 3/2 box. */

.case__title {
  margin-top: var(--s-3);
  max-width: var(--display-measure);
  font-size: var(--display-h2);
  line-height: var(--display-lh);
  letter-spacing: var(--display-track);
  color: var(--ink);
}

.case__sub {
  margin-top: var(--s-5);
  max-width: 54ch;
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  line-height: 1.7;
  color: var(--ink);
}

/* ---------- the meta strip ----------
   A description list, not a row of spans: Role/Year/Status/Stack are literally
   term-and-definition pairs, and a screen reader announces them as such. The
   <div> wrappers around each dt/dd are valid and are what let the pairs be grid
   items without wrapping every value in another element. */
.cmeta {
  margin: clamp(var(--s-6), 4vw, var(--s-7)) 0 0;
  display: grid;
  gap: var(--s-5) clamp(var(--s-6), 4vw, var(--s-8));
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  line-height: 1.6;
}

@media (min-width: 48rem) {
  .cmeta {
    grid-template-columns: repeat(4, minmax(0, auto));
    justify-content: start;
  }
}

.cmeta dt {
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--slate-meta); /* 4.77:1 on --page */
}

.cmeta dd {
  margin: var(--s-2) 0 0;
  color: var(--ink);
}

/* A live dot, and only on the row that says live. Decorative — the word is
   already there, so nothing is carried by colour alone. */
.cmeta__live::before {
  content: "";
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  margin-right: 0.6ch;
  border-radius: 50%;
  background: var(--teal);
  vertical-align: 0.05em;
}

.case__link { margin-top: clamp(var(--s-5), 3vw, var(--s-6)); }

/* ---------- pull quote ----------
   A <p>, deliberately not a <blockquote>: it is our own line about our own
   work, not a quotation from anyone, and blockquote would imply a source that
   does not exist.

   max-width in em, not ch. ch is the width of a "0" in the current font, so a
   ch-sized block re-wraps when the webfont swaps; at this size one line moving
   is a visible shift. */
.pullquote {
  margin: clamp(var(--s-8), 6vw, var(--s-10)) 0 0;
  max-width: 17em;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.5rem, 1.05rem + 2.1vw, 2.5rem);
  line-height: 1.25;
  letter-spacing: -0.025em;
  color: var(--ink);
}

.pullquote::before {
  content: "";
  display: block;
  width: 3rem;
  height: 2px;
  margin-bottom: clamp(var(--s-4), 2vw, var(--s-5));
  border-radius: 2px;
  background: var(--teal);
}

/* ---------- the second project, and the grid it becomes ----------
   ONE item is not a grid with a gap in it — it is a feature. So the list has
   two shapes and swaps between them on how many <li> are actually rendered:

     one   .wcard keeps .project's own layout — image in the wide 1.35fr track,
           copy beside it, full container width. A feature block, deliberately
           the same shape the homepage gives a project, one step down in type
           from the Memora case study above it.

     two+  the cards stack internally and sit side by side in a real 2-up grid.

   Uncommenting a held-back card is the whole migration: `> li + li` starts
   matching, the feature rules stop, and the section becomes a grid.

   `> li + li`, NOT `> * + *`. The held-back cards live in a <template>, and a
   template IS an element child of the list — `* + *` matched with a single
   visible card and produced exactly the two-column-with-a-hole this is written
   to avoid.

   Failure mode is the good one: an engine with no :has() support drops both
   rules and gets a full-width stacked card, which is correct at any count
   rather than broken at one. */
.wgrid {
  margin: clamp(var(--s-7), 5vw, var(--s-9)) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: clamp(var(--s-8), 6vw, var(--s-10));
}

@media (min-width: 48rem) {
  .wgrid:has(> li + li) {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: clamp(var(--s-7), 4vw, var(--s-9));
  }

  /* back to a stacked card once it is one of several */
  .wgrid:has(> li + li) .wcard.project { display: block; }
}

/* Same hazard as .case__hero, one step smaller. Stacked (below 64rem, or in
   2-up grid mode) the copy sits directly under a drifting .project__frame, so
   this margin has to clear --parallax-max too.

   At a flat --s-5 it never actually overlapped, but the worst case left only
   12px of visible gap between a drifting panel and its caption, which reads as
   a near miss rather than a decision. Expressed as drift + --s-5 the gap is
   --s-5 at every point in the drift instead of only at rest. */
.wcard__body { margin-top: calc(var(--parallax-max) + var(--s-5)); }

.wcard__title {
  margin-top: var(--s-3);
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);
  line-height: 1.1;
  letter-spacing: -0.03em;
  color: var(--ink);
}

.wcard__line {
  margin-top: var(--s-4);
  max-width: 44ch;
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.7;
  color: var(--slate);
}

.wcard__line + .wcard__line { margin-top: var(--s-4); }

.wcard__link { margin-top: var(--s-5); }

/* Feature mode only: the title carries the block instead of labelling a card,
   so it steps up to the showcase's project scale and the copy gets room. */
@media (min-width: 64rem) {
  .wgrid:not(:has(> li + li)) .wcard__title {
    font-size: clamp(1.875rem, 1.15rem + 3vw, 3rem);
    letter-spacing: -0.03em;
  }

  .wgrid:not(:has(> li + li)) .wcard__line { max-width: 40ch; }
}

/* ---------- 10m. Evidence figures ----------
   A pair of real captures with one caption between them: a wide desktop shot
   beside a tall phone shot.

   Deliberately NOT .project__media. These are evidence, not destinations —
   there is nothing to click through to, since a Stripe checkout session is not
   a public URL — so they are <figure>/<figcaption> rather than links: no tab
   stop, no magnetic bubble, no parallax, and the caption is programmatically
   tied to the images instead of floating near them as a stray <p>.

   Two different phone widths, and they are not interchangeable:

   --shot-phone-col is the grid column, a clamp so the two shots keep the same
   height relationship at every size. Fixed at 15rem the wide shot collapses on
   the way down — at 768px it would be 427px wide against a 496px phone, a 2.4x
   height difference that reads as two unrelated images. On the clamp both land
   at ~1.3x from 48rem all the way up.

   --shot-phone-w is the cap while STACKED, and it is deliberately flat. Reusing
   the column clamp here was the first attempt and it was wrong: below 48rem
   there is no wide shot to stay in proportion with, and 20vw bottoms out on the
   9rem floor, so at 390px the phone rendered 144px wide — a thumbnail of a
   screenshot, unreadable and obviously accidental. */
.shots {
  --shot-phone-col: clamp(9rem, 20vw, 15rem);
  --shot-phone-w: 15rem;
  margin: clamp(var(--s-6), 4vw, var(--s-8)) 0 0;
}

/* Full width wherever it lands inside a grid row: the .arow it sits in is a
   label/text 2-up from 48rem, and the .wcard is a media/copy 2-up from 64rem.
   Ignored while either is still stacked, which is exactly right. */
.arow > .shots,
.wcard > .shots { grid-column: 1 / -1; }

/* Once the .arow is a grid its own row-gap (up to 64px) already separates this
   from the paragraph, and the margin stacks on top of it — 112px at 1440,
   enough that the figure stopped reading as evidence FOR that paragraph and
   started looking like a separate block. Margin-collapsing does not apply
   inside a grid, so it has to be removed rather than relied on. */
@media (min-width: 48rem) {
  .arow > .shots { margin-top: 0; }
}

/* The .wcard's frame drifts by ±--parallax-max directly above this, and a
   transform reserves no space — same hazard .wcard__body clears, same fix. */
.wcard > .shots { margin-top: calc(var(--parallax-max) + var(--s-6)); }

.shots__grid {
  display: grid;
  gap: clamp(var(--s-5), 3vw, var(--s-7));
  justify-items: center; /* centres the capped phone while stacked */
}

@media (min-width: 48rem) {
  .shots__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, var(--shot-phone-col));
    align-items: center;
    justify-items: stretch;
  }
}

/* width/height on the element give the intrinsic ratio before the bytes
   arrive, and height:auto keeps it, so the box is reserved at parse time and
   these cannot shift anything. No aspect-ratio here on purpose: every shot has
   its own, and pinning one would crop three of them. */
.shot {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
  /* Both Stripe captures are white to the edge. On a white page they would
     dissolve into it without a boundary of their own, so the hairline is
     load-bearing, not decoration. */
  border: 1px solid var(--line);
  background: var(--surface);
  box-shadow: var(--shadow-elevated);
}

.shot--phone { max-width: var(--shot-phone-w); }

.shots__cap {
  margin-top: clamp(var(--s-5), 2.5vw, var(--s-6));
  max-width: 60ch;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  line-height: 1.6;
  letter-spacing: 0.01em;
  color: var(--slate-meta); /* 4.77:1 on --page */
}

/* ---------- 11. Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
  .chip__cursor { animation: none; opacity: 1; }

  /* Subpage headings sit at their final position from the first frame. The
     global duration override above would already land them there — the
     animation ends at translate 0 — but this stops the mask from being animated
     at all rather than animated instantly. */
  .js .title-line__in {
    animation: none !important;
    translate: none !important;
  }

  /* Contact chips: the checked state lands at once, in its final colours. The
     duration override above is not enough on its own here — it leaves
     transition-delay alone, and the label's colour swap is a 0s transition
     held back by the fill's 460ms, so the text would still turn late. */
  .lead--airy .chip-opt label,
  .lead--airy .chip-opt label::before,
  .lead--airy .chip-opt label::after { transition: none !important; }

  /* No press pop — the colour and shadow changes still acknowledge the click. */
  .btn:active { animation: none !important; }

  /* Buttons: the lift stops lifting, the wipe stops travelling and the arrow
     stops sliding. The fill and the white label are NOT suppressed — they are
     simply already there when the pointer or the focus arrives. Reduced motion
     asks for less movement, not for a state you cannot see. */
  .btn,
  .btn:hover,
  .btn:focus-visible,
  .btn:active {
    transform: none !important;
    translate: none !important;
  }

  .btn > span[aria-hidden],
  .btn:hover > span[aria-hidden],
  .btn:focus-visible > span[aria-hidden],
  .btn:active > span[aria-hidden] { translate: none !important; }

  .btn--outline::before { transition: none !important; }

  /* Arrow links: same rule, same reason — the underline carries the state. */
  .arrow-link:hover .arrow-link__arrow,
  .arrow-link:focus-visible .arrow-link__arrow,
  .arrow-link:active .arrow-link__arrow,
  .slist__link a:hover .slist__arrow,
  .slist__link a:focus-visible .slist__arrow,
  .slist__link a:active .slist__arrow { translate: none !important; }

  /* No nav roll: the duplicate is removed outright and the real label is pinned,
     so hovering changes colour and draws the underline and nothing moves. Not
     "instant roll" — no roll. */
  /* FAQ: the disclosure still opens and closes, it just does so instantly. The
     marker jumps from + to − and the answer is simply there. */
  .faq__sign::after { transition: none !important; }
  .js .faq__item[open] .faq__a { animation: none !important; }

  .nav__roll__dup { display: none !important; }
  .nav__roll__in { transition: none !important; }
  .nav__links a:hover .nav__roll__in,
  .nav__links a:focus-visible .nav__roll__in { translate: none !important; }

  /* Belt and braces: even if JS never runs, reveal content is fully visible
     and never transformed. Applies at first paint — no JS required. */
  .js .reveal,
  .js .reveal.is-revealed {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }

  /* the intro never renders under reduced motion, whatever JS does */
  .intro,
  html.intro-on .intro { display: none !important; }

  /* Mobile dropdown: it still opens and closes, it just does not animate doing
     it. The panel is at full height on the first frame, its contents are simply
     there, and the scrim is already dimmed — no expand, no stagger, no fade.

     --menu-exit goes to 0s as well, so the delay that normally holds the panel
     visible through its collapse collapses too — it hides on the same frame it
     closes, rather than sitting invisible over the page for a third of a
     second. */
  .menu { --menu-exit: 0s; }

  .menu__panel,
  .menu.is-open .menu__panel {
    grid-template-rows: 1fr !important;
    opacity: 1 !important;
    transition: none !important;
  }

  .menu__nav li,
  .menu__cta,
  .menu.is-open .menu__nav li,
  .menu.is-open .menu__cta {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
    transition-delay: 0ms !important;
  }

  .menu__scrim,
  .menu.is-open .menu__scrim {
    opacity: 1 !important;
    transition: none !important;
  }

  /* the hamburger bars snap between the two states rather than sweeping */
  .nav__bars i { transition: none !important; }

  /* Services page: the mark renders complete and completely still — no fly-in,
     no scale, no pulse, no caret blink, and no waiting on the first-scroll gate
     either. Every selector here drops .is-built so it holds in both states: the
     mark is finished from first paint whether or not JS ever sets the class.
     The global duration override above is not enough on its own — it would
     still run the build, just instantly, and the caret would be left frozen at
     whichever keyframe it landed on. */
  .js .brand-panel .brand-panel__br,
  .js .brand-panel .brand-panel__a {
    opacity: 1 !important;
    translate: none !important;
    scale: none !important;
    transition: none !important;
  }

  .js .brand-panel .brand-panel__glow {
    animation: none !important;
    opacity: 0.5 !important;
    scale: none !important;
  }

  /* no flip either — the mark sits at 0deg and never turns */
  .js .brand-panel .brand-panel__mark {
    animation: none !important;
    rotate: none !important;
  }

  /* the caret exists only to blink, so with the blink gone it goes too */
  .brand-panel__caret { display: none !important; }

  /* The dividers stay at their authored resting curve. JS bails out of
     initRules() before it binds anything, so no path is ever rewritten and the
     pull-point dot is never even created — these two are the half that holds
     if that ever changed. */
  .rule-curve { transition: none !important; }
  .rule-curve__dot { display: none !important; }

  /* Lead form: the field, chip and status transitions are already reduced to
     nothing by the global override above. The one thing left is the sending
     spinner — the global rule stops it turning, and a frozen three-quarter arc
     reads as a broken icon rather than as progress, so it is dropped and the
     "Sending…" text carries the state on its own. */
  .lead__status--busy::before { display: none; }

  /* Work showcase: hover scale, parallax and the reveal choreography are all
     off, and the project images sit completely still. JS bails out before it
     writes any of these variables, but the section must also be correct if the
     script never runs at all — hence flat overrides rather than trusting it. */
  .project__frame {
    translate: none !important;
    scale: none !important;
    transform: none !important;
    transition: none !important;
  }

  .js .project.reveal .project__frame,
  .js .project.reveal .device__phone,
  .js .project.reveal .tech li {
    opacity: 1 !important;
    translate: none !important;
    transition: none !important;
  }

  /* The bubble reverts to exactly the static corner button touch devices get:
     always visible, never trailing, never eased. JS skips binding it entirely,
     so no transform is ever written — this is the CSS half. Same specificity as
     the @media (hover: hover) rules it has to undo, so it wins on source order
     alone; the transform is the one thing needing !important, because an inline
     style would otherwise survive from before a mid-session preference change. */
  .js .project__bubble {
    inset-block-end: var(--bubble-inset);
    inset-inline-end: var(--bubble-inset);
    top: auto;
    left: auto;
    margin: 0;
    opacity: 1;
    scale: none;
    transform: none !important;
    transition: none !important;
    will-change: auto;
  }
}

/* =============================================================
   10k. Reveal bands — see initRevealBand() in main.js

   A full-bleed near-black band between two light sections, carrying one
   paragraph of display type that lights up word by word as the band scrolls
   through the viewport. Punctuation, not a section: no heading, no
   landmark, one sentence.

   The paragraph is authored at full white and the accent words at their
   full colour, and that is what the HTML renders: no JS, reduced motion, and
   the failsafe stripping .js all show the finished state. Only once the
   script has split the paragraph do the .rb__w spans exist, and only they
   carry the dim colour. The dim state is 16% white — 1.5:1, deliberately
   below any text threshold — which is exactly why the split is JS-only: the
   faint state is a moment in an animation, never the resting render.

   A lit word inherits: white from the paragraph, or teal/amber from the
   accent span it sits in. The snap is a 120ms linear colour transition and
   nothing else animates — no opacity, no transform, no layout.

   NO PIN. The band is a normal section at the height of its own text and
   padding, and scrolls with the page. Progress (initRevealBand) runs 0 when
   the band's top reaches 85% of the viewport height to 1 when its bottom
   reaches 25%, so the sentence completes while the band is still on screen
   and the page never stops moving. The pinned version this replaced is in
   _archive/2026-09-13-reveal-band-pin.txt. */
.reveal-band {
  background: var(--band-bg);
  color: #FFFFFF; /* 19.3:1 on --band-bg */
}

.reveal-band__inner {
  padding-block: clamp(var(--s-9), 8vw, var(--s-11));
}

.reveal-band__text {
  max-width: 20ch;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.75rem, 4.6vw, 3.875rem); /* 28px – 62px */
  line-height: 1.15;
  letter-spacing: -0.03em;
  text-wrap: pretty;
}

.reveal-band__accent         { color: var(--teal);  } /*  5.15:1 on --band-bg */
.reveal-band__accent--amber  { color: var(--amber); } /*  9.06:1 on --band-bg */

.js .rb__w {
  color: var(--band-dim);
  transition: color 120ms linear;
}

.js .rb__w.is-lit { color: inherit; }

@media (prefers-reduced-motion: reduce) {
  /* JS never splits under reduced motion; this covers a preference that
     changes after the spans already exist */
  .js .rb__w { color: inherit; transition: none; }
}

/* =============================================================
   10k2. Legal pages — /privacy/, /terms/, /cookies/
   Long prose at a readable measure on the page ground. The h1 is the
   shared .page-intro opener; the "Last updated" line takes its sub slot in
   mono. Section headings step down from the display scale so eight of them
   read as a document, not eight section titles.
   ============================================================= */
.legal__updated {
  font-family: var(--font-mono);
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: var(--text-2);
}

.legal__body {
  max-width: 70ch;
  margin-inline: auto;
}

.legal__body h2 {
  margin-top: clamp(var(--s-7), 5vw, var(--s-9));
  font-size: clamp(1.375rem, 1.1rem + 1.1vw, 1.875rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  scroll-margin-top: calc(var(--nav-h) + var(--s-6));
}

.legal__body h2:first-child { margin-top: 0; }

.legal__body p {
  margin-top: var(--s-4);
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.75;
  color: var(--ink);
}

.legal__body a {
  color: var(--teal);
  text-decoration: underline;
  text-underline-offset: 0.2em;
  overflow-wrap: anywhere; /* a 34-char email address must not widen a 155px column */
}

/* Lists and definition lists share the paragraph measure and rhythm. The
   dl (entity details, retention periods) is a two-column grid from 40em:
   term in the mono caption style, value in body ink. Below that it stacks. */
.legal__body ul,
.legal__body dl {
  margin-top: var(--s-4);
  font-size: clamp(1rem, 0.95rem + 0.3vw, 1.0625rem);
  line-height: 1.75;
  color: var(--ink);
}

.legal__body ul { padding-left: 1.25em; }
.legal__body li + li { margin-top: 0.5rem; }
.legal__body li::marker { color: var(--teal); }

.legal__body dl {
  display: grid;
  gap: 0.75rem;
  line-height: 1.6;
}

.legal__body dl > div {
  display: grid;
  gap: 0.25rem;
}

@media (min-width: 40em) {
  .legal__body dl > div {
    grid-template-columns: minmax(12rem, 15rem) 1fr;
    column-gap: var(--s-5);
    align-items: baseline;
  }
}

.legal__body dt {
  font-family: var(--font-mono);
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: var(--text-2);
}

.legal__body code {
  font-family: var(--font-mono);
  font-size: 0.9em;
}

/* Governing-law line: a footnote after the last section, not a section.
   Scoped under .legal__body so it outranks the paragraph rule above. */
.legal__body .legal__closing {
  margin-top: clamp(var(--s-7), 5vw, var(--s-9));
  padding-top: var(--s-5);
  border-top: 1px solid var(--line);
  font-size: 0.9375rem;
  color: var(--text-2);
}

/* =============================================================
   10l. Card edges

   The gradient borders are background layers, and forced-colors mode (Windows
   High Contrast) drops background images. Left alone, the cards would keep a
   transparent 1px border and lose their edge entirely; CanvasText gives them
   back the system's own foreground as a plain border.
   ============================================================= */
@media (forced-colors: active) {
  .scard,
  .js .slist__item,
  .compliance__card { border-color: CanvasText; }
}
