/* ==========================================================================
   JEEVMEDIA — section styles
   Ordered to match the page. `.section` owns all section padding; the only
   override is `.section--tight`, declared immediately after it.
   ========================================================================== */

/* ==========================================================================
   0 · LOADING CURTAIN
   --------------------------------------------------------------------------
   BONE, AND THE RULE BEHIND THAT HAS NOT CHANGED EVEN THOUGH THE COLOUR HAS.
   The curtain leaves by fading, so it has to be the same value as whatever is
   behind it when it goes — otherwise the hand-off is a flash rather than a
   reveal. Bone is the page's own ground, whatever bone currently is; it went
   from cream to near-black when the page did and the curtain followed it for
   the same reason it always has.

   THE ONE HAND-OFF THIS DOES NOT COVER is the hero itself. `hero__bg`'s
   fallback wall is a photograph, not a token, and photography stays full
   colour regardless of what the page around it is doing (see ASSETS.md) — so
   a near-black curtain still fades into a light hexagon wall underneath it, a
   real value jump where there used to be almost none. Accepted rather than
   fixed: darkening the wall photo to match would be adjusting a photograph to
   serve a palette, which is the one thing this site's colour rule does not do.

   The progress rule is the site's own vocabulary: the same left-origin
   `scaleX` draw the services rules and the link underlines use.

   THE FAILSAFE ANIMATION IS NOT DECORATION. A curtain is the one component
   that can take the entire site down: if main.js 404s, throws before
   initLoader, or the CDN hangs, nothing removes it and every visitor gets a
   black screen. The keyframe below clears it after six seconds using CSS
   alone, with no JS involved at all. Normal completion removes the node long
   before that, so it should never be seen — it exists for the day something
   upstream breaks. Do not delete it because it "never fires".
   ========================================================================== */

.loader {
  position: fixed;
  inset: 0;
  /* Above the nav, which is 100. */
  z-index: 200;
  display: grid;
  place-items: center;
  gap: var(--sp-6);
  background: var(--bone);
  opacity: 1;
  transition: opacity 600ms var(--ease-out);
  animation: loader-failsafe 0s 6s forwards;
}

.loader.is-done {
  opacity: 0;
  pointer-events: none;
}

@keyframes loader-failsafe {
  to {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
}

.loader__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-5);
}

/* The bird, moved here from the hero card it used to play in. `contain`
   because it is a white-on-black graphic rather than a photograph, and
   `cover` would crop the wings. Sized well below the source's 1600×900 —
   this is a mark on a loading screen, not the hero's subject any more.

   THE BLACK BOX IS IN THE FOOTAGE, NOT IN THIS RULE, WHICH IS WHY THE FIX IS
   A BLEND MODE. There used to be `background: var(--black)` and a
   `border-radius` here and removing them changes nothing on screen: the mp4
   and its poster are a white bird rendered on a solid black field, so the
   rectangle a reader sees is the video's own pixels. No CSS on the element
   can make a video's interior transparent — only compositing can.

   `screen` is the mode for it: the result is 1-(1-a)(1-b) per channel, so
   pure black composites to exactly the backdrop and pure white composites to
   exactly white. The bird stays white, the field becomes the purple curtain,
   and there is no edge because there is nothing to draw one with.

   IT IS CLEAN ONLY BECAUSE THE SOURCE IS CLEAN, AND THAT WAS MEASURED, NOT
   ASSUMED. Screen lifts the backdrop wherever the footage is near-black
   rather than black, which on a compressed video normally leaves a faint
   glowing rectangle. Sampled across five frames spanning the loop, the outer
   30px frame of hero-bird.mp4 has 36,600 pixels per frame at a maximum
   channel value of 0 — no noise at all. If the footage is ever re-encoded at
   a lower bitrate, check that again before trusting this: the failure is
   subtle and looks like a lighting effect rather than a bug.

   The blend resolves against `.loader`'s own background, since `.loader` is
   the nearest stacking context (fixed + z-index) and `.loader__inner` is a
   plain flex box that does not create one. Do not add `isolation`,
   `transform`, `filter` or a `z-index` to the inner — any of them would make
   it the blend group and the bird would composite against nothing. */
.loader__bird {
  display: block;
  width: min(160px, 34vw);
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: contain;
  mix-blend-mode: screen;
  pointer-events: none;
}

.loader__mark {
  font-family: var(--font-display);
  font-size: clamp(1.1rem, 2.2vw, 1.5rem);
  letter-spacing: 0.16em;
  color: var(--paper);
}

/* The unfilled track is the page's hairline, not a tinted white — the same
   `--rule` every other divider on the site is drawn in. */
.loader__track {
  width: min(220px, 44vw);
  height: 1px;
  background: var(--rule);
  overflow: hidden;
}

.loader__bar {
  display: block;
  width: 100%;
  height: 100%;
  background: var(--paper);
  transform: scaleX(0);
  transform-origin: left;
  /* Short and linear. An eased bar reads as decoration; this one is reporting
     a real measurement and should look like it. */
  transition: transform 180ms linear;
}

@media (prefers-reduced-motion: reduce) {
  .loader {
    /* The curtain still appears — it is what stops the masthead swapping from
       a fallback face to Instrument Serif in front of the reader — but nothing
       about it animates, and the JS drops the minimum display time. */
    transition: none;
  }

  .loader__bird {
    /* `autoplay` is in the markup rather than gated by JS, so this is the one
       thing standing between reduced motion and a bird flapping on load.
       The mark alone still identifies the brand while the curtain is up. */
    display: none;
  }

  .loader__track {
    display: none;
  }

  .loader__bar {
    transition: none;
  }
}

.section--tight {
  padding-block: var(--section-y-tight);
}

/* Roster and Work carry the page's heaviest content and its only imagery
   below the hero — they get more air than the rest of the page. */
.section--open {
  padding-block: var(--section-y-open);
}

/* Shared section header. Two columns on desktop: display heading left,
   standfirst right, separated by nothing but space. */
.sec-head {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  gap: var(--sp-7) var(--sp-9);
  align-items: end;
  margin-bottom: var(--sp-8);
}

.sec-head__title {
  font-size: var(--fs-display);
  margin-top: var(--sp-5);
}

.sec-head__body {
  padding-bottom: var(--sp-2);
}

@media (max-width: 900px) {
  .sec-head {
    grid-template-columns: 1fr;
    gap: var(--sp-5);
    margin-bottom: var(--sp-7);
  }
}

/* ==========================================================================
   1 · NAV — three states, and the order they are declared in matters

     default        black type, no bar. The state the page has with
                    JavaScript off, over the light hexagon-wall fallback.
     .is-on-media   white type. Set by initNav in main.js for as long as
                    scroll position is still inside the hero.
     .is-stuck      light type on a dark translucent bar. Past the hero
                    entirely — bone is a dark neutral now, so the bar is too.

   `.is-stuck` IS DECLARED LAST ON PURPOSE. `initNav` toggles both classes
   from the same scroll measurement — `is-on-media` while inside the hero,
   `is-stuck` once past it — so in practice they are never both true at once.
   Declaring `is-stuck` last is what wins if that ever changes, since past the
   hero it is the one that has to.

   Every colour is declared per state rather than inherited, because each has
   to be legible on its own ground.
   ========================================================================== */

/* Now that the hero runs edge to edge, the bar sits flush at the top of the
   viewport over the media, and only its colours change on scroll. */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  height: var(--nav-h);
  /* Black, not white. See the note above `.nav.is-on-media`. */
  color: var(--black);
  border-bottom: 1px solid transparent;
  transition: background var(--dur-mid) var(--ease-soft),
    color var(--dur-mid) var(--ease-soft),
    border-color var(--dur-mid) var(--ease-soft);
}

/* ---- White ink, and only where it is earned ------------------------------
   `.nav`'s default is BLACK, because black is correct in every state the
   page can reach WITHOUT JavaScript: the light hexagon-wall fallback at rest,
   bone page below it. White only works once the dark background footage is
   actually playing, which is a state only JavaScript can produce — exactly
   the sort of thing that belongs in a class rather than in a default.

   `initNav` adds this class for as long as scroll position is still inside
   the hero (see the comment above `.measure` in main.js); it carries no
   transition of its own beyond the ones already on `.nav`. */
.nav.is-on-media {
  color: var(--paper);
}

.nav.is-on-media .nav__cta {
  background: var(--paper);
  color: var(--black);
  border-color: var(--paper);
}

.nav.is-on-media .nav__cta:hover {
  background: rgba(255, 255, 255, 0.86);
  border-color: rgba(255, 255, 255, 0.86);
}

/* Last of the three, and see the block comment at the top of this section for
   why: it overlaps `.is-on-media` for one stretch of scroll and has to win.

   0.96 RATHER THAN 0.82, AND THE REASON IS THE GROUNDS BELOW IT. A translucent
   bar takes a cast from whatever it is over. That was always true — over the
   black press panels it went grey — but it only became obvious once the
   campaigns panels turned into coloured fields and the bar took a cast off
   them, which reads as a rendering fault rather than as glass. Killing it needs
   roughly 0.955: below that the bar cannot climb back to bone over a saturated
   ground. The blur stays, so the character survives; what goes is a tint the
   nav was never meant to have.

   The rgb below is the page's own value — the bar sits a fraction above the
   page itself and reads as a distinct plane rather than the ground
   continuing. Ink, not a tint, is what a floating bar over a dark page
   composites down to look like.

   NO BOTTOM RULE, AND THE 1px OF TRANSPARENT BORDER ON `.nav` STAYS ANYWAY.
   This used to set `border-bottom-color: var(--rule)`. That was a neutral
   grey hairline when the page was bone-white and the divider it drew was the
   ordinary one every other section uses; `--rule` is #533b6b now, hue 271,
   and against a near-black bar it stopped reading as a divider and started
   reading as a violet line ruled across the top of the page — the only
   coloured horizontal on the site, and permanently on screen.

   What the bar loses is any edge at all over a DARK section: its background
   composites to almost exactly the page, so between sections it is invisible
   apart from its own type, which is the floating-glass read this nav was
   always after. Over anything light — the press rack's white slats, the
   pinboard's cards — the dark bar draws its own edge and needs no help.

   Do not delete the `border-bottom: 1px solid transparent` on `.nav` while
   removing this. It is there so sticking does not change the element's
   height; take it out and every stick nudges the page by a pixel. */
.nav.is-stuck {
  color: var(--paper);
  background: rgba(10, 6, 14, 0.92);
  backdrop-filter: blur(16px) saturate(1.4);
  -webkit-backdrop-filter: blur(16px) saturate(1.4);
}

.nav__inner {
  height: 100%;
  max-width: 1800px;
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 3.4vw, 2.9rem);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-5);
}

/* Single weight face — the wordmark gets its presence from tracking and size,
   not from a weight the family does not have. */
.nav__mark {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 400;
  letter-spacing: 0.16em;
}

.nav__links {
  display: flex;
  gap: var(--sp-6);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--track-mono);
  text-transform: uppercase;
  opacity: 0.78;
}

.nav__links a:hover {
  opacity: 1;
}

.nav__actions {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}

/* The CTA inverts with the bar so it always reads as the primary action. Its
   default is the solid black pill, matching the bar's black default ink — a
   white pill on the light hero has a legible label and no discernible shape.
   `.is-on-media` above flips it to white; `.is-stuck` USED TO agree with the
   default and state it anyway. It no longer can: the sticky bar now floats
   over a page whose own ground is dark, so a black pill on it would be the
   button disappearing into the bar behind it. It takes the accent instead —
   the same swap `--solid` went through in base.css for the same reason. */
.nav__cta {
  padding: 0.68rem 1.2rem;
  background: var(--black);
  color: var(--paper);
  border-color: var(--black);
  font-weight: 500;
}

.nav__cta:hover {
  background: var(--graphite);
  border-color: var(--graphite);
}

.nav.is-stuck .nav__cta {
  background: var(--accent);
  color: var(--paper);
  border-color: var(--accent);
}

.nav.is-stuck .nav__cta:hover {
  background: var(--accent-lift);
  border-color: var(--accent-lift);
}

.nav__toggle {
  display: none;
  width: 40px;
  height: 40px;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.nav__toggle-bar {
  width: 22px;
  height: 1px;
  background: currentColor;
  transition: transform var(--dur-fast) var(--ease-soft);
}

.nav__toggle[aria-expanded="true"] .nav__toggle-bar:first-child {
  transform: translateY(3.5px) rotate(45deg);
}

.nav__toggle[aria-expanded="true"] .nav__toggle-bar:last-child {
  transform: translateY(-3.5px) rotate(-45deg);
}

.nav__drawer {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5) var(--gutter) var(--sp-7);
  background: var(--bone-deep);
  color: var(--paper);
  border-bottom: 1px solid var(--rule);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.nav__drawer a {
  padding-block: var(--sp-2);
  border-bottom: 1px solid var(--rule);
}

@media (max-width: 900px) {
  .nav__links,
  .nav__cta {
    display: none;
  }

  .nav__toggle {
    display: flex;
  }
}

/* ==========================================================================
   2 · HERO — the video, full-bleed, nothing else
   --------------------------------------------------------------------------
   Used to be two layers and a pinned scroll-expand: a small card playing the
   JEEVMEDIA bird grew to near full-screen while the headline flew apart
   around it, driven by one custom property, `--exp`, written by
   initHeroExpand in main.js. The bird now plays on the loading curtain
   instead (see the `0 · LOADING CURTAIN` block above), so there is nothing
   left to expand into — the background footage is the whole hero, at rest,
   all the time. Nothing here is positioned or resized by JavaScript.
   ========================================================================== */

.hero {
  position: relative;
  isolation: isolate;
}

/* Edge to edge — no inset, no rounding, no shadow. The `.panel` primitive
   still supplies the positioning context, so its radius and its bone ground
   are the only things that need unsetting here. */
.hero__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  border-radius: 0;

  /* Black, not bone, as the ground under the video's own edges. */
  background: var(--black);

  /* Full viewport, so the media reaches the fold with no strip of the bone
     page showing beneath it.

     `lvh`, and deliberately NOT `svh` or `dvh`. On a phone those three differ:
     `svh` is the viewport with the browser chrome showing, `lvh` is it hidden.
     Sizing to `svh` leaves the hero SHORTER than the screen once the chrome
     retracts. `dvh` tracks the chrome and so resizes the hero as the visitor
     scrolls past it, which reads as the page flexing under them. `lvh` is the
     largest of the three, so the media always covers; the cost is that with
     chrome showing the hero slightly overflows, which is invisible. */
  min-height: 100vh;
  min-height: 100lvh;

  padding: calc(var(--nav-h) + var(--sp-7)) var(--sp-6) clamp(4rem, 12vh, 10rem);
}

