/*
 * Base stylesheet — always shipped, regardless of which optional plug-ins
 * (like the contact form) are enabled in site.config.json.
 *
 * Plug-in-specific styles live in their own files (e.g. css/contact-form.css)
 * so they can be added or removed as a unit without touching this file.
 */

:root {
  --color-bg: #000000;
  --color-text: #ffffff;
  --color-accent: #8b5cf6;

  /* Straight off the logo mark's own radial gradient, so the service blobs
     read as the same family as the brand rather than a new palette. */
  --brand-magenta: #be00a8;
  --brand-violet: #8b00ff;

  /* The service names in their cards. Not a flat colour: the two stops are
     the bubbles' own, so the headings and the artwork behind them are lit
     from the same palette. */
  --brand-blend: linear-gradient(96deg, var(--brand-violet), var(--brand-magenta));

  /* The ground colour of the background artwork, reused for the service
     names so the two halves of the page share a palette. Only ever used at
     display sizes: against white it is about 3:1, which carries large bold
     text but would fail for body copy. */
  --brand-orange: #fe5728;

  /* The tone the background artwork's dark shapes actually render at. The bar
     is painted in it so it disappears against them and shows only where
     something bright passes underneath — at anything else you get a faint
     band along the bar's bottom edge.

     Note this is 28, not the 29 the background SVG declares (#1d1d1d): its
     gooey filter shifts the fill by a level on the way out. Sample the
     rendered pixels when swapping backgrounds rather than reading the value
     out of the file. */
  --color-ground: 28 28 28;

  /* The page turns over to white below the hero. Ink is not pure black: on a
     white ground it reads as harsh next to the softer brand colours. */
  --color-paper-rgb: 255 255 255;
  --color-ink-rgb: 23 23 29;
  --color-shadow-rgb: 0 0 0;
  --color-paper: rgb(var(--color-paper-rgb));
  --color-ink: rgb(var(--color-ink-rgb));
  --color-ink-muted: #5c5c68;

  /* One column width and one side gutter for the whole page. Every section
     measures itself against these, so the nav's logo, the hero copy and the
     services heading all start on the same line down the page. */
  --page-max: 1100px;
  --page-gutter: 1.5rem;

  /* Height of the curve where the dark half gives way to the white one. The
     bubble layer behind the services matches it so the two edges agree. */
  --page-curve: 8vh;
  /* The type scale. Every font-size on the site comes from here, so a size
     cannot be invented in one section and not another. Roles, not heading
     levels: the service names are visually larger than the hero's heading
     even though they sit a level below it, because on the barrel they are
     the thing being read.

     The large sizes are fluid, which is also why the stylesheet has no
     font-size inside a media query — one clamp covers every width. */
  --text-sm: 0.875rem;                        /* labels, footer, nav buttons */
  --text-base: 1rem;                          /* buttons, default body */
  --text-md: clamp(1.0625rem, 0.4vw + 0.95rem, 1.25rem); /* long-form body */
  --text-lg: clamp(1.125rem, 1.6vw + 0.6rem, 1.75rem);   /* h2 */
  --text-xl: clamp(1.5rem, 2.4vw + 0.7rem, 2.25rem);     /* h3 */
  --text-2xl: clamp(1.75rem, 3vw + 0.9rem, 2.75rem);     /* h1 */

  --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* Width of the logo in its full-size hero state — and its actual layout
     size, so main.js can measure it straight off the element. */
  --hero-logo-width: min(70vw, 280px);

  /* How far the background zooms: the .bg layer is oversized by 1/--bg-zoom
     so that at the top of the page it can be shown at 1:1 (cropped, i.e.
     zoomed in) and scaled *down* to fit as you scroll. Scaling down only
     means the pattern never gets upscaled and blurry. */
  --bg-zoom: 1;

  /* Width of the logo's mark once it has docked in the nav. Everything
     else about the brand's size derives from this and the two SVGs' own
     aspect ratios, so there is one number to change. */
  --brand-width: 72px;

  /* One full turn on the way down. Negative so the spin reads clockwise as
     the page scrolls (the angle runs from this value back up to 0). */
  --brand-spin: -360deg;

  /* How far the deck has been slid up so the portfolio designs clear the
     bottom of a small screen, 0..1. Zero, and inert, wherever the deck
     already fits — see main.js. */
  --s: 0;
  --deck-lift: 0px;

  /* Fractional index of the service facing front on the barrel: 0 is the
     first, 1 the second, and values between are mid-turn. */
  --sv: 0;

  /* How far the barrel has opened into the grid, 0..1. */
  --sg: 0;

  /* Progress of the portfolio wheel-out, 0..1, running over the stretch of
     hero scroll *after* the logo has docked and the panels have arrived. A
     second phase rather than more of --p, so the two can be retimed
     independently. */
  --q: 0;

  /* Progress of the hero -> nav transition, 0 (top of page) to 1 (docked).
     main.js eases this toward the scroll position frame by frame — it lags
     the scrollbar on purpose — and every animated property below is derived
     from it, so nothing can fall out of step. The 0 here is also the no-JS
     fallback: the logo simply stays in the hero. */
  --p: 0;
}

/* Use border-box sizing everywhere so padding/border never blow out
   an element's declared width — one less surprise when laying out forms
   and containers. */
* {
  box-sizing: border-box;
}

main {
  overflow-x: clip;
}

/* Only the service anchors carry scroll-snap-align, so this affects nothing
   else on the page. `proximity` rather than `mandatory`: mandatory would pull
   the long hero scroll toward the services section from a long way off. */
html {
  scroll-snap-type: y proximity;
}

body {
  margin: 0;
  font-family: var(--font-body);
  color: var(--color-text);
  background: var(--color-bg);
  line-height: 1.5;
}

