/* ==========================================================================
   Unpodesign — static port of Unpodesign.dc.html
   Palette, type and layout are carried over verbatim from the design file.
   ========================================================================== */

:root {
  /* Colour — oklch, exactly as authored in the source design */
  --bg:           oklch(97% 0.012 75);
  --ink:          oklch(18% 0.01 60);
  --ink-2:        oklch(20% 0.01 60);
  --muted:        oklch(45% 0.01 60);
  --muted-mid:    oklch(38% 0.01 60);
  --muted-body:   oklch(32% 0.01 60);
  --muted-soft:   oklch(48% 0.01 60);
  --muted-faint:  oklch(55% 0.01 60);
  --accent:       oklch(45% 0.02 50);

  --dark:         oklch(16% 0.01 60);
  /* Floor for the What We Do → Services run. Deliberately between .featured's
     --dark (16%) and the testimonial's --ink (18%): the gallery hands over to
     it and it hands over to the quote, and neither hand-over should read as an
     edge. Tune the run's darkness here and nowhere else. */
  --anthracite:   oklch(17% 0.009 60);
  --dark-2:       oklch(27% 0.008 60);
  --surface-3:    oklch(28% 0.01 60);
  --surface-4:    oklch(30% 0.01 60);
  --on-dark:      oklch(96% 0.012 75);
  --on-dark-hi:   oklch(98% 0.012 75);

  --rule:         oklch(80% 0.01 60 / 0.4);
  --rule-strong:  oklch(18% 0.01 60 / 0.25);
  --rule-faint:   oklch(18% 0.01 60 / 0.14);

  /* Type — Poppins across the whole panel; weights and sizes unchanged */
  --f-display: 'Poppins', sans-serif;
  --f-body:    'Poppins', sans-serif;
  --f-mark:    'Poppins', sans-serif;
  --f-serif:   'Poppins', sans-serif;

  /* Rhythm */
  --gut:      clamp(24px, 6vw, 64px);
  --sec-y:    clamp(80px, 10vw, 160px);
  --ease:     cubic-bezier(.2, .7, .2, 1);

  /* 100vw counts the scrollbar, so raw full-bleed sits ~15px off-centre.
     main.js measures the gutter into --sbw; --bleed is the corrected width. */
  --sbw:      0px;
  --bleed:    calc(100vw - var(--sbw));
}

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

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--f-body);
  overflow-x: clip;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, p, figure, blockquote, dl, dd { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }

a { color: var(--ink); text-decoration: none; }
a:hover { color: var(--accent); }

::selection { background: var(--ink); color: var(--bg); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.skip-link {
  position: absolute; left: -9999px;
  background: var(--ink); color: var(--bg);
  padding: 12px 20px; z-index: 200;
  font-family: var(--f-display); font-size: 14px;
}
.skip-link:focus { left: 12px; top: 12px; }

/* ---------- shared type utilities ---------- */

.eyebrow {
  font-family: var(--f-display);
  font-size: 20px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 clamp(24px, 2.8vw, 44px);
}

.label-sm {
  font-family: var(--f-display);
  font-size: 12px; letter-spacing: 0.15em;
  text-transform: uppercase; color: var(--muted);
  margin: 0 0 14px;
}

.h-md {
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(30px, 3.6vw, 48px);
  line-height: 1.08; letter-spacing: -0.02em;
  margin: 0 0 28px;
}

.h-sm {
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(28px, 3.2vw, 42px);
  line-height: 1.1; letter-spacing: -0.02em;
}

.body-lg { font-size: 17px; line-height: 1.7; color: var(--muted-body); max-width: 640px; }
.body-sm { font-size: 14.5px; line-height: 1.6; color: var(--muted-body); margin: 0 0 4px; }

.rule { border: 0; border-bottom: 1px solid oklch(18% 0.01 60 / 0.28); margin: clamp(18px, 2.4vw, 32px) 0 0; }

.dot { width: 6px; height: 6px; border-radius: 999px; background: var(--accent); display: block; flex: none; }

/* Read but not seen. For the heading a piece of display typography cannot be —
   a name split across a picture is two boxes on the page and one word to a
   screen reader, so the whole name is carried once, here. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

.section { padding: var(--sec-y) var(--gut); border-top: 1px solid var(--rule); }

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

.btn {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--f-display); font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase;
  border-radius: 999px; white-space: nowrap; cursor: pointer;
  transition: transform .3s var(--ease), background .3s, color .3s;
}

.btn--pill {
  flex: none; align-self: flex-end;
  font-size: 14px; padding: 13px 28px;
  background: var(--ink); color: var(--bg);
  border: 1px solid var(--ink);
}
.btn--pill:hover { background: transparent; color: var(--ink); }

.btn--solid {
  flex-shrink: 0; gap: 12px; padding: 16px 30px;
  font-size: 15px; letter-spacing: 0.08em;
  background: var(--ink); color: var(--bg);
}
.btn--solid:hover { background: var(--surface-3); color: var(--bg); }

/* The travelling copy of Selected Work's "All Work" — see the markup note in
   index.html for why it is a copy. Lives outside #smooth-wrapper, so its fixed
   position is the viewport's and not the transformed content's. z-index 40
   clears the gallery (3) without reaching the header (50), which still has to
   pass over it. display:none until main.js has something to show.
   box-shadow joins .btn's own transition list so the flip below carries the
   frame with it rather than snapping it on. */
.work-cta-fixed {
  position: fixed; z-index: 40; display: none;
  transition: transform .3s var(--ease), background .3s, color .3s, box-shadow .3s;
}
.work-cta-fixed.is-live { display: inline-flex; }

/* Flips once it is riding the gallery rather than sitting on the page: an ink
   pill over a photograph loses its own outline half the time. On the picture it
   stops being a filled pill altogether — an open frame in pure white, the same
   white the rail's marks are set in, so it reads as belonging to the gallery
   rather than as a label pasted onto it.
   The frame is an inset box-shadow, not a border: a border would grow the box
   by 2px and the copy's position is measured off the original's rect, so the
   two would stop lining up at the moment of the flip. */
.btn--solid.is-parked {
  background: transparent;
  color: oklch(100% 0 0);
  box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.7);
}

/* No hover change on this one, in either state. It spends most of its life
   travelling under the pointer's path rather than waiting to be pointed at, and
   a colour swap fired by the page moving past the cursor reads as a glitch
   rather than as a response. Scoped to this button — every other .btn--solid on
   the site keeps its hover. */
.work-cta-fixed:hover,
.work-intro .btn--solid:hover {
  background: var(--ink); color: var(--bg);
}
.work-cta-fixed.is-parked:hover {
  background: transparent;
  color: oklch(100% 0 0);
  box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.7);
}

.link-caps {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: var(--f-display); font-size: 13px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
  transition: transform .3s var(--ease);
}

/* ==========================================================================
   Header
   ========================================================================== */

/* ScrollSmoother shell. The wrapper is the viewport; the content is
   transformed, so it cannot host position:fixed children.
   Deliberately NO overflow:hidden here — ScrollSmoother applies that itself
   once it initialises. Setting it in CSS also applies when the smoother is off
   (reduced-motion, ?smooth=0, vendor files missing), which re-parents the
   sticky scroll context and silently breaks both sticky bands. */
/* The header's reserved height belongs to <main>, not to the content wrapper:
   <main> is what paints the page's ground and what takes the pointer, and a
   strip of unpainted padding above it would let the fixed footer show through
   at the very top of the page and swallow clicks meant for the page. */
#smooth-content { width: 100%; }
main { padding-top: var(--header-h, 104px); }

.site-header {
  /* fixed, not sticky: the header lives outside the transformed content */
  position: fixed; top: 0; left: 0; right: 0; z-index: 50;
  /* Three tracks, both outer ones 1fr: the mark sits in the middle one and so
     stays on the viewport's centre line whatever the controls beside it
     measure, whether or not those two sides carry the same content. The
     burger holds the first against the left corner; the links gather in the
     third against the right. */
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; gap: 16px;
  padding: 14px var(--gut);
  transition: transform .28s ease, opacity .28s ease;
  /* transform only. `will-change: opacity` would make this element a backdrop
     root, and the blur below — now on a child — would have nothing left to
     sample through it. */
  will-change: transform;

  /* Only the alpha of these stops matters — they are read as a mask. The
     values are 1 - smoothstep(t) sampled every 5%, so the curve leaves full
     opacity and arrives at zero with no corner at either end. */
  --header-fade: linear-gradient(to bottom,
    rgb(0 0 0 / 1)     60%,
    rgb(0 0 0 / 0.957) 65%,
    rgb(0 0 0 / 0.844) 70%,
    rgb(0 0 0 / 0.684) 75%,
    rgb(0 0 0 / 0.5)   80%,
    rgb(0 0 0 / 0.316) 85%,
    rgb(0 0 0 / 0.156) 90%,
    rgb(0 0 0 / 0.043) 95%,
    rgb(0 0 0 / 0)    100%);
}
/* The tint and the blur live on a pseudo-element so the mask below can fade
   them out without also fading the links, logo and burger — those are the
   header's own content and stay fully opaque.
   z-index:-1 keeps it behind that content but still inside the header's
   stacking context, so it never falls behind the page. */
.site-header::before {
  content: ""; position: absolute; z-index: -1;
  /* Hangs 30% of the header's height past its bottom edge. That overhang is
     the room the fade needs — the ramp can run long and gentle instead of
     having to finish by the time it reaches the header's own edge. */
  top: 0; left: 0; right: 0; bottom: -30%;
  pointer-events: none;
  /* Halved from 0.72: the band was reading as a solid light bar over the dark
     sections. The blur beneath still separates the header from what scrolls
     under it, so legibility rides on that rather than on the tint. */
  background: oklch(97% 0.012 75 / 0.36);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  /* No hard bottom edge: the band is solid down to 60% of its height, then
     tint and blur alike ramp away to nothing at the very bottom. The mask
     clips the backdrop-filter result too, so the blur fades with the tint.
     The stops trace a smoothstep rather than a straight line: a linear ramp
     leaves a visible crease where it starts and a flat stop where it ends,
     while this eases out of full opacity and into nothing at both ends. */
  -webkit-mask-image: var(--header-fade);
          mask-image: var(--header-fade);
}
.site-header.is-hidden { transform: translateY(-100%); opacity: 0; pointer-events: none; }

/* The right-hand cluster — the three links only now; the burger has its own
   track at the opposite corner. */
.site-header__right {
  grid-column: 3; justify-self: end;
  display: flex; align-items: center;
  gap: clamp(24px, 3.2vw, 48px);
}

.nav { display: flex; align-items: center; gap: clamp(20px, 3vw, 44px); }
.nav--end { gap: clamp(20px, 2.6vw, 38px); }
.nav a {
  /* inline-block so the magnetic translate written by main.js has a box to move
     — a transform on an inline box is ignored. */
  display: inline-block;
  font-family: var(--f-display); font-size: 24px; font-weight: 500;
  letter-spacing: 0.08em; text-transform: uppercase;
  will-change: transform;
}

.brand {
  grid-column: 2; justify-self: center;
  display: flex; flex-direction: column; align-items: center;
  line-height: 0; color: var(--ink);
}
/* Logo drop area. Fixed height keeps the header (and --header-h) stable
   whatever the artwork's proportions; width follows the image. */
.brand__slot {
  height: clamp(48px, 5vw, 84px);
  width: auto; min-width: 0;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0;          /* no placeholder hatching in the header */
  overflow: visible;
}
.brand__slot::after { content: none; }  /* and no placeholder caption */

/* The logo is brand furniture, not a content slot: never show the editing
   chrome (dashed outline, DRAFT badge) over it. */
.brand__slot.slot--draft,
.brand__slot.slot--drop { outline: none !important; }
.brand__slot.slot--draft::before { content: none; }
/* .slot.brand__slot beats the generic `.slot img { object-fit: cover }`.
   flex:none stops the image being shrunk to zero width by the flex parent. */
.slot.brand__slot img {
  height: 100%; width: auto; flex: none; object-fit: contain;
}
.brand__slot.is-filled .brand__fallback { display: none; }

.brand__fallback { display: flex; flex-direction: column; align-items: center; }

.brand__mark { height: 99px; width: auto; display: block; }
.brand__word {
  font-family: var(--f-mark); font-weight: 700;
  font-size: 44px; line-height: 1; letter-spacing: -0.01em;
}
.brand__sub {
  font-family: var(--f-mark);
  font-size: 8.5px; font-weight: 600;
  letter-spacing: 0.42em; margin-left: 0.42em; margin-top: 3px; line-height: 1;
}

/* Four solid dots in a 2×2 block rather than stacked bars. Filled, not
   outlined: the mark reads as weight rather than as a hairline, which is what
   the left corner needs to hold its own against the brand mark now sharing
   the same row. */
/* Sits in the right-hand cluster, after the links, so the control that opens
   the panel is on the same edge the panel itself comes from. It is display:none
   until the header goes compact, so at the top of the page the links still end
   flush with the gutter. */
.burger {
  display: none;
  grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr);
  gap: 5px;
  width: 30px; height: 30px; padding: 0;
  background: none; border: none; cursor: pointer;
  will-change: transform;
}
.burger__dot {
  display: block; width: 100%; height: 100%;
  border-radius: 50%;
  background: var(--ink);
}

/* Compact header — the state the page returns to when you scroll back up
   mid-document. The links are dropped and only the logo and the burger are
   left standing, so the band reads as a marker of where you are rather than
   as a full navigation bar re-imposing itself over the content. Faded rather
   than `display:none` so the links keep their width: the burger sits after them
   in the same row and would otherwise jump left as they left the flow. */
.site-header.is-compact .nav--desktop {
  opacity: 0; pointer-events: none;
  transition: opacity .22s ease;
}
.site-header .nav--desktop { transition: opacity .28s ease .06s; }

/* Above the mobile cutoff the burger is a compact-state affordance only; the
   media query at the foot of this file makes it permanent below it. */
.site-header.is-compact .burger { display: grid; }

/* Inner pages — studio, work, contact — carry .inner-page on <body> and run the
   header on the burger alone at every width and in every state. The three links
   are the home page's own navigation: there they name the whole site to a
   visitor who has just arrived, and here they would only repeat the page the
   reader is already standing on. The panel says the same thing on demand.
   display:none rather than the compact state's opacity fade: nothing follows
   the links in the row but the burger, so there is no width left to hold. */
.inner-page .site-header .nav--desktop { display: none; }
.inner-page .site-header .burger { display: grid; }

/* ---------------------------------------------------------- menu overlay */

/* Not a full-screen wipe: a card held against the right edge under the header,
   with the page dimmed behind it. The overlay element itself paints nothing —
   it is only the fixed layer that holds the two pieces that do, so the scrim
   can fade on its own clock while the card's edge travels.
   Revealed by animating clip-path rather than height or opacity: the card is
   laid out at full size from the start, so the type inside never reflows mid
   animation. inset() rather than polygon() because the card has round corners
   and a polygon clip would square them off for the whole of the move. */
/* Above the header, not under it. The header is fixed and so is a stacking
   context of its own: nothing inside it — least of all its glass band — can be
   put behind the card while the header itself is in front. So the whole
   overlay goes over the header instead, and the card is drawn over the band
   rather than beside a hole cut in it. The scrim goes with it, which is what
   dims the band, the links and the mark along with the page.
   The burger is the one control that has to stay in front of the card. It is
   moved into this layer for as long as the panel is open — see dockBurger in
   main.js — because from inside the header it could never climb out. */
.menu-overlay {
  position: fixed; inset: 0; z-index: 130;
  visibility: hidden;
}
.menu-overlay.is-open { visibility: visible; }

/* The burger while it is docked here. It keeps the exact box it had in the
   header — main.js pins it to the rect it measured before the move — so this
   only has to restate what the header's own rules were giving it: shown (the
   base rule hides it until the header goes compact) and white, since it now
   sits over a dark card rather than over the page. */
.menu-overlay > .burger { display: grid; position: fixed; z-index: 2; }
.menu-overlay > .burger .burger__dot { background: var(--on-dark-hi); }

/* Takes the pointer as well as the light: a click anywhere off the card closes
   it, which is the gesture the card's own edge implies. */
/* Neutral, not the page's warm anthracite: the card over it is glass, and a
   tinted ground behind glass reads as a colour cast on everything seen through
   it. Chroma 0 at both levels keeps the whole assembly grey. */
.menu-overlay__scrim {
  position: absolute; inset: 0;
  background: oklch(13% 0 0 / 0.5);
  opacity: 0;
}

/* 22px and not a clamp: the radius is written into the clip-path both here and
   in main.js, and the two have to be the same string or the corners change
   shape while the edge travels. */
/* The top edge clears the header rather than starting under it, so the burger
   is inside the card while the card is open — the control and the thing it
   controls are one object. The burger itself does not move a pixel to make
   that true: it keeps its place in the header and its own stacking context
   above the overlay, and the card is simply drawn out to meet it. The top
   padding is what keeps the content clear of it. */
.menu-panel {
  /* One number for all three free edges, so the card sits the same distance
     from the window wherever you measure it. It is also what holds the burger
     clear of the card's own corner: the control is inside the card, but not
     jammed against the edge of it. */
  --menu-inset: clamp(16px, 2.4vh, 24px);
  /* The card is held 20px further off the right edge than off the top, and the
     bottom carries the same figure as the right — those two are the pair the
     eye reads together, since the card's own corner joins them. */
  --menu-side: calc(var(--menu-inset) + 20px);
  position: absolute;
  top: var(--menu-inset);
  right: var(--menu-side);
  bottom: var(--menu-side);
  /* Portrait, and the proportion is the layout's only fixed dimension. The
     three edges above are set, so the height is already decided by the window;
     the width is whatever that height needs to hold this ratio, which is the
     one taken off the reference. max-width is the floor under it — on a narrow
     window the ratio gives way rather than the card leaving the screen. */
  width: auto;
  aspect-ratio: 714 / 1205;
  max-width: calc(100vw - var(--menu-inset) - var(--menu-side));
  /* Names above, picture below. */
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: clamp(18px, 2.6vh, 34px);
  /* One figure for the card's own side and bottom padding, so the picture
     below can cancel exactly that much and sit flush to those three edges. */
  --menu-pad: clamp(18px, 2vw, 32px);
  padding: calc(var(--header-h, 104px) - 22px) var(--menu-pad) var(--menu-pad);
  border: 0;
  border-radius: 22px;
  /* Still glass, but glass you cannot read a photograph through. At 0.38 the
     page behind was legible enough to compete with the names in front of it
     and the card had no edge of its own; carried at 0.8 — the strength the
     reference site uses — the ground goes quiet and the card reads as one
     surface. Neutral grey, no chroma: the warm anthracite the rest of the page
     is built in goes yellow once it is lit from behind like this. */
  /* Held as bare components rather than as a colour, so the vignette below can
     take the same grey at its own alphas and fade the picture into exactly the
     surface it is sitting on. */
  --menu-card: 21% 0 0;
  background: oklch(var(--menu-card) / 0.8);
  backdrop-filter: blur(18px) saturate(1.1);
  -webkit-backdrop-filter: blur(18px) saturate(1.1);
  color: var(--on-dark-hi);
  clip-path: inset(0% 0% 100% 0% round 22px);
  overflow: hidden;
}
.menu-overlay.is-open .menu-panel { clip-path: inset(0% 0% 0% 0% round 22px); }
.menu-overlay.is-open .menu-overlay__scrim { opacity: 1; }

/* The gallery beside the links, built exactly as the Services stage is: every
   picture occupies the frame at once, the frame never moves, and only the
   called one is seen. Each waits off-centre on a small ladder so the handover
   has a direction that matches the link's position in the list. */
/* The lower half of the card: the marks in a row under the last name, and the
   picture under them. The second track is the fraction, so whatever height is
   left over after the marks have taken theirs belongs to the picture — and the
   picture does not fill it. */
.menu-panel__stage {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: clamp(14px, 2vh, 26px);
  min-height: 0;
}
/* Bled out to the card's own left, right and bottom edges: the negative
   margins cancel the padding the card holds on those three sides, so the
   picture meets the card's corners rather than floating inside a margin. Its
   own radius is dropped with them — the card's 22px and its overflow:hidden
   are what round the two bottom corners now. */
.menu-panel__gallery {
  position: relative; min-height: 0; min-width: 0;
  display: flex; align-items: stretch; justify-content: center;
  margin: 0 calc(var(--menu-pad) * -1) calc(var(--menu-pad) * -1);
}
.menu-panel__frame {
  position: relative;
  /* Fills whatever height the row has left after the marks above it, at the
     full width of the card. No ratio of its own: the box decides the shape and
     the picture inside is cropped to it by object-fit. */
  width: 100%; height: 100%; max-height: none;
  border-radius: 0; overflow: hidden;
  background: oklch(26% 0 0 / 0.4);
}
/* The picture now runs to three of the card's own edges, so it needs an edge of
   its own that is not a line. Four ramps of the card's grey — one per side —
   laid over it: the picture arrives at each edge already the colour of the
   surface it is bled into, and there is nothing to see where the two meet.
   The top is the deep one, and the only one that reaches full strength. It is
   the edge that faces the names, where the picture would otherwise start on a
   hard horizontal against the card; the other three run along edges the card's
   own corners already close, so they are only felt.
   Each ramp is stepped rather than a straight fade to transparent: a two-stop
   linear gradient across this much distance bands visibly on a flat grey, and
   its midpoint sits far too high. The stops trace an ease-out instead, so the
   density falls away fastest where it starts. */
.menu-panel__frame::after {
  content: ""; position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(to bottom,
      oklch(var(--menu-card) / 1)    0%,
      oklch(var(--menu-card) / 0.94) 5%,
      oklch(var(--menu-card) / 0.74) 12%,
      oklch(var(--menu-card) / 0.45) 21%,
      oklch(var(--menu-card) / 0.18) 31%,
      oklch(var(--menu-card) / 0)    42%),
    linear-gradient(to top,
      oklch(var(--menu-card) / 0.8)  0%,
      oklch(var(--menu-card) / 0.55) 6%,
      oklch(var(--menu-card) / 0.28) 14%,
      oklch(var(--menu-card) / 0.09) 24%,
      oklch(var(--menu-card) / 0)    34%),
    linear-gradient(to right,
      oklch(var(--menu-card) / 0.78) 0%,
      oklch(var(--menu-card) / 0.52) 5%,
      oklch(var(--menu-card) / 0.26) 12%,
      oklch(var(--menu-card) / 0.08) 21%,
      oklch(var(--menu-card) / 0)    30%),
    linear-gradient(to left,
      oklch(var(--menu-card) / 0.78) 0%,
      oklch(var(--menu-card) / 0.52) 5%,
      oklch(var(--menu-card) / 0.26) 12%,
      oklch(var(--menu-card) / 0.08) 21%,
      oklch(var(--menu-card) / 0)    30%);
}
.menu-panel__shot {
  position: absolute; inset: 0;
  opacity: 0; pointer-events: none;
  transform: translateY(var(--vy, 0)) scale(1.06);
  transition: opacity .45s var(--ease), transform .6s var(--ease);
}
.menu-panel__shot:nth-child(1) { --vy: -34px; }
.menu-panel__shot:nth-child(2) { --vy: 0px; }
.menu-panel__shot:nth-child(3) { --vy: 34px; }
.menu-panel__shot.is-active { opacity: 1; pointer-events: auto; transform: none; }
/* border:0 so an empty slot's placeholder hairline does not draw a frame on
   the three edges the picture is meant to sit flush against. */
.menu-panel__shot .slot { width: 100%; height: 100%; border: 0; }

.menu-overlay__nav {
  display: flex; flex-direction: column; align-items: stretch;
  gap: clamp(6px, 1.2vh, 14px);
}
/* The rule under each name, and it sits on the row rather than on the link:
   the link is the mask the word rises through, so a border on it would be part
   of what that mask has to clip and would travel with the word. */
.menu-nav__row {
  border-bottom: 1px solid oklch(100% 0 0 / 0.13);
  padding-bottom: clamp(6px, 1.2vh, 14px);
}
/* Each link's text sits in a span so the mask (the <a>, which clips) can stay
   still while the span travels: the words rise into view from behind their own
   baseline instead of sliding across open background. */
/* The list rests grey and only the name under the cursor is in white, so the
   panel reads as one choice being made rather than as three offers competing.
   Carried on colour and not on opacity: an opacity fade takes the type's
   edges with it and the greyed names go soft as well as dim. */
/* Set light rather than semibold: at this size the names are the only type in
   the card and the weight was doing the work the size already does. Poppins
   carries a 300, which is loaded with the rest of the family in <head>. */
.menu-overlay__nav a {
  display: block; overflow: hidden;
  font-family: var(--f-display); font-weight: 300;
  font-size: clamp(30px, 3vw, 50px); line-height: 1.12;
  letter-spacing: -0.02em;
  color: oklch(100% 0 0 / 0.45);
  transition: color .3s var(--ease);
}
.menu-overlay__nav a span { display: block; }
/* The page you are already on is the one name that is lit without being
   asked for. */
.menu-overlay__nav a[aria-current="page"] { color: var(--on-dark-hi); }
/* And so is whichever name the pointer last named — it stays lit after the
   cursor has gone, until another name takes it. The panel opens with the first
   one lit, to match the picture the gallery is already showing. main.js moves
   the class; the hover rule below only doubles it while the cursor is actually
   on a name. */
.menu-overlay__nav a.is-lit { color: var(--on-dark-hi); }
/* Gated on a real pointer: on a touch screen :hover sticks to whatever was
   tapped last and would leave a name lit with nothing hovering it. */
@media (hover: hover) {
  .menu-overlay__nav a:hover { color: var(--on-dark-hi); }
}

/* The footer's marks exactly — bare glyphs, no ring, the name read out rather
   than drawn — ranged left in a row under the last name, and held in white
   throughout. They are not a state anything else depends on, so they do not
   take part in the lighting the names do; only the lift on hover marks them. */
.menu-social {
  flex-direction: row; align-items: center; justify-content: flex-start;
  gap: clamp(20px, 2.6vw, 36px);
}
.menu-social .social {
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--on-dark-hi);
  transition: transform .3s var(--ease);
}
.menu-social .social:hover { transform: translateY(-2px); }
/* Larger than the footer's marks — they are the only thing on this row — but
   not by as much as they first were. */
.menu-social .social__icon svg { width: 36px; height: 36px; }

/* Below this the ratio would put the card past the edge of the screen long
   before it ran out of height, so the width takes over and the proportion is
   dropped. The picture stays: it is the bottom half of this layout rather than
   a column beside the names, and there is room for it at any width. */
@media (max-width: 900px) {
  .menu-panel {
    /* The 20px offset goes with the ratio. On a phone the page gutter is
       narrower than that offset, so keeping it would have pulled the card's
       right edge left of the burger's — putting the control that opens the
       card outside it, which is the one thing this layout may not do. */
    --menu-side: var(--menu-inset);
    width: min(520px, calc(100vw - var(--menu-inset) * 2));
    aspect-ratio: auto;
  }
  .menu-social .social__icon svg { width: 26px; height: 26px; }
}

/* ------------------------------------------------------- page transition */

/* One panel wipes down over the outgoing page, the browser navigates while it
   is covered, and the incoming page — which paints with this same panel
   already covering it, per the polygon below — continues the wipe downward off
   the bottom edge. The two halves read as a single travelling edge, so the
   hard document swap in the middle is never seen.
   .js is set by an inline script in <head>: with scripting off the panel would
   have nothing to open it, so it is never drawn in the first place. */
.page-curtain { display: none; }
.js .page-curtain {
  display: block;
  position: fixed; inset: 0; z-index: 200;
  background: var(--ink);
  clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
  pointer-events: none;
}
/* Opened by JS on load. This is the resting state it is driven to, and also
   the state it is snapped to by the failsafe timer if the animation never
   runs — a page must never be left sitting behind a closed curtain. */
.js .page-curtain.is-open {
  clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%);
}

/* ---------------------------------------------- header over a dark section */

/* Over the anthracite runs the glass band stops being glass: there is nothing
   light behind it to blur, so the tint reads as a pale bar laid across the
   photograph instead of as a surface the page passes under. Above those
   sections the band is dropped in full and the type carries itself in white.
   main.js sets .is-on-dark from the ground under the header's own bottom edge.

   Transitioned rather than switched, and on the same .28s the header's
   hide-on-scroll uses, so crossing a section boundary is a fade rather than a
   flash. The band fades by opacity, not display — display has no in-between
   frame to animate. */
.site-header::before { transition: opacity .28s ease, background-color .28s ease; }
.site-header.is-on-dark::before { opacity: 0; }

/* No exceptions. The hero photograph, the studio page's opening frame and the
   gallery used to keep the band on the theory that over photography the tint
   reads as glass — over a full-bleed frame it reads as a pale panel sitting on
   the picture, which is exactly what the flat anthracite runs were dropping it
   for. All dark ground is treated alike now; only the type inverts. */

/* The statement band is the one section that never gets the bar, dark end or
   light end — see the note in updateHeaderGround. */
.site-header.is-over-statement::before { opacity: 0; }

/* transform is in here for the magnetic pull: main.js writes a translate on
   mousemove and clears it on leave, and without a transition the return to
   centre would snap. .3s matches the pull on the buttons elsewhere. */