/* ---- The ground ----------------------------------------------------------
   `cover`. An abstract hexagon pattern has no subject to protect, so one crop
   is as good as another, and it has to reach all four edges: letterboxed, the
   panel's black would show as two hard bars against something this light. */

.hero__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* ANCHORED TO THE TOP, BECAUSE THE ONE SHOT THAT CAN BE HURT FRAMES A HEAD
     HIGH. The hero is `min-height: 100lvh` and the footage is 16:9, so the
     moment a viewport is WIDER than 16:9 the cover fit scales to the width and
     crops the height. That is most maximised browsers: 1920 wide with any
     chrome at all lands near 2.05:1, which discards about 12% of the frame,
     and an ultrawide discards a quarter.

     At 50% that loss comes off the top and the bottom evenly. The brand film
     is mostly centred compositions on black -- the opening iris, the lens, the
     JEEVMEDIA end card -- and every one of them carries enough empty margin
     that a 6% shift down the visible frame is not readable as anything. The
     exception is the talking head at about 8s, which is framed tight with the
     crown already at the master's own top edge; half of a 12% crop off the top
     takes forehead with it.

     So the whole crop comes off the bottom, where the film carries floor,
     desk and rig rather than subject. Nothing up top is spare; plenty down
     there is.

     IT ONLY BITES ON WIDE VIEWPORTS, which is the point. A portrait or
     near-square viewport scales this to the HEIGHT and crops the sides
     instead, and the vertical half of `object-position` is inert there -- so
     phones and tablets see no change from this at all. */
  object-position: 50% 0%;
  /* THE HEXAGON WALL, AS THE FALLBACK UNDER THE FOOTAGE. The video carries no
     `poster`, so this is what shows whenever playback has not started --
     JavaScript off, GSAP blocked, reduced motion.

     That is load-bearing for the nav. Black ink is the nav's default because
     black is correct over this wall, and the white state is applied only once
     the footage is actually running. A dark still here instead would put black
     type on a near-black ground in precisely the states where no script can
     run to correct it. */
  background: url("../assets/img/hero-hex.jpg") 50% 50% / cover no-repeat
    var(--bone);
  pointer-events: none;
}

/* The legibility guarantee, in two layers rather than one flat wash.
   A flat scrim strong enough for a bright frame turns a vivid one to mud, and
   the whole direction depends on the imagery staying vivid. So: a linear
   pass that protects only the nav and corner bands, plus a radial pass with a
   flat plateau at centre, in case any content ever sits there again. The
   image keeps its contrast everywhere the type isn't.

   `--scrim-auto` is measured from `[data-hero-bg]` at load (see initHeroScrim
   in main.js) so a bright background gets a heavier scrim and a dark one gets
   almost none.

   THE LITERAL BELOW IS NOT A ROUND NUMBER, AND IT IS NOT COSMETIC.
   It is the fallback used whenever the measurement cannot run: over file://
   the canvas read from an image or video is blocked outright, and with JS off
   it never runs at all. Since this site is opened by double-clicking
   index.html, the fallback is the value most often on screen, not an edge
   case.

   0.62 is the smallest value that holds 4.5:1 for white type against a PURE
   WHITE ground. Compositing black at alpha a over white gives 255(1-a); white
   type needs that below 119 to clear 4.5:1, which is a >= 0.535. 0.62 leaves
   margin and measures 6.2:1. Lower it only if you have measured the new
   media. */
.hero__scrim {
  --scrim-auto: 0.62;
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    radial-gradient(
      /* THESE ARE RADII, NOT A BOX — a plateau sized to a type block rather
         than the whole frame, so an image with real contrast at its edges
         keeps it. Full strength holds to 58% of the radius, so 44% × 30%
         covers where a masthead would sit with margin. */
      ellipse 30% 24% at 50% 50%,
      rgba(10, 6, 14, calc(var(--scrim-auto) * 0.34)) 0%,
      rgba(10, 6, 14, calc(var(--scrim-auto) * 0.34)) 58%,
      rgba(10, 6, 14, calc(var(--scrim-auto) * 0.14)) 82%,
      rgba(10, 6, 14, 0) 100%
    ),
    /* A barely-there wash across the top and bottom that keeps the very edge
       of the frame from reading as a hard cut against the sections either
       side. */
    linear-gradient(
      to bottom,
      rgba(10, 6, 14, 0.1) 0%,
      rgba(10, 6, 14, 0.05) 10%,
      rgba(10, 6, 14, 0.05) 90%,
      rgba(10, 6, 14, 0.1) 100%
    );
  transition: background var(--dur-slow) var(--ease-soft);
}

/* ---- The sound switch ----------------------------------------------------
   Bottom right, which is the one corner of this hero with nothing in it. The
   nav owns the top, the masthead owns the middle band the scrim is shaped
   around, and the bottom left is where a wide viewport's crop is already
   eating into the frame (see `object-position` above).

   HIDDEN UNTIL initHeroSound SAYS OTHERWISE, the same bargain `.cf__nav`
   takes. With no JS there is no playback to unmute and no handler to do it,
   so the resting state of this button is `display: none` and the `.is-live`
   class is what pays for it. */

.hero__sound {
  display: none;
}

.hero__sound.is-live {
  display: grid;
  place-items: center;
  position: absolute;
  /* Clear of the panel edge by the same gutter the hero pads itself with, so
     it sits on the same invisible margin as everything else here. */
  right: var(--sp-6);
  bottom: var(--sp-6);
  /* Over the scrim (2) and alongside the content (3). The scrim is
     `pointer-events: none`, so nothing is in front of this to swallow a
     click. */
  z-index: 4;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  /* DARK GLASS, NOT LIGHT, AND THE ALPHA IS SOLVED RATHER THAN CHOSEN.
     `.cf__nav` is white-on-black because it sits on the page's own ground.
     This sits on footage that runs from pure black to pure white inside one
     loop -- the ring-light shot near 16s puts this corner at nearly 1.0
     relative luminance -- so the disc has to carry its own contrast instead
     of borrowing the frame's, and it cannot lean on the scrim either: the
     scrim's radial is shaped around the masthead and only the faint linear
     wash reaches this corner.

     A white icon needs 3:1 to clear WCAG 1.4.11 as a UI component. Over a
     white frame the disc composites to 1 x (1 - a), so 3:1 wants a >= 0.70.
     At 0.72 it lands at 3.16:1 on the worst frame in the film and 19.5:1 on
     the black opening, where the ink is doing the work and the disc is barely
     there at all. */
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: var(--r-pill);
  background: rgba(10, 6, 14, 0.72);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--paper);
  cursor: pointer;
  transition:
    background var(--dur-fast) var(--ease-soft),
    border-color var(--dur-fast) var(--ease-soft),
    scale var(--dur-fast) var(--ease-soft);
}

.hero__sound.is-live:hover {
  background: rgba(10, 6, 14, 0.85);
  border-color: rgba(255, 255, 255, 0.5);
}

.hero__sound.is-live:active {
  scale: 0.94;
}

/* WHICH ICON, decided here rather than by a `hidden` attribute on each SVG.
   `hidden` is an HTMLElement property; these are SVGElements, so setting it
   from script does nothing at all (see `paint()` in initHeroSound). The state
   is a class on the button, which is real HTML, and these two rules read it.

   Muted is the resting state because it is the one the film can always be in:
   an unmuted start is attempted but routinely refused, a muted one never is.
   `.is-sounding` is the earned state. */

.hero__sound-icon {
  width: 1.125rem;
  height: 1.125rem;
  /* The two icons are the same speaker cone with a different tail, so a shared
     box keeps the cone from shifting a pixel as they swap. */
  display: none;
}

.hero__sound:not(.is-sounding) [data-hero-sound-off],
.hero__sound.is-sounding [data-hero-sound-on] {
  display: block;
}

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

.hero__content {
  position: relative;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: min(1120px, 94%);
}

.hero__eyebrow {
  margin-bottom: var(--sp-6);
}

.hero__tag {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.hero__lead {
  margin-top: var(--sp-5);
  max-width: 46ch;
}

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-4);
  margin-top: var(--sp-7);
}

@media (max-width: 640px) {
  .hero__panel {
    padding-inline: var(--sp-5);
  }
}

/* ==========================================================================
   3 · SERVICES — a pinboard
   --------------------------------------------------------------------------
   Four cards pinned to a ruled board, alternating sides down a staircase,
   with a thread running pin to pin behind them.

   WHAT LEFT WITH THE ACCORDION. This was one open service at a time beside a
   single still, advancing itself every six seconds, with four separate pause
   conditions because the section could move while you were reading it. All of
   that existed to manage one problem — only one service visible — and a board
   does not have that problem. Gone with it: `--svc-dur`, `.is-playing`,
   `animation-play-state`, the progress rule that doubled as a clock, the
   figure crossfade, the counter, the prev/next arrows, and `initServices`.

   THE PALETTE IS NOT THE COMPONENT'S. The original is orange/blue/purple
   tinted cards with the step number set in Comic Sans. This page has no
   accent colour by rule (see tokens.css) and colour comes only from
   photography, so the cards are paper on bone with hairline rules, the number
   is IBM Plex Mono like every other number on the site, and the thing that
   makes each card different from the last is the photograph in it.

   THE PHOTOGRAPHS ARE THE REASON THE CARD HAS A MEDIA SLOT AT ALL. The
   component has no image; the section it replaced had four real stills. A pin
   through a photograph is what a pinboard is, so keeping them made the
   metaphor more literal rather than less.
   ========================================================================== */

.services__title {
  margin-top: var(--sp-5);
  font-size: var(--fs-display);
  max-width: 18ch;
}

/* ---- The board -----------------------------------------------------------
   The ruled ground, faded out towards its own edges so it reads as texture
   under the cards rather than as a table someone forgot to remove the borders
   from. 32px matches the original; it happens to sit close to this page's
   body leading, which is why it reads as ruled paper and not as a grid. */

.board {
  position: relative;
  margin-top: clamp(2.5rem, 6vw, 5rem);
}

.board::before {
  content: "";
  position: absolute;
  /* Bleeds past the cards so the ruling does not stop at a card's edge.

     THE HORIZONTAL BLEED IS PINNED TO `--gutter`, NOT ITS OWN CLAMP.
     It used to be `clamp(-4rem, -6vw, -1.5rem)` — a floor of 1.5rem, which
     is MORE than `--gutter`'s own floor of 1.25rem. Between roughly 400px
     and 1420px wide the bleed's magnitude also outgrows the shell's own
     padding well before either clamp's ceiling kicks in — worst around
     1000-1300px, where it overshot by up to 16px — so this pseudo-element
     was genuinely wider than the page and `document.documentElement`
     scrolled a few pixels sideways. Tying it to the exact token the shell's
     own padding uses makes that structurally impossible: the ruling can
     never bleed past where the page itself already has no more room. */
  inset: clamp(-3rem, -4vw, -1.5rem) calc(-1 * var(--gutter));
  z-index: 0;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    to bottom,
    var(--rule) 0,
    var(--rule) 1px,
    transparent 1px,
    transparent 32px
  );
  /* One radial rather than four linear masks composited together: fewer
     moving parts, and `mask-composite` support is the shakiest thing in this
     file. If the mask is unsupported the ruling simply reaches the edges,
     which is untidy rather than broken. */
  -webkit-mask-image: radial-gradient(
    ellipse 74% 64% at 50% 50%,
    #000 0%,
    #000 42%,
    transparent 84%
  );
  mask-image: radial-gradient(
    ellipse 74% 64% at 50% 50%,
    #000 0%,
    #000 42%,
    transparent 84%
  );
}

/* ---- The thread ----------------------------------------------------------
   Sized and pathed by initBoard, which measures where the pins actually
   landed. It is BEHIND the cards: where the curve passes under one it reads
   as string running behind it, which is what string on a board does, and it
   means the geometry never has to dodge anything.

   `overflow: visible` because the curve's control points can bulge a few
   pixels outside the box the cards define, and a clipped thread would show a
   flat spot exactly where the eye follows it. */

.board__thread {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;

  /* The thread is the accent's largest continuous mark on the page. It is
     a 1.5px dashed line, not text, so the 4.5:1 floor does not apply — and
     it is the one element in the section that exists purely to say "these
     four things are one sequence", which is exactly what an accent is for. */
  stroke: var(--accent);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 7 7;

  /* Drawn top to bottom once, when the board scrolls in. The original marches
     the dashes round the path forever, which is motion with no end and no
     control to stop it — the one thing WCAG 2.2.2 asks you not to do with
     decorative movement. A single wipe says the same thing about sequence and
     then stops.

     The wipe is a clip on the whole <svg>, not a `stroke-dashoffset` draw,
     because the dash pattern is already using the dash properties and the two
     cannot share them. The path descends monotonically, so a vertical wipe
     reveals it in card order anyway. */
  transition: clip-path 1.5s var(--ease-out);
}

/* The clip is applied by JS, not by default: with no JavaScript there is no
   path in this <svg> at all, and a permanently clipped empty element is a
   worse resting state than an unclipped one — nothing is hidden until
   something can reveal it. */
.board.will-draw .board__thread {
  clip-path: inset(0 0 100% 0);
}

.board.is-drawn .board__thread {
  clip-path: inset(0 0 0 0);
}

/* ---- The staircase -------------------------------------------------------
   Two columns with the right one dropped, rather than the original's absolute
   `top` values. Absolute positioning needs a height for every card decided in
   advance; these cards contain a paragraph whose height depends on the copy,
   and the first time someone edits `body` in data.js a hard-coded stack
   overlaps. In flow, the staircase is one margin. */

.board__cards {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(2.5rem, 5vw, 5rem) clamp(2rem, 7vw, 7rem);
  align-items: start;
  /* Room for the dropped column at the foot of the board. */
  padding-bottom: clamp(3rem, 7vw, 7rem);
}

.board__cards > :nth-child(even) {
  margin-top: clamp(3rem, 8vw, 8rem);
}