a {
  color: var(--color-accent);
}

/* Defaults by level, so a heading added anywhere is already on the scale.
   Sections that need a different visual weight say so with a class. */
h1 {
  font-size: var(--text-2xl);
}

h2 {
  font-size: var(--text-lg);
}

h3 {
  font-size: var(--text-md);
}

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

/* Oversized and pinned to the viewport, so it stays put while the page
   scrolls and only its scale changes. transform (rather than
   background-size) keeps the zoom on the compositor. */
.bg {
  position: fixed;
  top: calc(50% - var(--bg-zoom) * 50vh);
  left: calc(50% - var(--bg-zoom) * 50vw);
  width: calc(var(--bg-zoom) * 100vw);
  height: calc(var(--bg-zoom) * 100vh);
  z-index: -2;
  background: var(--color-bg);
  transform: scale(calc(1 - (1 - 1 / var(--bg-zoom)) * var(--p)));
  will-change: transform;
}

/* The background SVG is inlined into the page at build time so main.js can
   drive its SMIL clock; sizing it here does the job `background-size: cover`
   used to (the build sets preserveAspectRatio="slice" to match). */
.bg > svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* --- Header / nav ----------------------------------------------------- */

/* The bar itself only fades in as the logo docks into it — at the top of
   the page there is nothing to see but the logo in the middle of the hero. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
}

/* The bar has no surface of its own until something bright passes under it.
   Rather than frosting the artwork — blur only smears a flat, hard-edged
   vector shape into a glow, and glass is the wrong idiom for a flat graphic
   background anyway — it lays down a scrim in the same tone the artwork sits
   on. Against the dark parts of the background that is invisible; against the
   orange it reads as the shape passing behind the bar.

   It lives on a pseudo-element so it can extend past the bar and fade out
   below it, without the logo and buttons inside fading with it. */
.site-header::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;

  /* Flat ground tone, ending on a clean line. It used to run past the bar and
     fade out below it; that soft tail was the dated part, and it is also what
     smeared the artwork underneath. Against the dark background this is still
     invisible and only shows where something bright passes behind. */
  background: rgb(var(--color-ground));

  /* Nothing to protect until the logo is on its way into the bar, so the bar
     stays out of the way through the hero and arrives over the last of the
     transition. */
  opacity: clamp(0, calc((var(--p) - 0.6) / 0.4), 1);
}



.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 0.75rem var(--page-gutter);
}

/* .brand-slot is deliberately left untransformed: main.js measures it to
   find where the logo has to land, and a transform on the element itself
   would make that measurement chase its own tail. */
/* Right of the logo. Positioned so it paints above the header's scrim: that
   scrim is an absolutely-positioned ::before, and positioned boxes paint over
   static in-flow ones, so without this the buttons lay out correctly and are
   simply never seen. .brand-slot is relative for the same reason.

   They arrive with the bar rather than floating over the artwork before there
   is a bar to sit in — same gate as the scrim, so the nav assembles as one
   thing. Not clickable until then: main.js flips the class, because
   pointer-events cannot be derived from an opacity that is mid-fade. */
.nav-actions {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  opacity: clamp(0, calc((var(--p) - 0.6) / 0.4), 1);
  pointer-events: none;
}

.site-header.is-docked .nav-actions {
  pointer-events: auto;
}

.blob-button--sm {
  padding: 0.5rem 1rem;
  font-size: var(--text-sm);
}

/* Below this the bar cannot hold both without crowding the logo, and shrinking
   the mark to make room is not the trade to make — so the secondary link goes
   and the one that matters stays. It is still in the hero, and will be in the
   footer, so nothing is lost. */
@media (max-width: 29.99em) {
  /* Qualified so it outranks `.blob-button { display: inline-flex }`, which
     appears later in this file at equal specificity and would otherwise win
     on source order alone. */
  .nav-actions .nav-secondary {
    display: none;
  }
}

@media (min-width: 30em) {
  .nav-actions {
    gap: 0.75rem;
  }

  .blob-button--sm {
    padding: 0.6rem 1.35rem;
  }
}

.brand-slot {
  position: relative;

  /* Never shrinks. A flex item is allowed to shrink below its own width when
     the row is tight, which is how the mark was getting squeezed once two
     buttons sat beside it. */
  flex: none;

  width: var(--brand-width);
  aspect-ratio: 40 / 28;
  line-height: 0;
}

/* Travel and size only — anchored top left so the maths in main.js stays a
   straight "where does this corner have to start" calculation.

   The scale only ever runs downward: 1 in the hero, about a quarter of that
   in the nav. That direction matters. The browser rasterises this at its
   layout size, and a raster scaled up is a blurry, subtly misshapen raster —
   laying the logo out small and blowing it up 3.9x is what made it look soft
   and distorted on the way back up the page. */
.brand {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: top left;
  transform:
    translate(
      calc(var(--brand-dx, 0px) * (1 - var(--p))),
      calc(var(--brand-dy, 0px) * (1 - var(--p)))
    )
    scale(calc(1 - (1 - var(--brand-scale-min, 1)) * var(--p)));
}

/* The spin is a separate element purely so it can turn about the mark's own
   centre while its parent scales from a corner. Its box is the *mark* only,
   which is what the nav reserves once the wordmark has gone; the taller
   lockup hangs out of the bottom and orbits with it. */
.brand-spin {
  /* The spin holds off until the wordmark below has finished dissolving, so
     what turns is the bare mark rather than a lockup with upside-down text
     hanging off it. Remapped onto the remaining 0.25..1 of the scroll. */
  --spin-progress: clamp(0, calc((var(--p) - 0.25) / 0.75), 1);

  display: block;
  position: relative;
  width: var(--hero-logo-width);
  aspect-ratio: 40 / 28;
  transform: rotate(calc(var(--brand-spin) * (1 - var(--spin-progress))));
}