.site-header .nav a,
.site-header .burger {
  transition: color .28s ease, transform .3s var(--ease);
}
.site-header .brand,
.site-header .burger__dot {
  transition: color .28s ease, background-color .28s ease, border-color .28s ease,
              opacity .35s var(--ease);
}
.site-header.is-on-dark .nav a,
.site-header.is-on-dark .brand { color: var(--on-dark-hi); }
.site-header.is-on-dark .burger__dot { background: var(--on-dark-hi); }

/* The logo is artwork, not type, so colour cannot reach it. Inverting it alone
   is not enough: the mark is a dark grey rather than true black, and an
   inverted grey lands on a grey just as far from white. brightness(0) first
   crushes every pixel to black while leaving alpha untouched, so the invert
   after it has only black to work on and returns pure white — whatever the
   artwork's own colour happens to be. Only the loaded image is touched; the
   Comfortaa fallback lockup is real type and follows .brand's colour above. */
.site-header.is-on-dark .brand__slot.is-filled img {
  filter: brightness(0) invert(1);
}
.site-header .brand__slot.is-filled img { transition: filter .28s ease; }

/* Nothing in the header is cut away or moved while the panel is open: the mark
   keeps its place and the overlay is simply laid over the whole of it — see
   the z-index note there — so the scrim's own wash is what dims the header
   along with the page, and the card is drawn over it rather than behind it.

   The band itself is the one thing that goes. Dimming it does not make it
   disappear: it is a blur as much as a tint, and a blurred rectangle stays
   legible as a rectangle however dark it is drawn — which is what put those
   pale corners and hard vertical edges around the card. Every reason the band
   exists is a reason it should not be there now, too: it separates the header
   from what scrolls under it, and while the panel is open nothing scrolls.
   So it fades out on the same .28s it uses everywhere else, leaving the mark
   and the links on the dimmed page with no glass under them.

   Its own class rather than .menu-open, because the scroll lock has to hold
   until the card has finished closing while the band should be on its way back
   before then. */
body.menu-bandless .site-header::before { opacity: 0; }

/* And the mark goes with it. It was the last thing left standing on a header
   with no surface under it, and a lockup floating on the dimmed page read as
   something the panel had forgotten rather than as part of it. The links are
   already down — the header is compact whenever the burger is there to be
   pressed — so this empties the band completely and leaves the burger as the
   only thing in front of the card. It fades on the .35s .brand already carries
   for opacity, and stops taking the pointer: nothing invisible should be
   clickable, least of all a link off the page. */
body.menu-bandless .site-header .brand { opacity: 0; pointer-events: none; }

/* ==========================================================================
   Image slots — placeholders for the 25 photographs still in Claude Design
   ========================================================================== */

.slot {
  position: relative; display: block; overflow: hidden;
  background:
    repeating-linear-gradient(
      -45deg,
      oklch(90% 0.012 75) 0 10px,
      oklch(93% 0.012 75) 10px 20px
    );
  border: 1px solid var(--rule-strong);
}
.slot::after {
  content: attr(data-placeholder);
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--f-display); font-size: 11px; font-weight: 600;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: oklch(52% 0.01 60); text-align: center; padding: 8px;
}
/* No plate behind a loaded image: transparent PNGs (the reference logos) would
   otherwise sit on a dark square. Sections that need a dark ground already
   provide their own (.featured, .nd-frame). */
.slot.is-filled { background: transparent; border: 0; }
.slot.is-filled::after { content: none; }
.slot img { width: 100%; height: 100%; object-fit: cover; display: block; }

.slot--fill { position: absolute; inset: 0; width: 100%; height: 100%; }
.slot--hero { width: 100%; height: 100%; aspect-ratio: 18 / 9; }
.slot--logo { width: clamp(144px, 12vw, 202px); aspect-ratio: 1 / 1; flex: none; }
/* Logos must not be cropped to fill the square, and want a little breathing
   room inside it — unlike the photographic slots, which cover. */
.slot--logo img { object-fit: contain; padding: 8%; }
.slot--portrait { width: 100%; aspect-ratio: 3 / 4; filter: grayscale(1); }
.slot--inset {
  position: absolute; bottom: 6%; right: 5%;
  width: 11%; height: 20%; z-index: 3;
}

/* ==========================================================================
   Hero
   ========================================================================== */

.hero {
  /* ends at the rule, so it takes only the height of its content —
     no reserved minimum, no bottom padding holding the image band down */
  display: flex; flex-direction: column;
  padding: clamp(20px, 2.6vw, 40px) var(--gut) 0;
  /* Only as far as the location line can go without sliding under the fixed
     header; the rest of the lift comes from tightening the gap below it. */
  margin-top: -30px;
}

.hero__meta {
  align-self: flex-end; text-align: right;
  font-family: var(--f-display); font-size: 12px; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--muted); line-height: 1.85;
}
.hero__loc { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.hero__loc-sub { color: var(--muted-faint); }

.hero__headline-row {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 32px; margin-top: 20px;   /* was up to 84px */
}

.hero__title {
  font-family: var(--f-display);
  font-weight: 400;                    /* light by default */
  /* "MOTIVATION." is the longest line at 9.24em — it is set 35% larger than
     the other two, which more than pays back its shorter character count. So
     it, not "Style Function &", is what has to fit beside the Let's Talk
     button, and 9vw keeps all three lines unbroken with room to spare. */
  font-size: clamp(56px, 9vw, 200px);
  line-height: 0.82; letter-spacing: -0.02em; color: var(--ink);
  text-align: left;
  /* Entity / Style Function & stay in sentence case as written; only the
     "Motivation." payoff is set in caps below. */
}
/* "Motivation." anchors the headline: heaviest weight, 35% larger than the
   rest, and kept in caps so the last line reads as the editorial payoff. */
.hero__title b { font-weight: 800; font-size: 1.35em; letter-spacing: -0.03em; text-transform: uppercase; }
.hero__line { display: block; }
/* The opening is driven entirely from JS — the letter masks, the closed
   states and the emptied payoff word all live there, so a page whose script
   never runs shows the finished headline instead of a clipped one. */
/* nowrap locks the three-line structure: a line may never break in two. */
.hero__line-in { display: inline-block; white-space: nowrap; }

/* The size that keeps that promise on a phone.
   The headline may not break, so it has to be small enough to fit — and the
   floor of 56px in the clamp above was not. "Motivation." is 9.24em wide, so
   at 56px it measures 517px against a 390px screen: it ran off the right edge,
   dragged the document 151px wider than the viewport, and took the header and
   the burger off-screen with it. Every section below then read as shifted,
   which is what the whole page looked broken from.
   9.24em against the gutter's own margin is what decides this, so it is
   written as viewport width with no floor under it: 8.4vw fits the longest
   line at every phone size, and stops mattering at 667px where the clamp's
   56px takes over again. */
@media (max-width: 700px) {
  .hero__title { font-size: min(56px, 8.4vw); }

  /* The -30px lift on .hero was measured against the desktop header: main
     reserves --header-h (104px there) and the hero's own top padding gives
     back up to 40px, so the location line still cleared the bar. On a phone
     both of those shrink — the header measures 76px and the padding drops to
     its 20px floor — while the lift stays the size it was, and the line ends
     up 10px behind the fixed header, beside the centred logo. That is the
     collision the phone video shows.

     The lift is dropped rather than retuned: any constant here is a third
     number that has to be kept in step with the other two, and the gap it
     buys is worth less than the headline crashing into the logo. */
  .hero { margin-top: 0; }
}
/* One letter of the first two lines, split by the opening. inline-block is
   what makes a letter transformable; the cost is kerning between pairs, the
   same trade the band's letters already make. */
/* `pre` is not optional here, it is what keeps the spaces. This line is split
   straight to characters, so " " becomes an inline-block of its own — and an
   inline-block whose only content is a space collapses to nothing. "Style
   Function &" was being set as "StyleFunction&", 16px narrower than the same
   words unsplit, on every screen. Same fix .whatwedo__char and .xp__char
   already carry; the other split lines escape it because they are single words
   or are split by word first. */
.hero__char { display: inline-block; white-space: pre; }
.caret {
  display: inline-block; font-weight: 400; color: var(--ink);
  font-size: 1.35em;   /* rides at "Motivation." size, like a cursor after it */
  margin-left: 0.04em; animation: caretBlink 1.05s step-end infinite;
}

/* The hero now ends at the rule — no foot block, no scroll cue. */

/* ==========================================================================
   Full-bleed scroll bands
   ========================================================================== */

.band { position: relative; width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2); }
.band--hero { z-index: 2; height: 150vh; }

.band__sticky {
  /* top:0 — the frame locks flush against the top of the viewport, so no page
     background is left showing above the photograph while it is held. */
  position: sticky; top: 0;
  /* Full bleed width first, 18:9 height derived from it — the frame always
     spans edge to edge, even if that runs past the viewport's height. */
  height: calc(var(--bleed) * 9 / 18);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
}

/* ScrollSmoother wraps the page in an overflow:hidden container, which
   re-parents the sticky scroll context and stops sticky working entirely.
   With the smoother running these are pinned by ScrollTrigger instead. */
body.has-smoother .band__sticky { position: relative; top: auto; }

/* The sticky container is already full-bleed width with an 18:9-derived
   height, so the figure just fills it exactly — no separate ratio math. */
.band__figure {
  position: relative;
  width: 100%; height: 100%;
  overflow: hidden; transition: opacity .5s ease-out;
}
/* Light black scrim so the headline reads clearly over the photograph.
   Inside the figure, so it fades out together with the image on scroll. */
.band__figure::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
  background: oklch(0% 0 0 / 0.35);
  pointer-events: none;
}
/* --- hero overlay: UI layered over the photograph --- */

.band__overlay {
  position: absolute; inset: 0; z-index: 3;   /* above the figure's scrim */
  padding: clamp(18px, 2.6vw, 44px) clamp(20px, 3.4vw, 60px);
  color: oklch(97% 0.012 75);
  /* Lettering and marks laid over the photograph, not a surface of its own:
     it spans the whole figure, so leaving it hit-testable swallowed every
     pointer event aimed at the image beneath — including the dev tool's
     image drops, which never reached the slot. The two real links take
     their pointer events back below. */
  pointer-events: none;
}
.band__pill, .band__card { pointer-events: auto; }

/* project name, top-left */
.band__project {
  position: absolute; top: 5.2%; left: 3%;
  font-family: var(--f-display); font-size: clamp(12px, 1.05vw, 16px); font-weight: 500;
  letter-spacing: 0.01em; color: oklch(97% 0.012 75 / 0.92);
}

/* The mouse-wheel cue that stood top-right and the corner arrow bottom-right
   are both gone: the frame holds the page while it plays, and the instruction
   now rides the pointer itself — see .cursor__label's "Scroll", which appears
   wherever the reader is actually looking rather than in a corner they are not. */

/* headline + supporting copy, right side, vertically centred */
/* Percentages, not fixed padding: the reference's placement is proportional
   to the image, so it holds at any width. */
/* Headline, paragraph and pill are one group and are scaled as one: every
   measure from here to the pill is the old value times 1.2. Centred rather
   than hung at 43%, which is what buys the group the room to grow at all. */
.band__lead {
  position: absolute; right: 6.3%; top: 50%;
  transform: translateY(-50%);
  width: min(55.2ch, 56.4vw);
  text-align: right;
  /* Shared so the paragraph below can measure itself against the headline. */
  --headline: clamp(36px, 6.48vw, 106px);
  /* "Motivation" is the longest line and is set flush right, so its width is
     the paragraph's measure. It is a constant 5.042em of the display face at
     700 with this tracking and kerning off — checked at several sizes, it
     scales exactly, and split and unsplit render to the same number. */
  --measure: calc(var(--headline) * 5.042);
}
/* One word per line, set tight enough that the five lines read as a single
   stacked block: the leading is slightly less than the type size, so the
   lines nearly touch. Last two lines carry the weight. */
.band__word {
  font-family: var(--f-display); font-weight: 300;
  letter-spacing: -0.025em;
  /* Splitting the lines into per-letter boxes drops kerning between the pairs,
     which widens the line by about 1.3%. Turning kerning off here makes the
     split and unsplit renderings identical, so --measure below describes both
     and the paragraph stays flush with "Motivation" either way. */
  font-kerning: none;
  /* The 106px cap in --headline is what keeps the longest word ("Motivation",
     set bold) inside the column: past that width the column stops at 55.2ch.
     Leading is measured from the cap height, not the em box: the visible band
     between one line's caps and the next was 0.196em at 0.92, and 40% off that
     lands the pitch at 0.84 — tight enough to read as one stacked mass. */
  font-size: var(--headline); line-height: 0.84;
  color: oklch(98% 0.012 75);
  text-shadow: 0 2px 34px oklch(12% 0.01 60 / 0.4);
  pointer-events: none;
}
.band__word-line.is-heavy { font-weight: 700; }
/* main.js splits each line into per-letter spans and wipes them on left to
   right. The starting state is set there rather than here: if the script never
   runs, nothing is split and the headline is simply legible where it stands. */
/* nowrap because the split letters are separate boxes and the browser would
   otherwise be free to break a word between two of them. */
.band__word-line { display: block; white-space: nowrap; }
/* inline-block so each letter can carry its own transform. The tracking is set
   on the line and applies between the boxes either way, so the letterforms
   land where they did unsplit — checked against the measure below. */
.band__char { display: inline-block; }
/* Paragraph and pill share a row, the pill on the left and centred against the
   paragraph's height. The row carries the spacing under the headline — the old
   gap plus the 25px the paragraph was asked to drop. */
.band__lead-row {
  margin-top: calc(clamp(16.8px, 1.8vw, 28.8px) + 25px);
  display: flex; align-items: center; justify-content: flex-end;
  /* The paragraph holds the column's right end at a fixed measure, so this gap
     is what stands the pill off from it — and the row is allowed to overflow
     the column to the left, which is where the pill goes. */
  gap: clamp(20px, 3.4vw, 90px);
}
.band__blurb {
  /* Set to the headline's own measure, not to whatever the row leaves over —
     the paragraph's two edges land on "Motivation" above it. The type size is
     untouched; only the column it sets in changes. */
  flex: none; width: var(--measure);
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(15px, 1.14vw, 18px); line-height: 1.62;
  /* Pure white, not the warm off-white the rest of the frame is set in. */
  color: oklch(100% 0 0); text-wrap: pretty;
}

/* "The Studio" pill — last item in the lead column, so it sits directly under
   the paragraph instead of floating loose in the middle of the frame. In flow
   rather than absolutely placed, which also lets the magnetic hover use
   transform without fighting a centring translate.
   Sized with the rest of the lead group: the earlier +20% on the button
   alone, then the whole group scaled by 1.2 again. */
.band__pill {
  display: inline-flex; align-items: center; flex: none;
  /* Sits in the open space between the projects card and the paragraph. No
     nudge is needed any more: the paragraph now takes the headline's full
     measure, which by itself carries the pill left into that space. The row
     gap below places it there; z-index keeps it over the card's border on
     narrow frames, where the two come close. */
  position: relative; z-index: 1;
  padding: 18.72px 37.44px; border-radius: 999px;
  /* Outline only: the chip reads as a frame over the photograph rather than a
     solid tile laid on top of it. Text and border are the light overlay
     colour, not --ink, because a transparent chip has nothing of its own to
     hold contrast against the photograph underneath.
     2px rather than 1.5: the sub-pixel value was floored to 1px on this
     screen's pixel grid and read as a hairline. */
  background: transparent; border: 2px solid oklch(97% 0.012 75 / 0.7);
  color: oklch(97% 0.012 75);
  font-family: var(--f-display); font-size: clamp(15.84px, 1.368vw, 20.16px); font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase;
  white-space: nowrap;
  will-change: clip-path;
  transition: background .35s, border-color .35s, color .35s, transform .4s var(--ease);
}
/* A wash, not a fill: the hover confirms the pointer is over a control without
   the chip going solid and blocking the photograph behind it.
   color is repeated here, unchanged, only to outrank the site-wide `a:hover`
   rule (0,1,1) — a plain `.band__pill` (0,1,0) loses that fight and the text
   was flipping to --accent on hover despite nothing here asking it to. */
.band__pill:hover {
  background: oklch(97% 0.012 75 / 0.12);
  border-color: oklch(97% 0.012 75);
  color: oklch(97% 0.012 75);
}

/* bracketed "All Work" link, lower left.
   Portrait, at roughly 3:4 — the height carries the growth, so the box gains
   presence without reaching across into the lead column on its right. Offset
   200px in from its old left edge. */
.band__card {
  position: absolute; left: calc(5.1% + 200px); bottom: 11%;
  width: clamp(300px, 28vw, 416px); min-height: clamp(400px, 37.3vw, 555px);
  display: flex; align-items: flex-end;
  padding: clamp(24px, 2.4vw, 36px);
  /* Full strength, where the line used to sit at half. */
  border: 2px solid oklch(97% 0.012 75);
  border-radius: clamp(24px, 2.6vw, 40px);
  color: oklch(98% 0.012 75);
  will-change: transform;
  /* The magnetic pull rides the independent `translate` property, not
     `transform`. GSAP drives `transform` for the entrance slide, and a CSS
     transition on the same property would damp every frame of it. The two
     compose cleanly — main.js honours data-magnetic-prop="translate" for this.
     .3s matches the pull on the header links and the page's other buttons; the
     old hover lift is gone, the pull itself is now the response to a pointer. */
  transition: background-color .4s, border-color .4s, translate .3s var(--ease);
}
.band__card:hover {
  background-color: oklch(97% 0.012 75 / 0.1);
  border-color: oklch(97% 0.012 75 / 0.85);
  color: oklch(98% 0.012 75);
}
.band__card-arrow {
  position: absolute; top: clamp(22px, 2vw, 32px); right: clamp(22px, 2vw, 32px);
  font-size: clamp(34px, 3vw, 48px); line-height: 1;
  transition: transform .4s var(--ease);
}
/* Sized in em, so the font-size above still governs the mark. */
.band__card-arrow svg { display: block; width: 1em; height: 1em; }
.band__card:hover .band__card-arrow { transform: translate(5px, -5px); }
/* 1.2x the old clamp end to end, so the label grows at every width rather than
   just at its ceiling. */
.band__card-label {
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(36px, 3.36vw, 53px); line-height: 1.25; letter-spacing: -0.01em;
}

/* The project name enters once the band is in view. The lead column and the
   projects card are not in this group: both have their own clip-path sequence
   in main.js. */
.band__project {
  opacity: 0; transform: translateY(10px);
  transition: opacity .8s var(--ease), transform .8s var(--ease);
}
.band__overlay.is-in .band__project {
  opacity: 1; transform: translateY(0);
  transition-delay: .05s;
}

/* ==========================================================================
   Our expertise — one sector at a time, the photograph inside the letters
   ========================================================================== */

/* The base state is a plain stacked list, legible with no script at all. The
   held, one-word-at-a-time behaviour is an upgrade main.js opts into by adding
   .is-live once it has GSAP and motion is allowed — so a failed script or
   reduced-motion leaves five readable words rather than five stacked on top of
   one another inside a 460vh void. */
.xp {
  position: relative;
  width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2);
  padding: clamp(28px, 5vw, 72px) clamp(16px, 3vw, 48px);
}
.xp__sticky { display: flex; flex-direction: column; }
.xp__label { margin: 0 0 clamp(24px, 3vw, 44px); }
.xp__stage { position: relative; }
.xp__word { display: flex; align-items: center; }

/* On a phone the base state is not a fallback that nobody sees — it is the
   section (main.js leaves the held scene to wider screens), so it is set to be
   read next to its neighbours rather than to survive without them. What We Do
   above and Services below both open on 80px and hold the page gutter; the
   tighter box above was the panel's own, and at 16px it left this one list
   standing 8px inboard of every other line on the page. Bottom stays at 0:
   the dark run is continuous and Services brings its own opening. */
@media (max-width: 700px) {
  .xp { padding: 80px var(--gut) 0; }
}

/* --- upgraded: held panel, one word at a time --- */

/* The distance the panel is held for, and so — divided by the five words —
   what one word costs to scroll past. This was 460vh, which worked out at
   about 680px a word: five to seven turns of a wheel each, and the section
   read as heavy for it. At 190vh a word changes in roughly one deliberate
   scroll. Retune here and nowhere else; the timeline is written in proportions
   and follows whatever this is set to. */
.xp.is-live {
  height: 260vh;
  padding: 0;
}
.xp.is-live .xp__sticky {
  position: sticky; top: 0;
  height: 100vh; overflow: hidden;
  background: var(--anthracite);
  padding: clamp(28px, 5vw, 72px) clamp(16px, 3vw, 48px);
}
/* Same swap the other held panels make: ScrollSmoother's overflow:hidden
   wrapper re-parents the sticky scroll context and stops sticky working, so
   with the smoother running ScrollTrigger pins this instead. */
body.has-smoother .xp.is-live .xp__sticky { position: relative; top: auto; }

.xp.is-live .xp__label { margin: 0; }
.xp.is-live .xp__stage {
  flex: 1 1 auto;
  display: flex; align-items: center; justify-content: center;
}
/* All five occupy the same box and are switched by the scroll — none of them
   is in flow, so the stage's height is the sticky panel's, not the tallest
   word's. */
.xp.is-live .xp__word {
  position: absolute; inset: 0;
  justify-content: center;
}
/* The mask the letters rise out of and fall back behind.
   The word's size lives HERE, not on .xp__fill, and that placement is the
   whole point: this padding is what stops the mask shaving the type, and an em
   is only worth what the element's own font-size makes it. With the size set
   on the child instead, `0.14em` here resolved against an inherited 16px — 2px
   of room for a 327px word, which cut 62px off the glyphs. Sized here, the
   padding scales with whatever main.js fits the word to.
   Vertical only: the letters travel up and down, so there is nothing to mask
   sideways, and side padding would eat the very width the word is fitted to. */
.xp__line {
  display: block; flex: 0 0 auto;
  overflow: hidden;
  font-size: clamp(34px, 8vw, 92px);
  /* Both numbers are ink measurements, not guesses, and the box they pad is a
     layout box — which is exactly why scrollWidth/scrollHeight cannot police
     this. Glyph ink paints outside its own box without either ever noticing,
     and overflow:hidden still cuts it.
     0.32em down the sides of the line: the descenders of "y" and "p" reach
     0.27em below the baseline, past the bottom of a line-height:1 box.
     0.08em across: the negative tracking is charged after the last letter as
     well as between them, so the element ends ~0.045em short of its own last
     glyph, and letters like "y" overhang their advance width on top of that. */
  padding: 0.32em 0.08em;
  /* The fill is an inline-block, so this box would otherwise carry a line box
     around it and stand a whole em taller than the padding asks for. That extra
     height is not free: it is room a parked word can still be seen in, and the
     mask has to be exactly as tall as it is meant to be for PARK to clear it. */
  line-height: 0;
}

.xp__fill {
  /* inline-block, not block: the fit-to-width pass measures this element's own
     width against the stage, which a block filling its container cannot give.
     position:relative makes it the offset parent of the letters, so their
     offsets can be read as layout values that the timeline's transforms do not
     disturb — a getBoundingClientRect here would move with the animation. */
  display: inline-block; position: relative; white-space: nowrap;
  font-family: var(--f-display); font-weight: 800;
  /* Inherited from .xp__line, which is where the size is set and has to be —
     see the note there. Once .is-live is on, main.js measures each word and
     rescales the line so all five span the same measure; that common measure
     is what makes the type read as a window cut in the panel rather than a
     line of copy, and it can only be arrived at by measuring. */
  font-size: 1em; line-height: 1; letter-spacing: -0.045em;
  color: var(--on-dark-hi);
}
/* One letter, so each can carry its own slice of the animation independently
   of the rest of the word. */
.xp__char { display: inline-block; white-space: pre; }

/* ==========================================================================
   Statement
   ========================================================================== */

.statement__text {
  font-family: var(--f-display); font-weight: 600;
  font-size: clamp(27px, 4.3vw, 74px);
  line-height: 1.13; letter-spacing: -0.022em;
  color: var(--ink-2); text-wrap: pretty;
}
/* Sits directly under the hero band: its top rule shows through as the image
   fades out, reading as a stray line beneath the photo. The hero frame is
   released flush with the band's bottom edge, so this section's top edge meets
   the photograph exactly — it takes a little more than the usual section pad
   so the copy clears the image rather than crowding it. */
.section.statement {
  border-top: 0;
  padding-top: calc(var(--sec-y) + clamp(24px, 3vw, 56px));
}

/* Words start faded and are brought to full ink by a scroll-scrubbed
   stagger in main.js. Without JS (or with reduced motion) they stay legible. */
.statement__text .word { opacity: 0.18; will-change: opacity; }

.statement__cta { display: flex; justify-content: flex-end; margin-top: clamp(28px, 3.5vw, 52px); }

/* ==========================================================================
   Achievements

   The photo strip is gone; the word itself is the moving element. main.js lays
   it as two identical runs and wraps the track on one run's width, so the line
   has no end. Each run is a single text node rather than one element per word,
   because anything else leaves a visible seam at every join. The band takes no
   gutter — it is meant to run past both edges — so the section drops its own
   horizontal padding and the record puts it back.
   ========================================================================== */

/* No rules anywhere in this band. A full-bleed line of type is already a hard
   horizontal edge in both directions — a hairline above it and another under
   it just fence in something that is meant to run past the page. The section
   below drops its own top rule for the same reason: it would land immediately
   under the record and read as this band's floor.
   The selector reaches through .work-stack: .work-intro is that wrapper's
   child, not .achievements' direct sibling, so a bare `.achievements +
   .work-intro` never matched anything and the rule below it needed had been a
   dead rule all along, leaving that section's own border-top: 1px in place. */
.achievements {
  padding-block: clamp(48px, 6vw, 88px);
  padding-inline: 0;
  border-top: 0;
  overflow: hidden;
}
.achievements + .work-stack .work-intro { border-top: 0; }

.achievements__band { margin: 0; }
.achievements__marquee {
  display: flex; width: max-content;
  font-family: var(--f-display); font-weight: 800;
  font-size: clamp(64px, 15vw, 240px);
  line-height: .92; letter-spacing: -0.05em; text-transform: uppercase;
  color: var(--ink); white-space: nowrap;
  will-change: transform;
}
.achievements__run { display: block; white-space: nowrap; }

/* ---------- the record ---------- */

/* Indented off the left edge rather than set flush to the gutter. The band
   above is full-bleed and reads as starting off-page, so a record aligned to
   the page margin sits too far left against it and the row looks stranded;
   the indent gives the group back its counterweight. It is dropped below the
   fold-to-2×2 breakpoint, where there is no width to spend on it. */
.record {
  /* 300px from about 1360px up, where there is room for it. Held flat below
     that, four columns plus the indent leave "Collaborations" without the
     width to set on one line. */
  --record-indent: min(300px, 22vw);
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  /* +100px on top of the usual clamp: pushed down off the marquee's own
     baseline so the two read as separate things rather than one run-on block. */
  margin: calc(clamp(28px, 4vw, 56px) + 100px) var(--gut) 0 calc(var(--gut) + var(--record-indent));
}
/* Every entry carries its own leading rule, the first one included — the row
   is four of the same object, and exempting the first made it read as a label
   with three columns after it. */
.record__cell {
  position: relative;
  padding: clamp(18px, 2.4vw, 32px) clamp(10px, 1.6vw, 24px) clamp(14px, 2vw, 24px);
  border-left: 1px solid var(--rule-faint);
}
/* Under the pointer the accent hairline draws along the entry's own top rule
   and the type warms up — the row acknowledging the cursor without moving
   anything that is being read. */
.record__cell::after {
  content: ""; position: absolute; left: 0; right: 0; top: -1px; height: 1px;
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform .5s var(--ease);
}
.record__cell:hover::after { transform: scaleX(1); }

.record__value {
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(44px, 6vw, 99px); line-height: 1;
  letter-spacing: -0.035em; color: var(--ink);
  transition: color .35s var(--ease);
}
.record__cell:hover .record__value { color: var(--accent); }

.record__label { margin-top: .7em; overflow: hidden; }
.record__label span {
  display: block;
  font-family: var(--f-display); font-size: clamp(14px, 1.1vw, 17px);
  font-weight: 600; letter-spacing: .16em; text-transform: uppercase;
  color: var(--muted);
  transform: translateY(130%);
  transition: transform .8s var(--ease), color .35s var(--ease);
}
.record.is-visible .record__label span { transform: none; }
.record.is-visible .record__cell:nth-child(2) .record__label span { transition-delay: .08s; }
.record.is-visible .record__cell:nth-child(3) .record__label span { transition-delay: .16s; }
.record.is-visible .record__cell:nth-child(4) .record__label span { transition-delay: .24s; }
.record__cell:hover .record__label span { color: var(--ink); }

/* ---------- odometer numerals ----------

   Each digit is a 0–9 column run twice, so it turns a full revolution before
   it lands. The window is sized by an invisible copy of the digit it will land
   on, not by the widest digit in the column: Poppins sets 1 at little over
   half the width of 0, and a shared slot leaves a hole in the middle of
   "2012". The column is centred over that width, so wider digits are trimmed
   at both edges on the way past — which is what a counter wheel does anyway.
   Built by main.js from the number already in the markup.                   */

.odo { display: inline-flex; align-items: flex-start; --dh: 1.04em; }
.odo__win { position: relative; display: block; overflow: hidden; height: var(--dh); }
.odo__sizer { display: block; height: var(--dh); line-height: var(--dh); visibility: hidden; }
.odo__sym { display: block; height: var(--dh); line-height: var(--dh); }
.odo__col {
  position: absolute; top: 0; left: 50%;
  transform: translate(-50%, 0);
  transition: transform 1.5s var(--ease);
}
.odo__col > span { display: block; height: var(--dh); line-height: var(--dh); text-align: center; }
.is-visible .odo__col { transform: translate(-50%, calc(-1 * (10 + var(--to)) * var(--dh))); }