/* ---- A card --------------------------------------------------------------
   Tilted, because a pinned card is. ±2.5° rather than the original's ±8°:
   this page is set in Instrument Serif on bone, and eight degrees reads as a
   different website. It squares up under the pointer — the card you are
   reading is the one you would have straightened. */

.pincard {
  position: relative;
  /* A CARD, NOT A PANEL. Left to fill its grid column this is 600px wide on a
     desktop, the photograph inside it is 450px tall, and the section becomes
     two stacked billboards — the scattered-notes read the whole layout is for
     disappears the moment each note is the size of a poster. The original
     component fixes its cards at 280px; 25rem is the same decision made
     against this page's larger type. */
  width: min(100%, 25rem);
  transform: rotate(var(--tilt, 0deg));
  transition:
    transform var(--dur-mid) var(--ease-out),
    box-shadow var(--dur-mid) var(--ease-soft);
}

/* Pushed to the outer edges of their columns rather than centred in them, so
   the thread has the middle of the board to cross. Centred, the two cards in
   a row nearly touch and the curve between them has nowhere to go. */
.board__cards > :nth-child(odd) {
  --tilt: -2.5deg;
  justify-self: start;
}

.board__cards > :nth-child(even) {
  --tilt: 2.5deg;
  justify-self: end;
}

/* ONE COLOUR, SET ONCE. This cycled four hues per card for a while. It came
   out because the page reads as one thing now and the board is not a section
   with its own identity -- it is the house colour, like everything above and
   below it.

   The property is kept rather than inlining the accent on both elements: the
   pin and the step number are the same mark read twice, once through the
   paper and once printed on it, and one declaration is what stops them
   drifting apart. Set a hue here and both move together. */
.board__cards > * {
  --pin: var(--accent);
}

.pincard:hover,
.pincard:focus-within {
  z-index: 2;
  transform: rotate(0deg) translateY(-6px);
}

/* STILL PAPER, EVEN THOUGH THE PAGE INVERTED. Two reasons, and either alone
   would be enough: a bright card pinned to a dark board is a stronger image
   than a dark card on a dark board would be — the pin, the metaphor, the
   whole section reads better lit from underneath. And arithmetic — the
   accent text below it (`.pincard__num`) is only 0.140 itself, so it needs a
   ground at or above roughly 0.52 relative luminance to clear 3:1 at its
   size. Nothing in the new dark palette reaches that; paper still does,
   because paper never moved. */
.pincard__body {
  position: relative;
  padding: var(--sp-5) var(--sp-5) var(--sp-6);
  background: var(--paper);
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  /* Lifted off the board rather than flat on it. Soft and low — the site has
     no other drop shadow, so this is the one place the rule bends, and it
     bends because a card lying on a board without one looks printed on it. */
  box-shadow: 0 10px 30px rgba(10, 0, 20, 0.09);
}

.pincard:hover .pincard__body,
.pincard:focus-within .pincard__body {
  box-shadow: 0 18px 44px rgba(10, 0, 20, 0.15);
}

/* Half above the card's top edge, which is where a pin through paper sits. */
.pincard__pin {
  position: absolute;
  top: 0;
  left: 50%;
  width: 26px;
  height: 26px;
  translate: -50% -55%;
  /* The pins used to take the thread's colour outright, so the board would
     read as one object rather than as coloured string through grey tacks.
     They now take their own card's hue instead, and the original worry does
     not return: the failure mode was coloured string through NEUTRAL tacks,
     and every hue on this board is the thread's own 0.140. The value is
     constant across the board; only the hue moves. Petrol still lands on the
     first card, so the string arrives into a pin its own colour.

     Falls back to the thread colour if a card is ever rendered outside the
     cycle above. */
  color: var(--pin, var(--accent));
  fill: currentColor;
  /* The board shows through around the pin head, so it needs its own ground
     or the ruling runs straight through the metal. */
  filter: drop-shadow(0 1px 1px rgba(10, 0, 20, 0.3));
}

/* 16:9, MATCHING THE FILES. All four service stills are ~1.79 and were
   deliberately re-cropped to 16:9 in an earlier pass, because a 4:3 slot was
   throwing away a quarter of each one from the sides — which is where the
   subject is in every case. A card-shaped 4:3 window would put that crop
   straight back. See ASSETS.md. */
.pincard__media {
  margin-top: var(--sp-4);
  aspect-ratio: 16 / 9;
  border-radius: calc(var(--r-card) - 0.35rem);
}

.pincard__meta {
  display: flex;
  align-items: baseline;
  gap: var(--sp-4);
  margin-top: var(--sp-5);
}

/* The step number, in the face this site sets every other number in, and in
   its card's hue rather than graphite -- the second half of the pin.

   It qualifies on size, which is the only reason it is allowed the colour at
   all: the clamp FLOORS at 1.6rem, so it is 25.6px at every viewport, past
   the 24px where the threshold drops to 3:1. On the card's paper ground the
   current violet measures 5.55:1 and would in fact clear the small-text
   floor too, but the size is what licenses it and the size must not come
   down. Shrink this below 24px and the colour has to come off with it.

   `.pincard__tag` beside it stays a quiet grey for exactly that reason -- it
   is micro type, and no accent may go near it. */
.pincard__num {
  font-family: var(--font-mono);
  font-size: clamp(1.6rem, 2.6vw, 2.1rem);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--pin, var(--graphite));
}

/* NOT `--slate`. That token now means "quiet text on the page's own dark
   ground" and is solved against `--bone`; this card stayed paper on purpose
   (see the note above `.pincard__body`) and needed the opposite thing — a
   grey quiet enough on WHITE, which is what `--slate` was before the page
   inverted. Kept local rather than rethreaded through a token that no longer
   means this.

   THE VALUE MOVED HUE, NOT TONE. It was #6e6c68 — the literal it had carried
   since the bone-white page, hue 40, a warm grey. On a white card that used
   to be invisible as a choice, because the page around it was warm too. It is
   not invisible on a purple page: a warm grey card label surrounded by hue-271
   everything reads as olive, and it was the last hue-60 value left in the file
   that actually sets TYPE rather than a shadow or a scrim. #6e6874 is hue 270
   at the same tone — measured 0.145 relative luminance against the old
   0.150, which is 5.39:1 on white against 5.24:1, so the swap also happens to
   gain a little margin over the 4.5:1 floor this 12px label needs. */
.pincard__tag {
  color: #6e6874;
}

.pincard__title {
  margin-top: var(--sp-3);
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 2.2vw, 1.8rem);
  font-weight: 400;
  line-height: var(--lh-snug);
  letter-spacing: var(--track-display);
  color: var(--black);
}

/* Same reasoning as `.pincard__tag` above -- literal, not `--slate`, and
   retinted off hue 60 in the same pass. */
.pincard__text {
  margin-top: var(--sp-3);
  color: #6e6874;
  font-size: var(--fs-small);
}

/* ---- Narrow --------------------------------------------------------------
   One column, no staircase, no thread. A curve between two cards stacked
   directly above one another is a vertical line, which is not worth an <svg>
   — and the drop that makes the staircase read on a wide screen is just dead
   space in a single column. The tilt stays: it is the whole character. */

@media (max-width: 900px) {
  .board__cards {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(2.75rem, 9vw, 4rem);
    padding-bottom: 0;
  }

  .board__cards > :nth-child(even) {
    margin-top: 0;
  }

  .board__thread {
    display: none;
  }
}

@media (max-width: 560px) {
  .board__cards > :nth-child(odd) {
    --tilt: -1.4deg;
  }

  .board__cards > :nth-child(even) {
    --tilt: 1.4deg;
  }
}

/* base.css already flattens every transition to 0.01ms under the preference,
   which covers the thread's wipe and the card's lift. Nothing here runs on
   its own clock, so there is nothing else to stop — which is the whole point
   of having replaced a carousel with a board. */

/* ==========================================================================
   4 · ROSTER RING — a coverflow rake
   --------------------------------------------------------------------------
   One portrait square to the reader at the centre, its neighbours turning away
   and receding. It replaced a scatter of draggable cards, which had itself
   replaced a one-at-a-time carousel.

   TWO LAYOUTS, AND THE GRID IS THE DEFAULT. `.cf__track` is a plain responsive
   grid until initCoverflow adds `.is-live` to `.cf`. So the rake is opt-in
   from a working baseline rather than a thing that has to be undone: no JS,
   blocked CDN, a thrown exception — all get the grid, with the same portraits
   in the same order, and the three controls stay hidden because they would be
   wired to nothing.

   UNLIKE THE DECK, THIS SHIPS ON TOUCH. Dragging the scatter needed
   `touch-action: none` on every card, which tells the browser not to scroll
   from a gesture starting there — a scroll trap across a viewport-sized
   spread, and the reason coarse pointers were held back to the static grid.
   The ring drags on one axis, so `pan-y` is enough and the page keeps its
   vertical scroll. Every pointer gets the same thing.

   NO TRANSITION ON THE CARDS, DELIBERATELY. Every frame of both the drag and
   the settle is written to `transform` by rAF in initCoverflow. A CSS
   transition on top would fight it: the drag would lag behind the pointer on a
   rubber band, and the settle would ease an already-eased number twice.

   THE ONE MEASUREMENT IS `--cf-card`. Pitch, depth and perspective are all
   derived from card width in JS, so the whole rake scales from this one length
   and the responsive story is a single clamp rather than a media query.
   ========================================================================== */

.roster__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-5) var(--sp-6);
  margin-bottom: var(--sp-8);
}

.roster__title {
  font-size: var(--fs-display);
  /* Typed in sentence case in the markup and uppercased here: some screen
     readers spell all-caps words out letter by letter. */
  text-transform: uppercase;
  letter-spacing: 0.01em;
}

.roster__cta {
  text-align: left;
}

@media (max-width: 700px) {
  .roster__cta {
    width: 100%;
    justify-content: space-between;
  }
}

/* ---- The default: a grid ------------------------------------------------- */

.cf {
  /* Any CSS length. Everything else — pitch, recession, viewer distance — is
     derived from it in initCoverflow, so the rake scales with this alone. */
  --cf-card: clamp(148px, 22vw, 260px);
}

.cf__track {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
  gap: clamp(1.5rem, 2.6vw, 2.5rem);
}

.cf__card {
  overflow: hidden;
  /* Square, not the 3/4 the deck used. A coverflow rake reads as a rake
     because every card is the same shape turning through the same arc; a
     portrait crop puts the far cards' extra height where the perspective is
     already squeezing hardest. See ASSETS.md — the subject has to survive a
     centre crop either way. */
  aspect-ratio: 1;
  border-radius: var(--r-card);
  background: var(--bone-deep);
  box-shadow: var(--shadow-panel);
}

.cf__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* 25% DOWN, NOT THE CENTRE, AND IT ONLY MOVES FIVE OF THE FOURTEEN CARDS.
     The card is a square and the roster ships three shapes into it. A square
     source fills it exactly, so there is no crop for this to act on; a
     landscape source is scaled to the card's height and crops at the sides,
     where the vertical half of this is equally inert. Only a PORTRAIT source
     crops vertically, and at 50% it took the overflow evenly off the top and
     the bottom — which on a head-and-shoulders photograph is the one split
     that cannot work, because the head is at the top and all the spare room
     is at the bottom.

     Three of the five portrait sources lost the top of a head to that even
     split: 20.jpg clipped a cap, 22.jpg and 31.png sliced the crown, and
     10.jpg was a frame from the same. Their ratios run 0.72 to 0.81, so
     between 19% and 28% of the height is discarded and half of it was
     coming off the wrong end.

     25 RATHER THAN 0, and the difference matters. `top` clears every head
     with room to spare, but two of these five are full-length figures —
     19.jpg against a truck, 20.jpg standing — and pinning them to the top
     cuts their feet off instead. A quarter down is the one value that keeps
     every head whole without taking a figure's legs. It was chosen by
     rendering the real square crop at 50, 25 and 0 against all five, not by
     eye on one.

     THIS IS NOT A PER-IMAGE FIX AND MUST NOT BECOME ONE. Portraits are added
     to ROSTER as plain paths; nothing carries a crop hint, and a rule that
     needed one per creator would rot the first time a photo was swapped. If
     a future portrait cannot survive 25%, recrop the file — ASSETS.md
     already asks for a subject that survives a centre crop, and this only
     widens that target rather than replacing it. */
  object-position: 50% 25%;
  /* The portraits are the only full-colour photography left below the hero,
     so they are left entirely alone — no scrim, no duotone, no hover zoom. */
  user-select: none;
  -webkit-user-select: none;
}

/* All three controls are enhancement. Without `.is-live` they are wired to
   nothing, so they are not shown and not reachable by tab. */
.cf__nav,
.cf__dots,
.cf__caption {
  display: none;
}

/* ---- The rake — only once initCoverflow says so -------------------------- */

.cf.is-live .cf__stage {
  position: relative;
}

.cf.is-live .cf__frame {
  overflow: hidden;
  /* Clears the drop shadows of the overflow clip. Without it the crop cuts a
     hard line across the bottom of every card. */
  padding-block: var(--sp-7);
  /* Viewer distance as a multiple of card width — smaller is a wider lens.
     Must stay in step with the `perspective` constant in initCoverflow, which
     is what the depth term is scaled against. */
  perspective: calc(var(--cf-card) * 3);
  cursor: grab;
  /* Horizontal drag is ours; the page keeps its vertical scroll. This one
     declaration is why the ring ships on touch and the scatter could not. */
  touch-action: pan-y;
  outline: none;
}

.cf.is-live .cf__frame.is-held {
  cursor: grabbing;
}

/* The frame is focusable, so it has to show focus — and it is the only thing
   in the section that takes a keyboard. */
.cf.is-live .cf__frame:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

.cf.is-live .cf__track {
  display: block;
  position: relative;
  height: var(--cf-card);
  transform-style: preserve-3d;
  user-select: none;
  -webkit-user-select: none;
}

.cf.is-live .cf__card {
  position: absolute;
  top: 0;
  /* Every card is stacked on the centre line and pushed out from there by the
     transform, so `left` never changes and the whole layout is one property
     the compositor already owns. */
  left: 50%;
  width: var(--cf-card);
  will-change: transform;
}

/* A card off the centre is a target: clicking walks it in by the shorter way
   round. The one already centred is not, so it keeps the frame's grab. */