.brand-logo {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
}

/* The bare mark sits underneath at full opacity the whole way, so the shape
   never dips in brightness mid-crossfade — only the wordmark on top of it
   actually fades, and it is gone well before the logo finishes docking. */
.brand-logo--full {
  opacity: clamp(0, calc((0.3 - var(--p)) * 4), 1);
}

/* --- Hero ------------------------------------------------------------- */

/* Tall enough for both phases with room left over: main.js spends the first
   2.3 screenfuls docking the logo, another 2.3 turning the portfolio wheel,
   and the rest is a beat of stillness before the content starts. Raising the
   spans in main.js means raising this too, or the sequence runs off the end
   of the available scroll.
   The logo is not in here — it is the fixed .brand above, transformed into
   the middle of this section's first screenful. */
.hero {
  position: relative;

  /* Longer on a phone than on desktop, to pay for the stage's bottom margin
     below — see there. The margin brings the pin's release forward, so the
     hero has to grow by at least as much or the wheel would still be turning
     when the deck starts to move.

     Sized for the *worst* case, which is a short screen: there the deck
     overflows the stage badly, the lift needed to bring the designs into view
     is large (~200px against ~15px on a tall phone), and that pushes the end
     of the wheel's turn out by the same amount. Tuning this against a
     comfortable viewport leaves the tightest one a few pixels from failing. */
  min-height: 660vh;

  /* Catches the portfolio designs as they sail off the side. `clip` rather
     than `hidden`: hidden would turn this into a scroll container and break
     the sticky stage inside it, and it would also add a horizontal
     scrollbar to the page. */
  overflow-x: clip;
}

/* --- Blob buttons ------------------------------------------------------- */

/* Registering these makes them animatable. A custom property is an untyped
   string by default, so the browser can only flip it from one value to the
   next; typed as a colour or a percentage it can interpolate, which is what
   lets the gradient's blend and its focal point drift on hover rather than
   snapping.

   The two initial-values below are the only literal colours outside :root, and
   they have to be: initial-value must be a value the browser can resolve with
   no context, so var() is not allowed there. They restate --brand-magenta and
   --brand-violet, and are never the values actually painted — the rules below
   set both properties from the tokens before either is used. */
@property --blob-tint-a {
  syntax: "<color>";
  inherits: false;
  initial-value: #be00a8; /* = --brand-magenta */
}

@property --blob-tint-b {
  syntax: "<color>";
  inherits: false;
  initial-value: #8b00ff; /* = --brand-violet */
}

@property --blob-focus-x {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 25%;
}

@property --blob-focus-y {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 18%;
}

/* Never wraps: two short labels side by side is the whole design, and a
   stacked pair reads as a list of options rather than a choice. The padding
   below is sized so both fit the narrowest phone. */
.actions {
  display: flex;
  flex-wrap: nowrap;
  gap: 0.75rem;
  margin-top: 2.25rem;
}

/* Both of these are already in the nav bar, which is pinned on screen the whole
   way down the page. On a phone that is the same two words twice in one
   viewport, and the wordmark, the summary and the designs all want the room
   more than a duplicate does.

   It sits directly below `.actions` rather than up with the other phone-width
   rules: the two selectors have equal specificity, so whichever comes last
   wins, and above `.actions` this rule does nothing at all. */
@media (max-width: 29.99em) {
  .hero-actions {
    display: none;
  }
}

/* At rest these are just white words. The blob is a pseudo-element behind the
   label, so it can fade in and morph on its own without the text ever moving
   or dimming with it. `isolation` gives the button its own stacking context,
   which is what lets the blob sit at z-index -1 — behind the label but still
   in front of the page. */
.blob-button {
  --blob-tint-a: var(--brand-magenta);
  --blob-tint-b: var(--brand-violet);
  --blob-focus-x: 25%;
  --blob-focus-y: 18%;

  position: relative;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.85rem 1.5rem;
  white-space: nowrap;

  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-text);
  text-decoration: none;
  cursor: pointer;
}

.blob-button::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;

  border-radius: 58% 42% 47% 53% / 52% 46% 54% 48%;
  background: radial-gradient(
    140% 180% at var(--blob-focus-x) var(--blob-focus-y),
    var(--blob-tint-a),
    var(--blob-tint-b)
  );

  opacity: 0;
  transition:
    opacity 0.45s ease,
    border-radius 0.6s cubic-bezier(0.2, 0.8, 0.25, 1),
    --blob-tint-a 0.6s ease,
    --blob-tint-b 0.6s ease,
    --blob-focus-x 0.6s ease,
    --blob-focus-y 0.6s ease;
}

/* The pair start from opposite corners of the gradient, so they read as two
   of a kind rather than the same button twice. */
.blob-button--alt::before {
  --blob-tint-a: var(--brand-violet);
  --blob-tint-b: var(--brand-magenta);
  --blob-focus-x: 75%;

  border-radius: 45% 55% 60% 40% / 48% 55% 45% 52%;
}

/* The blob arrives, then keeps turning over slowly for as long as you stay on
   it — the shape never settles, which is the point. */
.blob-button:hover::before,
.blob-button:focus-visible::before {
  opacity: 1;
  animation: blob-morph 9s ease-in-out infinite;
}

@keyframes blob-morph {
  0%,
  100% {
    border-radius: 58% 42% 47% 53% / 52% 46% 54% 48%;
  }

  25% {
    border-radius: 45% 55% 62% 38% / 48% 57% 43% 52%;
  }

  50% {
    border-radius: 52% 48% 40% 60% / 58% 43% 57% 42%;
  }

  75% {
    border-radius: 62% 38% 52% 48% / 44% 52% 48% 56%;
  }
}