/* ==========================================================================
   Selected work
   ========================================================================== */

/* The extra 100px at the foot is deliberate: the gallery below is a
   full-bleed dark block, and without a run of empty page before it the
   paragraph reads as a caption sitting on the photograph. */
/* Held at the top while the gallery climbs over it. Sticky is the fallback
   path — with ?smooth=0, or if the vendor scripts never load, this alone
   carries the curtain. When the smoother is running it is switched off below
   and ScrollTrigger pins the panel instead, exactly as the hero and word
   bands do: the smoother's overflow:hidden wrapper re-parents the sticky
   scroll context and stops sticky working at all.
   The background is opaque on purpose. The panel is transparent by default
   and sits on the body's own colour, which is fine in flow but not while it
   is held — anything passing behind a held panel would read straight
   through it. .band__sticky--nd carries a background for the same reason. */
.work-intro {
  position: sticky; top: 0; z-index: 1;
  background: var(--bg);
  display: flex; flex-direction: column; align-items: flex-start;
  gap: clamp(20px, 3vw, 40px);
  padding: clamp(60px, 8vw, 120px) clamp(24px, 5vw, 72px)
           calc(clamp(28px, 3.5vw, 48px) + 100px);
}

/* Same swap the bands make, and for the same reason. */
body.has-smoother .work-intro { position: relative; top: auto; }

/* This one opens a section rather than labelling a block, so it sets in full
   ink instead of the muted grey the caption-sized eyebrows use, and it drops
   the trailing cue — the paragraph under it is the next thing to read, not
   something further down the page. */
.eyebrow--strong { color: var(--ink); }
/* The plus is a mark, not a character in the phrase: heavier and slightly
   larger than the caps beside it, with its own gap so the extra weight does
   not crowd the S. */
.eyebrow__plus {
  font-weight: 800; font-size: 1.2em; line-height: 0;
  letter-spacing: 0; margin-right: 0.5em;
}
.work-intro__row {
  width: 100%; display: flex; justify-content: space-between; align-items: flex-end;
  gap: clamp(24px, 5vw, 64px); flex-wrap: wrap;
}
/* Indented off the eyebrow above it rather than set flush with it, so the
   heading reads as the marker for the block and the paragraph as the body
   hanging under it. Dropped at phone width, where the gutter is all the
   indent there is room for. */
.work-intro__lead {
  max-width: 1000px; margin-left: 100px;
  font-size: clamp(30px, 2.7vw, 40px);
  line-height: 1.4; color: oklch(40% 0.01 60); text-wrap: pretty;
}

/* z-index 3: it has to paint over the held panel (1) as it climbs, and the
   hero band already claims 2. .nd-word (2) and .nd-frame (1) also compete at
   those weights, but neither .band--nd nor .band__sticky--nd creates a
   stacking context, so those numbers are live everywhere on the page, not
   just against .work-intro / .band--hero — the pins never share a frame
   today, but the next reuse of 3 needs to account for all four. */
.featured {
  position: relative; z-index: 3; height: 100vh; min-height: 600px;
  background: var(--dark); overflow: hidden;
}
.featured__scrim {
  position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(
    to top,
    oklch(8% 0.01 60 / 0.78) 0%,
    oklch(8% 0.01 60 / 0.12) 38%,
    transparent 62%,
    oklch(8% 0.01 60 / 0.42) 100%);
}

/* Panels are stacked, not laid out in a row. A row meant the whole strip had
   to travel, so jumping from the fourth project to the first dragged the eye
   across two unrelated images on the way. Stacked, only the outgoing and
   incoming panels move and the journey is exactly one frame wide however far
   apart the two projects sit in the set.
   Each panel is opaque and full-frame, so the one on top is the one seen and
   z-order alone decides that — JS raises the incoming panel as it enters. */
.featured__track { position: absolute; inset: 0; }
.featured__panel {
  position: absolute; inset: 0;
  overflow: hidden;
  will-change: transform;
}
/* A generated layer scoped to the panel's own box, not a sibling of it — the
   corner text and rail live outside .featured__panel entirely, so this can
   never sit over them or intercept a pointer meant for a mark or a link.
   Graded at the same strength as .services__frame::after, but pushed further
   out — transparent past 65% rather than 38% — so only the rim darkens and
   the middle of the photograph, where the corner text already has to sit,
   stays clear. */
.featured__panel::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(ellipse at center,
    transparent 65%, oklch(8% 0.01 60 / 0.45) 88%, oklch(8% 0.01 60 / 0.88) 100%);
}

/* The rail spans the whole frame so the marks can spread across it, which
   means the rail itself must not be hit-testable — only the marks take
   pointer events back. */
.featured__rail {
  position: absolute; z-index: 4; left: 0; right: 0; top: 50%;
  transform: translateY(-50%);
  display: flex; justify-content: space-evenly; align-items: center;
  padding-inline: clamp(24px, 5vw, 72px);
  pointer-events: none;
}
/* Every mark at full strength, in pure white rather than the warm off-white
   the rest of the frame uses — a faded mark read as disabled instead of as
   "not the current one". The active mark is called out by the rule that draws
   under it, so nothing has to be dimmed to make the selection legible. */
.featured__marker {
  pointer-events: auto; cursor: pointer; position: relative;
  background: none; border: 0; padding: 6px 10px;
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(22px, 2.2vw, 34px); line-height: 1;
  color: oklch(100% 0 0);
}
/* Drawn from the left rather than faded in: the rail reads left to right, so
   the underline arriving the same way ties the mark to the slide it triggers. */
.featured__marker::after {
  content: ""; position: absolute; left: 10px; right: 10px; bottom: 0;
  height: 2px; background: oklch(100% 0 0);
  transform: scaleX(0); transform-origin: left;
  transition: transform .45s var(--ease);
}
/* "PR" for project, set as a superscript against the numeral — the reference's
   time-of-day device reread as an abbreviation. */
.featured__marker sup {
  font-size: 0.42em; font-weight: 600; letter-spacing: 0.08em;
  vertical-align: super; margin-left: 1px;
}
.featured__marker:hover::after,
.featured__marker.is-active::after { transform: scaleX(1); }

/* Four corners, one treatment. Top edge clears the fixed header, which is why
   it starts at 10vh rather than at the section's own gutter. */
.featured__corner {
  position: absolute; z-index: 4; margin: 0;
  font-family: var(--f-display); font-size: clamp(11px, 0.95vw, 13px);
  font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
  color: oklch(96% 0.012 75 / 0.82); pointer-events: none;
}
/* Bottom-right: the look-ahead frame with its caption row beneath. Both are
   held to the frame's width so the two lines under the picture end where the
   picture ends. */
/* A button now, so the UA's own chrome has to be cleared the way .featured__marker
   clears it. pointer-events is restored from the `none` .featured__corner sets on
   the group — the other corners are labels and must not swallow clicks aimed at
   the rail behind them, but this one is the click. The transform transition is
   what the magnetic pull leans on: main.js writes a translate on mousemove and
   clears it on leave, and without a transition the return would snap. */
.featured__corner--br {
  bottom: clamp(28px, 4vw, 52px); right: clamp(24px, 5vw, 72px);
  display: flex; flex-direction: column; gap: 10px;
  width: clamp(176px, 18vw, 256px);
  pointer-events: auto; cursor: pointer;
  background: none; border: 0; padding: 0;
  text-align: left;
  transition: transform .3s var(--ease);
}
/* The frame is the affordance — it brightens as a whole rather than growing a
   hover state of its own on top of the photograph inside it. */
.featured__corner--br:hover .featured__nextcard-media { border-color: oklch(100% 0 0 / 0.7); }
.featured__corner--br:focus-visible { outline: 2px solid oklch(100% 0 0 / 0.8); outline-offset: 6px; }
.featured__nextcard-media {
  position: relative; display: block; width: 100%;
  aspect-ratio: 4 / 3; overflow: hidden;
  border: 1px solid oklch(100% 0 0 / 0.28);
  transition: border-color .3s var(--ease);
}
/* Name and position share one line under the frame, pushed to its two edges.
   Baseline-aligned rather than centred, so the two different sizes sit on the
   same line of type instead of floating against each other. */
.featured__nextcard-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
}
/* The frame's width is fixed, so a long project name is cut rather than
   allowed to wrap and shove the counter onto its own line. */
.featured__nextcard-name {
  min-width: 0; flex: 1 1 auto;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.featured__nextcard-count { flex: none; color: oklch(96% 0.012 75 / 0.6); }

/* Capped well short of the frame's midpoint. At four times its old size the
   name is wide enough to reach the centred timer, so the cap is what keeps the
   two apart — it decides where the name wraps rather than letting the longest
   project title decide the layout. 30vw leaves the timer clear at every width
   the timer's own clamp produces. */
/* The one corner that is a link. `.featured__corner` turns the hit test off so
   the caption cannot sit between the pointer and the photograph; this puts it
   back for the block that now goes somewhere. */
.featured__corner--bl {
  bottom: clamp(28px, 4vw, 52px); left: clamp(24px, 5vw, 72px);
  display: flex; flex-direction: column; gap: 10px;
  max-width: min(30vw, 400px);
  pointer-events: auto;
  color: inherit;
}
/* The name is already at full ink on a dark ground, so approach is said with
   the line under it instead — the type has nowhere brighter to go. */
.featured__corner--bl .featured__name {
  display: inline-block;
  border-bottom: 1px solid transparent;
  transition: border-color .35s var(--ease);
}
.featured__corner--bl:hover { color: inherit; }
.featured__corner--bl:hover .featured__name { border-bottom-color: var(--on-dark-hi); }

/* The panels open the project too — see the forwarded click in main.js. The
   custom cursor carries the affordance on a fine pointer; this is what says it
   on every other device. */
.featured__track { cursor: pointer; }
/* A short rule sitting in the gap between the two bottom corners, filling left
   to right as the current project's time runs out. Not full-bleed: run edge to
   edge it read as a page-level progress bar, as though it measured the whole
   site rather than this one project. Held to the same bottom offset as the
   corner blocks so it lands on their last line, and centred so it never
   crowds either. Shares the corners' z-index, so no image can cover it. */
.featured__timer {
  position: absolute; z-index: 4;
  left: 50%; transform: translateX(-50%);
  bottom: clamp(28px, 4vw, 52px);
  width: clamp(180px, 24vw, 400px);
  height: 1px; background: oklch(100% 0 0 / 0.22);
  pointer-events: none;
}
.featured__timer-fill {
  display: block; height: 100%; width: 100%;
  background: oklch(100% 0 0);
  transform: scaleX(0); transform-origin: left center;
}

/* The name carries the block; the type sits under it at label weight.
   Four times its old size, so the corner's shared 0.16em tracking has to be
   unwound — tracking that opens up a 13px label tears a 64px word apart. */
.featured__name {
  font-size: clamp(40px, 5vw, 68px); font-weight: 700;
  letter-spacing: -0.025em; line-height: 0.98; text-transform: none;
  color: var(--on-dark-hi);
}
.featured__type {
  font-size: clamp(10px, 0.85vw, 12px); letter-spacing: 0.18em;
  color: oklch(96% 0.012 75 / 0.55);
}

/* --- custom cursor: dot + trailing ring --- */

/* Native cursor is hidden only once JS proves a fine pointer and adds this
   class, so touch devices and no-JS keep the system cursor. !important beats
   the per-element `cursor: pointer` rules on buttons and links. */
body.has-cursor,
body.has-cursor * { cursor: none !important; }

/* Every size below is the original mark at 3x: ring 20 -> 60, stroke 1.6 -> 4.8,
   dot 5 -> 15, hover halo 46 -> 138, press 15 -> 45 and 40 -> 120. Scale the set
   together if it ever needs retuning, or the states stop reading as one object. */
.cursor {
  position: fixed; inset: 0; z-index: 9999;
  pointer-events: none;
  opacity: 0; transition: opacity .25s ease;

  /* The difference blend belongs HERE, on the container, not on the marks
     inside it. `position: fixed` + `z-index` makes this element a stacking
     context, and a stacking context isolates its descendants' blending: a
     blend on .cursor__dot would only ever see this box's own empty backdrop,
     so it painted flat white — visible on the dark hero, invisible on every
     cream or white section. Blending the container puts the page itself
     underneath, so the mark renders dark on light and light on dark with no
     per-section colour switching. */
  mix-blend-mode: difference;
}
.cursor.is-active { opacity: 1; }

.cursor__ring,
.cursor__dot {
  position: absolute; top: 0; left: 0;
  border-radius: 50%;
  will-change: transform;
}
.cursor__ring {
  width: 60px; height: 60px;
  /* Deliberately finer than a proportional 3x stroke would be: the ring reads
     as a drawn circle, not a donut. Chrome floors a fractional border-width to
     whole CSS pixels, so this lands on 2px at 1x and renders true at 2x. */
  border: 2.5px solid oklch(100% 0 0);
  /* size, not transform: gsap owns transform for the trailing motion. Because
     xPercent:-50 centres on the mark's own width, growing it stays centred. */
  transition: width .3s var(--ease), height .3s var(--ease), opacity .3s ease;
}
.cursor__dot {
  width: 15px; height: 15px;
  background: oklch(100% 0 0);
  transition: width .25s var(--ease), height .25s var(--ease), opacity .25s ease;
}

/* Over photography the blend is dropped and the mark is simply white.
   `difference` is the right answer against flat colour — it guarantees contrast
   on cream and on anthracite alike — but a photograph is neither: it inverts to
   whatever mid-tone happens to be under it, which is how the mark ended up
   reading as grey over the gallery. White over a picture is a decision that
   holds wherever the pointer lands in it.
   Both marks are already pure white, so turning the blend off is the whole
   change. mix-blend-mode is not transitionable, hence the cut. */
.cursor.is-on-image { mix-blend-mode: normal; }

/* Over a clickable target the ring swells into a halo and the dot steps aside. */
.cursor.is-hover .cursor__ring { width: 138px; height: 138px; }
.cursor.is-hover .cursor__dot  { width: 0; height: 0; opacity: 0; }

/* Rides the pointer on the up-right diagonal, offset far enough to clear the
   ring at rest. Anchored bottom-left (yPercent -100 in main.js) so it grows up
   and to the right from that point and never crosses back over the mark.
   The type stays level: the 45° is where it sits, not how it is set — a rotated
   label reads as decoration and is slower to take in. */
.cursor__label {
  position: absolute; top: 0; left: 0;
  white-space: nowrap;
  font-family: var(--f-display); font-size: 12px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: oklch(100% 0 0);
  will-change: transform;
  /* Both words occupy the same cell, so the box the anchor is measured from is
     one line high whichever of them is showing and neither can shove the other
     off the diagonal. */
  display: grid;
}
.cursor__label-text {
  grid-area: 1 / 1;
  opacity: 0;
  transition: opacity .25s ease;
}
.cursor.is-detail [data-cursor-label="detail"] { opacity: 1; }
/* The held hero frame: the page stops travelling while the reveal plays, and
   this is the only thing that says the scroll is still the way through. */
.cursor.is-scroll [data-cursor-label="scroll"] { opacity: 1; }

/* A press nudges the ring in — reads as a click even before the target reacts. */
.cursor.is-down .cursor__ring { width: 45px; height: 45px; }
.cursor.is-down.is-hover .cursor__ring { width: 120px; height: 120px; }

/* Over a project card the mark stops being an outline and becomes the thing
   itself: a filled black disc with the word set through the middle of it. The
   cards have no arrow badge and no link on them, so this is the whole
   affordance — the pointer says what the card will do rather than a chip in its
   corner saying it over the photograph.
   The word rides inside .cursor__ring rather than beside it. The ring trails the
   pointer on its own easing and the label out on the diagonal tracks the dot's;
   set as siblings the two would drift apart every time the pointer moved
   quickly. As a child the word cannot leave the disc it is centred in.
   The blend goes off for the same reason it does over photography: `difference`
   would invert the disc to whatever is behind it, and black is a decision here,
   not a contrast trick.
   After .is-down deliberately: a press must not shrink a disc with a word in it
   back to 45px and crop the word. */
.cursor.is-explore { mix-blend-mode: normal; }
.cursor.is-explore .cursor__ring,
.cursor.is-explore.is-down .cursor__ring {
  /* Sized off the word rather than off the ring's other states: the set word
     measures 57px, so this leaves it a little over its own width in black on
     each side — enough that the disc reads as a mark the word sits in rather
     than as a button the word has been fitted to. */
  width: 104px; height: 104px;
  background: oklch(0% 0 0);
  /* transparent rather than border:0 — the border is inside the box, and
     dropping it would step the disc's diameter by 5px as it appeared. */
  border-color: transparent;
}
.cursor.is-explore .cursor__dot { width: 0; height: 0; opacity: 0; }

/* inset:0 on the ring, so the word is centred on the disc whatever size the
   disc is, and the two never have to be kept in step by hand. */
.cursor__explore {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font-family: var(--f-display); font-size: 11px; font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase;
  /* The tracking is set on the word, so the last letter carries a trailing
     space the first one does not — enough to throw the centring off by half a
     letter at this size. Pulled back by the same amount. */
  text-indent: 0.14em;
  color: oklch(100% 0 0);
  white-space: nowrap;
  opacity: 0; transition: opacity .25s ease;
}
.cursor.is-explore .cursor__explore { opacity: 1; }

/* ==========================================================================
   Dark run — What we do → word band → services
   ========================================================================== */

/* One anthracite floor under three sections, full bleed by the same pattern
   .quote uses. Everything below re-states, for this floor, the handful of
   colours that were written against the light page: rules, muted greys and
   the hover accent. Nothing is restyled beyond colour. */
.dark-run {
  width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2);
  background: var(--anthracite); color: var(--on-dark);
}
/* The whole point of the run is that the gallery above it and the quote below
   it hand over without an edge, and .section's default rule is a near-white
   line that would draw exactly that edge across the seam. */
.dark-run .section { border-top: 0; }

/* Light enough to read as a hover rather than a dimming — var(--accent) is a
   45% ink written for the light page and goes muddy against this floor. */
.dark-run { --accent: oklch(80% 0.035 60); }

.dark-run .eyebrow { color: oklch(68% 0.012 70); }
.dark-run .eyebrow--strong { color: var(--on-dark); }
.dark-run .whatwedo__title { color: var(--on-dark-hi); }
.dark-run .whatwedo__body { color: oklch(70% 0.012 70); }
.dark-run .link-caps { color: var(--on-dark); }
/* The solid button is an ink fill on the light page — one shade off this floor
   and therefore invisible on it. Inverted rather than outlined, so it keeps
   reading as the same button it is everywhere else on the page. */
.dark-run .btn--solid { background: var(--on-dark); color: var(--anthracite); }
.dark-run .btn--solid:hover { background: var(--on-dark-hi); color: var(--anthracite); }

/* The band is held against the viewport while it plays, so its own opaque
   background has to be the run's floor — anything else and the sections
   scrolling behind it would show through, or it would flash light. */

/* No hairlines on the service words: the list is set as one solid block of
   type, and any rule through it would read as six boxes again. */

/* Image drop areas sit inside the run too. Left as authored they are a
   near-white checkerboard that lights up the whole band before any artwork is
   placed — this keeps the empty state at the floor's own value. */
.dark-run .slot:not(.is-filled) {
  background: repeating-linear-gradient(
    -45deg,
    oklch(26% 0.008 60) 0 10px,
    oklch(30% 0.008 60) 10px 20px);
}
.dark-run .slot:not(.is-filled) { border-color: oklch(100% 0 0 / 0.18); }
.dark-run .slot:not(.is-filled)::after { color: oklch(62% 0.01 70); }

/* ==========================================================================
   What we do + services
   ========================================================================== */

.whatwedo { padding-bottom: 0; }
/* One column, everything ranged right. The headline and the paragraph are
   stacked rather than set side by side, and they share a single right edge —
   that edge, not a box or a rule, is what holds the block together. The
   eyebrow stays ranged left at the top: it marks the section the way Selected
   Work's does, and a marker that moved with the copy would stop reading as
   one. */
.whatwedo__head { display: flex; flex-direction: column; }
.whatwedo__title {
  font-family: var(--f-display);
  font-weight: 800;                    /* the whole headline is set heavy */
  /* Every term here is bounded by a measurement, not chosen: the lines are
     locked to nowrap by .whatwedo__line-in, so a size that does not fit is not
     re-flowed, it is cut off — and centred, that means losing both ends of the
     line at once. "Built Through Experience." is the longer of the two at
     12.26em, so the size can never exceed (content width / 12.26).
     Between 618px and 1067px the gutter is 6vw, leaving 88vw of content and a
     hard ceiling of 7.18vw — which is what caps the preferred term at 7.15vw
     and is the tightest point on the whole curve. Above 1067px the gutter
     stops at 64px and the ceiling climbs away from it.
     The floor stays at 6.8vw: below ~700px the headline already runs the full
     width of the panel, so there is no room there to grow into.
     Re-measure all of this if either line's wording changes. */
  font-size: clamp(min(6.8vw, 50px), 7.15vw, 125px);
  /* A display line, not body copy, so it is given the panel's full width
     instead of the text gutter — negative margins pull it out to a tighter
     inset of its own. Without this the 6vw-a-side gutter at tablet widths is
     exactly what the nowrap line runs out of: it clears by 4px at 1000px and
     by 64px with this. Symmetric, so the centring is untouched, and left off
     the eyebrow on purpose — that marker lines up with Selected Work's across
     the page and must not move with the headline. */
  margin-inline: calc(clamp(16px, 3vw, 64px) - var(--gut));
  /* -0.04em, not the -0.03em this was set at while the lines were mixed: at 800
     the counters close up and the default tracking reads loose against them. */
  line-height: 1.02; letter-spacing: -0.04em;
  text-align: center;
  /* Dropped clear of the eyebrow rather than following it. The gap is the
     panel's opening beat — the marker is read, then the headline arrives. */
  margin-top: 100px;
}
/* Two spans per line, matching .hero__line / .hero__line-in: the outer one is
   the box the opening clips against, the inner one is what actually holds the
   letters and moves. Both are set from JS, so a page whose script never runs
   is left with a plainly legible headline rather than a clipped one. */
.whatwedo__line { display: block; }
.whatwedo__line-in { display: inline-block; white-space: nowrap; }
/* One letter, split by the opening. inline-block is what makes a letter
   transformable — the same kerning trade the hero and the band already make.
   `pre` is load-bearing, not tidiness: the split gives the space between two
   words a span of its own, and an inline-block whose entire content is one
   space has that space collapsed away to nothing — "Built Through" sets as
   "BuiltThrough". `pre` is what keeps the space occupying its width. */
.whatwedo__char { display: inline-block; white-space: pre; }

.whatwedo__aside {
  display: flex; flex-direction: column; align-items: center;
  gap: clamp(40px, 6vw, 80px);
}
.whatwedo__body {
  font-family: var(--f-display);
  /* Half the headline's scale, tracking it through the same clamp so the two
     hold their relationship at every width rather than only at one. Running
     the full width below the headline is what buys the size: set beside it in
     a third-width column, this copy fits about eight characters per line. */
  font-size: clamp(24px, 3vw, 52px);
  line-height: 1.42; color: oklch(42% 0.01 60);
  /* Long for a measure, deliberately: the paragraph is meant to fill the width
     the headline is centred in, so the two read as one centred block rather
     than a wide line over a narrow one. Set in ch so the line count holds as
     the type scales. */
  max-width: 68ch;
  text-align: center;
  /* Clear of the headline, not tucked under it — the two are separate beats. */
  margin-top: clamp(48px, 7vw, 130px);
}
/* Centred with the copy it closes. Ranged to one side it would be the only
   thing in the panel off the centre line, and would read as a stray. */
.whatwedo__aside .btn { align-self: center; }

/* Two columns, centred against each other. The frame is a fixed 4:5 and the
   word stack is whatever height six lines make — neither should stretch the
   other, so the shorter one floats on the centre line. */
.services {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  /* Column and row stated apart now that the label adds a second row: the
     measure between the frame and the words is not the measure a label wants
     under it, and a single `gap` would have paid the wider one twice. The
     label carries its own margin instead. */
  column-gap: clamp(28px, 5vw, 96px);
  row-gap: 0;
  align-items: center;
}
/* Half the usual section foot. Everything below the word list here is empty
   anthracite, and the band that follows opens on the same anthracite, so the
   padding is not separating the section from anything — it is dark screen the
   reader scrolls through before the statement's colour turn can even begin.
   The top is untouched; only the dead end is cut.
   position is safe here in a way it is not on .dark-run itself: the pinned
   band is in .xp, two sections up, so the stacking context this opens holds
   nothing that ScrollTrigger has to reach out of. */
.services { padding-bottom: calc(var(--sec-y) / 2); position: relative; }
/* The second half of the statement band's crossfade, and the reason that fade
   can start a screen-height earlier than it used to.
   This covers exactly the dead padding above — the strip of empty anthracite
   left in frame once the word list has gone — and carries the same opacity as
   .quote::before. The two are adjacent, flat, and move together, so the screen
   turns as one surface and there is no edge between them to see. Without it
   the band could only start lightening once its own top edge had left the
   viewport, because anything above it still dark would draw that edge.
   Defaults to 0, not 1: with no script the floor stays the anthracite it is
   drawn as, while .quote::before defaults the other way and leaves the band
   cream. Each wants the opposite end of the same variable. */
.services::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: calc(var(--sec-y) / 2);
  background: var(--bg);
  opacity: var(--quote-reveal, 0);
  pointer-events: none;
  /* Whatever easing this strip has, .quote::before has to have the same, to the
     millisecond: the two read one variable but each would ease to it on its own
     clock, and one strip chasing the other down the page is exactly the edge
     this pair exists to not draw. Both used to carry `transition: opacity .15s
     linear`, which smoothed the old hand-written per-frame updates. The
     variable is now scrubbed — ScrollTrigger already eases it against the
     scroll — so a transition on top is a second curve chasing the first, and it
     showed up as the turn lagging the wheel and then catching up. Neither has
     one now, which keeps them identical the other way. */
}

/* Spans the pair so it opens the section rather than sitting in a column.
   Lifted clear of the frame by translate rather than by margin: there is a
   screen of empty floor above this section and the label belongs up in it,
   while the frame and the words stay exactly where they were measured. A
   margin would have moved the label and the content together and paid for the
   distance twice — once in air above, once in section height. */
.services__label {
  grid-column: 1 / -1;
  margin: 0 0 clamp(24px, 3vw, 44px);
  translate: 0 -100px;
}

/* --- stage --- */

/* The stage carries the width cap, not just the frame, so the counter beneath
   ranges to the photograph's right edge rather than to the column's.
   The right-shift is a transform, not a margin: the first attempt used
   margin-left and subtracted it from the width, which moved the frame's left
   edge and left its right edge exactly where it was — to the eye the image
   shrank instead of moving. A transform slides the whole box and costs no
   width. Applied only once the viewport can afford it: below 1280px the
   columns sit too close for a 100px lean not to push the frame into the
   words. */
.services__stage {
  display: flex; flex-direction: column; gap: clamp(10px, 1vw, 16px);
  width: min(100%, calc(86vh * 4 / 5));
}
@media (min-width: 1280px) {
  .services__stage { transform: translateX(100px); }
  /* The lean walks the frame's right edge into the word column — 28px deep at
     1440. The words give way by the same order of distance, spending slack the
     list column has anyway; their long rows may now end inside the section's
     own side padding, which is air, not overflow. */
  .services__list { margin-left: 40px; }
}
/* Fixed 4:5, portrait. The proportion is the thing that must not move — an
   earlier version stretched the frame to the list's height, which turned the
   photograph landscape the moment the viewport got wide enough for the column
   to outgrow the rows. Width is what gives: capped by the column, and by what
   a 4:5 box of 86vh would need — a fifth up from the first cut's 72vh, and
   still short of the screen. */
.services__frame {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: clamp(10px, 1vw, 16px);
}
/* One vignette over the frame, not one per panel: it belongs to the window,
   so every photograph that fades in arrives already graded and the handover
   never flickers the corners. Drawn after the panels, so it paints above
   whichever one is showing; pointer-events off so drops still reach the slot
   beneath.
   Graded in the floor's own colour, not black: at the edges the photograph
   sinks almost to the anthracite the section sits on — 0.88, deliberately
   short of 1 — so the frame reads as emerging from the page rather than
   printed on it, and the radius corner never shows a hard image edge. */
.services__frame::after {
  content: ""; position: absolute; inset: 0;
  pointer-events: none;
  background: radial-gradient(ellipse at center,
    transparent 38%, oklch(17% 0.009 60 / 0.45) 68%, oklch(17% 0.009 60 / 0.88) 100%);
}
/* All six photographs occupy the frame at once and only the active one is
   seen. The frame does not slide — the reference this section is built to
   holds the image box perfectly still and lets the pictures hand over in
   place.
   Each panel parks off-centre vertically before it is called, on a ladder of
   30px steps that mirrors the list beside it: the first service's photograph
   waits highest and comes in from the top of the frame, the last waits lowest
   and opens from below, and every rung between is exactly 30px apart. The
   rest position also carries a slight scale, so arriving is a settle — drift
   to centre, zoom easing off — rather than a bare fade.
   pointer-events belongs to the active panel alone. All six overlap, so
   without this the drop tool could only ever reach the panel drawn last —
   hovering a service both shows its panel and makes it the one a dragged
   image lands in. */