.cf.is-live .cf__card:not(.is-centre) {
  cursor: pointer;
}

/* ---- Caption ------------------------------------------------------------- */

.cf.is-live .cf__caption {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: var(--sp-5);
  text-align: center;
  /* Held open so the dots below do not jump every time a creator with two
     platform rows follows one with three. */
  min-height: 11.5rem;
}

.cf__name {
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 2.2vw, 1.9rem);
  line-height: var(--lh-snug);
  letter-spacing: var(--track-display);
}

.cf__vertical {
  margin-top: var(--sp-2);
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--slate-dim);
}

.cf__meta {
  width: 100%;
  max-width: 15rem;
  margin-top: var(--sp-5);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
}

.cf__row {
  display: flex;
  justify-content: space-between;
  gap: var(--sp-4);
  padding-block: var(--sp-1);
  border-bottom: 1px solid var(--rule);
}

.cf__row dt {
  color: var(--slate-dim);
}

.cf__row dd {
  color: var(--paper);
  font-variant-numeric: tabular-nums lining-nums;
}

/* The caption's children are replaced wholesale on every change of centre, so
   new nodes run this from the start each time — no class to add and take off
   again, and no way for it to fall out of step with the ring. */
.cf.is-live .cf__caption > * {
  animation: cf-caption var(--dur-mid) var(--ease-out) both;
}

@keyframes cf-caption {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
}

/* ---- Arrows and dots ----------------------------------------------------- */

.cf.is-live .cf__nav {
  display: grid;
  place-items: center;
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  /* Above every card. The ring's own stacking runs 100 down to 93. */
  z-index: 200;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 1px solid var(--rule-firm);
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.7);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--black);
  cursor: pointer;
  transition:
    background var(--dur-fast) var(--ease-soft),
    border-color var(--dur-fast) var(--ease-soft);
}

.cf.is-live .cf__nav:hover {
  background: var(--paper);
  border-color: var(--black);
}

.cf__nav--prev {
  left: 0;
}

.cf__nav--next {
  right: 0;
}

.cf__nav svg {
  width: 1.25rem;
  height: 1.25rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.cf.is-live .cf__dots {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--sp-1);
  margin-top: var(--sp-5);
}

/* The mark is 8px; the button is 24px. The hit area is the button, because a
   pointer target the size of the mark would be unhittable on a phone. */
.cf__dot {
  display: grid;
  place-items: center;
  width: 1.5rem;
  height: 1.5rem;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.cf__dot::after {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: var(--r-pill);
  background: var(--paper);
  opacity: 0.25;
  transition: opacity var(--dur-fast) var(--ease-soft);
}

.cf__dot.is-current::after {
  opacity: 1;
}

/* ---- Narrow ------------------------------------------------------------- */

@media (max-width: 700px) {
  /* The arrows are 44px sitting over a 148px card, so at this width they
     cover most of the two neighbours they exist to bring in. They are a
     pointer affordance and they go with the pointer: a touch screen drags the
     ring, and the dots below stay as the discrete target for anyone who would
     rather tap a destination than swipe towards it. */
  .cf.is-live .cf__nav {
    display: none;
  }

  /* Fourteen 24px targets and their gaps overflow 390px and wrap to a second
     row, which reads as two separate controls. Tightened to 20px to hold one
     row down to 360px. It is a real reduction in target size, and the reason
     it is acceptable here is that it is the secondary control — the primary
     one on touch is a drag anywhere in the frame. */
  .cf.is-live .cf__dots {
    gap: 0;
  }

  .cf__dot {
    width: 1.25rem;
  }

  .cf.is-live .cf__caption {
    min-height: 10.5rem;
  }
}

/* ---- Reduced motion -----------------------------------------------------
   The ring can still be turned — a drag is something the visitor does, not
   something the page does to them — but nothing travels on its own. The
   settle jumps to its destination in initCoverflow, so this only has the
   caption's fade left to switch off. */

@media (prefers-reduced-motion: reduce) {
  .cf.is-live .cf__caption > * {
    animation: none;
  }
}

/* ==========================================================================
   5 · CAMPAIGNS — a stacked story scroll
   --------------------------------------------------------------------------
   Four full-height panels that do not queue up one after another. Each pins
   the moment its foot reaches the foot of the viewport, and the panel behind
   it swings in over the top, hinged on its own bottom-left corner. The read
   is a stack being dealt rather than a page being scrolled.

   THE ROTATION IS ON THE INNER, NOT ON THE PANEL. `.story__panel` is
   transparent and does nothing but clip; the ground colour and every scrap of
   type live on `.story__inner`, and that is what turns. This is the whole
   illusion: while the inner sits at 30deg most of the panel is empty, so the
   PREVIOUS panel shows through the wedge, and the incoming ground sweeps
   across it as the angle closes. Move the background out to the panel and you
   get an opaque rectangle with a tilted picture inside it — a completely
   different, and much worse, effect.

   THE PANEL MUST STAY FREE OF TRANSFORM AND FILTER. ScrollTrigger pins by
   switching the element to `position: fixed`, and either property would make
   the panel a containing block for its own fixed self and nail it back into
   the page. Everything that moves belongs on the inner.

   THE GROUNDS ALTERNATE PURPLE / BLACK, AND THE WEDGE NO LONGER DEPENDS ON
   THAT. Read this with the note above `.story__inner--fill`, which is where
   the change is argued. The short history: the two grounds were ink and bone;
   the ink half became `--accent` at full strength when the accent arrived, so
   panels 01 and 03 were whole violet screens; they are the purple plane now
   that the scheme is black-page-with-a-purple-plane.

   The alternation used to buy a hard VALUE edge for free — bone sat at 0.890
   against the accent's 0.126, a jump of most of the visible range, so the
   incoming wedge read as strongly as it ever would. Every step since has
   eroded that: the page inverted and bone fell to 0.008, which took the edge
   down to 3.37:1, and any two of this palette's darks stacked together give
   about 1.1:1, which is nothing.

   So the edge stopped being a property of the grounds. `.story__inner` draws
   a 1px accent line along its own top, and since the inner is what rotates,
   that line IS the hypotenuse of the incoming wedge — 3.77:1 on the page,
   3.37:1 on the purple, and unchanged by anything either ground does next.
   This is the fix that should have been made when the page first inverted
   rather than accepting a softer sweep; it removes the constraint instead of
   re-solving it, which is why the page and the plane could then be swapped
   outright without touching the animation.

   AND IT NO LONGER LANDS EVERY LOGO PLATE ON A GROUND IT SURVIVES. The three
   plate colours in data.js (`#000000`, `#ffffff`, `#ffffff`) are sampled from
   the actual logo files, not from this palette, and were chosen to sit on a
   BONE panel that was light. The white plates still read fine on the now-
   dark bone; the black Higgsfield plate does not — it sits close enough to
   `--bone`'s new 0.012 that the plate's own edge is hard to see. A known
   trade of the inversion, not something CSS alone can fix without recolouring
   a client's logo, and out of scope here — see ASSETS.md.

   THE LAST PANEL IS NEVER PINNED, AND IT IS BONE. It scrolls away into the
   bone statement band below with no seam to see.

   NOTHING INSIDE A PANEL IS `.reveal`. The scroll batch in initMotion fires
   at `top 88%`, a moment that arrives at an angle for a rotating panel and
   sometimes does not arrive at all for a pinned one. The rotation is the
   entrance here; the counters get their own trigger in initStory.
   ========================================================================== */

.story {
  position: relative;
  /* The panels are z-ordered against each other and against nothing else.
     Isolating the section keeps that private argument out of the root
     stacking context, where the fixed nav (z-index 100) has to keep winning
     over four panels that are about to be `position: fixed` themselves. */
  isolation: isolate;
  /* `clip`, not `hidden`. `overflow-x: hidden` silently forces the block axis
     to `auto`, which would make this a scroll container and break every pin
     inside it. `clip` leaves the other axis `visible`. */
  overflow-x: clip;
}

.story__panel {
  position: relative;
  width: 100%;
  /* `min-height`, never `height`. On a short phone in landscape the content
     runs past a viewport, and a fixed height would put it behind the clip
     below with no way to reach it. The panel grows instead and the pin still
     works — it is keyed to the foot of the panel, wherever that is. */
  min-height: 100vh;
  min-height: 100svh;
  /* Clips the rotated inner. Without it the wedge overhangs the section on
     both sides and the page grows a horizontal scrollbar. */
  overflow: hidden;
}

.story__inner {
  display: flex;
  flex-direction: column;
  /* The rules are flex items too, so this is what spaces the panel out: six
     children pushed to the full height, with the name in the middle. `gap` is
     the floor under that distribution, not the distribution itself. */
  justify-content: space-between;
  gap: var(--sp-5);
  min-height: 100vh;
  min-height: 100svh;
  /* The nav is fixed and overlays every one of these, so the top pad has to
     clear it — this is the only section on the page where content starts at
     the very top of the viewport. */
  padding: calc(var(--nav-h) + clamp(1.5rem, 4vw, 3rem)) var(--gutter)
    clamp(2rem, 4vw, 3.5rem);
  background: var(--bone);
  color: var(--paper);
  /* The hinge. Declared here as well as being set by GSAP so the geometry is
     already right on the first frame, before the script has read the
     element. */
  transform-origin: bottom left;
}

/* Only the three panels that actually turn. `will-change` on four viewport-
   sized layers is a real cost in memory, and the first panel never moves. */
.story__inner.is-hinged {
  will-change: transform;
}

/* THE PURPLE PLANE, NOT THE ACCENT, AND THE WEDGE IS DRAWN RATHER THAN
   INFERRED. These are panels 01 and 03. They were two whole viewports of
   `--accent` — the loudest thing on the site — then briefly black while
   #240046 was the page; now that black is the page they are the purple. The
   sequence reads black screen, purple screen, black screen, purple screen
   either way round; what changed is which of the two carries the page and
   therefore how much purple the site has in total.

   IT COSTS THE ONE THING THE ALTERNATION WAS FOR. The note above
   `.story__panel` explains that the incoming wedge is only visible because
   consecutive grounds differ in VALUE — the accent at 0.146 against a bone
   page gave it 3.37:1. The purple plane is 0.008 and the page is 0.002, so
   the two grounds are 1.12:1 apart and the sweep would have vanished
   completely. That is not a cost worth paying and it is not paid:
   `.story__inner` carries a 1px accent line on its leading edge (see the
   rule below), so the wedge has a hard edge of its own at 3.77:1 on the page
   and 3.37:1 on the purple. The edge moved from being a property of the two
   grounds to being a property of the panel — which is strictly more robust,
   and it is what made reversing the page and the plane a two-line change
   rather than a redesign.

   Do not put the two grounds back to relying on tone. Whatever the panels
   are, the line is what makes the turn read. */
.story__inner--fill {
  background: var(--bone-deep);
  color: var(--paper);
}

/* THE FORCED `opacity: 1` BLOCK THAT USED TO LIVE HERE IS GONE, and its
   removal is the real gain of a black fill rather than a side effect. The
   panel builds hierarchy out of `currentColor` at 0.62 and 0.78, which needs
   somewhere to fade TOWARDS. A mid-luminance violet ground had nowhere:
   white at 0.62 over it measured 3.20:1 and the 14px copy at 0.78 measured
   4.09:1, both failing, so every alpha on these two panels had to be pinned
   to 1 and hierarchy came from size and letterspacing alone.

   On either dark ground the alphas work again, and comfortably — 0.62
   measures 7.17:1 on the purple and 7.74:1 on the page, 0.78 measures 10.99:1
   and 12.07:1. So the filled panels build tone exactly the way the page
   panels do, with no override at all, and the section reads as one system
   instead of two. The old note said a deep ground would make this easier; it
   does.

   THE FIGURE OVERRIDE IS GONE TOO. It used to invert to `--black` because
   violet-on-violet was 1.00:1 — "the figure is whatever the panel is not".
   The accent clears 3:1 on both grounds now (3.77:1 on the page, 3.37:1 on
   the purple), so the figure is simply always the accent and `.story__figure`
   below handles both. Same for the `.link-wipe::after` override, which
   existed only because an isoluminant accent cannot sit on an accent
   ground. */

/* THE LEADING EDGE, AND IT ONLY EXISTS WHILE THE PANEL IS TURNING. 1px of
   accent along the top of the inner, which is what rotates -- hinged on its
   bottom-left corner, so while the angle is open that top edge IS the wedge's
   hypotenuse sweeping down across the panel behind it. It is what replaces
   the value edge the alternation used to supply, now that the two grounds are
   1.10:1 apart.

   IT USED TO BE ON PERMANENTLY AND THAT WAS WRONG. The first version was a
   flat `box-shadow: inset 0 1px 0 var(--accent)` on `.story__inner`, on the
   reasoning that a settled panel is pinned full-viewport with its top edge
   under the fixed nav. That is not true: the panel's top sits below the nav
   for most of the scroll, so the line read as a violet rule across the top of
   every campaign panel at rest -- decoration nobody asked for, on a section
   that has no other rules like it.

   So the opacity is scrubbed with the rotation instead. `--wedge` is tweened
   1 -> 0 in the same GSAP call that drives `rotation` 30 -> 0 (initStory in
   main.js), so the edge is at full strength when the angle is widest and is
   gone by the time the panel lands. The line exists exactly as long as there
   is a wedge for it to be the edge of, which is what it was always for.

   THE FALLBACK IS "NO LINE", NOT "LINE". `var(--wedge, 0)` -- panel 01 never
   turns and never gets the property, and neither does anything under reduced
   motion or with GSAP missing, so every path that does not animate renders
   a clean panel. Only a panel actually mid-rotation draws it.

   A PSEUDO-ELEMENT RATHER THAN THE INSET SHADOW, because the shadow's colour
   would have to be rebuilt from channels to take an alpha, which means
   duplicating `--accent`'s value somewhere a future swap would miss. An
   absolutely positioned 1px strip keeps the token intact and takes no layout
   either -- which the border it replaced would not have, on an element that
   is already `min-height: 100vh` plus padding. */
.story__inner {
  position: relative;
}

.story__inner::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 1px;
  background: var(--accent);
  opacity: var(--wedge, 0);
  pointer-events: none;
}