/* The hero pair wears its blob all the time — these are the page's calls to
   action, and a link that only looks like a button once you touch it is no
   use to someone deciding whether to. Hover shifts the shape a little and
   sets the gradient travelling around it, rather than introducing anything
   new. */
.blob-button--filled::before {
  opacity: 1;
}

.blob-button--filled:hover::before,
.blob-button--filled:focus-visible::before {
  border-radius: 47% 53% 55% 45% / 56% 48% 52% 44%;
  animation: blob-flow 7s ease-in-out infinite;
}

.blob-button--filled.blob-button--alt:hover::before,
.blob-button--filled.blob-button--alt:focus-visible::before {
  border-radius: 55% 45% 44% 56% / 45% 57% 43% 55%;
}

/* Walks the gradient's focal point round the blob. Only the focal point moves,
   so the colour appears to flow across the shape while the shape itself holds
   the position hover put it in. Animating custom properties like this is only
   possible because they are registered with a type — see @property above. */
@keyframes blob-flow {
  0%,
  100% {
    --blob-focus-x: 25%;
    --blob-focus-y: 18%;
  }

  25% {
    --blob-focus-x: 78%;
    --blob-focus-y: 32%;
  }

  50% {
    --blob-focus-x: 74%;
    --blob-focus-y: 82%;
  }

  75% {
    --blob-focus-x: 20%;
    --blob-focus-y: 72%;
  }
}

.blob-button:focus-visible {
  outline: 2px solid var(--color-text);
  outline-offset: 4px;
}

@media (prefers-reduced-motion: reduce) {
  .blob-button::before {
    transition: opacity 0.2s ease;
  }

  .blob-button:hover::before,
  .blob-button:focus-visible::before {
    animation: none;
  }
}

/* --- Portfolio wheel --------------------------------------------------- */

/* The third column of the deck, not something sitting inside it — a window
   onto a wheel far bigger than the column, turning as you scroll.
   Mobile-first: the hub sits below, so the designs cross the top of the wheel
   left to right. On wide screens the hub moves out to the right instead and
   they climb the wheel's left-hand side. Clockwise either way.

   The dials: more items, a wider gap, a bigger radius (a flatter arc), or
   larger designs are all one number. Gap on the rim is radius x step, so
   raising the radius spaces them out without leaning the neighbours over any
   harder, while raising the step leans them further. */
.wheel {
  --wheel-count: 5;
  /* The design that is dead centre when the hub has not turned. Derived, not
     typed: it was left at 2.5 when the count went from six designs to five,
     which started the wheel half a step past the first design — so the first
     one in the markup was already fading out before the page had been
     scrolled at all. */
  --wheel-mid: calc((var(--wheel-count) - 1) / 2);
  --wheel-step: 16deg;

  /* Grows with the screen: a fixed width leaves the designs looking stranded
     on a large phone or a tablet, which is still this layout right up to
     48em. Floor keeps small phones legible; the ceiling stops them running
     away with the layout on the widest screens this applies to — at 720px a
     design was overbearing by the time the viewport reached 767px, so the cap
     comes in around 550px of viewport and holds from there to the
     breakpoint. */
  --wheel-item-width: clamp(440px, 98vw, 510px);

  /* Derived from the width rather than set flat, so the gap between designs
     stays put as they grow. Spacing on the rim is radius x sin(step); this
     keeps that a constant ~40px clear of each design at any size. */
  --wheel-radius: calc(var(--wheel-item-width) * 0.75 + 145px);

  /* How far the hub has turned, counted in steps rather than degrees, so each
     design can work out where it is without redoing the sum. Zero means the
     middle one is dead centre in the column. */
  --hub-turn: calc((var(--q) - 0.5) * (var(--wheel-count) - 1));

  /* Where a design starts and finishes fading, in steps travelled past
     centre. Wide screens send them up towards the nav and they have to be
     gone before they reach it; across the top of a phone there is no nav in
     the way, so they can run much closer to the edge. */
  --wheel-fade-start: 1.5;
  --wheel-fade-end: 2.3;

  position: relative;
  /* Only the layout box — the designs are centred on it and spill well past
     it, so this shrinks the space taken, not the artwork. */
  min-height: 185px;

  /* How far the designs hang below that box: the arc drops the outer ones a
     little lower than the centred one. main.js adds this when working out how
     far the deck has to slide up on a short screen, otherwise the lift stops
     just short and leaves them clipped. */
  --wheel-spill: 28px;

  /* Deliberately not clipped: the designs are bigger than this column and are
     meant to sail out past the edge of the screen. .hero does the clipping,
     with overflow-x: clip rather than hidden, because hidden would make it a
     scroll container and strand the sticky stage inside it. */
  overflow: visible;
}

/* A zero-size point to rotate around, one radius below the middle of the
   column, so the wheel's twelve-o'clock position lands dead centre. */
.wheel-hub {
  position: absolute;
  left: 50%;
  top: calc(50% + var(--wheel-radius));
  width: 0;
  height: 0;

  /* Increasing angle is clockwise: along the top of the wheel that reads as
     left-to-right, up its left-hand side it reads as upwards. Runs from half
     a sweep behind to half a sweep ahead, so every design passes through the
     centre of the column in turn. */
  transform: rotate(calc(var(--hub-turn) * var(--wheel-step)));
}

/* Placed on the rim by rotating out to its own angle and pushing one radius
   along it. No counter-rotation needed here: twelve o'clock is already
   upright, so a design leans by exactly how far it has travelled. */