.services__panel {
  position: absolute; inset: 0;
  opacity: 0;
  pointer-events: none;
  transform: translateY(var(--vy, 0)) scale(1.06);
  transition: opacity .45s var(--ease), transform .6s var(--ease);
}
.services__panel:nth-child(1) { --vy: -75px; }
.services__panel:nth-child(2) { --vy: -45px; }
.services__panel:nth-child(3) { --vy: -15px; }
.services__panel:nth-child(4) { --vy: 15px; }
.services__panel:nth-child(5) { --vy: 45px; }
.services__panel:nth-child(6) { --vy: 75px; }
.services__panel.is-active { opacity: 1; pointer-events: auto; transform: none; }
.services__panel .slot { width: 100%; height: 100%; }
/* A slow drift, on the photograph itself rather than on the panel: the panel's
   own transform is spoken for by the settle above, and the two would overwrite
   each other.
   Run on all six rather than only the showing one. Which panel is visible is
   decided in two different places — .is-active from main.js, and the :has()
   hover rules further down that override it — so a selector keyed to one of
   them would leave the other's photograph still. Every image drifting on its
   own clock costs nothing: they are 4:5 crops of a still picture, and at 1.06
   over 18s no frame of the loop reads as a different composition from the one
   that faded in. Deliberately stronger and quicker than the hero's own drift —
   1.14 over 9s where that one takes 18s to reach 1.06. The hero is a wide
   photograph held under a whole scroll sequence, where a visible zoom would
   read as the page moving; this is a 4:5 crop looked at for as long as a name
   beside it takes to read, and at the hero's rate the movement was not felt
   at all. */
@keyframes servicesImgZoom { from { transform: scale(1); } to { transform: scale(1.14); } }
.services__panel .slot.is-filled img {
  animation: servicesImgZoom 9s ease-in-out infinite alternate;
}

/* Switched on by main.js. Frozen at 01 with no script, which would contradict
   whatever panel the :has() rules had faded in. */
.services__count { display: none; margin: 0; align-self: flex-end; }
.services--live .services__count { display: block; }
.services__count {
  font-family: var(--f-display); font-size: clamp(11px, 1vw, 13px);
  font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
  color: oklch(62% 0.012 70);
}

/* The handover, in CSS. :has() lets a hovered word drive a panel that is its
   parent's earlier sibling, which no other selector can reach — so the whole
   mechanic survives with the script off. While any word is hovered the
   resting first panel gives way; main.js's .is-active class covers the gap
   the moment the pointer leaves, and until the script runs the first panel
   simply returns. */
.services:has(.service:hover) .services__panel.is-active { opacity: 0; }
.services:has(.service[data-index="0"]:hover) .services__panel:nth-child(1),
.services:has(.service[data-index="1"]:hover) .services__panel:nth-child(2),
.services:has(.service[data-index="2"]:hover) .services__panel:nth-child(3),
.services:has(.service[data-index="3"]:hover) .services__panel:nth-child(4),
.services:has(.service[data-index="4"]:hover) .services__panel:nth-child(5),
.services:has(.service[data-index="5"]:hover) .services__panel:nth-child(6) {
  opacity: 1; transform: none;
}

/* --- the words --- */

/* A bare stack: no rules, no numbers, no gaps beyond what the leading gives.
   The reference sets the services as one block of type — the list reads as a
   single object, and the hovered line stepping out of it is the event. */
.services__list { display: flex; flex-direction: column; align-items: flex-start; }
.service { display: block; padding: 0; }
.service__title {
  font-family: var(--f-display);
  font-size: clamp(36px, 5.5vw, 91px); font-weight: 600;
  line-height: 1.12; letter-spacing: -0.025em;
  /* Resting state is a mid grey, not a dimmed white: every word must still be
     readable as part of the block, the way the reference keeps the whole
     stack legible with one line lit. */
  color: oklch(48% 0.008 60);
  transition: color .35s var(--ease), transform .5s var(--ease);
}
/* The hovered word steps out AND grows a fifth. Scale, not font-size: a
   transform never reflows, so the stack under the cursor never moves and the
   grown word simply paints over the leading above and below it. Origin at the
   left baseline edge — the word grows away from the stack's shared left edge,
   the same direction the step travels. */
.service__title { transform-origin: left center; }
.service:hover .service__title,
.service.is-active .service__title {
  color: var(--on-dark-hi);
  transform: translateX(clamp(18px, 2.6vw, 56px)) scale(1.2);
}
/* The rows either side of the hovered one lean after it — a fraction of the
   hovered word's step, uncoloured and unscaled, so the movement reads as the
   list being disturbed by the light rather than three rows being selected. */
.service:hover + .service .service__title,
.service:has(+ .service:hover) .service__title {
  transform: translateX(clamp(8px, 1.1vw, 22px));
}
/* While the pointer is somewhere on the list, the held row gives its light to
   the hovered one — two lit lines would leave the stage ambiguous about which
   photograph it is showing. The neighbour lean above wins over this reset when
   the held row happens to sit beside the hovered one, which is exactly right:
   it is a neighbour at that moment, not a holder. */
.services__list:has(.service:hover) .service.is-active:not(:hover) .service__title {
  color: oklch(48% 0.008 60);
  transform: none;
}
.services__list:has(.service:hover) .service:hover + .service.is-active .service__title,
.services__list:has(.service:hover) .service.is-active:has(+ .service:hover) .service__title {
  transform: translateX(clamp(8px, 1.1vw, 22px));
}

/* One column and no stage below the split, and on any touch device: hovering
   is the only way into the stage and there is no touch equivalent worth
   inventing. The list alone carries the content. */
@media (hover: none), (max-width: 900px) {
  .services { grid-template-columns: 1fr; }
  .services__stage { display: none; }
}

/* No travel, no fade, no growth — the panel is cut to and the word changes
   colour only. */
@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  /* The marks are simply there to be read when nothing is going to animate
     them. That used to be the whole of this setting; it is now the phone
     without script and the reader who asked for less motion, because the wall
     is the one reveal a phone keeps. main.js marks the wall .is-js at the
     moment it takes the marks over, so the state this rule describes is
     exactly "no script has claimed these" — which is the condition it was
     always really stating. Without the guard this would sit under GSAP's own
     inline opacity and describe a wall that is being animated. */
  .closing__wall:not(.is-js) .slot--logo { opacity: 1; transform: none; }
  .services__panel,
  .service__title { transition: none; }
  .services__panel { transform: none; }
  .service:hover .service__title,
  .service.is-active .service__title,
  .service:hover + .service .service__title,
  .service:has(+ .service:hover) .service__title { transform: none; }
}

/* ==========================================================================
   Testimonial
   ========================================================================== */

/* Opens as one more stretch of the services floor — same anthracite, so the
   seam above it never draws. The band is taller than the viewport on
   purpose: the extra height is scroll runway, and the page colour only
   rises through it (the ::before overlay) once the band owns the whole
   screen. While any part of Services is still visible the floor stays
   solid anthracite, so no horizontal edge can appear at the hand-over.
   The text is ink: invisible against the dark floor, it surfaces as the
   background turns light. main.js drives --quote-reveal and the type's
   scale; the defaults below are the finished state, so without script or
   with reduced motion the band is simply light with full-size text. */
.quote {
  position: relative;
  /* The section's own width, not --bleed. --bleed is 100vw corrected by a
     measured scrollbar, and when that measurement is off (it is taken once, and
     overlay scrollbars report zero) the band hangs a few pixels past the
     document on both sides and its edges round differently from every block
     around it. The parent is already full width and has no gutter. */
  width: 100%;
  /* Rides 1px up over the dark run: at fractional zoom levels the two
     full-bleed blocks round to different subpixel edges and the cream body
     shows through the crack as a hairline. Overlapping closes it at every
     zoom; both surfaces are the same anthracite, so nothing else shows. */
  margin-top: -1px;
  /* Exactly one screen, and no more. The runway the paragraph needs to grow
     across is not height any more — it is a ScrollTrigger pin (see
     initStatement in main.js), which holds this band still and spends real
     scroll distance on the animation instead of on empty band. Height and
     runway used to be the same number here, which is why the band had to be
     120vh and why the text had to be dragged 33vh up out of its own centre to
     be anywhere near the middle of the screen. Pinned, the band IS the screen,
     so its flex centre is the screen's centre and the offset goes away. */
  min-height: 100vh;
  background: var(--anthracite); color: var(--ink);
  display: flex; align-items: center; justify-content: center;
  /* The mount. At full size the paragraph runs to these edges and stops — the
     air left on either side is the whole point of the shot, so it is stated
     here as a real measure rather than left as whatever the old 2.2vw gutter
     happened to be. Vertical padding only matters at the small end, where a
     short viewport would otherwise let the block touch top and bottom. */
  /* The mount. At full size the paragraph runs to the side edges and stops —
     the air left on either side is the whole point of the shot, so it is stated
     as a real measure rather than left as whatever gutter the band happened to
     have. The vertical inset is in vh, not vw: it is competing with the
     paragraph for the one screen the pin holds, and 8vw on a wide short monitor
     was spending 200px of a 700px screen on air. */
  padding: clamp(24px, 6vh, 72px) clamp(20px, 5.5vw, 112px);
  overflow: hidden;
}
/* The other half of the pair — see the note on .services::after for why
   neither of them eases on its own any more. */
.quote::before {
  content: ""; position: absolute; inset: 0;
  background: var(--bg);
  opacity: var(--quote-reveal, 1);
}
/* Deliberately unmasked, and a note against trying it again: gradient-masking
   this overlay so the turn could start before the band's top edge cleared the
   screen trades one problem for a worse one. The turn stops being a change of
   ground the whole screen makes at once and becomes a visible band of grey
   sliding down the page — the reader sees the mechanism instead of the page
   changing colour under them. The flat crossfade is the effect; it only works
   because there is no edge anywhere in it. */
/* Positioned so the text paints above the overlay. */
.quote__text { position: relative; }
/* Closed, the type is the ground exactly — not nearly. It used to sit at
   --ink, oklch 18%, on an --anthracite floor at 17%: one percent of lightness
   apart, which is invisible as a colour and unmissable as a shape, so the
   paragraph ghosted through the dark band like a watermark before it had any
   business being there. Mixed against the reveal it starts as the floor's own
   colour and arrives at ink, so the block has nothing to show until the ground
   is light enough to show it.
   The two ends are a percent of lightness apart, so this is not really a
   colour change at all — the type holds still at ink and the ground moves out
   from under it. That is what keeps contrast through the middle of the turn,
   where a mix travelling to a light colour would wash out against a ground
   arriving at the same place.
   The plain declaration above it is the floor for anything without color-mix:
   ink throughout, which is what this was before. */
/* Closed, the type is the ground exactly — not nearly, and not a shade above
   it. A dim warm grey was tried here on the reasoning that the paragraph has to
   open OUT of the darkness, so there ought to be something in the darkness to
   open. There ought not: at a third of full size behind twelve pixels of blur
   it read as a smudge on the floor, and a smudge that resolves into text is not
   the same effect as text arriving where there was nothing. The emergence is
   the turn's job, and the turn only works if the type has genuinely nothing to
   show before it.
   The two ends are a percent of lightness apart, so this is barely a colour
   change at all — the type holds still at ink and the ground moves out from
   under it. That is what keeps contrast through the middle of the turn, where a
   mix travelling to a light colour would wash out against a ground arriving at
   the same place.
   The plain declaration above it is the floor for anything without color-mix:
   ink throughout. */
.quote__text {
  color: var(--ink);
  color: color-mix(in oklab,
    var(--ink) calc(var(--quote-reveal, 1) * 100%),
    var(--anthracite));
}
.quote__text {
  /* One running paragraph set wide: the block spreads across the page
     rather than sitting in a column. The cap is a backstop for very wide
     monitors only — on anything under ~2200px the padding is what decides
     the measure, so the block reaches both edges of the band. */
  max-width: min(100%, 2100px);
  font-family: var(--f-display); font-weight: 600;
  /* Sized against both axes, and the vh half is not decoration — it is the
     thing that makes the pin possible.
     Pinned, the band is exactly one screen and the paragraph has to finish
     inside it: anything taller is cut off by the band's own overflow, and no
     amount of scrolling brings it back, because the band is being held still.
     At 3.1vw the block measured 733px of type on a 720px screen — it did not
     fit at all, which never showed while the band was 120vh and free to grow.
     A block's height goes as the square of its type size (bigger type, fewer
     words per line AND taller lines), so the size has to answer to the screen's
     height as well as its width, and min() takes whichever of the two is
     tighter. 5.2vh is the ceiling a short wide monitor needs; 2.75vw is the one
     every ordinary window lands on. */
  font-size: clamp(18px, min(2.75vw, 5.2vh), 48px); line-height: 1.32;
  /* Justified: every line runs wall to wall of the measure, so the block
     sits as one horizontal slab instead of a centred rag. Balance would
     re-shorten the lines, so it stays off. The closing line centres. */
  letter-spacing: -0.01em; text-align: justify; text-align-last: center;
  /* Scaled about the middle of the screen, which is where the band's flex
     centre puts it once the pin has the band filling the viewport. Any other
     origin and the block would drift as it grew instead of opening outwards
     from where it already is.
     No transition: initStatement scrubs this transform against scroll
     position, and a transition on top of a scrub is a second easing curve
     chasing the first — it shows up as the block lagging the wheel and then
     catching up. The scrub itself is the smoothing. */
  transform-origin: 50% 50%;
  will-change: transform, filter;
}
.quote__text p { margin: 0; }

/* One per word, written in by initStatement. inline-block is what makes the
   word transformable at all — an inline box ignores translate — and it is safe
   against the justification because the spaces between the spans are still
   real text nodes, so the line still has something to stretch. */
.qw {
  display: inline-block;
  will-change: transform, opacity;
}

/* ==========================================================================
   Reference wall
   ========================================================================== */

/* A wall, not a strip: the marks are held still and meet both page edges, so
   the block reads as a plate on the page rather than as a list being scrolled
   past. Nothing here is masked or clipped at the sides — the fill is the
   point. */
/* The inset is a percentage, not a clamp, and percentage padding always
   resolves against the inline size — so the gap between two marks is the same
   fraction of the cell at every width and every column count, and the cell
   comes out square: a 1:1 mark plus two equal insets is exactly as tall as it
   is wide. That squareness is what keeps the rows on a grid rather than on a
   set of measurements that happen to be close. */
.wall__cell {
  display: grid; place-items: center;
  padding: 16%;
  min-width: 0;
}
/* The drop tool outlines a mark it is still holding in the browser and tags it
   DRAFT. That is the right warning on a photographic slot, where the artwork is
   the content; on this wall it is drawn as a dashed box around every logo and
   reads as part of the design. Hidden here the way the header and footer marks
   already hide it. The bar at the bottom still counts the drafts, so nothing
   about "not on disk yet" is actually lost. */
.wall__cell .slot--draft { outline: none !important; }
.wall__cell .slot--draft::before { content: none; }
/* The cell owns the size in the wall. A fixed logo width would stop the grid
   short of the right edge on wide screens and open the gaps the marks are
   being read across. */
.wall__cell .slot--logo { width: 100%; }

/* ==========================================================================
   Back to top
   ========================================================================== */

.to-top {
  /* 20px further out than the page gutter it used to share, so the ring clears
     the column of type it was sitting against. Floored at 8px: on a phone the
     gutter is smaller than the shift, and a negative offset would carry the
     button off the edge of the screen. */
  position: fixed;
  right: max(8px, calc(clamp(16px, 3vw, 34px) - 20px));
  bottom: clamp(16px, 3vw, 34px);
  z-index: 60; width: 58px; height: 58px;
  padding: 0; background: none; border: none; cursor: pointer;
  /* Hidden by default and brought in only on the way back up — going down, the
     way out is the scroll itself and the button is one more thing over the
     page. main.js sets .is-shown from the same direction read the header uses,
     so the two arrive and leave together. */
  opacity: 0; translate: 0 12px; pointer-events: none;
  transition: opacity .28s ease, translate .28s var(--ease);
}
.to-top.is-shown { opacity: 1; translate: 0 0; pointer-events: auto; }
.to-top svg { display: block; }
/* A solid disc, not a tinted chip: full opacity and no border, same weight as
   the reference mark. Read as the opposite of whatever is under the button —
   dark disc over the light page, so it reads as its own object rather than a
   near-invisible wash of the ground it sits on. is-on-dark below flips it. */
.to-top__bg { fill: oklch(18% 0.01 60); stroke: none; }
/* The mark is a drawn chevron rather than the ↑ glyph: one continuous stroke,
   round caps and join, weighted to hold its own against the solid disc behind
   it rather than reading as a hairline. */
.to-top__arrow {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  color: oklch(97% 0.012 75);
}
.to-top__arrow svg { width: 24px; height: 24px; display: block; }

/* Over the dark runs the disc turns over to the light cream instead, and the
   chevron goes to the dark ink — the opposite-of-ground rule holds on both
   grounds rather than the button fading into whichever one it is standing on.
   main.js sets .is-on-dark from what is under the button's own centre, the same
   way it reads the ground under the header. */
.to-top__bg, .to-top__arrow { transition: fill .28s ease, color .28s ease; }
.to-top.is-on-dark .to-top__bg { fill: oklch(97% 0.012 75); }
.to-top.is-on-dark .to-top__arrow { color: oklch(18% 0.01 60); }

/* ==========================================================================
   Footer
   ========================================================================== */

/* Fixed to the floor and never scrolled. The page is a card that slides off
   it, so this is uncovered rather than pushed up: the type at the bottom does
   not move a pixel while the closing section is scrolled away over it.
   z-index 0 keeps it under #smooth-wrapper, which carries the page.

   Height is the viewport, not the content: the reveal is a curtain, and a
   curtain that is taller than the opening cannot be fully drawn. Content that
   would exceed it is the one thing to watch when editing this block. */
/* Dark, and the only dark floor the page ends on: the cream card is drawn off
   it, so the change of ground is what tells you the page is over.

   The palette is inverted by re-declaring the page's own tokens on this one
   element rather than by restating a colour on every rule inside it — every
   descendant already reads --ink, --muted-*, --rule-faint and --bg, so the
   whole panel turns over at once and new rules inherit the inversion for
   free. Nothing below this block names a literal colour for that reason. */
.footer {
  --bg:           var(--anthracite);
  --ink:          oklch(97% 0.012 75);
  --surface-3:    oklch(88% 0.012 75);
  --muted:        oklch(70% 0.012 70);
  --muted-body:   oklch(78% 0.012 70);
  --muted-faint:  oklch(62% 0.010 70);
  --rule-faint:   oklch(100% 0 0 / 0.16);
  --accent:       oklch(80% 0.035 60);

  /* Pinned to all four edges rather than to the floor at a height of its own.
     Anchored at the bottom with height:100svh, the panel is the height of the
     viewport *with the browser's toolbar showing*; scrolling down on a phone
     hides that toolbar, the viewport grows by its height, and the panel — still
     svh tall and still anchored to the bottom — leaves a strip of unpainted
     page above it that the card can no longer be scrolled over. That strip is
     the empty band above the footer on the phone. inset:0 has no height of its
     own to fall behind: it is the viewport, whatever the toolbar is doing.
     Desktop is unaffected, where the two are the same number. */
  position: fixed; inset: 0; z-index: 0;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: clamp(56px, 8vh, 104px) 0 clamp(20px, 3vh, 36px);
  background: var(--bg); color: var(--ink); overflow: hidden;
}
.footer a { color: var(--ink); }

/* The page rides over the footer, but the wrapper itself must stay
   transparent: ScrollSmoother sets it to position:fixed across the whole
   viewport, so any background here is an opaque sheet laid over the fixed
   panel and the footer is never seen at all. The ground belongs to <main>,
   which ends where the page ends. */
#smooth-wrapper { position: relative; z-index: 1; background: none; }
main { position: relative; z-index: 1; background: var(--bg); }
/* Room to scroll the card off the panel. The padding sits below <main> and is
   deliberately unpainted: it is the window the footer is seen through. */
#smooth-content { padding-bottom: var(--footer-h, 100vh); background: none; }

/* The window is transparent but it is still a box, and a box takes the clicks
   that land on it: without this the footer is visible and none of its links
   can be reached, because the page's own padding is lying over them. Only the
   page's real content takes the pointer back. */
#smooth-wrapper,
#smooth-content { pointer-events: none; }
#smooth-content > * { pointer-events: auto; }

/* ---------------------------------------------------- closing: wall + call */

/* The lid. Square and unshadowed: the page hands over to the footer on a flat
   edge, so the seam above it stays cream-on-cream and nothing reads as a card
   sitting on a surface. Radius and drop shadow are deliberate omissions — no
   other block on the site has either. */
/* The extra 200px below the wall is deliberate: the section's own bottom
   padding put the last row of marks right on top of the edge the page hands
   over to the footer on, and a wall reading as if it were about to be cut is
   the one thing that seam cannot afford. */
.closing {
  position: relative; z-index: 1; background: var(--bg);
  /* Same trick the band above uses on its own top edge, and for the mirror of
     the same reason. The statement band is anthracite with a cream overlay
     painted over it, so its bottom edge is where a dark surface stops. Under
     ScrollSmoother the whole page sits on a transform at fractional offsets,
     and on the frames where that edge lands mid-pixel the anthracite shows
     through the rounding as a hairline that appears and goes with the scroll.
     Overlapping the last pixel removes what there is to round: this section is
     already the higher layer, so it covers rather than moves anything. */
  margin-top: -1px;
  padding: calc(clamp(72px, 9vw, 132px) + 1px) 0 calc(clamp(80px, 10vw, 140px) + 200px);
}

/* Two columns off one gutter. The marker holds the left edge exactly where
   What We Do's does, and the claim is set opposite it rather than under it, so
   the head reads across the page the way the wall below it does. */
.closing__head {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(24px, 5vw, 64px); align-items: start;
  text-align: left; max-width: none;
  margin: 0 0 clamp(44px, 5.5vw, 76px); padding: 0 var(--gut);
}
/* The two columns sit on one line, so the marker's stacking gap is not its to
   keep here. */
.closing__head .eyebrow { margin-bottom: 0; }

/* Running copy, not a headline: display face, but at reading scale and with a
   measure, so the sentence is read rather than announced. */
.closing__body {
  margin: 0;
  font-family: var(--f-display);
  font-weight: 500;
  font-size: 45px;
  line-height: 1.42; letter-spacing: -0.01em;
  color: oklch(42% 0.01 60);
  max-width: 34ch;
}
/* One per word, written in by initClosingClaim. inline-block is what makes the
   word transformable — rotateX needs a box to tip, and an inline span ignores
   transform entirely. */
.cw { display: inline-block; will-change: transform, opacity, filter; }
/* Under the fold width the two columns would each be too narrow to hold their
   line, so they stack and the marker takes its gap back. */
@media (max-width: 860px) {
  .closing__head { grid-template-columns: 1fr; }
  .closing__head .eyebrow { margin-bottom: clamp(20px, 2.4vw, 32px); }
}

/* Full-bleed and gutter-free: the wall is the one block on the page that runs
   into both edges. The width is the section's own — .closing carries no
   horizontal padding — rather than --bleed or 100vw, so there is no scrollbar
   gutter to correct for and nothing to knock the grid off-centre.

   The column count always divides the twelve marks, so the last row is full at
   every width and no ragged gap opens on the right. Six across, two rows deep.
   Narrower screens step down through the other divisors — four, then three —
   rather than to any count that would leave a short last row. */
.closing__wall {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  width: 100%; padding: 0;
}
/* Six columns means twelve marks land in exactly two rows, so "the bottom
   row" is unambiguous here: it is cells 7-12. Pulled up by margin rather than
   by shrinking row-gap — there is no row-gap set, the rows are only as far
   apart as .wall__cell's own padding makes them, so the distance being closed
   is that padding, not a gap between two grid tracks. */
/* The marks are square cells with a lot of air in them, so every row after the
   first is pulled up into the one above. The pull has to be told which cell
   starts the second row, and that changes with the column count — this was
   written once, for six columns, as a flat -130px on cell seven and after. At
   four columns the second row starts at cell five and at three columns it
   starts at cell four, so the rule was pulling the *third* and *fourth* rows
   up by a full 130px on a phone, where a cell is only about 130px tall: the
   rows landed on top of each other and the logos overlapped.
   Stated per column count now, and in vw rather than pixels — the pull is
   roughly 46% of a cell at every width, which is what it was designed as. */
.wall__cell:nth-child(n+7) { margin-top: -130px; }

/* Below the six-column layout the pull is restated twice over: once for which
   cell starts the second row, and once for how far it may travel.
   A cell is a square with the mark set inside 16% padding, so the empty space
   across a row boundary is 32% of a cell and the pull has to stay under it.
   Half of that is the figure used here — it closes the gap without ever
   letting two marks touch, at any width. */
@media (max-width: 980px) {
  .closing__wall { grid-template-columns: repeat(4, 1fr); }
  .wall__cell:nth-child(n+7) { margin-top: 0; }
  .wall__cell:nth-child(n+5) { margin-top: -4vw; }
}

/* On a phone three across leaves each mark about a fifth of the screen wide —
   small enough that the wordmarks stop being readable and the wall reads as
   texture rather than as a list of brands. Two across is the last divisor of
   twelve that still fills every row, and it doubles the mark: half the screen
   per cell instead of a third.
   The insets come in with it. 16% was written for six columns, where the cell
   is small and the air around the mark is what separates it from its
   neighbours; at two columns that same fraction is a wide empty border on a
   narrow screen. 7% on the cell and 4% inside the image put the drawn logo at
   roughly twice the size it was at three columns, which is the point of the
   change, while still keeping a visible lane between two marks. */
@media (max-width: 700px) {
  .closing__wall { grid-template-columns: repeat(2, 1fr); }
  .wall__cell { padding: 7%; }
  .wall__cell .slot--logo img { padding: 4%; }
  /* Two columns puts the second row at cell three, and every cell from there
     on starts a row or sits in one that has been pulled — so a single rule
     covering 3 and after states the whole thing and overrides the wider
     layouts' pulls at the same time. The travel stays under the 14% of empty
     space a row boundary now has: half of it, as everywhere else. */
  .wall__cell:nth-child(n+3) { margin-top: -3.5vw; }
  /* The run of empty page under the marks. It is the section's own bottom
     padding plus a flat 200px, and both figures were set against a wall two
     rows deep on a wide screen — there the wall and the space under it are
     seen together, and the space is what keeps the last row off the seam the
     footer is uncovered on.
     A phone sees neither together: the wall is six rows and taller than the
     screen, so by the time the last row is read the space below is a screenful
     of nothing with no wall left in view to balance. Cut to a fifth of what it
     was — 56px rather than 280px. That is still four times the 13px gap
     between two rows of marks, so the last row is nowhere near reading as cut
     off at the seam; below it the footer's own top padding carries on.
     Written as a fraction of the original rather than as a flat number so the
     two stay tied: change the padding above and this follows. */
  .closing { padding-bottom: calc((clamp(80px, 10vw, 140px) + 200px) * 0.2); }
}


/* ------------------------------------------------------- footer contents */

/* Two tracks. The way through the site owns the left at display size, and
   everything the visitor might need to act on is ranged against the right
   margin. The space between them is meant to be empty: the panel is held by
   its four corners, and filling the middle would take that structure apart.

   The horizontal padding is the page's own gutter and nothing more, so the
   panel's edges line up with the card that slides off it. */
.footer__field {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  column-gap: clamp(32px, 6vw, 140px);
  align-items: stretch;
  padding: 0 var(--gut) clamp(14px, 2.4vh, 34px);
  min-height: 0;
}

/* ------------------------------------------------------------- left track */

/* Spread, not centred: the mark holds the top corner, the chips hold the
   bottom one and the nav takes whatever is between them. */
.footer__lead {
  display: flex; flex-direction: column; align-items: flex-start;
  justify-content: space-between;
  gap: clamp(20px, 3vh, 44px);
  min-width: 0; min-height: 0;
}

.footer__brand { display: block; flex: none; }
.footer__mark {
  display: block; width: clamp(62px, 4.9vw, 96px); aspect-ratio: 1 / 1;
  background: none; border: 0;
  color: var(--ink);
}
/* The dropped mark is drawn in the page's ink, which on this panel is the one
   value it cannot be read at. Knocked to pure white rather than swapped for a
   second file: there is one logo asset and the panel is the only place it is
   ever seen inverted. */
.footer__mark img { object-fit: contain; filter: brightness(0) invert(1); }
.footer__mark.is-filled .brand__fallback { display: none; }

/* The largest type on the panel and the point of it: full ink, but drawn thin.
   At this size weight is not what carries it — the height is — and a light
   stroke keeps the four words reading as a way through the site rather than as
   a headline shouted at the floor. */
.footer__nav {
  display: flex; flex-direction: column; align-items: flex-start;
  width: 100%;
}
.footer .footer__nav a {
  font-family: var(--f-display); font-weight: 300;
  font-size: clamp(48px, 6vw, 134px);
  line-height: 1.14; letter-spacing: -0.025em;
  text-transform: uppercase;
  color: var(--ink);
  transition: color .35s var(--ease), transform .35s var(--ease), opacity .35s;
}
.footer .footer__nav a:hover,
.footer .footer__nav a:focus-visible { transform: translateX(14px); color: var(--accent); }