/* ---- Inside a panel ------------------------------------------------------
   Every colour below is `currentColor` at an alpha rather than a grey from
   the token set. These panels run ink, bone, ink, bone in turn, and a single
   fixed tone cannot be the quieter of the two on both grounds — `--slate` is
   legible on bone and nearly invisible on ink. The two alphas used are 0.62
   and 0.78; against both grounds the weaker of them still clears 4.5:1. */

.story__top {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-3) var(--sp-6);
}

.story__kicker {
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.story__kicker--dim {
  opacity: 0.62;
}

/* A real <hr>, and a flex item — which is the point. It takes its share of
   the space-between distribution, so the panel is ruled into bands by the
   same mechanism that spaces it. */
.story__rule {
  width: 100%;
  height: 0;
  margin: 0;
  border: 0;
  border-top: 1px solid currentColor;
  opacity: 0.22;
}

.story__name {
  font-family: var(--font-display);
  font-weight: 400;
  /* Larger than anything on the page bar the statement band. It has a whole
     viewport to itself and it is the only thing in the middle of it. */
  font-size: clamp(2.9rem, 9vw, 8rem);
  line-height: 0.94;
  letter-spacing: var(--track-display);
  text-wrap: balance;
}

.story__body {
  display: grid;
  grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
  gap: var(--sp-6) clamp(2rem, 5vw, 5rem);

  /* THIS IS WHAT MAKES THE PLATE FULL-HEIGHT. The body takes every pixel the
     panel has left after the kickers, the rules and the metrics have taken
     theirs, and `stretch` hands that height to both cells. The plate used to
     be a fixed band bottom-aligned in a short row; it is now the right-hand
     column of the panel, floor to ceiling.

     `min-height: 0` is load-bearing. A flex item defaults to a minimum of its
     content size, so on a short viewport the body would refuse to shrink and
     would push the metrics off the bottom of the panel instead. */
  flex: 1;
  min-height: 0;
  align-items: stretch;
}

/* The left column. The name heads it and the result is pushed to the foot by
   `margin-top: auto`, so the figure sits level with the bottom of the plate
   beside it and directly above the metrics row — the two things it belongs
   with. Everything between them is air. */
.story__lead {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
  min-width: 0;
}

.story__lead > .story__result,
.story__lead > .story__copy--lede {
  margin-top: auto;
}

/* The intro panel's right-hand cell — the roster figure, where the study
   panels put a logo plate. Its cell is full-height like theirs, but this is
   type rather than an image, and stretching type just moves it to the top of
   a tall empty box. Held at the foot instead, level with the result on the
   panels that follow it.

   It used to carry `.story__result` and no rules of its own. That worked
   while both cells were bottom-aligned by the grid; now that they are
   stretched it needs to say where it sits. */
.story__reach {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  min-width: 0;
}

.story__figure {
  font-family: var(--font-mono);
  font-size: clamp(2.4rem, 4.4vw, 3.9rem);
  font-weight: 400;
  line-height: 0.94;
  letter-spacing: -0.045em;
  font-variant-numeric: tabular-nums lining-nums;
  /* The section's headline numbers, and the only TEXT on the page allowed
     the accent. It qualifies on size: the floor of that clamp is 38px,
     well past the 24px where the large-text threshold drops to 3:1, and
     violet clears that on the bone panel (3.06:1) — the filled panel
     overrides this rule to ink instead, just below, since violet-on-
     violet is no contrast at all. Shrink this type and the colour has to
     come off with it.

     Note this catches the roster figure on the intro panel too. That is
     intended: the accent marks "this is the number", not "this number is
     verified" — the Illustrative chip is what does the second job. */
  color: var(--accent);
}

.story__figure-label {
  margin-top: var(--sp-3);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  opacity: 0.62;
}

.story__copy {
  margin-top: var(--sp-5);
  max-width: 46ch;
  font-size: var(--fs-small);
  line-height: var(--lh-body);
  opacity: 0.78;
}

.story__copy--lede {
  margin-top: 0;
  max-width: 34ch;
  font-size: var(--fs-lead);
  opacity: 0.86;
}

.story__metrics {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-4) clamp(1.5rem, 4vw, 3.5rem);
}

.story__metric {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.story__metric dt {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  opacity: 0.62;
}

.story__metric dd {
  font-family: var(--font-mono);
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums lining-nums;
}

/* The one metric that is not a result. It renders only while a study still
   carries `placeholder: true` in data.js and disappears on its own the moment
   that flag comes off — the disclaimer travels with the claim rather than
   sitting alone in the footer three screens away. */
.story__metric--flag dd {
  font-style: italic;
  opacity: 0.72;
}

.story__link {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  margin-top: var(--sp-5);
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: currentColor;
  text-decoration: none;
}

/* ---- The plate ----------------------------------------------------------
   Carried over intact from the bento layout, because the reason for it has
   not changed: the three studies supply wordmarks rather than campaign
   photography, and a wordmark cannot go through the photographic treatment.
   `cover` crops into the letterforms and the scroll parallax slides the mark
   out of its own frame.

   Nothing in the `--logo` rules fires unless a case sets `media: "logo"` in
   data.js. Drop in a real 16:9 photograph, delete the `media` and `plate`
   keys, and the cropped-and-parallaxed treatment comes back on its own. */

/* NO HEIGHT HERE ANY MORE. The plate is stretched by its grid cell, so it is
   as tall as the panel allows — which is the whole point of the two-column
   shape. It was a fixed band (172px, then 220px) and every increase was a
   number someone had to pick; now it is whatever is left over.

   `min-height: 0` for the same reason the body has it: without it the cell
   cannot shrink under its own image on a short screen. The narrow breakpoint
   further down puts a real height back, because there the grid is one column
   and a stretched cell has nothing to stretch to. */
.story__plate {
  min-height: 0;
}

.story__plate--logo {
  /* `--plate` comes from the data, sampled from the asset itself. The
     fallback matters: an unset variable leaves `.media`'s bone-deep showing
     through a mark drawn on white, which reads as a printing error. */
  background: var(--plate, var(--paper));
  /* Neutral grey at an alpha rather than `--rule`, because this edge has to
     hold on both grounds — a bone hairline vanishes against a bone panel. */
  border: 1px solid rgba(128, 128, 128, 0.3);
}

.story__plate--logo img {
  /* `scale-down`, which is `contain` with a hard stop at the mark's own
     native size. Not `cover`, which would crop — the three supplied assets
     are 1.90:1, 1.30:1 and 2.72:1, so one box would amputate each of them
     differently.

     WHY NOT PLAIN `contain` ANY MORE. The plate is full-height now, and
     `contain` would fill it: Morphic is only 510px wide natively and would be
     drawn at 1.06x at 1x DPR — an upscale visible on an ordinary screen, not
     just a retina one. `scale-down` enforces the invariant this project has
     always claimed and previously had to verify by hand: no mark is ever
     drawn larger than it was supplied. Give it a bigger asset and it uses
     it. */
  object-fit: scale-down;
  /* Padding on a replaced element insets its content box and `object-fit`
     resolves inside that, so this is the plate's margin in one property.
     Percentages resolve against the WIDTH on all four sides — which is why
     the vertical figure has to be so much smaller than it looks. Height is
     the binding constraint on every one of these plates. */
  padding: clamp(0.6rem, 2.4%, 1.1rem) clamp(1.25rem, 6%, 2.75rem);
  /* `.media img` carries a permanent `scale(1.01)` to hide the sub-pixel edge
     on a cropped photograph. On a contained logo it is a 1% blur on hard
     vector edges and buys nothing. */
  transform: none;
}

/* And no hover zoom. On a photograph the 1.05 push is life; on a wordmark it
   is the logo changing size, which looks like a rendering fault. */
.media-host:hover .story__plate--logo img {
  transform: none;
}

/* ---- Narrower ------------------------------------------------------------
   One column. The plate drops under the copy rather than beside it, and the
   name comes down to a size that will not run four words onto four lines. */

@media (max-width: 860px) {
  .story__body {
    grid-template-columns: minmax(0, 1fr);
    align-items: start;
  }

  .story__name {
    font-size: clamp(2.4rem, 11vw, 4.5rem);
  }

  .story__copy--lede {
    max-width: 46ch;
  }

  .story__plate {
    /* One column here, so there is no full-height cell to stretch into and
       the plate needs a definite height of its own again. Nearly full-bleed
       and with width to spare, so this is generous. */
    height: clamp(160px, 42vw, 240px);
  }

  /* Stacked, so nothing sits beside anything and the auto margin has no gap
     left to eat. Without this the copy is shoved to the bottom of a column
     it no longer shares. */
  .story__lead > .story__result,
  .story__lead > .story__copy--lede {
    margin-top: 0;
  }
}

/* A short viewport — a phone in landscape, or a small laptop. The panel is
   allowed to grow past the screen (it is min-height, not height, so nothing
   is ever clipped), but a panel taller than the viewport locks with its head
   off the top, so it is worth giving back whatever space is cheap. Tightening
   the distribution gap and the top pad recovers roughly 80px without touching
   a single type size. */
@media (max-height: 700px) {
  .story__inner {
    gap: var(--sp-3);
    padding-top: calc(var(--nav-h) + clamp(1rem, 3vw, 1.75rem));
  }
}

/* ==========================================================================
   STATEMENT BAND — the one full-bleed type break in the middle of the page.
   Bone ground, paper type, no image: scale and motion are all it has to work
   with, and that is the point of it. Moves only on scroll (initScrubs in
   main.js) and stops dead when the visitor stops, so it reads as a response to
   the reader rather than as an animation running on its own.
   ========================================================================== */

.statement {
  overflow: hidden;
}

.statement__clip {
  width: 100%;
  overflow: hidden;
}

.statement__line {
  display: inline-block;
  white-space: nowrap;
  padding-inline: var(--gutter);
  font-family: var(--font-display);
  font-size: var(--fs-statement);
  font-weight: 400;
  line-height: 1;
  letter-spacing: var(--track-display);
  color: var(--paper);
  will-change: transform;
}

/* ==========================================================================
   6 · FEATURED IN — the press rack
   --------------------------------------------------------------------------
   An accordion of panels rather than a list of rows. Three things carry it:

   1. THE STAIRCASE. Closed panels are bottom-aligned at ascending heights, so
      the row has a silhouette even before anything opens and the eye is led
      left to right into the open panel, which is always the tallest.

   2. WIDTH IS THE ANIMATION. Opening changes `flex-grow`, not `width` — the
      neighbours surrender their share and the rack's outer box never moves,
      so nothing below it reflows. Height animates alongside it.

   3. GROUNDS. Three of them, on a value ladder: paper, a filled accent,
      ink. Panels invert their own type rather than relying on an
      `.on-media` wrapper, because the ground is a flat fill here, not a
      photograph.

      The middle rung used to be graphite, which was the weakest link in
      the rack: at 0.024 relative luminance against ink's 0.004 the two
      dark grounds were barely a step apart, so a five-panel ladder was
      really a two-value one. The accent sits at 0.140 — well past thirty
      times the separation from ink — so this swap buys colour AND a rung that
      actually reads. Do not put the accent on the `--ink` panel as well;
      the rack needs its bottom rung.

   The 1px gaps are the rack's own background showing through — the same
   hairline the rest of the page rules with, without five extra borders that
   would double up at every join.
   ========================================================================== */

/* The standfirst and the prev/next pair that lived in the right-hand cell of
   this section head are both gone, so `.press__head`, `.press__nav` and
   `.press__step` went with them. The rack opens on hover, on focus and on the
   first tap, and steps on the arrow keys; none of that needed chrome.
   Restoring the buttons means restoring the markup, these three rules and the
   `[data-press-step]` loop in initPress. */

/* Deliberately unstyled: no ground, no border, no radius. A background on the
   rack would fill the empty box above the short panels and flatten the
   staircase back into a rectangle, and rounding the panels would read as five
   separate tiles rather than one rack. The 1px gaps are page bone showing
   through, which is all the separation the alternating grounds need. */
.press__rack {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

/* ---- A panel ------------------------------------------------------------ */

.press__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--sp-5);
  padding: clamp(1.25rem, 1.9vw, 1.85rem);
  /* Paper, not bone-deep — same call the pincard makes, for the same
     reason. `--bone-deep` is one of the page's own dark neutrals; a rung
     drawn in it would read as a fourth shade of the same dark rather than
     the bright plate this rung is meant to be against the other two.
     White reads as a plate laid on the rack, and gives the dark panels
     something real to alternate against. */
  background: var(--paper);
  color: var(--black);
  text-decoration: none;
  overflow: hidden;
  /* The screenshots are mostly white pages, so a white panel needs its own
     edge or the media plate has nothing to sit against. */
  box-shadow: inset 0 0 0 1px var(--rule);
  transition: background var(--dur-mid) var(--ease-soft);
}

.press__panel--fill {
  background: var(--accent);
  color: var(--paper);
  box-shadow: none;
}

/* Every text alpha on this ground goes to 1, for the same arithmetic that
   forces it on the campaign panels: the accent is mid-luminance, so fading white
   towards it drops contrast fast. On violet the index at 0.5 measures 2.62:1 and the
   headline at 0.76 reaches only 3.97:1; full white is 5.55:1 and passes at
   any size. `.is-open .press__index` has to be caught too — it
   raises the index to 0.85 on open, which only just clears the floor (4.52:1,
   little margin) rather than clearing it comfortably. */
/* The `.is-open` selector needs FOUR classes, not three. `.press__panel
   .is-open .press__index` further down the file also has three, and equal
   specificity is settled by source order — which that rule wins. Measured
   before this was fixed: the open panel index rendered translucent white at
   0.85, short of 4.5:1 at 10px. */
.press__panel--fill .press__index,
.press__panel.press__panel--fill.is-open .press__index,
.press__panel--fill .press__headline {
  opacity: 1;
}

.press__panel--ink {
  background: var(--black);
  color: var(--paper);
  box-shadow: none;
}

.press__index {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-mono);
  opacity: 0.5;
  transition: opacity var(--dur-mid) var(--ease-soft);
}

.press__panel.is-open .press__index {
  opacity: 0.85;
}

/* No `gap` here on purpose. On the stacked layout `.press__reveal` is an
   ordinary flex item and a gap would double up with the padding it already
   carries; on the rack it is taken out of flow, and a gap would leave a
   stubborn 1rem of dead space under the masthead in every closed panel. The
   spacing belongs to `.press__inner`, which comes and goes with the text. */