.wheel-item {
  --angle: calc(0deg - (var(--i) - var(--wheel-mid)) * var(--wheel-step));

  /* How far this one has travelled past the middle of the column, in steps.
     Negative is still on its way in, 0 is centred, positive is on its way
     out. */
  --travel: calc(var(--hub-turn) - (var(--i) - var(--wheel-mid)));

  opacity: clamp(
    0,
    calc(
      (var(--wheel-fade-end) - var(--travel)) /
        (var(--wheel-fade-end) - var(--wheel-fade-start))
    ),
    1
  );

  position: absolute;
  left: 0;
  top: 0;
  width: var(--wheel-item-width);
  margin: 0;

  translate: -50% -50%;
  transform: rotate(var(--angle)) translateY(calc(-1 * var(--wheel-radius)));

  /* The mockups are PNGs on transparency, so the shadow has to follow the
     alpha rather than a box. */
  filter: drop-shadow(0 14px 26px rgb(var(--color-shadow-rgb) / 0.55));
}

.wheel-item img {
  display: block;
  width: 100%;
  height: auto;
}

@media (min-width: 48em) {
  .wheel {
    /* Scales with the screen, same as the phone layout — a flat width leaves
       the designs looking small on a large monitor. */
    --wheel-item-width: clamp(1020px, 74vw, 1440px);

    /* Climbing the side of the wheel, the gap that matters is the vertical
       one, and a design is tall — so this arc is both wider and flatter than
       the phone one, where they pass edge to edge and can sit closer. Derived
       from the width, so the clearance between designs holds as they grow. */
    --wheel-step: 22deg;
    --wheel-radius: calc(var(--wheel-item-width) * 1.132 + 120px);

    /* These used to be dictated by the nav: a design had to be fully faded
       before it reached the bar, so a taller design — which climbs into the
       bar in fewer steps — had to be paid for out of the fade window, and
       size and fade could not be chosen independently.

       That is no longer the deal. The bar has an opaque surface of its own
       and paints over the deck, so a design may pass behind it and the fade
       is free to be about the composition instead: a design holds full
       strength for half a step past centre, then fades over the next
       three-quarters of a step as it climbs away.

       What replaced the old constraint is 'the nav bar paints over the
       portfolio', which hit-tests across the bar at every --q where a design
       is behind it. If the bar ever loses that fight — an ancestor stacking
       context outranking it, say — the fade will not save it and the check
       is what says so. */
    --wheel-fade-start: 0.55;
    --wheel-fade-end: 1.30;

    min-height: 420px;
  }

  /* Hub swings out to the right of the column, so what shows is the wheel's
     left-hand side and the designs climb it rather than crossing the top. */
  .wheel-hub {
    left: calc(50% + var(--wheel-radius));
    top: 50%;
  }

  /* -90deg is the nine-o'clock position; the matching +90deg at the end of
     the transform cancels that base tilt, so a design is upright exactly as
     it passes the middle of the column. */
  .wheel-item {
    --angle: calc(-90deg - (var(--i) - var(--wheel-mid)) * var(--wheel-step));

    transform:
      rotate(var(--angle))
      translateY(calc(-1 * var(--wheel-radius)))
      rotate(90deg);
  }
}

/* --- Hero panels ------------------------------------------------------ */

/* Sticks for the length of the hero so the panels hold in the middle of the
   viewport while the logo finishes docking, then scroll away with it.
   Fixed at one viewport tall on purpose: a sticky box taller than the screen
   pins its top and hides its own bottom. Worth revisiting once the panels
   carry real content and the stacked layout gets tall. */
/* Pinned at every size. Letting it flow on phones was tried and was worse:
   the deck slid away before the logo had finished docking, so the two moves
   stopped reading as one sequence. Holding it still keeps the choreography,
   and the room it needs is found by shrinking the wheel's *box* instead —
   the designs overflow that box anyway, so a shorter one lifts them up the
   screen without making them any smaller. */
.hero-stage {
  position: sticky;
  top: 0;
  display: flex;
  height: 100vh;
  padding: 4rem 0 1.25rem;

  /* Releases the pin this far short of the hero's end, leaving a stretch of
     empty hero before the white section arrives. Without it the white rises
     into view while the deck is still pinned — and on a phone the designs sit
     at the bottom of that pinned screen, so the white met them first. */
  margin-bottom: 22vh;

  /* Start-aligned rather than centred, so the top padding is always honoured.
     Centring looks the same whenever the deck fits, but when it does not —
     the stacked layout on a short screen — centring splits the overflow
     across both edges and pushes the first panel up under the nav. */
  align-items: flex-start;
}

@media (min-width: 48em) {
  /* Side by side always fits inside a screen, so the stage can pin and hold
     the deck in the middle while the wheel turns beside the copy. */
  .hero-stage {
    align-items: center;
    padding: 8rem 0 3rem;

    /* Same job as on a phone, just less of it: release the pin short of the
       hero's end so the wheel has finished and the deck has begun to leave
       before the white section rises into view. It has to exceed the curve's
       own height, or the white still arrives first. */
    margin-bottom: 16vh;
  }

  .hero {
    min-height: 620vh;
  }
}

/* Stacked by default, three across once there is room — no in-between
   two-column state, which would leave one panel orphaned on its own row. */
.panel-deck {
  display: grid;
  /* Slides up on a screen too short to hold the whole deck, so the designs
     are in view before the wheel starts turning. */
  transform: translateY(calc(-1 * var(--deck-lift) * var(--s)));

  /* The space between the buttons and the portfolio designs. It is the only
     thing setting that gap, so it is the dial to turn — but the designs sit
     below it and have to stay on screen, so widening it past this starts
     clipping them on a shorter phone. */
  gap: 4rem;
  width: 100%;
  max-width: var(--page-max);
  margin: 0 auto;

  /* Gutter inside the cap, matching .nav and the sections below, so the hero
     copy starts on the same line as the nav logo and the services heading. */
  padding-inline: var(--page-gutter);

  grid-template-columns: 1fr;

  /* Invisible panels must not swallow clicks meant for what is behind them,
     so the deck only becomes interactive once main.js says it has arrived. */
  pointer-events: none;
}