/* Three chips rather than three loose lines: the corner has to read as one
   small object under a block that large. */
.footer__chips { display: flex; flex-wrap: wrap; gap: 10px 12px; }
.footer__chip {
  display: inline-flex; align-items: center;
  padding: 10px 18px; border-radius: 999px;
  border: 1px solid oklch(100% 0 0 / 0.22);
  font-family: var(--f-display); font-size: 11px; font-weight: 600;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted-body);
}

/* ------------------------------------------------------------ right track */

/* Everything in this column shares the panel's right edge — the sentence, the
   pill, the values and the glyphs — so it reads as one block against the
   margin instead of as a second list. */
.footer__side {
  display: flex; flex-direction: column;
  align-items: flex-end; justify-content: space-between;
  gap: clamp(20px, 3vh, 44px);
  min-width: 0; min-height: 0;
  text-align: right;
}

.footer__ask {
  display: flex; flex-direction: column; align-items: flex-end;
  gap: clamp(16px, 2.6vh, 34px);
}
/* Italic, and the only italic on the site: it is the one sentence here spoken
   rather than labelled. */
.footer__pitch {
  margin: 0;
  font-family: var(--f-display); font-style: italic; font-weight: 300;
  font-size: clamp(19px, 1.8vw, 38px);
  line-height: 1.2; letter-spacing: -0.015em;
  color: var(--ink);
}

/* One outlined pill and nothing inside it but the words. On a floor this dark
   a filled pill is the brightest object on the panel and would pull ahead of
   the sentence it belongs to; the outline sits at the same value as the type
   and reads as an invitation rather than as an alert. */
.footer__cta {
  flex: none;
  justify-content: center;
  min-width: clamp(210px, 13vw, 250px);
  padding: 16px 34px;
  font-size: clamp(10.5px, 0.75vw, 12.5px); letter-spacing: 0.14em;
}
.btn--outline {
  gap: 18px; padding: 15px 28px;
  border: 1.5px solid var(--ink); background: none; color: var(--ink);
  font-size: clamp(12px, 1.1vw, 14px); letter-spacing: 0.12em;
}
.footer .btn--outline:hover { background: var(--ink); color: var(--bg); }
/* The label is restated against .footer a, which is a more specific selector
   than .btn--solid and would otherwise paint it the same value as the pill. */
.footer .btn--solid { color: var(--bg); }

/* Three pairs, not a table: label hard left of the column, value hard right,
   and no rule anywhere between them. The label is the quiet half — it names
   what is opposite it and nothing more. */
/* Narrower than the track it sits in. The pair is a label and the thing it
   names, so the two have to read as one line — given the column's full width
   the label drifts into the middle of the panel and stops being attached to
   anything. The block still ranges right, so the values keep the margin. */
.footer__rows {
  margin: 0; width: min(100%, clamp(300px, 23vw, 460px));
  display: flex; flex-direction: column;
  gap: clamp(20px, 5vh, 56px);
}
.footer__row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 24px;
}
.footer__row dt {
  flex: none; text-align: left;
  font-family: var(--f-display); font-size: clamp(11px, 0.78vw, 14px);
  font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--ink);
}
.footer__row dd {
  margin: 0; min-width: 0; text-align: right;
  font-size: clamp(13px, 0.92vw, 17px); line-height: 1.5;
  color: var(--ink); overflow-wrap: anywhere;
}
/* Drawn under the type rather than through its descenders, and only grown in
   on hover: the pair reads as an address first. */
.footer__row a {
  color: var(--ink);
  background-image: linear-gradient(currentColor, currentColor);
  background-size: 0% 1px; background-position: 100% 100%; background-repeat: no-repeat;
  transition: background-size .35s var(--ease), color .25s;
  padding-bottom: 2px;
}
.footer__row a:hover { background-size: 100% 1px; }

/* The details arrive in the order they are read rather than together.

   The hidden state is gated behind .is-live, which main.js only adds when it
   is actually going to reveal them: an address that is invisible until
   JavaScript says otherwise is a broken address. */
.footer__side [data-reveal] { opacity: 1; transform: none; }
.footer__side.is-live [data-reveal] { opacity: 0; transform: translateY(24px); }
.footer__side.is-live [data-reveal].is-visible { opacity: 1; transform: none; }
.footer__side > [data-reveal]:nth-child(2) { transition-delay: .08s; }
.footer__side > [data-reveal]:nth-child(3) { transition-delay: .16s; }

/* ------------------------------------------------------ shared: the glyphs */

/* Bare glyphs, not discs. Each mark used to sit on a filled 44px circle, which
   put the brightest shapes on the panel under the quietest text on it; drawn
   straight onto the floor they carry the same weight as the labels above them.
   The row is set wide rather than tight — with no disc there is no shape to
   hold them apart, so the space between them has to. */
.socials { display: flex; align-items: center; gap: clamp(22px, 2.4vw, 40px); }
.socials li { display: flex; }
/* Set as one small object, not as a spread row. The wide setting put three
   marks across half the track and read as a fourth column of the panel; held
   close they sit under the values as the one group they are. */
/* The floor is 24px rather than a smaller number because the links carry 8px
   of hit area past the glyph on each side: any gap under 24 puts two 40px
   targets closer than the 8px apart adjacent targets have to be. */
.footer__socials { gap: clamp(24px, 1.5vw, 30px); }
/* The glyph is drawn small, but the thing being clicked is not: WCAG 2.2 AA
   wants 24 CSS px of target and the mark alone is under it. The padding buys
   the box and the negative margin gives the space straight back to the layout,
   so the row keeps the tight setting above while each link takes a 40px hit
   area. Adjacent targets are still 8px apart at the narrowest gap. */
.footer .social {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 8px; margin: -8px;
  color: var(--ink);
  transition: transform .3s var(--ease), color .3s, opacity .3s;
}
.social__icon { display: flex; align-items: center; justify-content: center; color: inherit; }
.social__icon svg { width: 28px; height: 28px; display: block; }
.footer__socials .social__icon svg { width: clamp(24px, 1.5vw, 28px); height: clamp(24px, 1.5vw, 28px); }
/* Read out, never drawn. */
.social__name {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.footer .social:hover { color: var(--accent); transform: translateY(-2px); }

/* ------------------------------------------------ shared: lists and labels */

/* Kept for the Studio Details section on contact.html, which sets three ruled
   groups at page size. The footer itself no longer uses this grid. */
.footer__cols {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(20px, 2.4vw, 40px);
}
.footer__label {
  margin: 0 0 clamp(14px, 2vh, 22px);
  font-family: var(--f-display); font-size: 11px; font-weight: 700;
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink);
}
.footer__list { display: flex; flex-direction: column; gap: clamp(8px, 1.4vh, 14px); }
.footer__list li {
  font-size: 14.5px; line-height: 1.5; color: var(--muted-body);
  overflow-wrap: anywhere;
}
.footer__list a {
  color: var(--muted-body);
  background-image: linear-gradient(var(--ink), var(--ink));
  background-size: 0% 1px; background-position: 0 100%; background-repeat: no-repeat;
  transition: background-size .35s var(--ease), color .25s;
  padding-bottom: 2px;
}
.footer__list a:hover { background-size: 100% 1px; color: var(--ink); }

/* The one horizontal rule in the panel, and it earns it: it separates the
   studio's own record from everything above it. */
.footer__bottom {
  flex: none;
  display: flex; justify-content: space-between; align-items: baseline;
  gap: 16px 40px; flex-wrap: wrap;
  margin: 0 var(--gut); padding-top: clamp(16px, 2.4vh, 26px);
  border-top: 1px solid var(--rule-faint);
  /* 11.5px was under the floor for anything set as a sentence rather than as a
     label, and the terms here are a sentence somebody may actually have to
     read. 12.5px is the smallest size that is still body copy. */
  font-size: 12.5px; color: var(--muted-faint);
}
/* Set at reading size, not as a caps label: it is a sentence somebody may
   actually have to read, and the mark opposite is the label. */
.footer__rights { flex: 1 1 420px; max-width: 62ch; line-height: 1.7; }
.footer__copy { flex: none; text-transform: uppercase; letter-spacing: 0.1em; }

/* ------------------------------------------------------------- responsive */

/* One column below the split: the nav goes over the details rather than beside
   them, and the right track gives up its margin and ranges left with
   everything else. */
@media (max-width: 900px) {
  .footer__field {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(26px, 4vh, 48px); align-content: center;
  }
  .footer__lead, .footer__side { justify-content: flex-start; }
  .footer__side { align-items: flex-start; text-align: left; }
  .footer__ask { align-items: flex-start; }
  .footer .footer__nav a { font-size: clamp(45px, 11.5vw, 87px); }
  .footer__socials { gap: clamp(24px, 6vw, 32px); }
}
@media (max-width: 720px) {
  .footer__cta { min-width: 0; width: 100%; }
  .footer__row { flex-direction: column; align-items: flex-start; gap: 6px; }
  .footer__row dd { text-align: left; }
  .footer__row a { background-position: 0 100%; }
}

/* A curtain cannot be taller than the opening it is drawn from: anything the
   panel cannot fit is simply never seen, so below this height it is set
   tighter rather than being allowed to overrun. */
@media (max-height: 780px) {
  .footer { padding-top: clamp(28px, 4vh, 56px); }
  .footer__lead, .footer__side { gap: clamp(14px, 2.2vh, 26px); }
  .footer .footer__nav a { font-size: clamp(42px, 5vw, 87px); line-height: 1.1; }
  .footer__rows { gap: clamp(14px, 3.4vh, 34px); }
  .footer__cta { padding: 13px 28px; }
  .footer__chip { padding: 8px 15px; }
  .footer__mark { width: clamp(44px, 3.2vw, 60px); }
}

/* Phone: the panel is fitted rather than scrolled.
   Stacked into one column, this panel stands 1136px against an 844px screen,
   and the release valve below then hides the address, the mail and the
   copyright behind a scroll inside a fixed element — a gesture a thumb has no
   reason to try and, on a smoothed page, often does not answer. So the 292px
   is taken back here instead.
   Every figure below is the desktop one reduced, not a new idea: the display
   nav comes down to a size a phone reads as a list rather than as a poster,
   the airy gaps between the three detail rows close to the gap already used
   inside a row, and the panel's own opening halves. Nothing is dropped, which
   is the point — the terms chips and the copyright are the parts that were
   being lost.
   Placed after the max-height block above deliberately: on a short phone both
   match, and the later rule is the one that should win. */
@media (max-width: 700px) {
  .footer { padding: 28px 0 14px; }
  .footer__field { row-gap: 16px; }
  .footer__lead, .footer__side { gap: 14px; }
  .footer__mark { width: 44px; }
  .footer .footer__nav a { font-size: 28px; line-height: 1.16; }
  .footer__chips { gap: 8px; }
  .footer__chip { padding: 7px 13px; }
  .footer__rows { gap: 13px; }
  /* Back to a label-and-value row. Stacking these was the right call while the
     panel could scroll, but a label on its own line costs about 23px three
     times over, and here that is the difference between the copyright being on
     the screen and being behind a scroll. The label is 11px and the value is
     ranged right, so the pair still reads as an address rather than as a
     sentence. */
  .footer__row { flex-direction: row; align-items: baseline; gap: 16px; }
  .footer__row dd { text-align: right; }
  .footer__bottom { gap: 10px; }
  .footer__rights { font-size: 12px; line-height: 1.5; }
}

/* The short phones, and the tall ones while the browser's toolbar is still
   showing — the panel is the visual viewport now, so it is this height that
   the fit has to clear, not the screen's. One more pass over the same figures
   rather than anything new: 54px, taken from the opening, the gaps and one
   step of the nav. */
@media (max-width: 700px) and (max-height: 720px) {
  .footer { padding: 20px 0 10px; }
  .footer__field { row-gap: 12px; }
  .footer__lead, .footer__side { gap: 10px; }
  .footer .footer__nav a { font-size: 24px; }
  .footer__chips { gap: 6px; }
  .footer__rows { gap: 10px; }
}

/* Phone: what a thumb can hit, and what an eye can read.
   Four findings from a pass over the 390px layout against WCAG 2.2 and the
   platform touch guidance. Every rule is inside the phone query, so the
   desktop is untouched by construction — none of this is read above 700px.

   The tap areas are grown with an invisible ::after rather than with padding.
   Padding would grow the boxes, and the footer panel is fitted to the screen
   to the pixel — a taller row there would put the copyright back behind a
   scroll, which is the thing that was just fixed. The overlay costs no layout
   at all: it is a transparent rectangle centred on the control.

   Sizes are chosen against the room actually available. 44px where the
   neighbours are far enough away, and in the footer 30px, because the rows sit
   13px apart and two 44px areas there would overlap into each other — a
   mis-tap is worse than a small target. 30px still clears the 24px WCAG 2.2
   minimum, where 21px did not. */
@media (max-width: 700px) {
  .burger, .featured__marker, .gallery__filter, .gallery__discipline,
  .footer__row a { position: relative; }

  /* ::before, not ::after — .featured__marker already spends its ::after on the
     underline that says which project is showing, and taking that over left the
     rail with no active mark at all. None of the five uses ::before. */
  .burger::before,
  .featured__marker::before,
  .gallery__filter::before,
  .gallery__discipline::before,
  .footer__row a::before {
    content: "";
    position: absolute; left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    /* Never narrower than the control it covers, so a wide chip keeps its own
       width and only gains height. */
    width: 100%; min-width: var(--tap, 44px); height: var(--tap, 44px);
  }
  /* The two rows sit 13px apart — see the note above. */
  .footer__row a::before { --tap: 30px; }

  /* The resting grey measured 2.93:1 against the anthracite — under the 3:1
     WCAG asks for at type this size, and on a phone five of the six words are
     resting at any moment. 52% lands at 3.46:1 and keeps the lit word (98%)
     clearly ahead of the rest, which is what the stack is for. */
  .service__title { color: oklch(52% 0.008 60); }
}

/* The release valve. main.js sets this when the panel's contents do not fit:
   the panel scrolls inside itself rather than dropping an address or clipping
   the terms. The curtain is unaffected, since the panel is still exactly the
   height of the opening. */
.footer.is-tight { overflow-y: auto; overscroll-behavior: contain; }
/* The field stops being the compressed flex child and takes the height its
   contents need; the panel scrolls to reach the rest. */
.footer.is-tight .footer__field { flex: 0 0 auto; align-items: start; }
/* ==========================================================================
   Reveal + keyframes
   ========================================================================== */

[data-reveal] {
  opacity: 0; transform: translateY(56px);
  transition: opacity .9s var(--ease), transform .9s var(--ease);
}
[data-reveal].is-visible { opacity: 1; transform: translateY(0); }

@keyframes caretBlink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes heroImgZoom { from { transform: scale(1); } to { transform: scale(1.06); } }

.slot--hero.is-filled img { animation: heroImgZoom 14s ease-in-out infinite alternate; }

/* ==========================================================================
   Responsive
   ========================================================================== */

/* The paragraph now holds the headline's full measure, which pushes the pill
   beside it further left as the frame narrows. Below this width there is no
   longer clear frame between the projects card and the paragraph for it to
   stand in, so it drops back under the paragraph, still flush right. */
@media (max-width: 1700px) {
  .band__lead-row { display: block; }
  .band__blurb { margin-left: auto; }
  /* Tighter than the pill's old standoff: stacking gives the group back a row
     of height, and the frame has none to spare at these widths. */
  .band__pill { margin-top: clamp(18px, 1.7vw, 28px); }
}

/* The record folds earlier than the rest of the page. At the larger type it
   needs about 180px of content per column for "Collaborations" to hold one
   line, and four columns plus the indent stop clearing that at ~1300px — well
   above the 1100px the other grids fold at. Measured, not guessed: at 1125px
   the word was overrunning its cell by 8px and being clipped. */
@media (max-width: 1300px) {
  .record { --record-indent: 0px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .record__cell { border-top: 1px solid var(--rule-faint); }
  .record__cell:nth-child(-n+2) { border-top: 0; }
}

/* Below this a 2×2 leaves the label short again, so the record goes to one
   entry per line. */
@media (max-width: 560px) {
  .record { grid-template-columns: minmax(0, 1fr); }
  .record__cell:nth-child(2) { border-top: 1px solid var(--rule-faint); }
}

@media (max-width: 1100px) {
  .nav--desktop { display: none; }
  .burger { display: grid; }
  .brand__word { font-size: 34px; }

  /* Four entries across is unreadable at tablet width; they fold to a 2×2 and
     the dividing rules turn with them. The indent goes too — at these widths
     it would eat a third of the row. */

  /* single column would otherwise stretch the 3:4 portrait past 1000px tall */
  .slot--portrait { max-width: 380px; }
}

@media (max-width: 780px) {
  .hero__headline-row { flex-direction: column; align-items: flex-start; gap: 28px; }
  .btn--pill { align-self: flex-start; }

  .band--hero { height: 110vh; }
  .band__word { margin-top: 0; }
  /* Shorter again on a phone, where a scroll gesture covers less ground.
     .is-live, not a bare .xp: the upgraded rule is what sets the height, and a
     lower-specificity override here would never reach it. */
  .xp.is-live { height: 215vh; }
  /* the desktop 200px offset would carry the card off a phone-width frame */
  .band__card { left: 5.1%; }

  /* Four marks plus two corner columns do not fit at phone width at desktop
     sizes; the rail is the only control, so it shrinks rather than wraps. */
  .featured__marker { font-size: 18px; padding: 6px; }
  .featured__corner { font-size: 10px; letter-spacing: 0.12em; }

  .work-intro__row { flex-direction: column; align-items: flex-start; }
  .work-intro__lead { margin-left: 0; }
  /* No curtain on a phone. The panel already fills most of a narrow viewport,
     so holding it and then holding the gallery on top of that just makes the
     page feel heavy to scroll. */
  .work-intro { position: static; }
  /* Specificity, not source order, decides this one: the has-smoother rule
     that hands the panel to ScrollTrigger outranks a bare .work-intro here,
     so the phone opt-out has to be stated at the same weight or it never
     lands. */
  body.has-smoother .work-intro { position: static; top: auto; }
  /* Short lines make justified gaps ugly on a phone — back to a plain
     centred rag at this width. */
  .quote__text { max-width: none; text-align: center; text-align-last: auto; }
}

@media (hover: none) {
  /* Coarse pointers never get the custom cursor — belt and braces alongside
     the JS pointer check. */
  .cursor { display: none; }
}

@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  [data-reveal] { opacity: 1; transform: none; }
  .statement__text .word { opacity: 1; }
  /* The numerals still land on the right digit — the transition is what goes,
     not the end state — and the labels skip their rise. */
  .record__label span { transform: none; }
  /* Not restated for the phone: the band is one of the two things the phone
     keeps, so its transform belongs to the script there. Under true
     reduced-motion the script never builds it and the line stands still on its
     own — see the rule below this block. */
  /* The band is never pinned for these readers (initStatement bails on
     reduced-motion), so the paragraph is simply already at full size, in
     focus, with every word up. The colour turn is skipped the same way: with
     --quote-reveal never written, both overlays fall back to their light
     defaults and the band renders as the light ground it ends on. */
  .quote__text { transform: none; filter: none; }
  .qw { opacity: 1; transform: none; }
  .cw { opacity: 1; transform: none; filter: none; }
  .band__word, .band__word-line { opacity: 1; transform: none; clip-path: none; }
  .band__project, .band__blurb, .band__card { opacity: 1; transform: none; }
  .band__pill { clip-path: none; }
  .band__pill { opacity: 1; transform: none; }
  /* Guarded for the same reason the other statement of this rule is: the wall
     is the one reveal the phone keeps, so "already arrived" is the state for a
     wall no script has claimed, not for every wall at this width. */
  .closing__wall:not(.is-js) .slot--logo { opacity: 1; transform: none; }
  .work-intro { position: static; }
  /* Same specificity problem the phone block solves: has-smoother's rule
     (0,2,1) outranks a bare .work-intro (0,1,0), so the reduced-motion
     opt-out has to be restated at that weight or initSmoother's class wins. */
  body.has-smoother .work-intro { position: static; top: auto; }
  /* Nothing advances on its own here, so a bar counting down to a change that
     never comes would be a lie about what the page is going to do. */
  .featured__timer { display: none; }

  /* The transition panel is motion with nothing under it but a page load, so
     it is dropped outright rather than played instantly — there is no state it
     needs to leave behind. The menu still has to open, so it keeps its two end
     states and simply arrives at the open one with no travelling edge. */
  .js .page-curtain { display: none; }
  .menu-overlay__nav a span { opacity: 1 !important; transform: none !important; }
  /* The card's two end states are in the stylesheet above, so nothing extra is
     needed to open it here — it simply arrives with no travelling edge. */
  .menu-panel__shot { transition: none; }
}

/* The band's own still state, for the reader who asked for it rather than for
   the width. main.js leaves the marquee unbuilt at this setting, so there is
   no transform to cancel on the home page — this is here for a stale inline
   one left behind by a resize into the setting mid-session. */
@media (prefers-reduced-motion: reduce) {
  .achievements__marquee { transform: none !important; }
}

/* ==========================================================================
   connect.html — the brief panel
   ==========================================================================
   Additive only. Everything below is scoped to .connect-page or to a class
   that appears on no other page, so nothing here can reach the four pages that
   were built first. The tokens, .btn and .page-curtain above are shared; that
   is the whole of the overlap, and it is what makes this screen belong to the
   studio without being wired into it.

   The screen is a panel rather than a page: no header, no menu, no footer, no
   scroll of its own on a normal window. The type holds the bottom-left corner
   and the form the right column, which is the shape of the reference and, more
   usefully, the shape of the sentence — the promise on the left, the answer to
   it on the right. */

/* No smooth-wrapper here, so the page scrolls natively. svh rather than vh:
   on a phone the address bar makes vh taller than the screen actually is, and
   a panel built to fill the window should not need scrolling to see its own
   floor. */
.connect-page {
  min-height: 100svh;
  overflow-x: hidden;
}

/* GSAP is not loaded on this page, so the curtain travels on a transition
   rather than on a tween. Same two end states main.js drives it between, and
   the same eased curve, so arriving here is the move the other pages make. */
.connect-page .page-curtain { transition: clip-path .75s var(--ease); }
.connect-page .page-curtain.is-closing {
  clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
  transition-duration: .45s;
}

/* Fixed to the window rather than allowed to grow: the form is longer than a
   screen, and if the page took that scroll the headline would slide off the
   floor and the whole thing would stop being a panel and start being a page.
   The panel below takes the scroll instead, so the type and CLOSE stay put and
   only the fields move. */
/* Three columns: the greeting, then the form split in two. Split because the
   form stacked in one column is taller than any laptop screen, and a panel you
   have to scroll to find the button of is a page wearing a panel's clothes.
   Side by side it ends about 110px short of the floor on a 720px window, which
   is the whole point of the arrangement.
   The type column is the narrowest and gives way first — the two form columns
   are the ones that have to keep a usable measure. */
.connect {
  position: relative;
  height: 100svh;
  overflow: hidden;
  display: grid;
  grid-template-columns: minmax(0, 0.95fr) minmax(0, 1fr) minmax(0, 1fr);
  align-items: end;
  gap: clamp(28px, 3.4vw, 72px);
  padding: clamp(64px, 8vh, 110px) var(--gut) clamp(44px, 5.5vh, 72px);
}

/* ----------------------------------------------------------- the mark */

/* .brand is written for the header's centre track, so the two rules that put
   it there have to be undone before it can hold a corner. Fixed like CLOSE and
   on the same line as it, which is what makes the two read as the frame around
   the panel rather than as two loose objects. */
.connect__brand {
  position: fixed; z-index: 10;
  top: clamp(18px, 2.6vh, 34px); left: var(--gut);
  grid-column: auto; justify-self: auto;
  align-items: flex-start;
}
.connect__brand .brand__slot { height: clamp(44px, 4.4vw, 74px); }

/* ---------------------------------------------------------------- close */

/* Held to the corner rather than placed in a bar, because there is no bar. The
   word carries it and the cross repeats it — the same pairing the reference
   uses, and the reason it reads as an exit rather than as a delete. */
.connect__close {
  position: fixed; z-index: 10;
  top: clamp(18px, 2.6vh, 34px); right: var(--gut);
  display: inline-flex; align-items: center; gap: 14px;
  padding: 10px 10px 10px 22px;
  border-radius: 999px;
  font-family: var(--f-display); font-weight: 600;
  font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink);
  background: oklch(18% 0.01 60 / 0.06);
  transition: background .3s var(--ease), color .3s var(--ease);
}
.connect__close:hover { background: var(--ink); color: var(--bg); }
.connect__close-mark {
  display: grid; place-items: center;
  width: 34px; height: 34px; border-radius: 999px;
  background: var(--bg); color: var(--ink);
}
.connect__close-mark svg { width: 17px; height: 17px; }

/* ---------------------------------------------------------- the intro */

/* The greeting and the headline as one block, held to the floor of the column.
   They travel together: the sentence is the headline's second line as far as
   the reader is concerned, and separating them would put a paragraph adrift in
   the middle of an empty half-screen. */
/* The column runs the full height and the two things in it hold its two ends:
   the sentence parked under the mark, the headline on the floor. They used to
   travel together at the bottom, which left the sentence stranded in the
   middle of the column with the mark alone above it.
   The padding is what keeps it clear of the mark — the mark is fixed and out of
   the flow, so nothing else here knows how tall it is. */
.connect__intro {
  grid-column: 1;
  align-self: stretch;
  display: flex; flex-direction: column; justify-content: space-between;
  gap: clamp(20px, 3vh, 40px);
  padding-top: clamp(46px, 5.5vh, 80px);
  min-width: 0;
}

/* ----------------------------------------------------------- the type */

/* Sits on the floor of its column, which is what makes the empty half of the
   screen read as room rather than as a gap. Two lines, the second heavy: the
   footer's slab makes the same sentence the same way, and this is that
   sentence at the moment it is being answered.
   Sized by clamp and not by the measuring pass main.js runs on the footer —
   that file is not loaded here, and two words do not need fitting. */
.connect__type {
  margin: 0;
  font-family: var(--f-display); font-weight: 300;
  font-size: clamp(64px, 11vw, 190px);
  line-height: 0.86; letter-spacing: -0.04em;
  color: var(--ink);
}
.connect__type-line { display: block; }
.connect__type-line--em { font-weight: 700; }

/* ---------------------------------------------------------- the panel */

/* Spans both form columns. The overflow rules are a backstop and not the plan:
   at the sizes this is built for the form fits, but a window short enough to
   cut the button off must give it back rather than hide it. min-height:0 is
   what allows a flex item to overflow inside itself at all. */
.connect__panel {
  grid-column: 2 / 4;
  align-self: stretch;
  /* Started at the top rather than centred on the window. The mark and CLOSE
     are the line the panel hangs from; centred, the first label floated
     somewhere below both of them with nothing to line up against. */
  display: flex; flex-direction: column; justify-content: flex-start;
  padding-top: clamp(38px, 5vh, 66px);
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--rule-strong) transparent;
}
.connect__panel::-webkit-scrollbar { width: 6px; }
.connect__panel::-webkit-scrollbar-thumb {
  background: var(--rule-strong); border-radius: 999px;
}
.connect__panel::-webkit-scrollbar-track { background: transparent; }

/* The one line of prose on the screen. Held to seven tenths of its column so
   it reads as a note pinned under the mark rather than as a paragraph filling
   the width the headline below it fills — the two are not the same kind of
   thing and should not measure the same.
   The size comes down with the width. At the old 26px this measure would have
   run to eight short lines; this keeps it to five. */
.connect__lead {
  margin: 0;
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(16px, 1.35vw, 21px);
  line-height: 1.36; letter-spacing: -0.01em;
  color: var(--muted-body);
  max-width: 70%;
}

/* ----------------------------------------------------------- the form */

/* Two columns and a full-width foot under them. The columns come from the two
   .connect-form__col wrappers rather than from dealing the fieldsets into grid
   cells: each wrapper flows on its own, so the shorter column ends early
   instead of stretching a group to match the one beside it. */
.connect-form {
  /* Half the distance between the columns. The gap carries one half and the
     second column's own padding the other, so the rule between them — drawn on
     that column's left edge — lands exactly on the middle. */
  --form-split: clamp(18px, 1.8vw, 38px);
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-content: start;
  gap: clamp(20px, 2.6vh, 34px) var(--form-split);
}
/* A `display` of any kind beats the UA's own `[hidden] { display: none }`, so
   both of the blocks that get hidden here have to say so themselves. Without
   this the acknowledgement sits under the form from the moment the page loads,
   and the form stays put after it has been sent. */
.connect-form[hidden] { display: none; }
.connect-form__col {
  display: flex; flex-direction: column;
  gap: clamp(18px, 2.4vh, 30px);
  min-width: 0;
}
/* The rule between the two halves. On the second column rather than as a
   pseudo-element on the form, so it is exactly as tall as the columns are —
   the grid stretches them to match, so it runs from the first label to the
   button and stops. */
.connect-form__col + .connect-form__col {
  border-left: 1px solid var(--rule-strong);
  padding-left: var(--form-split);
}
/* Pushed to the foot of its column rather than sitting under the last field:
   whichever column ends first, the button stays on the floor of the block. */
.connect-form__foot { margin-top: auto; }

/* fieldset for the grouping the labels imply anyway — it is what puts the
   three legends in the accessibility tree as the headings they read as. Its
   own frame and padding are dropped; the legend and the rules under the fields
   are the whole of the structure. */