.press__stack {
  display: flex;
  flex-direction: column;
  /* `flex: none` is load-bearing. The media plate above grows into whatever
     is left of the panel, and without this the text block was fair game to be
     shrunk to make room for it. The text sizes itself; the image gets the
     remainder, never the other way round. */
  flex: none;
}

.press__pub {
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 1.75vw, 1.6rem);
  line-height: var(--lh-snug);
  letter-spacing: var(--track-display);
  /* Long mastheads ("Business Briefing Today") have to wrap inside a 150px
     closed panel without pushing a word out of the box. */
  overflow-wrap: break-word;
}

/* ---- The clipping ------------------------------------------------------
   A screenshot of the page as it ran, not the article's own og:image — those
   turned out to be three copies of the JEEVMEDIA logo on black, two of them
   the same file syndicated twice. The masthead of the desk that carried the
   story is the thing worth showing, and only the page itself has it.

   `object-position: top center` matters: these are crops of a web page, so
   the masthead and headline live along the top edge. Anchoring anywhere else
   throws away the only part that identifies the publication when the panel's
   proportions change. */

.press__media {
  position: relative;
  overflow: hidden;
  /* Barely rounded. Enough to read as a plate sitting on the panel rather
     than a hole cut in it, without becoming a card in its own right. */
  border-radius: 3px;
  /* Holds the space before the image decodes, in a tone that belongs to the
     panel it sits on. */
  background: rgba(0, 0, 0, 0.06);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
  aspect-ratio: 16 / 10;
}

.press__panel--fill .press__media,
.press__panel--ink .press__media {
  background: rgba(255, 255, 255, 0.07);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
}

.press__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
}

/* The collapsing half.

   THIS WAS A `grid-template-rows: 0fr → 1fr` COLLAPSE AND IT DID NOT WORK.
   That technique needs `overflow: hidden` on the grid item, and on a grid
   item `overflow: hidden` also drops the automatic minimum size to zero —
   which takes the content floor out of `minmax(auto, 1fr)`. The row then
   sized short in the OPEN state, clipping the last line of every headline
   and the call to action underneath it, inside a panel with room to spare.
   Verified by A/B: forcing `overflow: visible` restored the text; pinning the
   media plate's height did not.

   So the closed state is taken out of flow instead. Absolute means the block
   costs the panel no height at all, `top: 100%` parks it under the masthead
   where the panel's own `overflow: hidden` clips it, and the open state is
   ordinary static flow — which can only ever be content-sized. The box change
   is instant, but it happens behind a 400ms fade while the panel is still
   travelling, so there is nothing to see.

   Neither `display: none` nor `visibility: hidden` appears here, so the
   headline and its destination stay in the accessibility tree even while the
   panel is shut — the link's name is always publication plus headline. */
.press__reveal {
  opacity: 1;
}

/* Deliberately carries no `overflow` and no `min-height` — see the note on
   `.press__reveal`. Both were part of the collapse that clipped its own open
   state, and the panel already clips everything that leaves it. */
.press__inner {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding-top: var(--sp-4);
}

.press__headline {
  font-size: var(--fs-small);
  line-height: var(--lh-body);
  opacity: 0.76;
}

.press__cta {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.press__cta svg {
  width: 14px;
  height: 14px;
  transition: transform var(--dur-mid) var(--ease-out);
}

.press__panel:hover .press__cta svg,
.press__panel:focus-visible .press__cta svg {
  transform: translate(2px, -2px);
}

/* ---- The rack proper — only above the stack breakpoint ------------------
   Below 900px every panel is open and the rack is a plain vertical list, so
   all of the accordion machinery is scoped here rather than being written
   once and then unwound for small screens. */

@media (min-width: 900px) {
  .press__rack {
    flex-direction: row;
    align-items: flex-end;
    /* The tallest a panel gets. The rack is this high whatever is open, so
       the section below it never moves. Tuned against the open panel's
       contents: index, media, masthead, headline and link all have to fit,
       and what is left over is the media plate's height — too short a rack
       letterboxes the screenshot down to a strip. */
    height: clamp(430px, 37vw, 530px);
  }

  .press__panel {
    flex: 1 1 0;
    min-width: 0;
    /* The staircase floor — overridden per panel below. */
    height: 62%;
    transition: flex-grow var(--dur-slow) var(--ease-out),
      height var(--dur-slow) var(--ease-out),
      background var(--dur-mid) var(--ease-soft);
  }

  .press__panel:nth-child(1) { height: 54%; }
  .press__panel:nth-child(2) { height: 61%; }
  .press__panel:nth-child(3) { height: 68%; }
  .press__panel:nth-child(4) { height: 75%; }
  .press__panel:nth-child(5) { height: 82%; }

  .press__panel.is-open {
    flex-grow: 4.4;
    height: 100%;
  }

  /* The clipping takes the empty middle of the panel and nothing else: basis
     stays at 0 and only `flex-grow` moves, so it opens into exactly the space
     left over once the index and the masthead have taken theirs, and the
     panel never has to guess at a height. `aspect-ratio` is dropped here —
     on the stacked layout it holds the shape, but in the rack the flex line
     is what decides. */
  .press__media {
    flex: 0 1 0%;
    aspect-ratio: auto;
    opacity: 0;
    transition: flex-grow var(--dur-slow) var(--ease-out),
      opacity var(--dur-mid) var(--ease-soft);
  }

  .press__panel.is-open .press__media {
    flex-grow: 1;
    opacity: 1;
    /* Behind the panel, like the text below it — the image should arrive into
       a space that already exists rather than stretch open with it. */
    transition-delay: 120ms, 200ms;
  }

  /* Closed: the headline and the call to action leave the flow entirely and
     only the masthead is left standing at the foot of the panel. */
  .press__stack {
    position: relative;
  }

  .press__reveal {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    opacity: 0;
    /* Out of flow but still painted, so it must not swallow the pointer over
       the panel below its own. */
    pointer-events: none;
    transition: opacity var(--dur-mid) var(--ease-soft);
  }

  .press__panel.is-open .press__reveal {
    position: static;
    opacity: 1;
    pointer-events: auto;
    /* Held back until the panel has most of its width, so the text fades up
       into space that already exists rather than arriving with it. */
    transition-delay: 200ms;
  }

  /* Smaller than the mobile default: a closed panel is only ~170px wide at
     full page width, and "Business Briefing Today" has to wrap inside it
     without going to four lines. */
  .press__pub {
    font-size: clamp(0.95rem, 1.15vw, 1.15rem);
    transition: font-size var(--dur-slow) var(--ease-out);
  }

  .press__panel.is-open .press__pub {
    font-size: clamp(1.5rem, 2.3vw, 2.15rem);
  }

  .press__panel.is-open .press__headline {
    max-width: 46ch;
  }

  /* A closed panel is a target, so it says so on approach. Each ground moves
     one step away from itself — paper settles towards a duller white, ink
     lifts towards grey. Paper can no longer settle towards `--bone`: that
     token is a dark neutral now, and the few-percent dim this hover has
     always been would become paper dropping to almost black. `#ebe9ee` is
     the same-sized step paper always took (relative luminance 0.82, a dim
     of roughly the same magnitude as the old paper-to-bone move), just
     picked directly instead of borrowed from a token that changed jobs.

     THE FILLED PANEL LIFTS LIKE THE OTHERS, and that is only possible
     because of the accent's lightness. White text needs the ground at or
     below 0.183 to hold 4.5:1. The accent sits at 0.140, which leaves
     little room: the lift below lands at 0.175, right against that
     ceiling, and white on it is 4.66:1 — past the floor but with none of
     the margin earlier hues had here. If an accent is ever swapped for
     something nearer the ceiling than 0.140 already is, this lift has to
     be re-solved, not assumed to still fit. */
  .press__panel:not(.is-open):hover {
    background: #ebe9ee;
  }

  .press__panel--ink:not(.is-open):hover {
    background: var(--graphite);
  }

  .press__panel--fill:not(.is-open):hover {
    background: var(--accent-lift);
  }
}

/* Keyboard focus has to be visible against black, graphite and bone alike,
   so it is drawn inset in the panel's own type colour rather than the shared
   outline token, which is tuned for bone grounds only. */
.press__panel:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px currentColor;
}

/* ---- Closing bar --------------------------------------------------------
   The one piece of the section that is an invitation rather than a record. */

/* Black on bone was a solid, self-evident shape below the rack when the page
   was bone. It sits directly on the page now, which is a dark neutral of its
   own — a black pill there would be the invitation disappearing into the
   ground behind it, so this takes the accent instead. */
.press__pill {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-4);
  margin-top: var(--sp-5);
  padding: clamp(0.95rem, 1.4vw, 1.15rem) clamp(1.25rem, 2.5vw, 2rem);
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--paper);
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  text-align: center;
  text-decoration: none;
  transition: background var(--dur-fast) var(--ease-soft);
}

.press__pill:hover {
  background: var(--accent-lift);
}

.press__pill svg {
  width: 18px;
  height: 18px;
  flex: none;
  transition: transform var(--dur-mid) var(--ease-out);
}

.press__pill:hover svg {
  transform: translateX(4px);
}

/* No reduced-motion block for the rack: base.css already flattens every
   transition on the page to 0.01ms under the preference, which leaves the
   panels snapping between states rather than travelling. The open panel is
   still legible without the travel — it is marked by height and ground, not
   by the animation. */


/* ==========================================================================
   7 · TEAM — staggered photo columns, hover-linked to a name list
   --------------------------------------------------------------------------
   Two independent layouts sharing one id space. `.team__photo` and
   `.team__row` both carry `data-team-id`, and initTeamInteraction in
   main.js is the only thing that connects them: hovering either adds
   `.is-active` to both and `.is-dimmed` to every other pair. Nothing here
   has to visually line up because of it.

   `.team__photo` HOLDS EITHER A PHOTO OR A MONOGRAM. All six people
   currently have one, so `.team__mark` is dormant rather than dead — add a
   person to TEAM in data.js with no `image` and the black-square,
   white-mark language (the same one the favicon and the loader's bird use)
   comes back on its own for that one card, no CSS to touch. See
   ASSETS.md — "Team portraits" for the crop brief.

   FULL COLOUR AT REST, AND GREYSCALE IS THE HOVER STATE NOW. This section
   used to be the one standing exception to "every photograph runs full colour
   at rest" -- all six were desaturated permanently and hover moved opacity
   only. It is not an exception any more: the wall is in colour like every
   other photograph on the site, and desaturation became the DIM rather than
   the default. Hover one face and the other five drop to grey behind it.

   The dim is a better fit for the filter than the default ever was. What the
   interaction is for is picking one person out of six, and draining the other
   five of colour separates them far harder than the 0.55 opacity it replaced
   -- opacity fades a photograph towards the ground behind it, which on a
   near-black page means it just gets darker and murkier. Grey stays legible
   and still plainly reads as "not this one." */

.team__layout {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-8);
  margin-top: var(--sp-8);
}

/* ---- The photos -----------------------------------------------------------
   Three columns, offset vertically so the row of six reads as a loose
   arrangement rather than a grid. The offsets and the per-column sizing are
   both hand-picked for six photos in three columns of two — not a formula,
   because there is no larger N this ever has to generalise to. */
.team__photos {
  display: flex;
  gap: var(--sp-4);
  flex: 1 1 55%;
}

/* Sized by FLEX-GROW, not `transform: scale`. A transform is paint-only —
   it does not reserve the extra layout space it displays in, so a scaled-up
   card simply draws on top of its neighbours rather than pushing them
   aside. Giving each column a different grow factor makes the middle one
   genuinely wider, which is what a real size difference has to be. */
.team__photo-col {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  flex: 1 1 0%;
}

.team__photo-col:nth-child(2) {
  flex-grow: 1.3;
  margin-top: 4rem;
}

.team__photo-col:nth-child(3) {
  flex-grow: 1.15;
  margin-top: 1.75rem;
}

.team__photo {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--black);
  cursor: pointer;
}

/* THE DIM IS ON THE IMAGE, NOT ON THE TILE, and that is what lets the
   monogram fallback keep working. A person with no `image` renders
   `.team__mark` instead -- white letterforms on black, which `grayscale()`
   cannot touch, since it is already grey. So the two children take the dim
   in the terms each can express: the photograph desaturates, the monogram
   fades. Putting a single rule on `.team__photo` would silently leave every
   monogram tile at full strength while the photographs dimmed around it. */
.team__photo.is-dimmed .team__portrait {
  filter: grayscale(1);
}

.team__photo.is-dimmed .team__mark {
  opacity: 0.55;
}

/* `cover`, not `contain`: this is a photograph with a subject to fill the
   frame, not a graphic on black that would lose content to a crop.

   No filter at rest. `--team-photo-filter` used to live here holding
   `grayscale(1)` so the one place on the page that broke "full colour,
   always" stayed grep-able; the section no longer breaks that rule, so the
   custom property has gone with it rather than sitting at `none` pretending
   to be a switch. Desaturation is now `.team__photo.is-dimmed`, above.

   The transition is on `filter` and belongs to the image rather than to the
   tile: the tile no longer animates anything. */
.team__portrait {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: filter var(--dur-mid) var(--ease-soft);
}

.team__mark {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(1.1rem, 2.6vw, 1.8rem);
  color: var(--paper);
  /* Matches the portrait's filter transition, so a wall mixing photographs
     and monograms dims as one thing rather than at two speeds. */
  transition: opacity var(--dur-mid) var(--ease-soft);
}

/* ---- The list --------------------------------------------------------------
   One column, top to bottom, in TEAM's own order — the photos' scatter has
   already done the "arrangement" job, so the list stays plain and legible. */
.team__list {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
  flex: 1 1 45%;
  padding-top: var(--sp-3);
}

.team__row {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  cursor: pointer;
  transition: opacity var(--dur-mid) var(--ease-soft);
}

.team__row.is-dimmed {
  opacity: 0.55;
}

/* Grows and fills solid on hover — the same left-origin draw language the
   services rules and the underline wipes use elsewhere on the page, just
   run on width instead of `scaleX` since this mark has no natural long axis
   to draw along. */
.team__dot {
  flex: none;
  width: 14px;
  height: 3px;
  border-radius: var(--r-pill);
  background: var(--paper);
  opacity: 0.3;
  transition: width var(--dur-mid) var(--ease-soft),
    opacity var(--dur-mid) var(--ease-soft);
}