.panel-deck.is-revealed {
  pointer-events: auto;
}

@media (min-width: 48em) {
  /* The copy takes the larger share: the wheel spills well past its column in
     any case, so the width it is given matters far less than the text's. */
  .panel-deck {
    grid-template-columns: 1.4fr 1fr;
  }
}

/* Each column reveals itself off the shared scroll progress, staggered so
   they arrive in sequence rather than as one block, with the last landing
   just as the logo docks. Applied to the deck's children rather than to
   .panel, because the third column is the wheel and not a panel at all. */
.panel-deck > * {
  --reveal-start: 0.45;
  --reveal-span: 0.4;
  --reveal: clamp(0, calc((var(--p) - var(--reveal-start)) / var(--reveal-span)), 1);

  opacity: var(--reveal);
  transform: translateY(calc(2rem * (1 - var(--reveal))));
}

.panel-deck > *:nth-child(2) {
  --reveal-start: 0.53;
}

/* Framed by two soft pools rather than a card: both gradients fade to nothing
   well before the edges, so the copy gets a ground to sit on without a box
   drawn around it. The lower one is the same ground tone as the nav scrim, so
   it disappears against the dark background and only does visible work when
   something bright drifts behind the text; the faint white one above it lifts
   the block just enough to read as deliberate. */
.panel {
  max-width: 34rem;

  /* No side padding on a phone — the stage already insets the whole deck, and
     a second inset just narrows the measure for no gain. The pool still frames
     the copy vertically. */
  padding: 5rem 0 0 0;

  /* `closest-side` is doing the important work: it lands the final, fully
     transparent stop exactly on the nearest edge, so the pool can never be
     cut off by the element's box. Sized any larger and the gradient is still
     opaque where the box ends, which draws a hard rectangle.

     A single whitish pool, not a dark one. It used to lay the ground tone
     down underneath, which read fine over the dark artwork but turned the
     orange muddy whenever a shape drifted behind the copy. Lifting in white
     instead sits well on both — over dark it is a soft glow, over orange it
     barely registers. */
  background: radial-gradient(
    ellipse closest-side at 50% 50%,
    rgb(var(--color-paper-rgb) / 0.1),
    rgb(var(--color-paper-rgb) / 0.05) 55%,
    rgb(var(--color-paper-rgb) / 0)
  );
}
@media (min-width: 48em) {
  /* Must come after the base .panel rule, not before it: same specificity, so
     source order decides. Room for the side padding here, and the column
     governs the width rather than a cap. */
  .panel {
    max-width: none;

    /* Vertical only. Side padding here pushed the copy inboard of every other
       section's left edge, which is exactly the inconsistency being removed —
       the pool behind it is sized off this box and still reaches wider than
       the text. */
    padding: 3rem 0;
  }

  .panel-deck {
    gap: 1.5rem;
  }

  .panel-logo {
    width: min(96%, 520px);
    margin-bottom: 2.25rem;
  }

  .panel h2 {
    margin-bottom: 0.75rem;
  }

  .panel-lede {
    margin-top: 1.25rem;
  }

  .actions {
    gap: 1rem;
    margin-top: 2.25rem;
  }

  /* The hero pair only — the nav pair keeps its own smaller sizing above. */
  .blob-button--filled {
    padding: 1.05rem 2.4rem;
  }
}

.panel-lede {
  margin: 1.1rem 0 0;
  max-width: 32rem;
  font-size: var(--text-md);
  line-height: 1.6;
  color: rgb(var(--color-paper-rgb) / 0.76);
}

.panel-logo {
  display: block;
  width: min(72%, 260px);
  height: auto;
  margin-bottom: 1.5rem;
}

.panel h2 {
  margin: 0 0 0.5rem;
  font-size: var(--text-lg);
}

.panel p {
  margin: 0;
  color: rgb(var(--color-paper-rgb) / 0.72);
}