.connect-form__group {
  margin: 0; padding: 0; border: 0;
  display: flex; flex-direction: column;
  gap: clamp(11px, 1.5vh, 18px);
}
/* The footer's own label, at the footer's own size: three of these down the
   panel are what make it three groups rather than one long list. */
.connect-form__legend {
  padding: 0; margin: 0 0 clamp(4px, 0.8vh, 10px);
  font-family: var(--f-display); font-size: 11px; font-weight: 700;
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink);
}

/* Web3Forms' spam trap. Taken out of the layout and out of the reading order
   without display:none, which some bots check for. */
.connect-form__trap {
  position: absolute; left: -9999px;
  width: 1px; height: 1px; opacity: 0;
}

.field { display: flex; flex-direction: column; gap: 5px; }
.field__label {
  font-family: var(--f-display); font-size: 12px; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--muted);
}

/* A ruled line rather than a box. The footer's lists, the menu's rows and the
   hero's rule are all one hairline over the cream; a form of outlined boxes
   would be the only framed thing on the site. The rule darkens on focus and
   the box never moves, so the page does not shift as it is filled in. */
.field__input {
  width: 100%;
  padding: 8px 2px;
  border: 0; border-bottom: 1px solid var(--rule-strong);
  border-radius: 0;
  background: none;
  color: var(--ink);
  font-family: var(--f-body); font-size: clamp(15px, 1.15vw, 17px);
  font-weight: 400; line-height: 1.4;
  transition: border-color .3s var(--ease);
  /* Safari puts its own inset shadow and radius on inputs; both are wrong for
     a line. */
  -webkit-appearance: none; appearance: none;
}
.field__input::placeholder { color: var(--muted-faint); }
.field__input:hover { border-bottom-color: var(--muted); }
.field__input:focus { outline: none; border-bottom-color: var(--ink); }
/* The focus mark is on the rule, which is the whole control — an outline
   around the box would draw a rectangle the field does not have. The extra
   pixel of rule is taken out of the padding so nothing below it moves. */
.field__input:focus-visible {
  outline: none;
  border-bottom-width: 2px;
  padding-bottom: 7px;
}
/* :user-invalid and not :invalid — a field is not wrong while it is still
   being typed into, only once it has been left or submitted. */
.field__input:user-invalid { border-bottom-color: oklch(52% 0.17 25); }

/* Tied to the window's height rather than pinned: the message box is the one
   field that can give ground, and on a short screen it is what buys the button
   its place above the floor. */
.field__input--area {
  resize: vertical;
  min-height: clamp(78px, 13vh, 150px);
  height: clamp(78px, 13vh, 150px);
  padding-top: 10px;
}

/* select needs its own arrow: appearance:none takes the native one with it,
   and without a mark the control reads as a text field that will not type. */
select.field__input {
  padding-right: 26px;
  background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
                    linear-gradient(135deg, var(--muted) 50%, transparent 50%);
  background-position: right 12px center, right 7px center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  cursor: pointer;
}
select.field__input option { color: var(--ink); background: var(--bg); }

/* 16px is a threshold, not a preference: Safari zooms the whole page the moment
   a field under it takes focus, and the layout is thrown for the rest of the
   form. The clamp above resolves to 15px at phone width, which is on the wrong
   side of it. Written here rather than with the other phone rules because it
   has to come after that clamp to win — both are single-class selectors, so
   source order decides. The desktop is untouched: at 1425px the clamp already
   resolves above 16. */
@media (max-width: 700px) {
  .field__input { font-size: 16px; }
}

/* Two boxes under one label. The code is sized to its own content and the
   number takes the rest, so the pair reads as one field split rather than as
   two fields that happen to be adjacent. */
.field__row { display: flex; align-items: flex-end; gap: 14px; }
.field__row > .field__input { flex: 1 1 auto; }
.field__input--code { flex: 0 0 auto; width: auto; min-width: 8.5em; }

/* The unit belongs to the number, so it sits on the same rule rather than
   beside the field. */
.field__row--unit { gap: 8px; }
.field__unit {
  padding-bottom: 11px;
  border-bottom: 1px solid var(--rule-strong);
  font-family: var(--f-display); font-size: 14px; color: var(--muted);
}

.connect-form__foot {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: 14px clamp(18px, 2vw, 28px);
  margin-top: clamp(2px, 0.6vh, 10px);
}
.connect-form.is-sending .connect-form__submit { pointer-events: none; opacity: 0.55; }
.connect-form__status {
  margin: 0;
  font-family: var(--f-display); font-size: 13px; letter-spacing: 0.02em;
  color: var(--muted);
}
.connect-form__status.is-error { color: oklch(48% 0.17 25); }

/* ------------------------------------------------- the acknowledgement */

/* Takes the form's place rather than appearing under it, so the screen after
   sending is about the one thing that just happened. */
.connect-done {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: clamp(14px, 1.8vh, 22px);
  outline: none;
}
.connect-done[hidden] { display: none; }
.connect-done__mark {
  margin: 0;
  display: grid; place-items: center;
  width: 46px; height: 46px; border-radius: 999px;
  background: var(--ink); color: var(--bg);
  font-size: 20px; line-height: 1;
}
.connect-done__title {
  margin: 0;
  font-family: var(--f-display); font-weight: 400;
  font-size: clamp(24px, 2.2vw, 34px); line-height: 1.2;
  letter-spacing: -0.01em;
}
.connect-done__body {
  margin: 0; max-width: 34em;
  font-size: clamp(15px, 1.1vw, 17px); line-height: 1.6;
  color: var(--muted-body);
}
.connect-done__body a { text-decoration: underline; text-underline-offset: 3px; }
.connect-done__back { margin-top: clamp(6px, 1vh, 14px); }
.connect-done__back:hover { background: var(--ink); color: var(--bg); }

/* -------------------------------------------------------------- narrow */

/* One column below this: the type stops being a column beside the form and
   becomes the thing above it, which is the same order it is read in on a wide
   window. It also stops holding the floor, so the panel starts where the
   reader's eye already is. */
/* Below this the two form columns are narrower than the fields they hold —
   the phone pair in particular — so the form goes back to one column and the
   screen to two. The panel takes its own scroll again, which is what it is
   there for. */
@media (max-width: 1150px) {
  .connect { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); }
  .connect__panel {
    grid-column: 2;
    justify-content: flex-start;
    padding-right: 14px; padding-bottom: clamp(16px, 3vh, 40px);
  }
  .connect-form { grid-template-columns: minmax(0, 1fr); }
  /* One column, so there is nothing for the rule to divide. */
  .connect-form__col + .connect-form__col { border-left: 0; padding-left: 0; }
  .connect-form__foot { margin-top: 0; }
}

@media (max-width: 1000px) {
  /* The document takes the scroll back here. In one column the headline is
     above the form rather than beside it, so scrolling past it is the right
     thing to do — pinning it would only cost the form the top third of the
     screen on every field. */
  .connect {
    grid-template-columns: minmax(0, 1fr);
    height: auto; min-height: 100svh; overflow: visible;
    align-items: start;
    gap: clamp(28px, 4vh, 48px);
    padding-top: clamp(96px, 12vh, 140px);
  }
  /* The headline goes first here. Beside the form it is the room you are
     standing in and the sentence introduces it; stacked above the form it is
     the title of the screen, and a title under its own subtitle reads as an
     afterthought. */
  /* Stacked, the column is only as tall as its contents, so there are no two
     ends to hold and nothing to push apart. The sentence also takes the full
     measure back — seven tenths of a full-width column is a ribbon. */
  .connect__intro {
    grid-column: 1; align-self: start;
    justify-content: flex-start; padding-top: 0;
  }
  .connect__lead { order: 2; max-width: 34em; }
  .connect__type { order: 1; font-size: clamp(56px, 15vw, 120px); }
  .connect__panel {
    grid-column: 1; justify-content: flex-start;
    overflow: visible; padding-right: 0; padding-bottom: 0;
  }
}

@media (max-width: 560px) {
  /* The word goes and the cross carries it alone: at this width the label was
     taking a third of the row the headline needs. */
  .connect__close { padding: 6px; gap: 0; }
  .connect__close > span:first-child { display: none; }
  /* The code select on its own row: side by side, neither half had the width
     to show what it held. */
  .field__row { flex-wrap: wrap; }
  .field__input--code { width: 100%; min-width: 0; }
}

/* Same bargain the rest of the file strikes: the curtain is motion with
   nothing under it but a page load, so it is dropped rather than played. */
@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  .connect-page .page-curtain { display: none; }
  .field__input, .connect__close { transition: none; }
}

/* ==========================================================================
   work.html — the project gallery
   Replaces the four-panel .featured carousel on this page only. .featured
   itself is untouched: index.html still runs it as its Selected Work section,
   and both its CSS block above and its carousel in main.js are shared.
   ========================================================================== */

/* The section spans the page and pads its own children, rather than padding
   itself: .gallery__bar needs its rule to reach both edges of the viewport,
   and a padded parent would inset it by a gutter on each side. */
.gallery { padding: clamp(48px, 6vw, 96px) 0 0; }

.gallery__head {
  padding: 0 var(--gut) clamp(36px, 4.5vw, 72px);
  text-align: center;
}

/* Full ink, at the largest size on the page: this names what the page is, and
   it holds that on its own rather than deferring to the discipline row under
   it. The two disciplines still separate themselves from each other by colour —
   the live one is ink and the other is pale — so the choice is as legible as it
   was; the title simply stops being the palest thing above them. */
.gallery__title {
  font-family: var(--f-display);
  font-size: clamp(38px, 7vw, 96px);
  font-weight: 700; line-height: 1;
  letter-spacing: -0.015em; text-transform: uppercase;
  color: var(--ink);
  margin: 0 0 clamp(6px, 0.8vw, 14px);
}

.gallery__disciplines {
  display: flex; align-items: baseline; justify-content: center;
  gap: clamp(10px, 1.2vw, 18px); flex-wrap: wrap;
}

.gallery__discipline {
  appearance: none; background: none; border: 0; padding: 0;
  font-family: var(--f-display);
  font-size: clamp(26px, 5vw, 64px);
  font-weight: 700; line-height: 1.05;
  letter-spacing: -0.02em;
  color: oklch(78% 0.01 60);
  cursor: pointer;
  transition: color .35s var(--ease);
}
.gallery__discipline:hover { color: var(--muted); }
.gallery__discipline.is-active { color: var(--ink); }

.gallery__slash {
  font-family: var(--f-display);
  font-size: clamp(26px, 5vw, 64px);
  font-weight: 300; line-height: 1.05;
  color: oklch(80% 0.01 60);
}

/* In flow, and it scrolls away with the head it belongs to. It was sticky, held
   under the header for the length of the grid — but a bar parked over the work
   is a bar sitting on top of the photographs for the whole of the page, and the
   filter is a choice made once on the way in rather than a control that has to
   stay in reach at row twelve. The head is a short scroll back up.
   The hairlines above and below are this element's own borders, so they leave
   with it. */
.gallery__bar {
  position: relative; z-index: 4;
  display: flex; align-items: center; justify-content: flex-start;
  gap: clamp(16px, 3vw, 40px);
  padding: 14px var(--gut);
  background: var(--bg);
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
}

/* Each card repaints inside its own box. Sixteen rounded, clipped photographs
   under a layer the smoother transforms every frame is the one heavy thing on
   this page; without this, a change in any of them invalidates the paint of the
   grid around it. */
.pcard__media { contain: paint; }

.gallery__filters {
  display: flex; align-items: center;
  gap: clamp(16px, 2.4vw, 36px);
  overflow-x: auto; scrollbar-width: none;
}
.gallery__filters::-webkit-scrollbar { display: none; }

.gallery__filter {
  appearance: none; background: none; border: 0; padding: 2px 0;
  font-family: var(--f-display);
  font-size: 17.25px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--muted-faint);
  white-space: nowrap; cursor: pointer;
  /* The underline is a transparent border at rest rather than a border added
     on selection: added, it would grow the button by a pixel and nudge every
     tab after it sideways each time the choice changed. */
  border-bottom: 1px solid transparent;
  transition: color .3s var(--ease), border-color .3s var(--ease);
}
.gallery__filter:hover { color: var(--muted-mid); }
.gallery__filter.is-active { color: var(--ink); border-bottom-color: var(--ink); }

/* Two columns, and the row gap is the larger of the two: the meta line sits
   directly under its own image, so the vertical space has to separate pairs
   that the horizontal space does not. */
.gallery__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(18px, 2.4vw, 40px);
  row-gap: clamp(44px, 5vw, 88px);
  padding: clamp(40px, 5vw, 76px) var(--gut) var(--sec-y);
}

.pcard { min-width: 0; }

/* The whole card is the hit area — the photograph and the line under it both
   go to the same project, and a link that covers only one of them makes the
   other look like it belongs to something else.
   Colour is held rather than inherited from the global `a:hover`: the meta row
   is set in three deliberate values (ink name, muted category, faint year) and
   an accent wash over all three on hover flattens that back to one. The card
   answers with the zoom below instead. */
.pcard__link { display: block; color: inherit; }
.pcard__link:hover { color: inherit; }
/* display:none, not visibility or opacity: the grid has to close over a
   filtered-out card, and anything that leaves the box in flow would leave a
   hole where the project used to be. */
.pcard.is-hidden { display: none; }

.pcard__media {
  position: relative;
  aspect-ratio: 16 / 9;
  border-radius: clamp(10px, 1vw, 18px);
  overflow: hidden;
}

/* The frame is fixed and the photograph moves inside it. Scaling the card
   itself would push its neighbour and drag the whole row. */
.pcard__media .slot img { transition: transform 1.1s var(--ease); }
.pcard:hover .pcard__media .slot img { transform: scale(1.04); }

/* Baseline, not centre: the name and the year are set at different sizes, and
   aligning their boxes would leave the year floating above the name's foot. */
.pcard__meta {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px;
  margin-top: clamp(12px, 1.2vw, 18px);
}
.pcard__name {
  font-family: var(--f-display);
  font-size: clamp(20.3px, 1.61vw, 25.2px); font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.005em; color: var(--ink);
  min-width: 0; overflow-wrap: anywhere;
}

/* The slash carries its own spacing rather than sitting in a gap on the name:
   the pair has to be able to break onto two lines on a narrow card, and a
   margin on the divider keeps the category clear of the name whichever line it
   lands on. Set light and pale, like the one between the two disciplines in the
   heading — it separates the two readings, it is not a third one. */
.pcard__slash {
  font-weight: 300;
  color: oklch(80% 0.01 60);
  margin: 0 0.4em;
}

/* Same size as the name, quieter: what the project is called is the line, and
   what kind of project it is qualifies it. Dropping the size instead would set
   up a third step in a meta row that already has two. */
.pcard__cat {
  font-weight: 400;
  color: var(--muted);
}

.pcard__year {
  font-family: var(--f-display);
  font-size: clamp(16.8px, 1.26vw, 18.9px); font-weight: 500;
  line-height: 1.15;
  color: var(--muted-faint); flex: none;
  font-variant-numeric: tabular-nums;
}

@media (max-width: 900px) {
  /* One column. Two columns of 16:9 at this width leave each photograph too
     short to read as a room. */
  .gallery__grid { grid-template-columns: minmax(0, 1fr); row-gap: clamp(36px, 7vw, 56px); }
}

@media (max-width: 560px) {
  .gallery__bar { padding: 12px var(--gut); }
  /* The tabs are set a size and a half against the grid below them; at this
     width that row no longer fits and scrolls sideways, so it is brought back
     towards its old size rather than left to be swiped through. */
  .gallery__filter { font-size: 14px; letter-spacing: 0.12em; }
  .pcard__meta { gap: 12px; }
}

/* The hover zoom and the filter transition are both motion over a still page,
   so both are dropped. JS checks the same query before it animates anything. */
@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  .pcard__media .slot img,
  .gallery__filter,
  .gallery__discipline { transition: none; }
  .pcard:hover .pcard__media .slot img { transform: none; }
}


/* ==========================================================================
   Studio page

   The page is a magazine spread rather than a second home page: one
   photograph at the top, two portraits a third of the way down, one wall of
   marks at the end, and running type between them. Nothing here is pinned and
   nothing scrubs sideways — the only kinetic element is the opening headline.

   Everything is prefixed .studio- except the team spread (.crew) and the two
   list objects (.whygrid, .tcard), which are named for what they are because
   they would work on any page that wanted them.
   ========================================================================== */

/* ---------- hero: the one full-bleed photograph ---------- */

/* svh, not vh: on mobile the toolbar's collapse would otherwise leave the
   headline sitting below the fold on first paint and jump up as it retracts. */
.studio-hero {
  position: relative;
  width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2);
  height: 100svh; min-height: 560px;
  /* main reserves the fixed header's height (see `main { padding-top }`), which
     is right for every other page opening — the headline is meant to start
     below the bar. This one is a photograph and has to run under it, or the
     header sits on a strip of page colour and never inverts, because
     updateHeaderGround probes just below the header's own bottom edge and
     would find the gap rather than the picture. Pulled up by exactly the
     padding, so the frame's top edge lands at the top of the document. */
  margin-top: calc(var(--header-h, 104px) * -1);
}

/* Named in main.js's darkGrounds — see the comment there. */
.studio-hero__figure {
  position: relative; margin: 0;
  width: 100%; height: 100%; overflow: hidden;
}

/* Graded rather than flat: the meta line sits at the top and the headline on
   the baseline, so the scrim is heaviest at both ends and lets the middle of
   the photograph — the part actually worth looking at — come through. */
.studio-hero__figure::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(
    to bottom,
    oklch(0% 0 0 / 0.44) 0%,
    oklch(0% 0 0 / 0.16) 34%,
    oklch(0% 0 0 / 0.20) 52%,
    oklch(0% 0 0 / 0.66) 100%);
  pointer-events: none;
}

/* The top pad clears the fixed header; the type below it is laid over the
   picture rather than on a surface of its own, so the whole overlay stays
   out of the hit test and the dev tool's image drops reach the slot. */
.studio-hero__overlay {
  position: absolute; inset: 0; z-index: 3;
  display: flex; flex-direction: column;
  padding: clamp(92px, 12vh, 156px) var(--gut) clamp(26px, 4vh, 54px);
  color: oklch(97% 0.012 75);
  pointer-events: none;
}

.studio-hero__meta {
  display: flex; align-items: center; gap: clamp(14px, 1.6vw, 24px);
  font-family: var(--f-display); font-size: 13px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
}
.studio-hero__loc { display: inline-flex; align-items: center; gap: 8px; }
.studio-hero__since { color: oklch(97% 0.012 75 / 0.62); }

/* margin-top:auto rather than space-between on the column: the headline and
   the cue belong together at the foot of the frame, and space-between would
   float the headline to the middle of the photograph instead. */
.studio-hero__title {
  margin: auto 0 0;
  font-family: var(--f-display); font-weight: 300;
  font-size: clamp(40px, 8.4vw, 136px);
  line-height: 0.95; letter-spacing: -0.035em;
  max-width: 15ch;
}
/* Two weights, one sentence — the same device the footer's line uses: the
   qualification is set light and the claim is set heavy. */
.studio-hero__title b { font-weight: 700; }

.studio-hero__line { display: block; overflow: hidden; }
.studio-hero__line-in { display: inline-block; }

/* Gated on .js so the headline is simply set when no script runs — the closed
   state must never be the state the markup renders in. Timed to arrive as the
   page curtain finishes opening (0.9s, see initPageTransition). */
.js .studio-hero__line-in {
  transform: translateY(112%);
  animation: studioHeroLine 1s var(--ease) forwards;
}
.js .studio-hero__line:nth-child(1) .studio-hero__line-in { animation-delay: 0.55s; }
.js .studio-hero__line:nth-child(2) .studio-hero__line-in { animation-delay: 0.70s; }
@keyframes studioHeroLine { to { transform: translateY(0); } }

.studio-hero__cue {
  align-self: flex-end; margin-top: clamp(16px, 2.2vh, 30px);
  font-family: var(--f-display); font-size: 12px; font-weight: 600;
  letter-spacing: 0.22em; text-transform: uppercase;
  color: oklch(97% 0.012 75 / 0.74);
}

/* ---------- who we are ---------- */

/* No top rule: it would draw a hairline directly under the photograph, which
   reads as an artefact of the image rather than as a section boundary. */
.section.studio-intro {
  border-top: 0;
  padding-top: calc(var(--sec-y) + clamp(20px, 2.6vw, 48px));
}

/* Narrow marker column against a wide measure — the home page sets the same
   copy as one centred block, and running that layout twice would make the two
   pages open identically. */
.studio-intro__grid {
  display: grid;
  grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr);
  column-gap: clamp(28px, 5vw, 88px);
  align-items: start;
}
.studio-intro__side .eyebrow { margin-bottom: clamp(18px, 2.2vw, 30px); }
.studio-intro__lead {
  font-family: var(--f-display); font-weight: 600;
  font-size: clamp(23px, 2.4vw, 36px);
  line-height: 1.18; letter-spacing: -0.022em;
  color: var(--ink);
}

/* .statement__text is the hook initTextReveal splits on, so the class has to
   stay — but its display size is written for a full-width block and would run
   to four words a line in this column. Size only; the scrub is untouched. */
.studio-intro .statement__text {
  font-size: clamp(21px, 2.05vw, 32px);
  line-height: 1.42; letter-spacing: -0.018em;
  max-width: 44ch;
}
.studio-intro__note {
  margin: clamp(24px, 2.8vw, 40px) 0 0;
  font-size: 16.5px; line-height: 1.75;
  color: var(--muted-body); max-width: 58ch;
}

/* ---------- the team: two people, one spread ---------- */

/* Set as a magazine page rather than as a component. Two blocks, each a
   portrait with its name and biography beside it, and the second one mirrored
   and pushed to the other edge — so the pair sits on a diagonal across the page
   rather than stacking into a table of two rows.

   The block is a fixed object, not a band: it takes the width its picture and
   its measure of copy ask for and no more, and it is the block's position on
   the page that changes between the two, not its shape. */
.crew {
  --plate: clamp(210px, 20vw, 290px);
  --crew-gap: clamp(24px, 3.4vw, 56px);
  /* The measure the copy is set to. Held here rather than on the paragraph so
     the block's total width is one sum, and the block can be positioned off
     that sum instead of off whatever the copy happened to wrap to. */
  --crew-read: clamp(300px, 34vw, 500px);
  display: grid;
  row-gap: clamp(56px, 8vw, 120px);
}

/* Picture and copy side by side, topped out rather than centred: the name is
   the first line of the block and it belongs at the picture's head, not
   floating at its middle. */
.crew__row {
  display: grid;
  grid-template-columns: var(--plate) minmax(0, var(--crew-read));
  column-gap: var(--crew-gap);
  align-items: start;
  width: max-content;
  max-width: 100%;
}

/* Both boxes are pinned to the row's single line as well as to their column.
   Placing the column alone is not enough on the mirrored row: the picture is
   first in the markup and sits in the second column, and auto-placement will
   not step back to the first column of a line it has already passed — the copy
   would be dropped onto a second line under it. */
.crew__row > .crew__plate { grid-column: 1; grid-row: 1; }
.crew__row > .crew__read  { grid-column: 2; grid-row: 1; }

/* Set in from the left edge rather than sat on it. The indent is what makes
   the second block's move across the page read as a move; against the section's
   own margin there would be nothing to move away from. */
.crew__row {
  margin-right: auto;
  margin-left: clamp(0px, 11vw, 190px);
}

/* The mirror: columns swapped, block moved to the other edge, copy set
   ragged-left so it closes on the picture instead of running away from it. */
.crew__row--flip > .crew__plate { grid-column: 2; }
.crew__row--flip > .crew__read  { grid-column: 1; }
.crew__row--flip {
  /* The tracks are swapped with the boxes. Moving the picture into the second
     column alone would hand it the copy's measure and leave the copy holding
     the picture's width. */
  grid-template-columns: minmax(0, var(--crew-read)) var(--plate);
  margin-left: auto;
  margin-right: clamp(0px, 8vw, 140px);
}
.crew__row--flip .crew__read { text-align: right; }

/* ---- the plate ---- */

.crew__plate { margin: 0; width: var(--plate); }

/* Squared off a little from the usual 3:4 portrait — the plate is small, and a
   tall crop beside this much display type reads as a column of its own rather
   than as a picture set into the page. */
.crew__frame {
  position: relative; display: block;
  width: 100%; aspect-ratio: 4 / 5;
}

/* Black and white with the rest of the section's photography. Colour is kept
   for the hover, so the picture answers the pointer without anything moving. */
.crew__frame .slot {
  filter: grayscale(1) contrast(1.04);
  transition: filter .7s var(--ease);
}
.crew__row:hover .crew__frame .slot { filter: grayscale(0) contrast(1); }

/* Crop marks. Four corners, two drawn here and two by .crew__marks: a box has
   only the two pseudo-elements and the fourth corner is not worth a fifth
   element in the markup that says nothing. Sat a hair outside the frame so the
   picture is not cropping its own marks. */
.crew__frame::before,
.crew__frame::after,
.crew__marks::before,
.crew__marks::after {
  content: "";
  position: absolute; z-index: 2;
  width: clamp(14px, 1.6vw, 22px); height: clamp(14px, 1.6vw, 22px);
  border: 1px solid var(--rule-strong);
  pointer-events: none;
}
.crew__frame::before { top: -8px;    left: -8px;  border-right: 0; border-bottom: 0; }
.crew__frame::after  { bottom: -8px; right: -8px; border-left: 0;  border-top: 0; }

.crew__marks { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.crew__marks::before { top: -8px;    right: -8px; border-left: 0;  border-bottom: 0; }
.crew__marks::after  { bottom: -8px; left: -8px;  border-right: 0; border-top: 0; }

/* ---- the reading matter ---- */

/* Level with the picture's top edge, not below it: the two boxes start on the
   same line and the block reads as one thing seen at a glance. */
.crew__read { padding-top: 0; }

/* One heading in two weights. The given name is light and the family name
   heavy directly under it, on a leading tight enough that the pair reads as one
   object — the split is typographic, and the heading is still one heading. */
.crew__name {
  /* Optical, not geometric. A line box carries the font's full ascender above
     the capitals, so a heading whose box is flush with the frame prints its
     letters a few pixels low; the gap between the two is pulled back off the
     top so it is the capitals that line up with the picture's edge. */
  margin: -0.14em 0 0;
  font-family: var(--f-display);
  font-size: clamp(28px, 3.3vw, 48px);
  line-height: 0.98; letter-spacing: -0.03em;
  color: var(--ink);
}
.crew__given  { display: block; font-weight: 300; }
.crew__family { display: block; font-weight: 700; }

.crew__role {
  margin: clamp(10px, 1.2vw, 16px) 0 0;
  font-family: var(--f-display); font-size: 12px; font-weight: 700;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink);
}

/* The line the eye lands on. Sized between the section heads and the body so
   the block has an opening sentence of its own — this is the one piece of
   display type under the name and everything after it is quieter. */
.crew__lead {
  margin: clamp(22px, 2.6vw, 34px) 0 clamp(16px, 1.9vw, 24px);
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(18px, 1.55vw, 24px);
  line-height: 1.32; letter-spacing: -0.02em;
  color: var(--ink);
}

.crew__bio {
  margin: 0;
  font-size: 15.5px; line-height: 1.72;
  color: var(--muted-body);
}

/* The row's own reveal is the shared one; these are the beats inside it, so
   the picture arrives first and the copy a moment behind it rather than the
   two landing together as one block. */
.crew__row[data-reveal] .crew__plate,
.crew__row[data-reveal] .crew__read {
  opacity: 0; transform: translateY(28px);
  transition: opacity .9s var(--ease), transform .9s var(--ease);
}
.crew__row[data-reveal] .crew__read { transition-delay: .14s; }
.crew__row[data-reveal].is-visible .crew__plate,
.crew__row[data-reveal].is-visible .crew__read {
  opacity: 1; transform: none;
}

/* ---------- shared head: marker, two-weight title, lead ---------- */

/* Why Collaborate opens on a two-column head: the marker spans the grid, the
   title takes the left column and the lead sits opposite it, bottom-aligned to
   the title's last line rather than to its top. Collaboration Model is
   deliberately not in this set — it is set against the page's right edge and
   has its own head below. */
.studio-why__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: clamp(24px, 5vw, 72px);
  align-items: end;
  margin: 0 0 clamp(48px, 6vw, 96px);
}
.studio-why__head .eyebrow {
  grid-column: 1 / -1;
  margin-bottom: clamp(22px, 2.8vw, 40px);
}

.studio-collab__title,
.studio-why__title {
  margin: 0;
  font-family: var(--f-display); font-weight: 300;
  font-size: clamp(36px, 5.6vw, 104px);
  line-height: 0.98; letter-spacing: -0.034em;
  color: var(--ink);
}
.studio-collab__line,
.studio-why__line { display: block; }
.studio-collab__line:last-child,
.studio-why__line:last-child { font-weight: 700; }

/* ---------- collaboration model ---------- */

.studio-collab__head { margin: 0; }

/* The marker keeps the left margin every other section opens on. Nothing is
   drawn between it and the type on the far side: the gap is what says the
   reading has moved, and a rule across it made the two read as one bar. */
.studio-collab__head .eyebrow {
  margin: 0 0 clamp(48px, 7vw, 104px);
}