.team__row.is-active .team__dot {
  width: 22px;
  opacity: 1;
  background: var(--accent);
}

.team__text {
  display: flex;
  flex-direction: column;
  gap: 0.3em;
}

.team__name {
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.6vw, 1.3rem);
  line-height: 1;
}

.team__role {
  font-family: var(--font-mono);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--slate);
}

@media (max-width: 900px) {
  .team__layout {
    gap: var(--sp-6);
  }

  .team__photo-col:nth-child(2) {
    margin-top: 2.5rem;
  }

  .team__photo-col:nth-child(3) {
    margin-top: 1rem;
  }
}

@media (max-width: 640px) {
  .team__layout {
    flex-direction: column;
  }

  .team__photos,
  .team__list {
    flex: 1 1 auto;
    width: 100%;
  }

  /* The scatter needs horizontal room three columns wide; stacked above a
     full-width list there isn't any, so it flattens to a plain 3-up grid
     instead of getting cramped into unreadably narrow columns.
     `display: contents` unwraps `.team__photo-col` -- it stops being a flex
     item and its photos become direct grid children instead, so the grid
     places all six rather than three column-shaped groups of two. */
  .team__photos {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--sp-3);
  }

  .team__photo-col {
    display: contents;
  }

  .team__photo-col .team__photo {
    margin-top: 0;
    transform: none;
  }
}

/* ==========================================================================
   8 · CONTACT — one address, one button, centred
   --------------------------------------------------------------------------
   THE FORM IS GONE ON PURPOSE. It was two tabbed modes, eight fields and a
   client-side validator that, with no endpoint configured, ended up composing
   a mailto: anyway — a lot of apparatus to arrive where a link arrives on the
   first click. The pair of buttons that replaced it was the same thinking one
   step smaller: two subject lines, and a visitor made to classify themselves
   before being allowed to write. One button now, one address under it.

   Everything here is an <a href="mailto:">. Nothing on this page needs
   JavaScript to reach a human any more, which is a better failure mode than
   the form ever had.
   ========================================================================== */

/* ---- One centred card ----------------------------------------------------
   This was a two-column split: heading on bone at the left, black panel at
   the right. Centred, that split has nothing left to do — a centred column
   next to another centred column reads as two half-finished pages — so the
   two halves are one object now.

   IT WAS BLACK, THEN THE ACCENT, AND IT IS `--accent-deep` NOW. Black failed
   first: on a dark-neutral page a black card barely read as a shape, the two
   darks being 0.0043 and 0.012 apart. The accent fixed that but cost the
   type — white on a mid-luminance violet is 5.55:1, and the notes further
   down this file record how much hierarchy had to be given up because
   nothing could fade towards a ground in the middle of the range. The token
   itself has since moved from #3c096c to #240046 with the page reversal; the
   card did not have to change to follow it, which is the point of the
   indirection.

   #240046 is the purple plane of the scheme, and as a card fill it beats the
   accent on every figure that matters here: white on it measures 18.05:1
   against 5.55:1, slate 5.98:1 against nothing that passed, and the CTA's own
   accent pill still clears the 3:1 non-text floor at 3.37:1 — which #3c096c,
   the purple this replaced, does not (2.71:1). It is a colour chosen to be
   seen rather than a neutral, which is what this shape needs: it is the last
   thing before the footer and what gives the page a bottom.

   THE HAIRLINE IS NOT OPTIONAL, IT IS THE WHOLE EDGE. The fill is 1.12:1
   against the page — no value step at all — and that is exactly the
   arrangement that was abandoned once before, when #240046 was tried as a
   card on a near-black page and the card dissolved. What makes it work now is
   that the edge is DRAWN rather than implied: `inset 0 0 0 1px` in
   `--rule-firm`, which reads at 2.88:1 against the page. Delete that line and
   the card disappears; it is load-bearing in a way a hairline usually is not.
   See tokens.css for why the palette has no darks that can separate on tone.

   IT WAS SCALED UP AS ONE THING, NOT ONE PROPERTY. Widening the box on its
   own leaves the same type stranded in more ground and reads as a card that
   failed to fill — so the measure, the heading, the standfirst, the divider
   and the button all moved with it. The proportions below are the ratios the
   smaller card had; only the multiplier changed. */

.contact__card {
  /* 62rem before. The `.shell` gives it 1456px to work with at a 1600
     viewport, so this is still a card on a ground rather than a band across
     it — which is the point of the shape. */
  max-width: 76rem;
  margin-inline: auto;
  /* Shorter than the clamp this replaced (3rem–6rem vertical): the card was
     reading as tall for how little is actually in it -- a kicker, a
     two-line heading, one sentence and a button. Horizontal padding is
     unchanged; only the top/bottom air came down. */
  padding: clamp(2.25rem, 5vw, 4rem) clamp(2rem, 6vw, 5rem);
  background: var(--accent-deep);
  color: var(--paper);
  border-radius: var(--r-card);
  /* The drawn edge — see the note above. Inset rather than a border so it
     does not add to the box and shift the padding that was tuned inside it. */
  box-shadow: inset 0 0 0 1px var(--rule-firm);
  text-align: center;
}

/* Children are blocks in a centred column, so every one of them that carries
   a width cap needs auto side margins to sit on the centre line rather than
   at the text start. Applied once here instead of on each in turn. */
.contact__card > * {
  margin-inline: auto;
}

/* Solid paper, not translucent white — the card is a filled accent now (see
   the note above `.contact__card`), and a mid-luminance fill is exactly the
   ground the campaign panels already proved a translucent white cannot sit
   on. Same forced-to-1 rule, same reason. */
.contact__kicker {
  color: var(--paper);
}

/* The eyebrow's rule is a leading device — lopsided the moment the label is
   centred. Mirrored rather than dropped: the mark is the section signature
   and appears above every other heading on the page. */
.contact__kicker::after {
  content: "";
  width: 28px;
  height: 1px;
  /* Matches `.eyebrow::before`, which this mirrors. Both marks, one
     colour — if they diverged the centred label would have an accent-
     coloured rule on one side and a grey one on the other. */
  background: var(--accent);
  flex: none;
}

.contact__title {
  margin-top: var(--sp-6);
  /* Its own scale rather than `--fs-display`, and the only heading on the
     page that gets one. This is the closing statement and it now has a card
     large enough to carry it — 80px against the shared display's 68px at a
     1600 viewport. Deliberately still under `--fs-masthead` (83px): the
     hero's headline outranks the sign-off, and only `--fs-statement` is
     allowed past both. */
  font-size: clamp(2.5rem, 5.6vw, 5rem);
}

.contact__note {
  margin-top: var(--sp-5);
  /* Wider than the 46ch used on bone: centred text needs a longer measure
     before the ragged edges on both sides start to look like a poem. Stepped
     up from `--fs-small` with the card — 14px was sized against a box two
     thirds of this one's width and read as fine print in the new one.

     60ch, NOT 56ch. This copy wraps to exactly two lines at 60ch and three at
     56ch -- the difference is one word's width, and the measure sat right on
     that knife-edge. Widened rather than left there so a font substitution or
     a translated string doesn't flip it back to three on its own. */
  max-width: 60ch;
  /* Solid, not translucent — see `.contact__kicker`. */
  color: var(--paper);
  font-size: var(--fs-body);
}

/* ---- The one route -------------------------------------------------------
   One control, and nothing beside it. Two buttons only ever chose between two
   subject lines, which is the first thing anyone writing says in their own
   words; the mono address that briefly replaced the second button read as a
   second button too. It survives in the footer sign-off, which is where a
   visitor who wants to copy an address rather than open a mail client looks.

   The hairline is capped well short of the card so it reads as a divider
   between the argument and the action, not as a second edge to the card. Its
   cap tracks the card's width — at 32rem inside a 76rem card it had stopped
   reading as a divider and started reading as a short dash. */

.contact__action {
  display: flex;
  justify-content: center;
  max-width: 40rem;
  margin-top: var(--sp-6);
  padding-top: var(--sp-6);
  border-top: 1px solid rgba(255, 255, 255, 0.18);
}

.contact__cta {
  /* The only button in the section, so it can afford to be larger than the
     shared pill makes it — and in a card this size the shared pill is what
     would look undersized. Both axes, because padding on one alone changes
     the pill's shape rather than its scale. */
  padding: 1.15rem clamp(2rem, 5vw, 3rem);
}

/* THE HOVER WENT BACK TO THE ACCENT, because the card changed under it. It
   was `--black`, chosen when the card was the mid-luminance accent and a dark
   pill was the only thing that could read against it. On `--accent-deep` that
   inverts: black on this card is 1.41:1 and the pill would disappear into it
   on hover, while the accent -- the brighter cut of the same hue -- sits at
   3.37:1 as a shape and takes white at 5.35:1. So the pill lightens into the
   family instead of dropping out of it. */
.contact__cta:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--paper);
}

.contact__meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-5) var(--sp-7);
  margin-top: var(--sp-7);
}

/* Solid, not translucent — see `.contact__kicker`. */
.contact__meta .micro {
  color: var(--paper);
}

.contact__meta-v {
  display: block;
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: var(--fs-small);
  color: var(--paper);
}

/* ==========================================================================
   9 · FOOTER
   --------------------------------------------------------------------------
   Rebuilt around the same single address. The old footer carried three links
   into the contact form and two of them chose a form mode that no longer
   exists; what replaces them is the address itself plus the two subject
   links, so every route out of the page ends in the same inbox.
   ========================================================================== */

.footer {
  padding-block: var(--sp-8) var(--sp-6);
}

/* The sign-off. Full-width above the columns — the mark had been sitting in a
   grid cell the same size as a list of three links, which made the name of
   the company the least prominent thing in its own footer. */
.footer__sign {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-5);
  padding-bottom: var(--sp-7);
}

.footer__mark {
  font-family: var(--font-display);
  font-size: clamp(2rem, 6vw, 3.6rem);
  line-height: 1;
  letter-spacing: var(--track-display);
  color: var(--paper);
}

/* The mark sets `line-height: 1` so its descender sits tight, which left this
   line touching it. Wide enough to stay on one line at the size the mono
   micro renders, rather than wrapping into two cramped rows under the name. */
.footer__tag {
  margin-top: var(--sp-3);
  max-width: 46ch;
}

.footer__reach {
  display: inline-flex;
  align-items: baseline;
  gap: var(--sp-3);
  font-family: var(--font-mono);
  font-size: clamp(0.95rem, 1.6vw, 1.2rem);
  color: var(--paper);
  text-decoration: none;
  overflow-wrap: anywhere;
}

.footer__reach span[aria-hidden] {
  transition: transform var(--dur-mid) var(--ease-out);
}

.footer__reach:hover span[aria-hidden] {
  transform: translate(3px, -3px);
}

.footer__grid {
  display: grid;
  /* NOT four equal columns, and the wide one is the FOURTH now. The tracks
     are Site, Services, Contact, Offices; the 1.35fr moved from third to
     fourth when the addresses did.

     The weights follow the content, which is the only rule here. Offices
     holds two postal addresses whose longest line, "Plot No. 11/F/13, Action
     Area-II", sets at 305px -- so it takes the wide track and gets 320px at
     the 1200px breakpoint, 15px of headroom, which is what lets an address
     break the way an address breaks instead of being chopped into a list of
     fragments. Contact no longer holds anything longer than an email address,
     so it drops to the 0.8fr the old Elsewhere column had: 190px at that same
     breakpoint against the 162px "ceo@jeevmedia.net" needs. Services keeps
     1fr for "Clipping & Distribution" at 161px of 237px, and Site's four
     short links take the remainder.

     WHY NOT FIVE COLUMNS, since there are now five groups. Five tracks means
     four 48px gaps instead of three, and at the bottom of this band that
     leaves about 149px each for everything that is not Offices -- under what
     Services already needs. It would hold above roughly 1400px and nowhere
     below, so the row was re-cut to four instead: the socials folded up into
     Contact, Elsewhere stopped existing, and Offices took the slot. Contact
     is how to reach us, Offices is where we are. */
  grid-template-columns: 0.85fr 1fr 0.8fr 1.35fr;
  gap: var(--sp-6) var(--sp-7);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--rule);
}

/* THREE SPACINGS, AND THEY ARE A SET. The four columns hold different kinds
   of thing -- links, plain list items, two postal blocks -- and before this
   each ran at its own pitch: links 35px apart, service items 26px, address
   lines 20px. Side by side in one row that read as three unrelated densities
   rather than one footer, and the group breaks inside Contact and Offices
   were too small to do their job against it.

   The set is now two numbers, not three:

     8px   between anything and the next thing  (--sp-2, the column's own gap)
     20px  under a column head                  (--sp-3 on top of that gap)

   THERE ARE NO GROUP BREAKS LEFT, and that is the settled answer rather than
   an omission. Both columns that held sub-groups were given one and both had
   it taken out again: Contact, where 20px in front of the unlabelled socials
   made them look like a column whose heading had been deleted, and Offices,
   where the same 20px between the two addresses was simply more air than the
   row wanted. What separates a group now is whatever is already different
   about it -- a change of typeface in Contact, a head line in Offices.

   So 20px means one thing in this footer and one thing only: a column head is
   above you. Anything else that opens to 20px will be read as a heading, which
   is the trap both of those attempts fell into. Add air here only together
   with something that names what the air introduces.

   8px AND NOT LESS, because of the target-size rule. The links are 23px tall,
   a pixel under the 24px WCAG 2.5.8 asks for, so they pass on the spacing
   exception instead: 24px circles centred on adjacent targets must not
   intersect, which needs 24px centre to centre. At 8px they sit 31px apart.
   4px would still pass at 27px and is not worth the three pixels. */
.footer__col {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-2);
}

.footer__col a {
  color: var(--slate);
  text-decoration: none;
  font-size: var(--fs-small);
}

.footer__col a:hover {
  color: var(--paper);
}

/* A SET OF DESTINATIONS NOW, and this rule needs nothing for that. The list
   used to be plain text -- there was no page for an individual service -- and
   the note here explained that it borrowed the colour and size of the links
   beside it without their underline or hover. Each item is now an anchor to
   its own page under /services/, and `.footer__col a` above already gives it
   the colour, size and hover every other footer link has, so the anchors
   needed no new declarations at all.

   Its own gap is still the point of the rule: .footer__col spaces its
   children and the whole list is one child, so the <ul> has to space itself.
   --sp-2 matches the column exactly, which also keeps the links on the right
   side of the target-size note further up -- 23px tall, passing on the 24px
   spacing exception rather than on height. */