.scroll-cue {
  position: fixed;
  bottom: 2rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  font-size: var(--text-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgb(var(--color-paper-rgb) / 0.7);
  opacity: calc(1 - var(--p));
  pointer-events: none;
}

/* --- The light half of the page ---------------------------------------- */

/* Everything past the hero sits on white. It is an ordinary block in flow, so
   it simply scrolls up over the fixed background artwork — no second fixed
   layer to keep in step, and nothing for JavaScript to drive. */
.page-light {
  position: relative;
  color: var(--color-ink);
  background: var(--color-paper);
}

/* The boundary is a shape, not a fade. A wide, shallow dome rising into the
   last of the hero — the same language as the blob artwork and the mark, and
   it lets the orange stay fully saturated right up to the edge instead of
   desaturating through grey on its way to white, which is what a gradient
   blend was doing to it.

   It is wider than the viewport on purpose: only the flat middle of the arc
   is on screen, so the curve reads as a gentle bow rather than a dome with
   two visible ends. main clips the overhang. */
.page-light::before {
  content: "";
  position: absolute;
  left: -12%;
  right: -12%;
  bottom: 100%;
  height: var(--page-curve);
  pointer-events: none;

  background: var(--color-paper);
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}

.services-section {
  position: relative;
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 2rem var(--page-gutter) 2rem;
}

/* The SVG inside the layer below is sticky, so it holds still in the viewport
   while the section scrolls past it. As the grid opens the bubbles are blown
   outward and faded off, which is what leaves the section plain white by the
   time the cards have landed. */
/* Spans the whole light half rather than the capped section, so the bubbles
   run the full width of the page, and reaches up past its top edge by the
   curve's own height with the same curved top — otherwise there is a band of
   plain white between the curve and where the bubbles begin.

   The inset and radius are deliberately the same numbers as .page-light::before
   above: they describe the same arc, so the two edges agree. Change one and
   change the other. */
.services-bg {
  position: absolute;
  left: -12%;
  right: -12%;
  top: calc(-1 * var(--page-curve));
  bottom: 0;
  pointer-events: none;
  border-radius: 50% 50% 0 0 / var(--page-curve) var(--page-curve) 0 0;

  /* Not z-index: -1. Neither this nor .page-light establishes a stacking
     context, so a negative index would put the bubbles behind .page-light's
     white background — painted, but never seen. They sit at 0 and the content
     above them is lifted instead. */
  z-index: 0;

  /* `clip`, not `hidden`: hidden would make this a scroll container and the
     sticky SVG inside would pin to it rather than to the viewport, so the
     bubbles would scroll away with the section instead of drifting past. */
  overflow: clip;
}

.services-scroller {
  position: relative;
  z-index: 1;
}

.services-bg > svg {
  position: sticky;
  top: 0;
  display: block;
  width: 100%;
  height: 100vh;

  transform: scale(calc(1 + var(--sg, 0) * 0.9));
  opacity: calc(1 - var(--sg, 0));
  will-change: transform;
}

.services-section > h2 {
  margin: 0 0 2.5rem;
  font-size: var(--text-2xl);
  line-height: 1.15;
}

/* --- Services ----------------------------------------------------------- */

.services-label {
  margin: 0 0 1.5rem;
  text-align: center;
  font-size: var(--text-sm);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-ink-muted);
}

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

.service h3 {
  margin: 0 0 0.5rem;
  font-size: var(--text-lg);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.service p {
  margin: 0;
  font-size: var(--text-sm);
  line-height: 1.55;
  color: var(--color-ink-muted);
}

/* Back at 48em, where it belongs: it was held to 64em only because "Hosting
   and support" wrapped in a half-width card at the first two-column tier, and
   the ampersand took it back under. The one-line check is what guards this —
   a longer service name added later will fail it here rather than quietly
   colliding on the barrel. */
@media (min-width: 48em) {
  .service h3 {
    font-size: var(--text-xl);
  }
}

@media (min-width: 48em) {
  .service p {
    font-size: var(--text-md);
    line-height: 1.65;
  }
}

/* One dwell of scroll per service to turn the barrel, plus one more for it to
   open into the grid. The extra 100vh is slack so the stage stays pinned
   through the last of it rather than releasing mid-move.

   This runs at every width. A phone gets the same barrel and the same 2x2
   grid, just tighter — two columns rather than four stacked cards, because
   four narrow cards in a column are taller than a phone screen and the
   pinned stage would not be able to show them all at once. */
.services-scroller {
  --service-count: 4;
  --service-dwell: 60vh;
  --service-sticky-top: max(6.4rem, 14vh - 0.3rem);
  --barrel-lift: -6rem;

  position: relative;
  height: calc(var(--service-count) * var(--service-dwell) + 100vh);
}

/* Anchor k is the scroll position where service k is centred on the barrel;
   the last one is where the grid has finished opening. Being the only snap
   targets on the page, scrolling settles on one of them rather than
   halfway through a turn. */
.service-snap {
  position: absolute;
  left: 0;
  width: 1px;
  height: 1px;
  top: calc(var(--k) * var(--service-dwell) - var(--service-sticky-top));
  scroll-snap-align: start;
}

.service-stage {
  position: sticky;
  top: var(--service-sticky-top);
  transform: translateY(calc(var(--barrel-lift, 0px) * (1 - var(--sg, 0))));
}

/* Fades up as the grid opens: while the barrel is turning there is one
   service on screen, and calling that "our services" would be odd. */
.services-label {
  opacity: var(--sg, 0);
  transition: opacity 0.2s linear;
  margin-bottom: 0.8rem;
}

.service-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.5rem;
  align-items: start;
}

/* Each card sits in its grid cell; --dx/--dy (written by main.js) are the
   distance from that cell back to the middle, so multiplying them by
   (1 - --sg) pulls every card onto the barrel and releases them into the
   grid as it opens. */
.service {
  --offset: calc(var(--i) - var(--sv, 0));
  --distance: max(var(--offset), calc(0 - var(--offset)));
  --focus: clamp(0, calc(1 - var(--distance)), 1);
  --settled: var(--sg, 0);
  --stacked: calc(1 - var(--settled));

  padding: 0.8rem 1.15rem 0.95rem;
  text-align: center;

  /* Squared enough to read as a card, with each corner still a little out of
     true on both axes so it belongs to the same family as the buttons. The
     eight radii differ per card, below. */
  border-radius:
    var(--r1) var(--r2) var(--r3) var(--r4) /
    var(--r5) var(--r6) var(--r7) var(--r8);
  --r1: 22px; --r2: 30px; --r3: 20px; --r4: 26px;
  --r5: 26px; --r6: 20px; --r7: 30px; --r8: 22px;

  /* The surface belongs to the grid; on the barrel there is only type, so
     every part of it is tied to --settled. */
  background: rgb(var(--color-paper-rgb) / var(--settled));
  border: 1px solid rgb(var(--color-ink-rgb) / calc(0.09 * var(--settled)));
  box-shadow:
    0 20px 44px rgb(var(--color-ink-rgb) / calc(0.08 * var(--settled))),
    0 2px 6px rgb(var(--color-ink-rgb) / calc(0.04 * var(--settled)));

  /* Set per card by main.js to the heading's own centre — see there. */
  --barrel-radius: 6rem;

  transform-origin: 50% var(--title-axis, 50%);
  /* The travel to the barrel is listed *before* perspective(), so it happens
     in flat screen space. Put it after, and the projection magnifies it by
     the same factor as the translateZ below — the card overshoots the
     centre by about a fifth of the distance it had to travel. */
  transform:
    translate(
      calc(var(--dx, 0px) * var(--stacked)),
      calc(var(--dy, 0px) * var(--stacked))
    )
    perspective(52rem)
    rotateX(calc(var(--offset) * -24deg * var(--stacked)))
    translateZ(calc(var(--barrel-radius, 9rem) * var(--stacked)));
  backface-visibility: hidden;

  /* On the barrel only the service in focus is legible; once the grid has
     opened they are all equal. Written as arithmetic rather than
     max(clamp(...), …): Safari did not resolve the nested form here, and
     left the opened cards short of full opacity. */
  --legibility: clamp(0, calc(1.15 - var(--distance)), 1);
  opacity: calc(var(--legibility) + (1 - var(--legibility)) * var(--settled));
  transition: opacity 0.2s linear;
}