/* Claim and paragraph share one box, hung off the page's right edge, and the
   box is sized to its own content: max-content resolves to the claim's longest
   line, so the paragraph is measured to the width of "Within Your Workflow."
   and the two share a left edge as well as a right one.

   The paragraph must not take part in that measurement — a paragraph's
   max-content is its whole text on one line and the box would run off the page.
   width:0 keeps its contribution out of the sizing and min-width:100% fills it
   back out afterwards; a percentage min-width is treated as auto while the box
   is being measured, which is exactly what makes the pair work. */
.studio-collab__stack {
  width: max-content; max-width: 100%;
  margin-left: auto;
}

/* Sized above the shared title because this one is the only thing in its
   section: the left half of the band is empty by design, and type set for a
   two-column head leaves it reading as a section that failed to fill rather
   than as one that turned. */
.studio-collab__title {
  text-align: right;
  font-size: clamp(40px, 6.6vw, 128px);
  line-height: 0.94; letter-spacing: -0.038em;
}

.studio-collab__body {
  margin: clamp(38px, 4.4vw, 68px) 0 0;
  width: 0; min-width: 100%;
  text-align: right;
  font-size: clamp(25px, 2.2vw, 34px); line-height: 1.45;
  letter-spacing: -0.015em;
  color: var(--muted-body);
}

/* ---------- why collaborate ---------- */

/* Column flow, not row flow: eight claims filled across would put 01 next to
   02, and the pair would be read as one line. Down four and over reads as two
   lists, which is what it is. */
.whygrid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-template-rows: repeat(4, auto);
  grid-auto-flow: column;
  column-gap: clamp(32px, 5vw, 88px);
}
.whygrid__item {
  display: flex; align-items: baseline;
  gap: clamp(16px, 1.9vw, 32px);
  padding: clamp(20px, 2.5vw, 38px) 0;
  border-top: 1px solid var(--rule-faint);
}
.whygrid__num {
  flex: none;
  font-family: var(--f-display); font-size: 13px; font-weight: 600;
  letter-spacing: 0.1em; color: var(--muted-faint);
}
/* Sized up with the heads above them: eight short claims set at body size left
   the block reading as a footnote under a headline three times its height. */
.whygrid__claim {
  font-family: var(--f-display); font-weight: 500;
  font-size: clamp(19px, 1.95vw, 31px);
  line-height: 1.28; letter-spacing: -0.018em;
  color: var(--ink);
}

/* ---------- closing wall: the foot of the page ---------- */

/* The shared .closing reserves clamp(80px, 10vw, 140px) + 200px under the
   marks. That runway was written for the home page and leaves the studio page
   ending on a third of a screen of nothing.

   Set from the wall's own spacing instead of from a round number, so it holds
   at every width: .wall__cell pads 16% of its own width on all four sides, so
   the gap between two marks is twice that, and one of those pads already sits
   below the bottom row inside the cell. Two more make the space under the wall
   one and a half times the gap between the marks in it — 32% of a cell,
   divided by the column count to express it against the wall's full width.
   Percentage padding resolves against width, which is what makes this track
   the cells rather than the page's height.

   Scoped to this page: the home page's own footing is its own decision. */
.studio-page .closing { padding-bottom: calc(32% / 6); }
/* The divisors follow .closing__wall's column counts exactly — if those
   breakpoints move, these move with them or the spacing stops matching. The
   numerator is twice the cell's own inset, which the phone layout changes from
   16% to 7%, so that width carries 14% rather than 32%. */
@media (max-width: 980px) { .studio-page .closing { padding-bottom: calc(32% / 4); } }
@media (max-width: 700px) { .studio-page .closing { padding-bottom: calc(14% / 2); } }

/* ---------- studio page: narrow widths ---------- */

@media (max-width: 900px) {
  /* Every two-column block on the page collapses at the same width, so the
     reading order down the page stays one column the whole way rather than
     changing shape section by section. */
  .studio-intro__grid,
  .studio-why__head {
    grid-template-columns: minmax(0, 1fr);
  }
  /* Ragged-left type needs room on the line to be worth the trouble, and a
     phone has none: the reading goes back to the left margin with everything
     else, and the box goes back to filling the column. */
  .studio-collab__stack { width: auto; margin-left: 0; }
  .studio-collab__title,
  .studio-collab__body { text-align: left; }
  .studio-intro__side .eyebrow { margin-bottom: clamp(16px, 2vw, 24px); }
  .studio-intro__lead { margin-bottom: clamp(28px, 4vw, 44px); }

  /* The spread is a two-column idea and there is no second column left here,
     so the row goes to one and the mirror stops mirroring: both people read
     picture-then-copy, which is the order they would be read in aloud. The
     plate keeps its own width rather than filling the column — a portrait
     stretched to the full width of a phone stops being a plate. */
  .crew__row,
  .crew__row--flip {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(24px, 4.5vw, 36px);
    width: 100%;
    margin-left: 0;
    margin-right: 0;
  }
  .crew__row > .crew__plate,
  .crew__row--flip > .crew__plate,
  .crew__row > .crew__read,
  .crew__row--flip > .crew__read {
    grid-column: 1;
    grid-row: auto;
    justify-self: start;
  }
  /* Ragged-left copy needs a right edge to hang off and there is none left in a
     single column, so the mirror gives up its alignment along with its second
     column. */
  .crew__row--flip .crew__read { text-align: left; }
  .crew__read { padding-top: 0; }
  .crew { --plate: min(74%, 360px); --crew-read: 100%; }
  /* No pointer to answer, so the photograph is simply in colour. */
  .crew__frame .slot { filter: none; }

  .studio-collab__head .eyebrow { margin-bottom: clamp(30px, 5vw, 48px); }
  .studio-why__head .eyebrow { margin-bottom: clamp(18px, 2.4vw, 28px); }

  /* Four fixed rows in a single column would strand 05-08 off the grid, so the
     flow goes back to rows and the list runs 01 through 08 straight down. */
  .whygrid {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    grid-auto-flow: row;
  }
}

@media (max-width: 560px) {
  .studio-hero__meta { flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
  .studio-hero__title { max-width: none; }
}

/* Nothing in the team spread is behind its motion: the biographies are on the
   page whether or not anything moves, so the beats inside the reveal are simply
   switched off and both boxes are shown outright. The shared [data-reveal] rule
   is already neutralised further up under this same query. */
@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  .js .studio-hero__line-in { transform: none; animation: none; }
  .crew__frame .slot { transition: none; }
  .crew__row[data-reveal] .crew__plate,
  .crew__row[data-reveal] .crew__read {
    opacity: 1; transform: none; transition: none;
  }
}



/* ==========================================================================
   project.html — one project

   A magazine spread in three beats. The page opens on a 21:9 photograph with
   the project's name across it and a corner button at each bottom edge. The
   credits panel below is dragged up over that photograph as the scroll starts
   — it is opaque, and it takes the picture's place rather than sitting under
   it — and locks once its head is under the header. Then the gallery: two
   picture ratios and nothing else, wide mounts on all four sides.

   The choreography is initProject() in main.js. Everything here is the page
   with no script running: it stacks in the same order and reads the same way,
   without the pinning.

   Prefixed .pj- throughout. The only shared objects it borrows are .slot and
   .visually-hidden.
   ========================================================================== */

.pj {
  position: relative;
  /* The ground the credits panel and the gallery both stand on. One token, and
     not two matching values: the panel is dragged up over the photograph and
     the gallery rides directly under it, so any difference between the two
     draws a seam across the page at the join. Near-white rather than the site's
     cream — under a project the page is a sheet the photographs are mounted
     on, which is a different kind of surface from the sections everywhere
     else. Change it here and both follow. */
  --pj-sheet: oklch(99% 0.003 75);
}

/* ---------------------------------------------------------------- the hero */

/* Full bleed and pulled up under the fixed header — `main` reserves the
   header's height for every other page opening, which is right where a page
   starts with type. This one starts with a photograph, and a strip of page
   colour above it would leave the bar sitting on cream and never inverting:
   updateHeaderGround probes just below the header's own bottom edge, and it
   has to land on the picture. See darkGrounds in main.js. */
.pj-hero {
  position: relative; z-index: 0;
  width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2);
  margin-top: calc(var(--header-h, 104px) * -1);
  aspect-ratio: 21 / 9;
  min-height: 460px;
  background: var(--dark);
  overflow: hidden;
}

/* Named in main.js's darkGrounds — see the comment there. */
.pj-hero__figure { position: absolute; inset: 0; margin: 0; overflow: hidden; }
.pj-hero__figure .slot { position: absolute; inset: 0; border: 0; }

/* Graded rather than flat: the name sits in the middle of the frame and the
   two pagers at its feet, so the scrim is heaviest at the bottom and along the
   left, where the type is, and lets the middle of the photograph through. */
.pj-hero__figure::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
  background:
    linear-gradient(to bottom,
      oklch(0% 0 0 / 0.34) 0%,
      oklch(0% 0 0 / 0.12) 38%,
      oklch(0% 0 0 / 0.30) 72%,
      oklch(0% 0 0 / 0.62) 100%),
    linear-gradient(to right,
      oklch(0% 0 0 / 0.46) 0%,
      oklch(0% 0 0 / 0.10) 46%,
      oklch(0% 0 0 / 0) 100%);
  pointer-events: none;
}

/* The whole overlay is out of the hit test so the dev tool's image drops reach
   the slot under it; the two pagers put themselves back in. */
.pj-hero__overlay {
  position: absolute; inset: 0; z-index: 3;
  display: flex; flex-direction: column; justify-content: center;
  padding: clamp(96px, 13vh, 168px) var(--gut) clamp(104px, 14vh, 168px);
  color: var(--on-dark-hi);
  pointer-events: none;
}

/* The largest type anywhere on the site after the home page's headline. Set in
   caps and tight: this is the one word the page is about, and at this size the
   tracking has to come in or the name reads as separate letters. */
.pj-hero__title {
  font-family: var(--f-display);
  font-size: clamp(44px, 9vw, 178px);
  font-weight: 700; line-height: 0.9;
  letter-spacing: -0.035em; text-transform: uppercase;
  margin: 0; max-width: 15ch;
}

/* One character of the name. inline-block is what makes a letter
   transformable; the cost is kerning between pairs, the same trade the home
   page's headline already makes. */
.pj-hero__char { display: inline-block; will-change: transform, opacity; }

/* The motto, set light against the name's weight — the same two-weight device
   the footer's line and the studio headline use. It is a claim, not a caption,
   so it is set at sentence size rather than at label size. */
.pj-hero__motto {
  margin: clamp(14px, 1.8vw, 30px) 0 0;
  font-family: var(--f-display);
  font-size: clamp(17px, 1.7vw, 32px);
  font-weight: 300; line-height: 1.3;
  letter-spacing: -0.01em;
  color: oklch(97% 0.012 75 / 0.86);
  max-width: 26ch;
}
.pj-hero__word { display: inline-block; }

/* ------------------------------------------------------------- the pagers */

/* One in each bottom corner of the photograph. The disc is the back-to-top
   control's, turned a quarter each way: the chevron is the same path, so the
   two controls on the page are visibly one family and the rotation is what
   says direction. */
.pj-pager {
  position: absolute; bottom: clamp(20px, 3.4vh, 46px); z-index: 4;
  display: flex; align-items: center; gap: clamp(10px, 1vw, 18px);
  color: oklch(98% 0.012 75);
  pointer-events: auto;
}
.pj-pager--prev { left: var(--gut); }
.pj-pager--next { right: var(--gut); text-align: right; }
.pj-pager:hover { color: oklch(98% 0.012 75); }

.pj-pager__disc {
  position: relative; flex: none;
  width: 58px; height: 58px;
  display: grid; place-items: center;
}
.pj-pager__disc svg { display: block; }

/* Open frame rather than a filled disc: a solid pill over a photograph loses
   its own outline half the time, and the ring reads at every exposure. It
   fills on approach, which is when it stops being scenery. */
.pj-pager__bg {
  fill: oklch(0% 0 0 / 0.22);
  stroke: oklch(100% 0 0 / 0.62); stroke-width: 1;
  transition: fill .35s var(--ease), stroke .35s var(--ease);
}
.pj-pager:hover .pj-pager__bg { fill: oklch(100% 0 0 / 0.94); stroke: oklch(100% 0 0 / 0.94); }

.pj-pager__arrow {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  color: currentColor;
  transition: color .35s var(--ease), transform .45s var(--ease);
}
.pj-pager__arrow svg { width: 19px; height: 19px; }
.pj-pager--prev .pj-pager__arrow { transform: rotate(-90deg); }
.pj-pager--next .pj-pager__arrow { transform: rotate(90deg); }
.pj-pager:hover .pj-pager__arrow { color: var(--ink); }
/* Steps toward its own edge on approach — the direction is the point of the
   button, so the movement is along it. */
.pj-pager--prev:hover .pj-pager__arrow { transform: rotate(-90deg) translateY(3px); }
.pj-pager--next:hover .pj-pager__arrow { transform: rotate(90deg) translateY(3px); }

.pj-pager__kicker {
  display: block;
  font-family: var(--f-display);
  font-size: 11px; font-weight: 600;
  letter-spacing: 0.2em; text-transform: uppercase;
  color: oklch(98% 0.012 75 / 0.72);
}
.pj-pager__name {
  display: block; margin-top: 5px;
  font-family: var(--f-display);
  font-size: 15px; font-weight: 500; line-height: 1.2;
}

/* ------------------------------------------------------------- the credits */

/* Opaque, and stacked above the hero: this panel is dragged up over the
   photograph, and anything translucent here would carry the picture along with
   it for the whole of the move. Near-white rather than the page's cream — the
   credits are a different kind of surface from the gallery below them, and the
   step in ground is what separates the two without a rule. */
/* Everything under the photograph, painted once. The credits, the gallery and
   the foot used to carry the colour themselves, and the sub-pixel seam between
   two boxes at the same colour let a 1px slice of the pinned photograph
   through — a hairline that appeared and vanished with the scroll and was
   ragged along its length, because what was showing was the picture. There is
   no seam inside one painted box.
   z-index 2 puts the whole sheet over the hero, which is what lets it be
   dragged up across the frame. */
.pj-body {
  position: relative; z-index: 2;
  background: var(--pj-sheet);
}

.pj-credits {
  position: relative;
  padding: clamp(56px, 7vw, 128px) var(--gut) clamp(56px, 7vw, 128px);
}

/* The account against the record: a wide measure and a narrow column of facts.
   They are deliberately unequal — one is read and the other is scanned. */
.pj-credits__inner {
  display: grid;
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.8fr);
  column-gap: clamp(40px, 6vw, 130px);
  row-gap: clamp(40px, 5vw, 72px);
}

.pj-concept__title {
  font-family: var(--f-display);
  font-size: clamp(30px, 3.6vw, 62px);
  font-weight: 700; line-height: 1;
  letter-spacing: -0.03em; color: var(--ink);
  margin: 0 0 clamp(20px, 2.2vw, 38px);
}
.pj-concept__char { display: inline-block; }

.pj-concept__p {
  font-size: clamp(17px, 1.35vw, 24px);
  line-height: 1.75;
  color: var(--muted-body);
  max-width: 52ch;
}
.pj-concept__p + .pj-concept__p { margin-top: 1.1em; }
.pj-concept__word { display: inline-block; }

/* Glyph and value, four rows, no labels set. The names are carried for a
   screen reader and drawn for everyone else: `Location` beside `İstanbul` is
   a word longer than the fact it introduces, four times over. */
.pj-specs {
  display: flex; flex-direction: column;
  gap: clamp(22px, 2.8vw, 46px);
  padding-top: clamp(8px, 1.4vw, 22px);
}

.pj-spec {
  display: flex; align-items: center;
  gap: clamp(16px, 1.6vw, 30px);
}
.pj-spec dt { flex: none; }

.pj-spec__icon {
  display: block;
  width: clamp(26px, 2.1vw, 36px);
  color: var(--ink);
}
.pj-spec__icon svg { width: 100%; height: auto; display: block; }

/* One character of a value. inline-block is what makes a letter transformable;
   `white-space: pre` is what stops the spaces between the words of a location
   from collapsing to nothing once each of them is a box of its own. */
.pj-spec__char { display: inline-block; white-space: pre; }

.pj-spec dd {
  font-family: var(--f-display);
  font-size: clamp(14px, 1.15vw, 19px); font-weight: 500;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--muted-mid);
  line-height: 1.25;
}

/* -------------------------------------------------------------- the gallery */

/* The mount is the point: wide margins on all four sides and between the
   frames, so each photograph is presented rather than tiled. Six columns — a
   landscape takes four and a portrait two, which is what lets the two
   orientations share a row in either order without leaving a hole. */
.pj-gallery {
  position: relative;
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  /* One fixed figure, not a clamp: every space between two photographs is the
     same 30px at every width, horizontally and vertically. A gap that scales
     with the viewport is a gap that reads as a different measurement in each
     direction the moment the rows are not all the same height. */
  gap: 30px;
  /* Stretch, not start. The two ratios do not resolve to the same height in a
     row — 16:9 across four tracks is about a tenth shorter than 4:5 across two
     — and with the tops aligned that difference is left under the shorter
     frame as a ragged foot, which is what made the vertical spacing look
     different in every row. Stretched, both frames take the row's height and
     the shorter one's photograph covers the last tenth: the landscape reads a
     touch squarer than 16:9, and every gap on the page is 30px. */
  align-items: stretch;
  /* The mount is the same 30px at the two edges as it is between the frames:
     one measurement for every space in the gallery, so the outer margin is not
     read as a second, larger unit. The head and foot keep their own room —
     that space separates the gallery from the credits above it and the foot
     below, which is a different job from holding two photographs apart. */
  padding: clamp(56px, 7vw, 128px) 30px;
}

.pj-frame {
  position: relative; margin: 0;
  /* Load-bearing with the stretch above. Stretching gives the frame a definite
     height, and `aspect-ratio` then reads that height and computes a *width*
     from it — a 16:9 frame stretched to a 4:5 row grew 79px past its own grid
     area and over its neighbour. With the width pinned to the area, both
     dimensions are determined and the ratio steps back to what it is here for:
     sizing the row in the first place. */
  width: 100%;
  overflow: hidden;
  border-radius: clamp(4px, 0.5vw, 10px);
  /* Each frame repaints inside its own box — six photographs under a layer the
     smoother transforms every frame is the one heavy thing on this page. */
  contain: paint;
}

.pj-frame--wide { grid-column: span 4; aspect-ratio: 16 / 9; }
.pj-frame--tall { grid-column: span 2; aspect-ratio: 4 / 5; }

/* The frame is fixed and the photograph moves inside it. Scaling the figure
   would push its neighbour and drag the row. */
.pj-frame .slot img { transition: transform 1.2s var(--ease); }
.pj-frame:hover .slot img { transform: scale(1.035); }

/* ----------------------------------------------------------------- the foot */

.pj-foot {
  width: var(--bleed); margin-left: calc(50% - var(--bleed) / 2);
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; gap: clamp(16px, 3vw, 56px);
  padding: clamp(40px, 5vw, 84px) var(--gut) clamp(56px, 6vw, 104px);
  border-top: 1px solid var(--rule);
}

.pj-foot__side { display: block; min-width: 0; color: inherit; }
.pj-foot__side--next { text-align: right; justify-self: end; }

.pj-foot__kicker {
  display: block;
  font-family: var(--f-display);
  font-size: 12px; font-weight: 600;
  letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--muted-faint);
  transition: color .3s var(--ease);
}

/* The neighbour's name at heading size: this is the last offer the page makes,
   and a 14px line under an arrow is not an offer. */
.pj-foot__name {
  display: block; margin-top: 8px;
  font-family: var(--f-display);
  font-size: clamp(20px, 2.2vw, 38px); font-weight: 700;
  line-height: 1.05; letter-spacing: -0.025em;
  color: var(--ink);
  transition: color .3s var(--ease), transform .45s var(--ease);
}
.pj-foot__side:hover .pj-foot__kicker { color: var(--muted); }
.pj-foot__side--prev:hover .pj-foot__name { transform: translateX(-8px); }
.pj-foot__side--next:hover .pj-foot__name { transform: translateX(8px); }

/* The arrow travels on approach, the same beat the name does. */
.pj-foot__arrow { display: inline-block; transition: transform .45s var(--ease); }
.pj-foot__side--prev:hover .pj-foot__arrow { transform: translateX(-5px); }
.pj-foot__side--next:hover .pj-foot__arrow { transform: translateX(5px); }

/* An outlined pill that fills from the floor up rather than swapping colour:
   the ink is a panel that rises behind the word, which is the same move the
   page curtain makes between pages. */
.pj-foot__all {
  position: relative; overflow: hidden;
  display: inline-flex; align-items: center; justify-self: center;
  padding: clamp(14px, 1.4vw, 20px) clamp(26px, 2.6vw, 40px);
  border: 1px solid var(--ink); border-radius: 999px;
  font-family: var(--f-display);
  font-size: 13px; font-weight: 600;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ink); white-space: nowrap;
}
.pj-foot__all::before {
  content: ""; position: absolute; inset: 0;
  background: var(--ink);
  transform: translateY(101%);
  transition: transform .5s var(--ease);
}
.pj-foot__all:hover { color: var(--bg); }
.pj-foot__all:hover::before { transform: translateY(0); }
.pj-foot__all-in { position: relative; z-index: 1; }

/* ------------------------------------------------------------- miss state */

/* No project at this address: the hero carries the line and the foot carries
   the way out. Nothing else on the page has anything to show. */
.pj.is-missing .pj-hero { background: var(--dark); }
/* Both neighbours are gone on that page, and a three-track grid with only its
   middle child left would drop that child into the first track — the one link
   on the foot would sit hard left under nothing. */
.pj.is-missing .pj-foot { grid-template-columns: minmax(0, 1fr); justify-items: center; }

/* --------------------------------------------------------- narrow widths */

@media (max-width: 1100px) {
  /* The account and the record stack. The facts follow the reading rather than
     standing beside it — at this width a column of four glyphs beside a
     paragraph is a margin note, not a second column. */
  .pj-credits__inner { grid-template-columns: minmax(0, 1fr); }
  .pj-specs { flex-direction: row; flex-wrap: wrap; gap: 22px clamp(28px, 5vw, 64px); }
}

@media (max-width: 900px) {
  /* 21:9 on a narrow screen is a letterbox with no room for the name in it. */
  .pj-hero { aspect-ratio: 3 / 4; min-height: 0; max-height: 92svh; }
  .pj-hero__overlay { justify-content: flex-end; padding-bottom: clamp(132px, 20vh, 200px); }
  /* The two pagers would meet in the middle of a phone screen. Names go, discs
     stay — the arrows are the control. */
  .pj-pager__text { display: none; }

  /* A long location — İSTANBUL I NİŞANTAŞI — set in caps and tracked out runs
     two pixels past a phone screen, and two pixels is enough to make the whole
     page scroll sideways. The tracking comes in and the value is allowed to
     break rather than push the page. */
  .pj-spec { min-width: 0; }
  .pj-spec dd { letter-spacing: 0.06em; overflow-wrap: anywhere; }
}

@media (max-width: 760px) {
  /* Halved: two of six tracks is a thumbnail at this width. A landscape takes
     the row and two portraits still pair up across it, which keeps the mix of
     orientations that the gallery is for. */
  .pj-frame--wide { grid-column: span 6; }
  .pj-frame--tall { grid-column: span 3; }
  /* The 30px edge holds at every width — it is the gallery's one measurement.
     Only the head and foot come in. */
  .pj-gallery { padding: clamp(40px, 8vw, 72px) 30px; }
}

@media (max-width: 560px) {
  .pj-foot { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: 28px; }
  .pj-foot__side--next { text-align: center; justify-self: center; }
  .pj-foot__name { font-size: 22px; }
}

/* The hover zooms, the pill's rise and the arrows' travel are all motion over
   a still page, so all of them go. initProject() checks the same query before
   it builds anything. */
@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  .pj-frame .slot img,
  .pj-foot__name,
  .pj-foot__arrow,
  .pj-foot__all::before,
  .pj-pager__arrow { transition: none; }
  .pj-frame:hover .slot img { transform: none; }
  .pj-foot__side--prev:hover .pj-foot__name,
  .pj-foot__side--next:hover .pj-foot__name,
  .pj-foot__side--prev:hover .pj-foot__arrow,
  .pj-foot__side--next:hover .pj-foot__arrow { transform: none; }
  .pj-foot__all:hover::before { transform: translateY(101%); }
  .pj-foot__all:hover { color: var(--ink); }
}


/* ==========================================================================
   404.html — the wrong address

   One block, no motion. Every other page opens on a beat of its own; this one
   interrupts someone on their way somewhere, and the only thing it owes them
   is the list of right addresses, immediately.
   ========================================================================== */

.nf {
  border-top: 0;
  min-height: 62svh;
  display: flex; flex-direction: column; justify-content: center;
  padding-top: clamp(48px, 8vh, 120px);
}

.nf__title {
  font-family: var(--f-display);
  font-size: clamp(44px, 8vw, 132px);
  font-weight: 700; line-height: 0.92;
  letter-spacing: -0.035em; color: var(--ink);
  margin: 0 0 clamp(22px, 3vw, 44px);
}

.nf__lead {
  font-size: clamp(16px, 1.25vw, 21px);
  line-height: 1.7; color: var(--muted-body);
  max-width: 52ch;
  margin: 0 0 clamp(34px, 4.5vw, 68px);
}

/* The four ways on, set as the largest thing after the heading. On this page
   the navigation is the content, so it is not a row of small links under a
   paragraph — it is the paragraph the reader came for.
   Each line steps right on approach, the same move the footer nav makes. */
.nf__nav { display: flex; flex-direction: column; gap: clamp(6px, 0.8vw, 12px); }

.nf__nav a {
  font-family: var(--f-display);
  font-size: clamp(26px, 3.4vw, 56px);
  font-weight: 700; line-height: 1.15;
  letter-spacing: -0.025em; color: var(--ink);
  width: max-content;
  transition: transform .35s var(--ease), color .35s var(--ease);
}
.nf__nav a:hover { color: var(--accent); transform: translateX(14px); }

@media (prefers-reduced-motion: reduce), (max-width: 700px) {
  .nf__nav a { transition: none; }
  .nf__nav a:hover { transform: none; }
}

/* ---------- closing head: the claim and the way on ---------- */

/* studio.html only. The head is a two-column grid; this holds the claim and
   the button as one item in its right column, so adding the offer does not
   start a row of its own under the eyebrow. index.html's closing has no
   button and its <h2> stays the grid item directly, which is why this is
   scoped rather than written on .closing__head itself. */
.closing__claim { display: flex; flex-direction: column; align-items: flex-start; }

/* Far enough down that it reads as the answer to the sentence above it rather
   than as part of it. */
.closing__cta { margin-top: clamp(26px, 3vw, 44px); }

/* ==========================================================================
   Phone: the hero band

   The band lays a headline, a paragraph, a pill and a projects card over an
   18:9 photograph, all of it absolutely positioned against that frame. At
   desktop width the frame is 900px tall and there is room for the arrangement.
   On a phone the same 18:9 gives a 195px strip — and a card with a 400px
   minimum height was still being placed inside it, so the card, the paragraph
   and the pill ended up stacked on top of each other and hanging out of the
   picture. That is the pile-up the whole section read as.

   Here the frame is turned portrait, which is the shape a phone has to give,
   and the overlay stops being a composition and becomes a column: word at the
   top, paragraph and pill under it, card at the foot. Nothing is repositioned
   by hand — the flex column places all four, so there is nothing left to
   collide.
   ========================================================================== */

@media (max-width: 700px) {
  /* Nothing is held on a phone. main.js does not start the smoother at this
     width, and without it these three would fall back to CSS sticky and hold
     themselves — which is the behaviour being switched off, not a fallback for
     it. The bands lose the runway they were given to be held over at the same
     time: a 150vh band around a frame that no longer stops is 150vh of which
     the reader sees an empty two thirds. */
  .band__sticky, .work-intro { position: static; }
  .band--hero { height: auto; }
  /* The quote band was one screen exactly because the pin made the screen and
     the band the same thing. Unpinned it is a paragraph in a tall box, sitting
     in 322px of its own air — so it takes the height its words need and the
     padding does the rest. */
  .quote { min-height: 0; }

  /* Tall enough to hold the column, never taller than the screen. */
  .band__sticky { height: min(100svh, calc(var(--bleed) * 15 / 9)); }

  /* The top pad clears the project's name, which is pinned to the frame's top
     left corner and is not part of this column — at the desktop pad the first
     line of the stack was set straight through it. */
  .band__overlay {
    display: flex; flex-direction: column; justify-content: space-between;
    padding: clamp(62px, 15vw, 84px) var(--gut) clamp(20px, 4vw, 32px);
  }

  /* The stack of words carries the frame at this size, so it is set against
     the frame's width rather than against a column inside it. */
  .band__word { margin-top: 0; font-size: clamp(30px, 10.4vw, 52px); }

  /* Out of the absolute placement and into the column. The measure is the
     frame's own width now — there is no headline beside it to line up with. */
  .band__lead {
    position: static; transform: none;
    width: auto; text-align: left;
    --measure: 100%;
  }
  .band__lead-row {
    margin-top: clamp(14px, 3vw, 22px);
    flex-direction: column; align-items: flex-start;
    gap: clamp(14px, 3vw, 20px);
  }
  /* Set whole. This was clamped to four lines on the reasoning that a
     paragraph over a photograph on a hand-sized screen should be short — but
     the sentence runs to five, so the clamp cut it mid-word and the reader was
     left at "...and 3D" with no way to reach the end. The room was never the
     problem: the column leaves 161px unspent at 390px, and the missing line
     costs 22. A clamp is only honest where it lands on a boundary, and CSS
     cannot see one. */
  .band__blurb { width: 100%; font-size: 14.5px; line-height: 1.55; }
  .band__pill { padding: 13px 26px; font-size: 13px; }

  /* A band across the foot of the frame rather than a tall panel inside it.
     The label and the arrow sit at its two ends, which is the same reading the
     desktop card gives, at a size a phone can carry. */
  .band__card {
    position: static;
    width: auto; min-height: 0;
    align-self: stretch;
    display: flex; align-items: center; justify-content: space-between;
    gap: 16px;
    margin-top: clamp(16px, 4vw, 28px);
    padding: 14px 20px;
    border-width: 1.5px; border-radius: 999px;
  }
  .band__card-label { font-size: 15px; line-height: 1.1; }
  /* The two-line "All / Work" break is a desktop shape; on one line it reads
     as a label on a bar. */
  .band__card-label br { display: none; }
  /* The space between the two words comes from the markup — a real space after
     the <br>, which collapses while the break is showing and is all that is
     left of it once the break is hidden. A trailing ::after space was the
     first attempt and it set "AllWork " rather than "All Work". */
  .band__card-arrow { width: 20px; flex: none; }
}