/* The address and the phone number. Mono because this site sets data in that
   face, and here it does a second job: it splits the two details you copy
   from the link you click through to, without needing a rule or a gap to say
   so. Sized below --fs-small deliberately, since mono at the same px reads
   larger than the sans link above it and the column would lose its rhythm. */
.footer__detail {
  font-family: var(--font-mono);
  letter-spacing: var(--track-mono);
  font-size: 0.82rem;
}

/* The office. It takes .footer__detail's spec exactly -- same face, same
   0.82rem, same --track-mono, same --slate -- because it sits directly under
   the email and the number and the three of them are one group: the data in
   this column. An earlier version stepped the size down to 0.75rem and cut
   the tracking to make a long line fit, and the block read as a different
   kind of thing from the two lines above it, which is the one thing it must
   not do.

   `font-style: normal` because <address> is italic by default and nothing
   else in this footer is. The leading is the only number that differs from
   .footer__detail, and it is tighter on purpose: eight hard-broken lines at
   the column's own leading read as eight separate details rather than as one
   address. */
.footer__address {
  font-family: var(--font-mono);
  font-style: normal;
  letter-spacing: var(--track-mono);
  /* --fs-small (14px), NOT the 0.82rem that .footer__detail declares.
     .footer__detail never gets its own size: `.footer__col a` out-specifies
     it (0,1,1 against 0,1,0), so the email and the number have always
     rendered at --fs-small and that declaration has been dead since it was
     written. Matching the rule rather than the rendering is what made this
     block look a size too small next to them -- 13.12px against their 14px.
     Left as-is rather than fixed there: raising .footer__detail's specificity
     would shrink the email and the number, which is a change to two lines
     nobody asked about. */
  font-size: var(--fs-small);
  /* 1.45 against the column's own leading. Six full lines want less air than
     the eight short ones this replaced -- at 1.55 the block read as spaced
     out rather than as one address. */
  line-height: 1.45;
  color: var(--slate);
}

/* Slate, like every other line in the column. It was --paper, to lift the
   label off the block the way .footer__col-head lifts "Contact" off the
   column -- but a second paper line under that heading reads as a second
   heading, and it was the brightest thing in a footer of slate. The line
   does not need the colour: it is first, it is short, and the eight lines
   under it are plainly one address. */
/* PAPER, WHICH IS WHAT MAKES THE 8px GAP ABOVE IT SURVIVABLE. These two
   lines used to be `--slate` like the address under them, so "Dubai HQ" was
   the same weight, size, face and colour as "West Bengal, India" and the only
   thing marking it as the start of a second entry was the space above it --
   which is now 8px, two pixels more than an ordinary line break. The pair
   read as one long block. Colour does the separating instead, which is the
   remedy this file already recommended when that gap was closed.

   THREE TIERS IN ONE COLUMN, and they stay distinct because only one thing
   changed. The column head "OFFICES" is 11.52px, uppercase, tracked at
   0.16em; these are 14px, sentence case, tracked at 0.08em; the address is
   the same again but slate. So the head and the HQ lines now share a colour
   and are still told apart instantly by size and case, and the HQ lines and
   the address share size and case and are told apart by colour. Nothing has
   to carry two signals at once.

   Do not reach for bold. The mono face ships in one weight here and a faux
   bold on 14px mono at this size comes out muddy rather than emphatic. */
.footer__address-head {
  display: block;
  color: var(--paper);
}

/* NOTHING BETWEEN THE TWO OFFICES BUT THE COLUMN'S OWN 8px. There was a
   margin here -- --sp-4, then --sp-3 -- opening the pair to 20px so the
   second head could not be mistaken for another line of the first address.
   It is gone by request: every gap in this footer is now the same 8px, with
   the single exception of the space under a column head.

   IT WORKS BECAUSE THE HEADS CARRY IT, NOT BECAUSE THE SPACE DOES. An
   address line sits about 6px from its own next line, so at 8px the break
   between the two offices is two pixels wider than an ordinary line break
   inside one of them -- nowhere near enough on its own. What separates the
   entries is `.footer__address-head` being `--paper` against the address's
   `--slate`; see the note on that rule. That was the recommendation left here
   when this margin was removed, and it has since been taken.

   So the two are a pair: this gap is only safe while the heads are the
   brighter colour. Put them back to slate and the margin has to come back
   with them, or the column reads as one nine-line block again. */

/* NO HANGING INDENT, and it was there until it was measured. The idea was
   insurance: hard-broken lines, so a track too narrow for one would wrap it
   onto an indented continuation reading as the tail of the line above. What it
   actually did was cause the wrap it was insuring against -- `padding-left:
   1.5ch` takes about 13px out of the measure, which pushed the 224px track
   just above the 1140px breakpoint under the 214px the longest line needs.
   The insurance was the risk. Removed, the same track has 10px of slack and
   nothing wraps at any width.

   The remaining exposure is a metrics change in the mono stack, and it runs
   the safe way: every fallback there is narrower than IBM Plex Mono, so
   losing the webfont shortens these lines rather than lengthening them. */

/* Same 8px as the columns' own gap, so a service sits at the same pitch as a
   link beside it. These are the only items in the row that are not anchors,
   and running them looser than the links was the most visible of the three
   mismatched rhythms -- two adjacent columns of four short lines that did not
   line up with each other at any row. */
.footer__services {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}

.footer__services li {
  color: var(--slate);
  font-size: var(--fs-small);
}

.footer__col-head {
  margin-bottom: var(--sp-3);
  color: var(--paper);
}

/* NO BREAK BETWEEN THE DETAILS AND THE SOCIALS, AND THAT IS THE SECOND
   ANSWER RATHER THAN THE FIRST. There was a rule here adding a step of air
   between the phone number and Instagram, on the reasoning that mono data and
   sans destinations are two groups and a group deserves a gap. Tried at 8px
   and then at 20px, and the larger one was worse: 20px is exactly the space
   this footer puts under a column head, so the three socials stopped reading
   as the tail of Contact and started reading as a column whose heading had
   gone missing. A gap that says "a new thing starts here" is a lie when
   nothing names the new thing.

   So the column runs one even 8px throughout and the two faces carry the
   distinction on their own, which is what they were already doing. Five rows,
   one list, two treatments: an email and an Instagram handle are both just a
   way to reach us, which is the whole argument for the column holding them
   together in the first place.

   Offices keeps its 20px break because it earns one -- each half is a labelled
   entity with "India HQ" or "Dubai HQ" at the top of it. That is the test for
   adding air back here too: give the socials a real sub-head first, or leave
   the spacing even. */

.footer__legal {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-4);
  margin-top: var(--sp-8);
  padding-top: var(--sp-5);
  border-top: 1px solid var(--rule);
}

/* 1140px, raised from 980px, and before that from 760px when the fourth
   column landed. 980 was set by "Clipping & Distribution", which starts
   wrapping to three lines below it; the office block in the Contact column
   asks for more. Its longest line, "Ecospace Business Towers", measures 214px
   at 13.12px -- and 228px at the 14px the column's other data actually renders
   at. A quarter track only clears 228px at about 1160px of viewport, so four
   columns now begin at 1201, where the track is 238px.

   THE ALTERNATIVE WAS SHRINKING THE TYPE, and it was tried: 0.75rem with the
   tracking cut to 0.02em fits a 187px track, and made the address read as a
   different kind of thing from the email and number directly above it. Moving
   one breakpoint is the cheaper fix -- it costs the four-column layout the
   980-1200 band, where two columns of --fs-small read perfectly well. */
/* Three links at the far end of the legal row. Its own wrap, so on a narrow
   screen they break among themselves and drop under the copyright instead of
   compressing into a half-width track. The row gap is tighter than the column
   gap for the same reason -- wrapped, they should read as one block rather
   than as three separate lines. */
.footer__policies {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-5);
}

@media (max-width: 1200px) {
  .footer__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* 780px, raised from 440px, and the office block is the reason here too. A
   two-column track drops under the 305px the address's longest line needs at
   around 730px of viewport, so the band below that wrapped it and stranded
   "Action Area-II" on a line of its own. One column from 780 down removes the
   band and leaves 342px at the boundary.

   Below about 340px the single column is itself narrower than 305px and two
   lines wrap. That is left alone: it is a 320px viewport, where an address
   taking eight lines instead of six is what a narrow screen does, and the
   alternative was shrinking the type on phones to protect a case almost
   nobody is in. */
@media (max-width: 780px) {
  .footer__grid {
    grid-template-columns: 1fr;
    gap: var(--sp-6);
  }
}

/* ==========================================================================
   8 · BRAND PARTNERS
   --------------------------------------------------------------------------
   A ticker, not a section. It sits between the team wall and the closing card
   with no heading at display size and no rules of its own, because what it is
   for is to be passed on the way down — the moment it asks to be stopped at
   it starts competing with the card underneath it, which is the one thing on
   the bottom half of this page that is meant to be acted on.

   Adapted from the logo ticker on smoothmedia.co. The label held at the left
   of the track is theirs, and it earns its place here for the same reason: a
   row of wordmarks with nothing to introduce it reads as decoration, and the
   two words say what the row is in the space a heading would have taken.
   ========================================================================== */

.brands {
  /* Its own padding rather than `.section`'s. The block is a single row of
     small type and `.section` is scaled for a heading, a standfirst and a
     grid -- given that much air the ticker reads as a section that lost its
     content. */
  padding-block: var(--sp-8);
}

.brands__shell {
  display: flex;
  align-items: center;
  gap: var(--sp-7);
}

/* Two words, hard-broken, held at the left while the row moves past it. The
   break is in the markup: "Our brand / partners" stacks into a block the
   height of the track instead of a line as long as the label, which is what
   keeps the rail's start edge where the eye expects it. */
.brands__label {
  flex: none;
  max-width: 12ch;
  color: var(--paper);
  line-height: 1.35;
}

/* `clip`, NOT `hidden`. Both cut the overflowing row, but `hidden` makes this
   a scroll container -- a keyboard can land in it and scroll a decorative
   rail sideways, and on iOS a stray horizontal drag does the same. `clip`
   cuts without ever becoming scrollable.

   The mask is what stops the row from starting and ending on a hard edge. It
   fades both ends into the page ground over the same distance, so a wordmark
   arrives and leaves rather than appearing at a boundary; without it the row
   reads as a strip laid on the page instead of one passing behind it. */
.brands__rail {
  flex: 1 1 auto;
  min-width: 0;
  overflow: clip;
  -webkit-mask-image: linear-gradient(
    to right,
    transparent,
    #000 var(--sp-8),
    #000 calc(100% - var(--sp-8)),
    transparent
  );
  mask-image: linear-gradient(
    to right,
    transparent,
    #000 var(--sp-8),
    #000 calc(100% - var(--sp-8)),
    transparent
  );
}

/* The track holds N identical sets and slides left by exactly one set, so the
   frame it wraps on is the frame it started on and the seam is invisible.
   Both numbers come from main.js: `--brands-shift` is one set's measured
   width, `--brands-dur` is that width divided by a fixed speed, so the row
   travels at the same px/s whether it carries three wordmarks or twenty.

   `width: max-content` so the sets lay out at their natural width instead of
   being squeezed into the rail, and the flex row never wraps. */
.brands__track {
  display: flex;
  align-items: center;
  width: max-content;
  will-change: transform;
  animation: brands-run var(--brands-dur, 30s) linear infinite;
}

/* Paused on hover, so a name that catches the eye can be read. Pointer-only
   by design -- there is nothing to click, and pausing on focus would mean
   giving a decorative rail a tab stop to be focused through. */
.brands__rail:hover .brands__track {
  animation-play-state: paused;
}

@keyframes brands-run {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(calc(var(--brands-shift, 0px) * -1), 0, 0);
  }
}

.brands__set {
  display: flex;
  align-items: center;
  /* The reference's 64px, in the scale this site actually uses. */
  gap: var(--sp-7);
  /* And the same gap again between one set and the next, so the seam is
     spaced like every other pair of names rather than closing up. */
  padding-right: var(--sp-7);
}

/* Wordmarks, in the display face. The size is well under `--fs-small`: these
   are names passing by, and set any larger they start to read as a list of
   headings. `--slate` rather than paper for the same reason -- the row is
   proof, not an announcement, and the card below it is what the eye should
   land on. */
.brands__item {
  flex: none;
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.6vw, 1.35rem);
  line-height: 1;
  letter-spacing: var(--track-display);
  color: var(--slate);
  white-space: nowrap;
}

/* A real logo file, when one is supplied. Capped on height rather than width,
   which is what keeps a wide wordmark and a square mark reading as the same
   weight in the row -- see the note in README about Morphic drawing 385px
   against ByteDance's 603px under an even-padding rule. */
.brands__logo {
  display: block;
  width: auto;
  height: clamp(1.15rem, 1.8vw, 1.5rem);
  object-fit: contain;
}

/* Stacked at the point the label and the rail stop fitting side by side. The
   label loses its hard break here -- two stacked words above a full-width
   rail is a lot of vertical space for two words -- and the rail keeps the
   whole shell to itself. */
@media (max-width: 700px) {
  .brands {
    padding-block: var(--sp-7);
  }

  .brands__shell {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--sp-5);
  }

  .brands__label {
    max-width: none;
  }

  .brands__label-br {
    display: none;
  }

  .brands__rail {
    align-self: stretch;
  }
}

/* No motion, no ticker. What is left is the honest version of the same thing:
   one set of names, wrapped and centred, with the repeats and the mask gone
   -- a row that scrolls past on its own is exactly the kind of thing this
   query exists to stop. The extra sets are hidden rather than not built, so
   the same DOM serves both and nothing has to be re-rendered if the
   preference changes mid-session. */
@media (prefers-reduced-motion: reduce) {
  .brands__rail {
    -webkit-mask-image: none;
    mask-image: none;
  }

  .brands__track {
    width: 100%;
    flex-wrap: wrap;
    animation: none;
  }

  .brands__set {
    flex-wrap: wrap;
    padding-right: 0;
  }

  .brands__set[aria-hidden="true"] {
    display: none;
  }
}