.service:nth-child(2) {
  --r1: 30px; --r2: 20px; --r3: 28px; --r4: 22px;
  --r5: 20px; --r6: 28px; --r7: 22px; --r8: 30px;
}

.service:nth-child(3) {
  --r1: 20px; --r2: 28px; --r3: 24px; --r4: 30px;
  --r5: 30px; --r6: 22px; --r7: 26px; --r8: 20px;
}

.service:nth-child(4) {
  --r1: 28px; --r2: 22px; --r3: 30px; --r4: 20px;
  --r5: 22px; --r6: 30px; --r7: 20px; --r8: 26px;
}

.service h3 {
  /* How much larger the name is on the barrel than in its card. Applied as a
     scale, not a larger font-size: a bigger font would make the card taller,
     which moves the grid cells, which invalidates the travel main.js
     measured in the grid's own layout. A scale changes nothing but pixels. */
  --barrel-scale: 1.4;

  transform-origin: 50% 50%;
  transform: scale(calc(1 + (var(--barrel-scale) - 1) * (1 - var(--sg, 0))));

  /* Near-black on the barrel, giving way to the brand blend as it settles
     into its card. There is no interpolating from a colour to a gradient, so
     the gradient is clipped to the glyphs and the flat colour painted over
     it: fading that colour's alpha to nothing reveals the gradient beneath,
     with no second element to keep in sync.

     Guarded, because the transparent text is only legible if the clip works.
     Every browser at or below the support floor has the -webkit- form. */
  color: rgb(var(--color-ground));
  transition: transform 0.2s linear, color 0.2s linear;
}

@supports (background-clip: text) or (-webkit-background-clip: text) {
  .service h3 {
    background-image: var(--brand-blend);
    -webkit-background-clip: text;
    background-clip: text;
    color: rgb(var(--color-ground) / calc(1 - var(--sg, 0)));
  }
}

/* A small keel above each name — the one piece of ornament the card gets, in
   the same near-black as the nav bar so the two halves of the page agree.
   Part of the surface, so it arrives with it rather than riding the barrel. */
.service::before {
  content: "";
  display: block;
  width: 2rem;
  height: 3px;
  margin: 0 auto 0.5rem;
  border-radius: 999px;
  background: rgb(var(--color-ground));
  opacity: var(--settled);
  transition: opacity 0.2s linear;
}

/* The copy belongs to the card, so it only exists once the grid has opened.
   It still occupies its space on the barrel, which is what keeps every
   card's heading in the same place as they stack. */
.service p {
  margin: 0;
  opacity: var(--settled);
  transition: opacity 0.2s linear;
}
@media (min-width: 48em) {
  /* Same mechanism, given room to breathe. */
  .services-scroller {
    --service-dwell: 70vh;
    --service-sticky-top: max(9.4rem, 21vh - 1.75rem);
    --barrel-lift: -7.75rem;
  }

  .services-label {
    margin-bottom: 1.5rem;
  }

  .service-grid {
    grid-template-columns: 1fr 1fr;
    gap: 2.5rem;
  }

  .service {
    padding: 2.75rem 2.5rem 3rem;
    --barrel-radius: 9rem;
  }

  .service h3 {
    --barrel-scale: 1.5;
  }

  .service::before {
    width: 2.75rem;
    margin: 0 auto 1.5rem;
  }
}

/* --- Content / footer ------------------------------------------------- */

/* Clears the fixed header once the page has scrolled past the hero. */
.content {
  max-width: var(--page-max);
  margin: 0 auto;
  padding: 6rem var(--page-gutter) 2rem;
}

/* On the white half of the page, so it takes the light palette rather than
   the dark one used above the fold. Full width rather than sharing the
   column cap above — the cap belongs to the text inside it, and leaving it on
   the element itself left the white band floating with the background
   artwork showing down either side. */
footer {
  max-width: none;
  padding: 2rem var(--page-gutter) 3rem;
  font-size: var(--text-sm);
  color: var(--color-ink-muted);
  text-align: center;
  background: var(--color-paper);
}

footer p {
  max-width: var(--page-max);
  margin: 0.25rem auto;
}

/* Keeps content in the accessibility tree and readable by search engines
   while removing it visually. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Scroll-linked motion is exactly the kind that triggers motion sickness,
   so drop the effect entirely: the logo starts docked (main.js pins --p to
   1) and the hero shrinks to a single, static screen. */
@media (prefers-reduced-motion: reduce) {
  .hero {
    min-height: 100vh;
  }

  .bg {
    will-change: auto;
  }
}