/* The narrowest phones still sold. The paragraph is set whole, and at this
   measure it runs to seven lines where a 375px screen takes five — the column
   then overruns the 15:9 frame by 19px, which is exactly the clearance the
   card is supposed to have below it. Here the frame stops being a ratio and
   becomes the screen: 100svh is already the ceiling in the expression above,
   so this only stops the ratio binding before it. The photograph is cover, so
   a taller frame crops it rather than distorting it, and the pin reads the
   frame's real height (see heroFrameHeight) so nothing downstream needs to
   know this happened. */
@media (max-width: 340px) {
  .band__sticky { height: 100svh; }
}


/* ==========================================================================
   Phone: the type scale

   The desktop sets its sizes with clamp(), which is the right tool for a
   layout that has to survive every width between a laptop and a wall. What it
   is not is a decision about a phone: at 390px eighteen different sizes
   resolved out of those clamps, eight of them over 30px, and a page where
   everything is large is a page where nothing is emphasised. Measured against
   two studios' phone sites the difference was not taste, it was arithmetic —
   one holds its largest type at 34px, the other builds the whole site from
   five sizes.

   So the phone gets a scale of its own, five steps, stated as numbers rather
   than derived from a viewport:

     32  the voice — the opening line and the band's word, twice on the page
     24  a section's own headline
     16  body
     12  labels, eyebrows, meta
     11  the small print

   Nothing here changes a colour, a face or a weight. The language is the
   desktop's; what changes is how many voices it speaks in at once.
   ========================================================================== */

@media (max-width: 700px) {
  /* --- 32: the voice ------------------------------------------------- */
  .hero__title { font-size: 32px; }
  .band__word { font-size: 32px; }

  /* --- 24: section headlines ----------------------------------------- */
  .whatwedo__title { font-size: 24px; }
  .statement__text { font-size: 24px; line-height: 1.25; }
  .xp__line { font-size: 24px; }
  .service__title { font-size: 24px; }
  .closing__head h2, .closing__claim h2 { font-size: 24px; }
  .work-intro__lead { font-size: 24px; }

  /* --- 16: body ------------------------------------------------------- */
  /* Left, not centre. A centred paragraph asks the eye to find a new start
     for every line, which is a price worth paying for two lines and not for
     ten; both of these ran past ten before the copy was cut. */
  .whatwedo__body,
  .quote__text p {
    font-size: 16px; line-height: 1.6; text-align: left;
  }
  .band__blurb { font-size: 16px; }

  /* The band is one full-bleed line of type on a wide screen, where its own
     movement is the point. Standing still on a phone it is a 64px fragment of
     a word — "MENTSACHIE" — running off both edges. Set at the section's own
     headline size it reads as the heading it is. */
  .achievements__band, .achievements__marquee { font-size: 24px; }
  .achievements__marquee { padding: 0; }

  /* The numerals are the section — four figures and their labels — so they
     keep a step of emphasis over the headline, but not the 44px they inherited
     from a screen four times as wide. */
  .record__value, .odo__sizer, .odo__col span, .odo__sym { font-size: 32px; }

  /* The project's name sits over its photograph in a corner of the frame. At
     40px it was competing with the picture it labels. */
  .featured__name { font-size: 24px; }
}

/* ==========================================================================
   Phone: services carry their photographs

   The stage beside the list is a hover object and is hidden wherever there is
   no pointer — which left the phone with six words and no imagery at all, in
   the one section of the page whose subject is what the studio makes. The
   photographs already exist and are already in the manifest; the list simply
   carries its own copy of each, at a size a row can hold.

   4:5 to match the stage's own proportion, so the same crop reads the same way
   in both places. The row is the image and the word together — the pair is the
   unit, which is how both of the studios measured against here set a list of
   work: a picture and its name, repeated, at a size the thumb can scan.
   ========================================================================== */

.service__shot { display: none; }

@media (max-width: 700px) {
  .services__list { gap: 0; }
  .service {
    display: flex; align-items: center; gap: 16px;
    padding: 12px 0;
    border-top: 1px solid var(--rule-faint);
  }
  .service:last-child { border-bottom: 1px solid var(--rule-faint); }
  .service__shot {
    /* Height stated, not derived: .slot--fill carries height:100% for the
       stage it was written for, and in a flex row that resolves against the
       row rather than against the aspect-ratio — the thumbnails came out
       56x486. */
    /* position too: .slot--fill is absolute so it can fill the stage's frame,
       which took the thumbnail out of the row entirely — it was landing 134px
       above its own list item. In a row it is an ordinary item. */
    display: block; position: relative; flex: none;
    width: 56px; height: 70px;
    overflow: hidden; border-radius: 4px;
    background: oklch(100% 0 0 / 0.06);
  }
  .service__shot img { width: 100%; height: 100%; object-fit: cover; }
  .service__title { margin: 0; }
}

/* ==========================================================================
   Two lengths of the same copy

   Some paragraphs are written for a screen that has room for them. Rather than
   set the long one small enough to fit a phone — which is how the page ended
   up with a 710-character block set centred — the two lengths are both in the
   markup and the screen picks one. The wide one stays exactly as it was, so
   nothing about the desktop reading changes.

   Both are real text in the page, not swapped by script: whichever is hidden
   is hidden by CSS, so the copy survives a failed stylesheet as two paragraphs
   rather than as none.
   ========================================================================== */

.phone-copy { display: none; }

@media (max-width: 700px) {
  .wide-copy { display: none; }
  .phone-copy { display: block; }
}

/* ==========================================================================
   Phone: the mark, the menu and the buttons

   Three changes asked for directly, all of them below 700px only.

   The mark is set 30% larger — 48px to 62px. Nothing else needs telling: the
   header's height is measured from it in main.js and published as --header-h,
   and every clearance on the page is stated against that variable rather than
   against a number, so the page moves down with it on its own.

   The menu button leaves the top corner for the foot of the screen, centred.
   On a phone the top right is the one corner a thumb has to stretch for, and
   the button is the only navigation the page has once the links are gone. It
   is given a solid disc for the same reason .to-top has one: it now floats
   over both the cream page and the anthracite sections, and the header's own
   light/dark inversion is driven by a scroll handler that no longer runs at
   this width. Centred, it clears .to-top's corner — 167-223 against 324-382 at
   390px — so the two never meet.
   ========================================================================== */

@media (max-width: 700px) {
  .brand__slot { height: 62px; }

  /* .site-header carries will-change:transform for the slide it performs on a
     wide screen, and that makes it the containing block for any fixed child —
     the button pinned itself to the bottom of the header instead of the
     bottom of the screen. The header does not slide at this width (the scroll
     handler that moved it does not run), so the hint has nothing to earn. */
  .site-header { will-change: auto; }

  /* The page's own button, and the one docked inside the open panel, take the
     same position — the control must not jump under the thumb as the panel
     opens over it.
     Four dots and nothing behind them, exactly as in the header at display
     size. The disc that used to be here was a fifth circle around the four and
     read as a different control from the one at the top of a wide screen; the
     mark is the four, and a container drawn around them is not part of it.
     The 56px box stays — it is the thumb's target, not a shape. Only the
     padding names how much of it the dots take: 13px in from a 56px box leaves
     the 30px cluster the header draws, so the mark is the same size in both
     places rather than the same idea at two sizes. */
  .site-header .burger,
  .menu-overlay > .burger {
    position: fixed;
    left: 50%; translate: -50% 0;
    /* Clear of the home indicator on a phone that has one. */
    bottom: max(16px, env(safe-area-inset-bottom, 0px));
    top: auto; right: auto;
    z-index: 60;
    width: 56px; height: 56px;
    padding: 13px;
    background: none;
    gap: 6px;
  }
  /* Over the open panel's black card, where nothing else is being read. */
  .menu-overlay > .burger .burger__dot { background: var(--on-dark-hi); }

  /* The tap extender from the touch pass is already at least 44px; the box is
     56, so it is the box that decides now. */
  .burger::before { --tap: 56px; }

  /* With no disc to carry the contrast the dots carry it themselves, and they
     follow the ground under them the way the back-to-top button beside them
     already does. Docked at the foot of the screen the burger is over a
     different part of the page from the header it belongs to — over the
     footer's anthracite, dark dots on dark ground would take the way out of
     the page with them.
     Ink by default and light over dark ground: the same pair the header uses
     at display size, where .site-header.is-on-dark does the swapping. Here the
     class is on the burger itself, because what is under it is its own
     question — main.js reads it at the disc's own centre. Scoped to the
     header's copy: the one docked inside the open menu is over the panel's own
     black at all times and is set white above. */
  .site-header .burger__dot { background: var(--ink); }
  .site-header .burger.is-on-dark .burger__dot { background: var(--on-dark-hi); }

  /* --- every button on its own centre line -------------------------------
     Written against the classes rather than the sections, so a button added
     later lands centred without a new rule. align-self carries the flex
     parents, the auto margins carry the block ones; a button is never both. */
  .btn,
  .btn--pill,
  .btn--solid,
  .btn--outline,
  .band__pill,
  .footer__cta,
  .studio-hero__cue,
  .closing__cta {
    align-self: center;
    margin-left: auto; margin-right: auto;
  }
  /* Auto margins only centre a block-level box, and .band__pill is inline-flex
     inside a block parent — it sat at the gutter while every other button had
     already moved. flex keeps the label and the arrow laid out exactly as they
     were; fit-content keeps the capsule the width of its own text. */
  .band__pill { display: flex; width: fit-content; }
  /* The footer's ask column is shrink-to-fit, so centring the button inside it
     centred it inside its own width. The column takes the panel's width and
     the button centres in that; the sentence above it stays where it is. */
  .footer__ask { width: 100%; }
}

/* ==========================================================================
   Phone: the footer keeps only what it is for

   The panel was carrying the whole of the site's furniture a second time — the
   mark, the four nav links, and the three policy chips — under a menu button
   that now sits at the foot of every screen and opens onto the same four
   links. On a wide screen that repetition is a colophon and costs nothing; on
   a phone it was two thirds of the panel, and it was pushing the address, the
   mail and the copyright behind an internal scroll.

   So the lead column goes and the panel keeps what only it carries: the line
   that asks for the project, the button that starts it, the studio's address
   and numbers, the social marks, and the legal foot.

   The chips are no loss in particular — Privacy, Terms and Cookie are not
   links yet (there are no pages behind them), so on a phone they were three
   words shaped like buttons that do nothing.
   ========================================================================== */

@media (max-width: 700px) {
  .footer__lead { display: none; }

  /* With two thirds of the panel gone there is room to breathe again. These
     are the figures from before the fit was squeezed to get the copyright onto
     the screen — that squeeze was buying space the lead column was spending. */
  .footer { padding: 44px 0 20px; }
  .footer__side { gap: 22px; }
  .footer__rows { gap: 20px; }
  .footer__rights { font-size: 12.5px; line-height: 1.6; }

  /* And room to clear the menu button, which floats over the foot of every
     screen — the fine print was reading under a 56px disc. */
  .footer__bottom { padding-bottom: 56px; }
}

/* ==========================================================================
   Phone: the footer on one centre line

   With the lead column gone the panel is five things down a narrow screen, and
   they were still laid out to the rules of a two-track wide one: the sentence
   ranged left, the button run edge to edge, each address label pinned to the
   left margin with its value thrown against the right, the social marks in the
   left corner, the legal foot split into two columns that had nothing left to
   sit opposite each other. Five different alignments in one panel, and the eye
   has to find a new starting edge for each. That is what reads as untidy — not
   any one of them.

   So the panel is given a single centre line and everything is hung from it.
   A phone column is one measure wide; there is no second track for anything to
   be ranged against, and centring is the only alignment that does not imply
   one. The wide layout is untouched: there the two tracks are real and the
   edges are what hold the panel.
   ========================================================================== */

@media (max-width: 700px) {
  .footer__side,
  .footer__ask { align-items: center; }
  .footer__side { text-align: center; }

  /* The button stops being a bar. Run to both gutters it is the largest and
     brightest object on a dark panel and pulls ahead of the sentence it
     belongs to — which is the one thing the panel is actually asking. Capped
     at the width it has everywhere else on the site and centred, it reads as
     the same invitation rather than as an alert. */
  .footer__cta { width: min(100%, 264px); }

  /* Label over value again, both on the centre line.
     These were stacked once and then put back on one line to save about 23px
     three times over — that was bought when the panel also carried the mark,
     four display-sized nav links and three policy chips, and the copyright was
     being pushed behind an internal scroll. None of that is here any more:
     the panel now ends with 240px of empty floor under it. The saving is being
     made against a shortage that no longer exists, and it costs the address
     its shape — a label at one margin and its value at the other, with a hand
     of empty screen between them, reads as two columns rather than as one
     entry. */
  .footer__row { flex-direction: column; align-items: center; gap: 5px; }
  .footer__row dd { text-align: center; }
  /* The underline under the phone number and the mail is drawn from the left
     of the link box. The box is the link's own width either way, so this only
     needs restating because the ≤720 rule set it for a left-ranged column. */
  .footer__row a { background-position: 0 100%; }

  /* Spending the floor rather than leaving it in one piece. The panel is fixed
     to the height of the screen and its content is shorter, so the difference
     has to go somewhere; gathered at the bottom it reads as the panel having
     run out, and opened between the four groups it reads as spacing. */
  .footer__side { gap: 32px; }
  .footer__rows { gap: 22px; }

  /* 40px was under the 44 a thumb is drawn against, and these three sit in a
     row with nothing but air between them. The glyphs do not change size —
     the box around them does. */
  .footer .social { min-width: 44px; min-height: 44px; }

  /* The legal foot is two things, not two columns: on a wide panel the terms
     hold one corner and the record holds the other, and here there are no
     corners to hold. Stacked on the same centre line as everything above.
     The sentence is given a measure of its own and balanced wrapping, because
     a centred paragraph with one word alone on the last line is the shape that
     makes centring look like an accident rather than a decision. */
  .footer__bottom {
    flex-direction: column; align-items: center;
    text-align: center; gap: 12px;
  }
  .footer__rights { flex: none; max-width: 36ch; text-wrap: balance; }
}

/* ==========================================================================
   Phone: the opening

   The hero is the first screen and it was doing four things at once — a
   location line, a mark, a headline broken where a wide screen wanted it, and
   a button — inside the height of one phone. Four requested changes, and the
   spacing they force.

   The location line goes. It is the third piece of furniture in the first
   120px of the page, after the mark and the menu, and the same two words are
   set again in the footer's address block.

   The mark takes another 50%, 62px to 93px. The header measures itself and
   publishes --header-h, so the page follows.

   The headline breaks by word: Entity / Style / Function & / Motivation. Four
   lines, all four at one size — the payoff word's own — so the opening reads
   as one statement rather than as a build-up to a punchline. The split is done
   with spans rather than a <br> on purpose: on a wide screen main.js splits
   this line to characters by reading its textContent, and a <br> there would
   either vanish or leave a doubled space in the middle of "Style Function &".
   Word spans flatten into exactly the string that is there today.
   ========================================================================== */

@media (max-width: 700px) {
  .hero__meta { display: none; }

  .brand__slot { height: 93px; }

  /* One size for all four lines: 43px is what the payoff word already
     resolved to (32 x 1.35), so nothing grows — the other three catch up. */
  .hero__title { font-size: 43px; line-height: 1.06; }
  .hero__title b { font-size: 1em; }
  .hero__word { display: block; }

  /* Air, in the two places it was missing. The headline needs to clear the
     mark above it by more than the gutter, and the button below has to read as
     the answer to the sentence rather than as its last line. */
  .hero { padding-top: 40px; }
  .hero__headline-row { gap: 34px; margin-top: 0; }
  .hero .rule { margin-top: 34px; }
  /* Four lines of 43px need leading that separates them without opening the
     block into a paragraph. 1.06 keeps the stack reading as one object; the
     desktop's own 0.82 would have the descenders of "y" touching the line
     below at this size. */
}

/* ==========================================================================
   Phone: the hero frame stands up

   The frame under the opening was 15:9 — a landscape crop turned portrait only
   as far as a phone's width would carry it. At 9:16 it is the shape of the
   screen it is seen on, which is the proportion the photograph inside it was
   always going to be cropped to by the frame's own overflow.

   min(100svh, ...) still caps it: on a short phone the screen wins and the
   frame stops at the fold rather than running past it.
   ========================================================================== */

@media (max-width: 700px) {
  .band__sticky { height: min(100svh, calc(var(--bleed) * 16 / 9)); }
}

/* ==========================================================================
   Phone: the hero frame as a plate, not a bleed

   Full bleed is a wide-screen gesture — it works when the frame is wider than
   it is tall and the page has margins of its own to lose. Standing 9:16 on a
   phone it ran edge to edge with the type inside it hard against the same
   edges, and the section read as a screenshot rather than as a picture the
   page is holding. So it takes the page's own gutter and a small radius, and
   becomes a plate with the page visible around it.

   Inside it, three things go. The project's name has been sitting behind the
   fixed header for the whole of the section — measured, all 18px of it — so it
   was never readable here anyway; work.html names every project properly. The
   pill under the headline sends the reader to the studio page, which the
   button at the foot of this same frame and the menu both already do. And the
   drawn arrow beside All Work is a wide-screen flourish on a control that at
   this size only has to read as a button.
   ========================================================================== */

@media (max-width: 700px) {
  .band--hero {
    width: auto; margin-left: 0;
    padding: 0 var(--gut);
  }
  .band__sticky {
    /* Stated as a ratio now that the width is the padded column rather than
       the viewport — the calc against --bleed would still be measuring the
       whole screen. */
    width: 100%; height: auto;
    aspect-ratio: 9 / 16; max-height: 100svh;
    border-radius: 14px;
  }
  /* The overlay is inset:0 on a frame that now has rounded corners; without
     this its own background and the photograph's would square them off again. */
  .band__figure { border-radius: inherit; }

  .band__project { display: none; }
  .band__pill { display: none; }
  .band__card-arrow { display: none; }

  /* The word is the frame's subject. At 40px the longest line, "Motivation",
     measured 213px inside a 294px column — 72% of the measure, which reads as
     a caption on a photograph rather than as the thing the photograph is
     under. 48 takes it to 256 and leaves the frame its own margin. */
  .band__word { font-size: 48px; }

  /* An ordinary button: the width of its own label, centred with every other
     button on the page, rather than a bar stretched across the foot of the
     frame. */
  .band__card {
    align-self: center;
    width: fit-content;
    justify-content: center;
    padding: 14px 28px;
  }
}

/* ==========================================================================
   Phone: the opening, second pass

   Five changes to the first two screens, asked for directly.

   The mark and the headline are given a whole line of the headline between
   them rather than the 40px they had. Written as the line rather than as the
   sum, so it stays one line if the size ever moves.

   The hairline under the hero goes. It is the section's own closing rule, and
   it lands a few pixels above the frame below it — from a phone's distance it
   reads as a scratch along the top edge of the photograph rather than as the
   end of the block above.

   Inside the frame the paragraph goes and the stack moves. The sentence is the
   studio's description, set a second time in full on the studio page and again
   under WHO WE ARE two screens below; over a photograph on a phone it was five
   lines of body copy competing with the picture. What is left is the word and
   the button — which is what the frame is for.

   With the paragraph gone the stack has no reason to sit at the top of a frame
   it no longer fills, so it goes to the foot of it, where the photograph has
   already been darkened for exactly this. Ranged right there: the frame is a
   picture with one block of type in it, and against the right edge the type
   reads as placed on the image rather than as a caption starting at its
   margin. The button keeps its own centre line, as every other button on the
   phone does.
   ========================================================================== */

@media (max-width: 700px) {
  .hero { padding-top: calc(40px + 43px * 1.06); }
  .hero .rule { display: none; }

  .band__blurb { display: none; }

  /* Both children go to the foot of the frame — the stack of words, then the
     button under it. justify-content rather than margin-top:auto on the card,
     which would push only the button down and leave the words at the top. */
  .band__overlay { justify-content: flex-end; }

  .band__lead { text-align: right; }
  /* The lines are blocks, so the alignment has to be stated on what holds
     them; the row under it carries the button and is already centred. */
  .band__word { text-align: right; }
}

/* ==========================================================================
   Phone: the achievements band runs again

   The band was set at the section's headline size and held still, on the
   reasoning that a 64px fragment of a word running off both edges — "MENTSACHIE"
   — is not a heading. That reasoning was sound while it could not move: a
   marquee that has been stopped is a word cut in half.

   Moving, the same line is what it was drawn as. It is the second thing the
   phone is let out of the still page for and it passes the same test the
   reference wall does — nothing pinned, nothing scrubbed, nothing moved but
   its own transform, and with the script off it is a line of type standing
   still. So the size goes back up to the display one, scaled to the screen it
   is on rather than to a laptop's.

   Under it the record is centred and set at twice the size. Four figures down
   a single column, each with its label under it: ranged left off a border they
   read as the left edge of a table that has no other columns, and centred with
   the rule dropped they read as four marks, which is what they are.
   ========================================================================== */

@media (max-width: 700px) {
  .achievements__band,
  .achievements__marquee { font-size: clamp(72px, 22vw, 120px); }

  /* The +100px was pushing the record clear of a band that stood still and
     took one line. It still wants clearing, at the size the band now is. */
  .record { margin-top: clamp(28px, 8vw, 56px); }
  .record__cell {
    text-align: center;
    border-left: 0;
    padding-left: 0; padding-right: 0;
  }
  .record__value,
  .odo__sizer, .odo__col span, .odo__sym { font-size: 64px; }
  .record__label span { font-size: 28px; }
}

/* ==========================================================================
   Phone: the Services label comes back into its own section

   The label is lifted 100px on a wide screen, where the section above ends in
   a stage with room to spare under it and the pull reads as the two blocks
   sharing a margin. On a phone the section above ends on the last word of the
   expertise list, and 100px lands the label inside it — "+ SERVICES" was being
   set straight through "Exhibition".
   ========================================================================== */

@media (max-width: 700px) {
  .services__label { translate: 0; }
}

/* ==========================================================================
   Phone: the gallery is the frame and the name

   Four things over the picture were drawn for a wide screen and are furniture
   on a narrow one.

   The rail of numbered marks goes. Four numerals set down the side of a frame
   are a legend for a set the reader can see the whole of anyway, and at this
   width they sit on the photograph rather than beside it.

   The look-ahead card in the bottom right goes with it. It is a second
   photograph, its own name and a counter, laid over the corner of the first —
   on a phone that is a picture inside a picture.

   Which leaves the set with no control at all, so the frame takes the gesture
   its shape already implies: a drag across it moves to the next project. See
   the swipe listeners in main.js.

   And the name doubles. It is what is left over the photograph and the one
   thing in the corner that has to be read from a phone's distance; at 24px it
   was sharing a size with the body copy two sections above.
   ========================================================================== */

@media (max-width: 700px) {
  .featured__rail { display: none; }
  .featured__corner--br { display: none; }
  .featured__name { font-size: 48px; line-height: 1.05; }

  /* The button under the Selected Work paragraph reads as that paragraph's
     last line when it sits directly under it. A line's worth of air between
     them and it reads as the answer to the sentence, which is the same move
     the opening headline and its button already make. */
  .work-intro__row .btn { margin-top: clamp(20px, 5vw, 34px); }
}

/* ==========================================================================
   Phone: the opening, third pass

   Five more, all of them about the two frames on the home page.

   The caret keeps blinking. It is the one piece of movement on the page that
   is not choreography — it is what the mark means, a cursor sitting after the
   word, and a cursor that does not blink is a letter. The phone block above
   flattens every animation on the page to nothing, which is right for
   everything it was written against and wrong for this one, so it is restated
   here — and only where the reader has not asked for less movement, which is
   the one case where a still caret is the correct answer.

   It also comes down to the size of the word it follows. 1.35em is what makes
   it read as a cursor beside a headline set smaller than it; here every line
   is one size and the caret was simply the largest thing in the block.

   The frame gets the same air under the button that the button has above it.
   It was sitting directly against it — 34px over the button and nothing under.

   And it stands 4:5 rather than 9:16. Sixteen-by-nine on end is the shape of
   the screen, so the frame filled it and the page had no room around the
   picture; 4:5 is a picture's proportion, and the gutter comes in to 12px so
   the plate reads as nearly full-width with a margin rather than as a column.

   Inside it the button centres on the stack of words instead of on the frame.
   The words are ranged right and the frame's centre is somewhere off to their
   left, so a button centred on the frame lined up with nothing.
   ========================================================================== */

@media (max-width: 700px) {
  .caret { font-size: 1em; }

  .band--hero { padding: 0 12px; margin-top: 34px; }
  /* Back to the screen's own proportion. 4:5 was tried here and gave the page
     room around the picture, but it cost the frame the thing it is for: the
     word stands in it, and at 4:5 the stack of five lines took most of the
     height it had left. The gutter stays in at 12px — that part held. */
  .band__sticky { aspect-ratio: 9 / 16; }

  /* The wrapper takes the width of its widest child — the longest line of the
     word — and hugs the right edge with it. The button then centres inside
     that width rather than inside the frame's. */
  .band__stack {
    display: flex; flex-direction: column; align-items: center;
    width: fit-content; max-width: 100%;
    margin-left: auto;
  }
  /* Stated again here: the lead is a flex item in the stack now and would
     otherwise be stretched to it, which is the same width either way but only
     by accident. */
  .band__lead { width: 100%; }
}

/* Restated outside the phone block's blanket animation reset, and only for the
   reader who has not asked for stillness. */
@media (max-width: 700px) and (prefers-reduced-motion: no-preference) {
  .caret {
    animation-duration: 1.05s !important;
    animation-iteration-count: infinite !important;
  }
}

/* The gallery is deliberately not given the hero's plate treatment.
   It was tried — 4:5, the same 12px gutter, the same 14px radius, on the
   reasoning that the two frames on the page should be the same object seen
   twice — and taken out again. The two frames are not doing the same job: the
   hero is one photograph the page is holding up, and the gallery is a set of
   four the reader moves through. Standing it in from the edges made it a
   second plate stacked under the first, and the swipe lost the full width it
   is read across. It stays the full-bleed screen it was drawn as. */

/* ==========================================================================
   Phone: one voice for the four section leads

   Under each + label the page sets a paragraph, and the four of them were
   written in four different hands: 24px at weight 600 in near-black under WHO
   WE ARE, 24 at 400 in grey under SELECTED WORK, 16 at 400 in light grey under
   WHAT WE DO, and 24 at 500 in grey again under REFERENCE PROJECTS — with
   three different leadings and two different letter-spacings between them.
   Each was right for the composition it came from on a wide screen. Stacked
   one under another in a single column they read as four different websites.

   One treatment, then: 24px, leading 1.4, regular weight, no tracking, ranged
   left. A paragraph is not a headline, which is what weight 600 and negative
   tracking were making of the first of them.

   Colour is the one thing that cannot be a single value — .dark-run does not
   restate the palette tokens, so --muted is the same 45% everywhere and would
   sit at about 2.4:1 on the anthracite. The three on the cream take the same
   token; the fourth keeps the light grey its own ground gives it. Same tone,
   resolved against what is behind it.
   ========================================================================== */

@media (max-width: 700px) {
  .statement__text,
  .work-intro__lead,
  .whatwedo__body,
  .closing__head h2 {
    font-size: 24px;
    line-height: 1.4;
    font-weight: 400;
    letter-spacing: normal;
    text-align: left;
  }

  .statement__text,
  .work-intro__lead,
  .closing__head h2 { color: var(--muted-mid); }

  /* The title above the fourth paragraph was the only centred block in the
     column. Ranged left with everything else, its weight and its size are what
     say it is a title — which is what they are for. */
  .whatwedo__title { text-align: left; }
  .whatwedo__head, .whatwedo__aside { align-items: flex-start; text-align: left; }
  /* The title carries margin: 100px -8px 0 — the side inset is an optical
     correction for display type set centred, and ranged left it simply pushes
     the first letter 8px outside the gutter every other line in the column
     holds. The 100px above it was the air a two-column composition needed
     between the label and the headline; in a single column it reads as a gap
     where something failed to load. */
  .whatwedo__title { margin: 40px 0 0; }
}
