/* ===================================================================
   Design tokens
=================================================================== */
:root{
  --yellow: #FFD700;
  --yellow-deep: #FFD700;
  --ink: #14130F;
  --ink-soft: #2b2a24;
  --cream: #FBF6E3;

  /* Height of one nav link row (its 44px min-height plus the pill's own
     0.4rem of padding). The nav is taken out of flow to turn it into a
     page-wide fixed overlay, so the hero reserves exactly this much
     room again — see the .hero padding-top below. */
  --nav-h: 3.55rem;

  /* Albert Sans is a variable font (100-900), so one family covers both
     the display and body roles — weight alone separates them. */
  --font-display: 'Albert Sans', system-ui, sans-serif;
  --font-body: 'Albert Sans', system-ui, sans-serif;

  --container: 1400px;
  --edge: clamp(1.5rem, 5vw, 5rem);
}

*, *::before, *::after{ box-sizing: border-box; }
html{ scroll-behavior: smooth; }

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

img{ max-width: 100%; display: block; }

a{ color: inherit; text-decoration: none; }

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

/* Respect reduced motion globally */
@media (prefers-reduced-motion: reduce){
  html{ scroll-behavior: auto; }
  *, *::before, *::after{
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ===================================================================
   Global grain / noise overlay
   Fixed, full-viewport, absolute top-most layer — sits above every
   other element including the hero WebGL canvas. pointer-events:none
   is critical: it lets all mouse/touch interaction pass straight
   through to the canvas and the nav/buttons beneath it.
=================================================================== */
.noise-overlay{
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  z-index: 99999;
  pointer-events: none;

  /* Self-contained fractalNoise tile as a data-URI — no network request,
     no runtime SVG filter to re-seed, nothing that can silently fail to
     paint. The tile is 300x300 and `stitchTiles="stitch"` makes it
     repeat seamlessly. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;
  background-size: 200px 200px;

  /* Blend strength, measured rather than guessed:
       overlay    @0.05 -> pixel stdev 0.10 (one grey level: invisible)
       soft-light @0.50 -> max delta 7-15  (present but very subtle)
       overlay    @0.55 -> max delta 30+   (clearly reads as film grain)
     `overlay` pushes both directions from mid-grey so it bites on the
     bright yellow as well as on the dark nav and artwork, which
     soft-light was too gentle to do. */
  mix-blend-mode: overlay;
  opacity: 1;

  /* Animate by stepping the tile around rather than re-seeding a filter:
     background-position is compositor-friendly, so this costs no
     per-frame JS and no filter re-evaluation. */
  animation: grainShift 700ms steps(1, end) infinite;
  will-change: background-position;
}

/* Eight discrete jumps per cycle — the grain reads as flickering film
   rather than sliding in one direction. */
@keyframes grainShift{
  0%   { background-position:   0px   0px; }
  12.5%{ background-position: -33px  17px; }
  25%  { background-position:  19px -41px; }
  37.5%{ background-position: -47px -23px; }
  50%  { background-position:  29px  37px; }
  62.5%{ background-position: -13px  53px; }
  75%  { background-position:  43px -11px; }
  87.5%{ background-position: -27px -49px; }
  100% { background-position:   0px   0px; }
}

/* Keep the texture, drop the flicker, for motion-sensitive users. */
@media (prefers-reduced-motion: reduce){
  .noise-overlay{ animation: none; }
}

/* ===================================================================
   Custom cursor — white outlined ring with a + in the centre.
   Sits above the hero canvas (z 90) but below the noise layer (99999),
   and never intercepts pointer events so the shader keeps receiving
   every move. Only rendered for fine pointers; touch devices skip it.
=================================================================== */
.hero-cursor{
  position: fixed;
  top: 0;
  left: 0;
  width: 2.5rem;
  height: 2.5rem;
  z-index: 90;
  pointer-events: none;
  color: #ffffff;
  opacity: 0;
  transition: opacity 220ms ease, width 220ms ease, height 220ms ease;
  will-change: transform;
  mix-blend-mode: difference;
}

.hero-cursor.is-visible{ opacity: 1; }

.hero-cursor.is-pressed{
  width: 2rem;
  height: 2rem;
}

.hero-cursor svg{
  width: 100%;
  height: 100%;
  display: block;
}

/* Hide the OS cursor over the interactive hero so only the custom one
   reads — but keep a real cursor on the nav links and buttons inside it
   so they still feel clickable. */
@media (pointer: fine){
  .hero{ cursor: none; }
  .hero a,
  .hero button{ cursor: pointer; }
}

/* ===================================================================
   Hero
=================================================================== */
.hero{
  position: relative;
  min-height: 100svh;
  width: 100%;
  overflow: hidden;
  /* The nav used to be the hero's first in-flow child; it is a fixed
     page-wide overlay now, so the hero re-reserves the same band to
     keep the wordmark, badge and CTA exactly where they were. */
  padding-top: calc(clamp(1.25rem, 3vw, 2.75rem) + var(--nav-h));
  padding-bottom: 2rem;
  background:
    radial-gradient(120% 90% at 15% 10%, rgba(255,255,255,0.18), transparent 60%),
    var(--yellow);
}

/* ---- Nav ----
   A fixed overlay that now spans the whole page, so it has to stay
   legible over four very different backdrops: the hero's yellow (and the
   dark smoke/portrait its shader reveals), the cream Services and
   Contact sections with their white cards, the yellow Portfolio section
   with its dark photographic cards.

   It does that with two states, one class apart:

     .nav-wrap              flat ink pill — the plain design colours.
     .nav-wrap.is-blended   difference — the same design colours over the
                            brand yellow, inverting to yellow wherever a
                            dark backdrop slides underneath.

   Why the blend is scoped instead of running everywhere: difference
   outputs |backdrop - source|, so the yellow source below only cancels
   to ink over the brand yellow. Over the cream sections (#FBF6E3) the
   same source renders blue (#041FE3) and over the light case-study
   screenshots a purple pill, which would repaint the nav in a palette
   the site does not own. So the blend runs where the site's difference
   language already lives — the hero — and js/nav-sticky.js drops the
   class as the hero leaves the bar.

   The blend must live HERE, on the element that owns the stacking
   context (`position: fixed` + `z-index`), not on the pill: a stacking
   context is the backdrop root for anything blended inside it, so a
   blended child would only ever see the pill and would render as if
   unblended. Same rule as .badge-magnet. */
.nav-wrap{
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above every section, below the custom cursor (90) and the grain
     overlay (99999). */
  z-index: 80;
  display: flex;
  justify-content: center;
  padding-top: clamp(1.25rem, 3vw, 2.75rem);

  /* The bar spans the full width so the pill stays centred, but only
     the pill itself should be clickable — otherwise the whole top band
     of the viewport would swallow clicks aimed at the content under it.
     Keeping the band transparent to pointers also lets hero canvas
     ripples keep firing either side of the pill. */
  pointer-events: none;
}

.nav-pill{
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  gap: clamp(0.25rem, 1.5vw, 0.5rem);
  background: var(--ink);
  border-radius: 999px;
  padding: 0.4rem;
  /* The old drop shadow is deliberately gone. With the blended state in
     play its source colour is black, and |backdrop - 0| leaves the
     backdrop untouched, so that shadow could only ever blend to
     nothing. */
}

.nav-link{
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: clamp(0.55rem, 0.3vw + 0.45rem, 0.7rem) clamp(0.9rem, 1vw + 0.5rem, 1.4rem);
  border-radius: 999px;
  font-size: clamp(0.8rem, 0.35vw + 0.7rem, 0.95rem);
  font-weight: 600;
  color: rgba(255, 215, 0, 0.82);
  white-space: nowrap;
  transition: color 200ms ease, background-color 200ms ease;
  cursor: pointer;
  min-height: 44px;
}

/* A soft gold wash: the label is already at full strength, so hover reads
   through the pill's tone rather than another text colour. */
.nav-link:hover{ background: rgba(255, 215, 0, 0.14); }

.nav-link.is-active{
  background: var(--yellow);
  color: var(--ink);
}

/* ---- Blended state (hero behind the bar) ----
   Each source below is solved so the render lands on exactly the colours
   above it, using the same |backdrop - source| maths as the headline and
   the badge:

     pill    |255,215,0 - 235,196,0|    = 20, 19,  0  the ink pill
     label   |255,215,0 -  20, 19, 15 at 88%| ≈ 207,173,13  the dim gold
     active  |255,215,0 -   0,  0,  0| = 255,215,  0  the yellow pill
     its label |255,215,0 - 255,215,0| =   0,  0,  0  ink on that yellow

   so over the hero's own yellow the blend is invisible — the bar looks
   exactly like the flat state — and the moment the shader's dark smoke
   or the portrait passes under it the whole thing flips to a yellow
   pill with ink type, the same inversion the badge and the CTA do. */
.nav-wrap.is-blended{ mix-blend-mode: difference; }

.nav-wrap.is-blended .nav-pill{ background: #ebc400; }

.nav-wrap.is-blended .nav-link{ color: rgba(20, 19, 15, 0.88); }

.nav-wrap.is-blended .nav-link:hover{ background: rgba(20, 19, 15, 0.16); }

.nav-wrap.is-blended .nav-link.is-active{
  background: #000000;
  color: var(--yellow);
}

.nav-link:focus-visible{
  outline: 2px solid var(--yellow);
  outline-offset: 2px;
}

/* ---- Rotating badge ----
   Blends against the hero canvas with `difference`, same colour maths as
   the headline: source #FFD700 reads as solid black over the flat yellow
   page and inverts to exactly the brand yellow wherever the dark smoke
   or the revealed photo passes underneath.

   The badge must NOT create its own stacking context beyond the one
   z-index needs, and must not carry `isolation` or a 3D transform —
   either would seal the blend off from the canvas below it. The spin
   animation deliberately uses a 2D `rotate` (not translateZ/rotate3d)
   for exactly that reason. */
/* Positioned catch zone: GSAP applies x/y translate here on pointer
   proximity. Some padding beyond the badge's own size widens the catch
   radius so the pull starts slightly before the cursor reaches the ring. */
.badge-magnet{
  position: absolute;
  /* Nudged right and up from clamp(4.5rem,11vh,7rem) / clamp(1rem,9.5vw,12.5rem).
     The wrapper is square but the artwork inside it is a circle, so the
     box may sit level with (or slightly behind) the nav pill's rounded
     end without the visible ring ever touching it. */
  top: clamp(3.25rem, 8vh, 5.25rem);
  right: clamp(0.75rem, 3vw, 6.5rem);
  width: clamp(13rem, 22vw, 20rem);
  aspect-ratio: 1;
  /* Query container so the centre label can size off the BADGE's own
     width (cqw) rather than the viewport. The ring text is already
     badge-relative for free — it lives in the SVG's 500-unit viewBox,
     which scales with the badge — so tying the centre text to the same
     reference is what keeps the two in constant proportion. */
  container-type: inline-size;
  z-index: 15;
  padding: clamp(0.75rem, 1.5vw, 1.5rem);

  /* The blend lives HERE, not on the inner .badge. `position:absolute`
     + `z-index` already make this element a stacking context, and a
     stacking context is the backdrop root for any mix-blend-mode inside
     it — a blended child would have nothing but itself to difference
     against and would render as if unblended. Putting the blend on the
     same element that owns the stacking context makes it composite
     against the hero (canvas + page) behind it instead.

       over the yellow page  |255,215,0 - 255,215,0| = #000000  black badge
       over the black smoke  |    0,  0,  0 - 255,215,0| = #FFD700 yellow badge

     No will-change:transform for the same reason: it would force yet
     another stacking context and re-break the blend. GSAP manages
     will-change itself for the duration of each tween. */
  mix-blend-mode: difference;
  color: var(--yellow);
}

.badge{
  position: relative;
  width: 100%;
  height: 100%;
  pointer-events: none;
  /* Colour and blend are inherited from .badge-magnet above; setting
     mix-blend-mode here would isolate it inside that wrapper again. */
  color: inherit;
}

.badge-ring{
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  animation: spin 16s linear infinite;
  /* Rotate about the exact centre so the ring never wobbles off-axis.
     This is the <svg> root element (a replaced element in CSS terms),
     so the default border-box origin is what we want — no transform-box
     override, which would change the reference box and shift the pivot. */
  transform-origin: 50% 50%;
}

.badge-ring-text{
  font-family: var(--font-body);
  font-weight: 700;
  /* Sized in the SVG's own 500-unit user space, not px: the viewBox
     scales it to whatever the badge's rendered size is — so this value
     is only "correct" for one badge diameter. The badge itself does NOT
     shrink linearly with viewport (it's clamped separately per
     breakpoint, and much more aggressively on mobile than the centre
     "Product Design" text is), so a single user-space size here goes
     out of proportion with the centre text at small sizes: at 375px the
     ring rendered at ~31% of the centre text's height instead of the
     ~55-60% it holds on desktop. Bumped and overridden per breakpoint
     below to hold that ratio steady instead of just tracking the SVG's
     own scale. textLength stays fixed to the ring's circumference
     regardless of font-size, so the wrap around the circle is unaffected. */
  font-size: 26px;
  letter-spacing: 0.1em;
  /* currentColor picks up the blend colour from .badge above. */
  fill: currentColor;
}

@keyframes spin{
  from{ transform: rotate(0deg); }
  to{ transform: rotate(360deg); }
}

.badge-core{
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  line-height: 1.05;
}

/* Scales with the badge itself (cqw would need a container query, so the
   vw form is matched to the badge's own clamp above). */
/* 13.05cqw holds the ring/centre ratio at a constant ~0.55 at EVERY badge
   size. The old `clamp(1.3rem, 2.6vw, 2.1rem)` was viewport-based, so it
   bottomed out at its 1.3rem floor on phones while the ring kept
   shrinking with the badge — the ring fell to ~31% of the centre text at
   375px versus ~55% on desktop, which is what read as "the rotating text
   is tiny on mobile". The min() floor keeps it legible if the badge is
   ever rendered very small. */
.badge-line{ display: block; font-size: max(1.05rem, 13.05cqw); }
.badge-line--bold{ font-weight: 800; }
.badge-line--light{ font-weight: 400; font-family: var(--font-body); }

/* ---- Headline copy ---- */
.hero-copy{
  position: relative;
  z-index: 10;
  /* Shifted right of the page edge; scales with the viewport so the
     indent stays proportional instead of jumping at breakpoints. */
  padding-left: clamp(1.5rem, 8vw, 11rem);
  padding-top: clamp(1.5rem, 14vh, 9rem);
  max-width: 72%;
}

/* Difference blending against the hero's own stacking context.
   Difference outputs |backdrop - source|, so the source colour is
   chosen to solve for both states at once. Setting the source to the
   page yellow itself (#FFD700 = 255,215,0):

     over the yellow page  |255,215,0 - 255,215,0| = #000000  black
     over the black smoke  |    0,  0, 0 - 255,215,0| = #FFD700 yellow

   So the headline reads as the design's solid black over the flat
   background, and inverts to exactly the brand yellow wherever the
   dark smoke or the revealed photo passes beneath it — no JS, no
   per-frame sampling. Mid-grey photo tones land between the two,
   which reads as a natural transition rather than a hard switch. */
.hero-copy--blend{
  mix-blend-mode: difference;
  color: #FFD700;
}

/* The SVG wordmark paints with fill:currentColor, so it inherits the
   blend colour set on .hero-copy--blend above. */
.hero-copy--blend .hero-line{
  color: inherit;
}

/* Isolate the hero so the headline blends against the canvas and hero
   background only — without this the blend would reach through to the
   page/body backdrop and the colours would be wrong. */
.hero{
  isolation: isolate;
}

/* Real heading for screen readers and search engines; the visible
   wordmark is the SVG pair below. Not display:none — that would hide it
   from assistive tech too. */
.hero-title-a11y{
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Shared width lock: both SVGs are the same width as their container, and
   each has textLength="1000" inside a 1000-unit viewBox, so the two words
   render at byte-identical widths on every screen. */
.hero-line{
  display: block;
  width: 100%;
  /* Caps the wordmark so it never runs under the badge or off the edge;
     the two lines stay equal because they share this same max-width. */
  max-width: min(100%, 46rem);
  height: auto;
  overflow: visible;
  fill: currentColor;
}

/* The two viewBoxes are cropped tight to their glyphs, so without this
   the cap-height of NEJADJAFARI would touch the baseline of Amirhosein.
   Scales with the type so the gap stays proportional at every size. */
.hero-line--sub{
  margin-top: clamp(0.75rem, 2.2vw, 1.9rem);
}

/* Sized so the word's NATURAL width already lands on the 1000-unit
   target — textLength then has essentially no gaps left to open, so the
   title keeps its tight, solid setting instead of being spaced out. */
.hero-line--title text{
  font-family: var(--font-display);
  font-size: 215px;   /* in the SVG's own user space, scaled by viewBox */
  font-weight: 700;
  letter-spacing: -0.07em;
}

/* The subtitle's wide tracking is intentional in the design, so it is
   deliberately left ~40% short of the target and `spacing` opens the
   gaps evenly to fill it — a real tracked-out setting, not stretched
   glyphs. */
.hero-line--sub text{
  font-family: var(--font-body);
  font-size: 113px;
  font-weight: 100;
}


/* ---- Full-section WebGL canvas ----
   Spans the entire hero section. The shader itself applies object-fit:
   cover math internally so the portrait keeps its native 749x682 aspect
   ratio at any section size — the canvas stretching to fill the section
   never stretches the textures. */
.hero-canvas{
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 1;
  touch-action: pan-y;
}

.hero-fallback{
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: right bottom;
  z-index: 1;
}

/* The [hidden] attribute is normally display:none via the UA stylesheet,
   but the class rules above (needed for layout once shown) override it
   with display:block for every element regardless of the attribute —
   without this the fallback image silently renders on top of the WebGL
   canvas at all times, masking the ripple entirely. */
.hero-canvas[hidden],
.hero-fallback[hidden]{
  display: none;
}

/* ===================================================================
   Secondary sections (stubs for nav targets)
=================================================================== */
/* The nav is a fixed overlay now, so an anchor jump must stop short of
   the viewport top by the height of that band — otherwise "Services"
   lands with its heading hidden underneath the pill. */
section[id]{
  scroll-margin-top: calc(clamp(1.25rem, 3vw, 2.75rem) + var(--nav-h) + 0.5rem);
}

.section{
  background: var(--cream);
  padding: clamp(4rem, 10vw, 7rem) 0;
}

.section--alt{ background: var(--yellow); }

.section-inner{
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 var(--edge);
}

.section-title{
  font-family: var(--font-display);
  /* The `vw + rem` form keeps a fixed base so the type scales smoothly
     with the viewport instead of collapsing on small screens. */
  font-size: clamp(1.9rem, 3vw + 0.9rem, 3.5rem);
  font-weight: 800;
  margin: 0 0 1.5rem;
  letter-spacing: -0.01em;
}

.section-lead{
  font-size: clamp(0.95rem, 0.6vw + 0.8rem, 1.25rem);
  max-width: 60ch;
  line-height: 1.7;
  color: var(--ink-soft);
}

/* ---- Services ----
   White section per the brief, so the cards read as soft grey wells
   against it rather than the white-on-cream the old stubs used. */
.services{
  background: #ffffff;
}

/* The cards run wider than the page's 1400px measure — they are
   image-led and the brief calls for them full width. The edge padding
   from .section-inner is kept, so they still breathe at the viewport
   sides rather than bleeding to the very edge. */
.services .section-inner{
  max-width: none;
}

.services-head{
  text-align: center;
  margin-bottom: clamp(2.5rem, 5vw, 4rem);
}

.services-title{
  font-family: var(--font-display);
  font-size: clamp(2.1rem, 4vw + 1rem, 3.75rem);
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1.05;
  margin: 0 0 clamp(0.5rem, 1vw, 1rem);
  color: var(--ink);
}

.services-lead{
  margin: 0;
  font-size: clamp(1rem, 0.6vw + 0.85rem, 1.25rem);
  font-weight: 300;
  color: #6b7280;
}

/* Two rows of two, running the full width of the section rather than
   the page's usual 64rem measure — the cards are image-led and read
   better big. The section's own horizontal padding is the only inset. */
.service-grid{
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1.25rem, 2vw, 2rem);
  width: 100%;
  /* The cards translate in from the right. Without this the off-screen
     start position widens the document and the page scrolls sideways. */
  overflow: hidden;
}

/* Side by side only once there is genuinely room for it. Each card needs
   the widest word of its heading plus its fanned stills, and below
   ~1100px two of those do not fit — the old `repeat(2, 1fr)` breakout
   proved it: `1fr` bottoms out at the track's min-content width, so the
   tracks grew past the container and the second card was clipped by the
   grid's own overflow:hidden. `minmax(0, 1fr)` lets the tracks shrink,
   and the min-width breakpoint guarantees the content fits inside them. */
@media (min-width: 1120px){
  .service-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Taller than the original 17-20rem so the full-width cards keep a
   generous, deliberate proportion instead of reading as wide letterbox
   strips. */
/* Horizontal banner: title on the left, artwork on the right. Row
   layout on wider screens; drops to a stacked column on phones, where
   there isn't enough width to fit both side by side at a legible size. */
.service-card{
  display: flex;
  flex-direction: column;
  gap: clamp(1.5rem, 4vw, 2.5rem);
  min-height: clamp(14rem, 20vw, 18rem);
  padding: clamp(1.5rem, 4.5vw, 3.75rem);
  border-radius: 1.75rem;
  background: #f7f7f8;
  transition: background-color 300ms ease;
  /* The card is its own query container, so the heading and the stills
     below size themselves against the card rather than the viewport —
     the only way one set of numbers can serve both a 300px phone card
     and an 800px desktop one without clipping. */
  container-type: inline-size;
  min-width: 0;
}

@media (min-width: 1120px){
  .service-card{
    flex-direction: row-reverse;
    align-items: center;
    justify-content: space-between;
    gap: clamp(1rem, 2.5vw, 2.5rem);
      align-items: center;
  }
}

@media (hover: hover){
  .service-card:hover{ background: #f0f0f2; }
}

/* The two stills are absolutely positioned and centred on top of each
   other, so this needs its own containing block. */
.service-card__media{
  position: relative;
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: clamp(8rem, 16vw, 11rem);
}

@media (min-width: 1120px){
  .service-card__media{
    flex: 0 0 auto;
    /* A share of the card instead of the viewport: 32cqi keeps the stills
       in proportion when the grid halves the card's width. */
    width: min(36cqi, 18rem);
    min-height: 0;
    align-self: stretch;
  }
}

.service-card__shot{
  position: absolute;
  /* Scaled up with the cards — a share of the media column's own width
     rather than the card's, so it keeps its proportion now that the
     media sits in a fixed-width column instead of the whole card. */
  width: min(15rem, 84%);
  height: auto;
  border-radius: 0.5rem;
  /* Transform is the only animated property (plus the parent's
     background-color), so the hover fan stays on the compositor. */
  transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}

.service-card__shot--back{
  transform: rotate(-6deg);
  box-shadow: 0 4px 12px rgba(20,19,15,0.10);
}

.service-card__shot--front{
  transform: rotate(3deg);
  box-shadow: 0 10px 24px rgba(20,19,15,0.16);
}

/* Splay the pair apart on hover. Guarded so it never fires on touch,
   where there is no hover to leave and the cards would stick open. */
@media (hover: hover){
  .service-card:hover .service-card__shot--back{
    transform: rotate(-10deg) scale(1.05);
  }
  .service-card:hover .service-card__shot--front{
    transform: rotate(5deg) scale(1.05);
  }
}
/* ---- Services on phones ----
   The card only turns into a two-column banner at 640px, so everything
   below that is the stacked single-column layout. The heading leads the
   card and the artwork follows underneath it: the DOM order is media
   first, so column-reverse is what puts the title on top without
   duplicating the heading in the markup. The fixed 24rem min-height the
   cards used to carry is gone — once the title and the fanned stills are
   the only two things in the box, the content sets the height and the
   card stops showing a pocket of empty space below the artwork. */
@media (max-width: 1119px){
  .service-card{
    flex-direction: column-reverse;
    gap: clamp(1.75rem, 6vw, 2.5rem);
    min-height: 0;
    padding: clamp(1.75rem, 6vw, 2.25rem) clamp(1.25rem, 5vw, 1.75rem);
  }

  /* Two centred lines in the stacked cards. `h3.service-card__title` is
     the selector used here (rather than the bare class) so the centre
     alignment outranks the base .service-card__title rule regardless of
     where the base rule sits in the file. */
  h3.service-card__title{
    align-items: center;
  }

  /* A full-width well under the title, with the stills sized to sit
     inside it instead of overflowing a min-height box. */
  .service-card__media{
    width: 100%;
    max-width: 22rem;
    min-height: clamp(10rem, 46vw, 13rem);
    align-self: center;
  }

  .service-card__shot{
    width: min(13rem, 68%);
  }
}


/* Two-line title: a bold filled word over a thin outlined word, both in
   the same oversized display size. */
.service-card__title{
  display: flex;
  flex-direction: column;
  margin: 0;
  line-height: 1.05;
  letter-spacing: -0.02em;
  min-width: 0;
}

/* Sized against the card, not the viewport: the longest word in a heading
   ("DEVELOPMENT", ~7x the font size) plus the stills column has to stay
   inside the card's content box at every width, and cqi is what keeps
   those two locked together. The clamp floor stops the type collapsing
   in a narrow two-up card; the phone layout overrides it back up. */
.service-card__title-line{
  font-size: clamp(1.8rem, 7.8cqi, 3.2rem);
  font-weight: 800;
  text-transform: uppercase;
}

/* Stacked cards are the full width of the page, so a cqi-sized heading
   would either hit the clamp ceiling or (on a phone) sink to the floor.
   Viewport-relative type keeps the single-column heading at a readable
   size instead of tracking the card's width. */
@media (max-width: 1119px){
  .service-card__title-line{
    font-size: clamp(2.1rem, 4vw + 1rem, 3.2rem);
  }
}

/* Solid, in the brand yellow. */
.service-card__title-line--fill{
  color: var(--yellow-deep);
  -webkit-text-stroke: 0;
}

/* Same weight and size as the line above it, but rendered as an outline
   only: a thin stroke with no fill lets the card's own background show
   through the letterforms, which is what reads as "outline" rather than
   just a thinner font weight. -webkit-text-stroke is the only way to
   stroke text in CSS; Firefox without the prefix falls back to a solid
   ink fill, which is still legible even though it loses the outline. */
.service-card__title-line--outline{
  color: #000000;
  font-weight: 100;
}

@supports not (-webkit-text-stroke: 1px black){
  .service-card__title-line--outline{
    color: var(--ink);
  }
}

/* ---- Services scroll reveal (see js/services-reveal.js) ----
   The JS wraps each heading word in .reveal-word > .reveal-word__inner
   and slides the inner span up from behind this mask. Nothing here hides
   anything on its own: the initial hidden state is set by GSAP at
   runtime, so with JS disabled — or under prefers-reduced-motion, where
   the module bails before setting anything — the section renders fully
   visible instead of being stranded at opacity 0. */
.reveal-word{
  display: inline-block;
  overflow: hidden;
  /* The descenders on "Help?" are clipped by overflow without a little
     breathing room; the negative margin keeps the line box unchanged. */
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
  vertical-align: top;
}

.reveal-word__inner{
  display: inline-block;
  will-change: transform;
}

/* The cards' resting rotation is authored in CSS, and the reveal tweens
   toward it. Both share the transform property, so the hover transition
   is scoped to hover-capable pointers only (already the case above) and
   the entry tween simply overwrites the inline transform when it runs. */

.contact-btn{
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  /* Stroke-only pill: no fill, a solid black outline. Sized up
     noticeably from the old filled version per the design update. */
  padding: clamp(1.1rem, 0.8vw + 0.9rem, 1.5rem) clamp(2.2rem, 2.2vw + 1.4rem, 3.2rem);
  background: transparent;
  border: 2px solid var(--ink);
  color: var(--ink);
  border-radius: 999px;
  font-size: clamp(1.05rem, 0.5vw + 0.9rem, 1.3rem);
  font-weight: 700;
  cursor: pointer;
  transition: background-color 200ms ease, color 200ms ease, box-shadow 200ms ease;
}

.contact-btn:hover{
  background: var(--ink);
  color: var(--yellow);
  box-shadow: 0 10px 24px rgba(20,19,15,0.25);
}

.contact-btn:focus-visible{
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* CTA row. Matches .hero-copy's left indent so the button lines up with
   the wordmark, while staying outside the blended subtree.

   Deliberately NOT `position:relative; z-index:10`. That pair creates a
   stacking context on this wrapper, and a stacking context becomes the
   backdrop root for any mix-blend-mode inside it — the button would then
   have nothing but itself to difference against and would render as if
   unblended. The button still paints above the canvas (z-index 1)
   without it, because a positioned/blended descendant in later DOM order
   already wins the paint order. */
.hero-cta{
  padding-left: clamp(1.5rem, 8vw, 11rem);
}

/* ---- Magnetic CTA in the hero ----
   The wrapper is the magnet's catch area: it is padded out beyond the
   visible pill so GSAP starts easing the button toward the cursor while
   the pointer is still approaching, then releases it on leave. */
.magnetic{
  display: inline-block;
  /* Above the hero canvas (z-index 1) so the whole catch radius is
     hit-testable — otherwise the canvas swallows the magnet's
     pointermove. This makes the zone a stacking context, so the blend
     has to live on this same element (see below), never on the pill
     inside it. */
  position: relative;
  z-index: 2;
  margin-top: clamp(2rem, 3.5vw, 0rem);
  /* Invisible catch radius around the pill. */
  padding: clamp(1.25rem, 2.5vw, 2.25rem);
  margin-left: calc(-1 * clamp(1.25rem, 2.5vw, 2.25rem));
  /* No static will-change:transform: it creates a stacking context just
     like a real transform would, which seals off .contact-btn's
     mix-blend-mode:difference from the page behind it. GSAP toggles
     will-change itself only while a tween is actually running. */
}

/* Inside the magnet, GSAP applies the transform to .magnetic itself
   (see js/magnetic.js), so the shared .contact-btn hover translate must
   not also write to the same property on this element. */
.magnetic .contact-btn{
  transition: box-shadow 200ms ease;
}

.magnetic .contact-btn:hover{
  box-shadow: 0 14px 30px rgba(20,19,15,0.28);
}

/* Same solved colours as the badge. Setting the source to the page
   yellow makes `difference` cancel to black over the flat page and
   invert to yellow wherever the black smoke passes beneath the pill:

     over the yellow page  |255,215,0 - 255,215,0| = #000000  black stroke
     over the black smoke  |    0,  0,  0 - 255,215,0| = #FFD700 yellow stroke

   The blend sits on the pill itself, and GSAP's magnet transform is
   pointed at this same element (data-magnetic-move) so the transform
   never lands on an ancestor and seals the blend off. Scoped to the
   hero instance — the Contact-section button keeps its literal ink. */
.magnetic{
  mix-blend-mode: difference;
}

.magnetic .contact-btn{
  border-color: var(--yellow);
  color: var(--yellow);
}

/* Hover solves the same way: a yellow-sourced fill cancels to a solid
   black pill, with the label inverting to yellow against it. */
.magnetic .contact-btn:hover{
  background: var(--yellow);
  color: #000000;
}

.contact-btn__label{
  display: inline-block;
  will-change: transform;
}

/* No magnet on touch — there is no hover to track, and the padding
   would only add dead space around the tap target. */
@media (hover: none){
  .magnetic{ padding: 0; margin-left: 0; }
}

/* ===================================================================
   Responsive breakpoints
=================================================================== */
@media (max-width: 1024px){
  /* Narrower here so the wordmark clears the badge, which is now large
     enough to reach into the copy column at this width. */
  .hero-copy{ max-width: 58%; }
}

@media (max-width: 768px){
  .nav-pill{ transform: scale(0.92); }

  /* Stack order flips on phones. Side by side there is no room for both
     the wordmark and the badge at a legible size, so the wordmark drops
     into the upper band below the nav and the badge hangs on the
     artwork's own top-LEFT corner.

     That corner is not a guess. The shader draws the portrait with
     object-fit: cover, focus (0.5, 0.14) measured from the bottom and
     scale 0.98 (see layout() in js/fluid-hero.js), so the artwork's top
     edge sits exactly

       (0.14 * heroHeight) + (0.49 * 0.98 * imageW/imageH * heroWidth)
       = 14% + 44.55vw

     up from the hero's bottom edge — 292px on a 390x844 phone. Inverting
     that gives the corner's distance from the hero's top, which is where
     the badge is anchored:

       86% - 44.55vw

     so it hangs its top edge on the artwork's own top-left corner at
     every phone aspect ratio instead of at one hard-coded offset, since
     both the band and the badge scale from the same two viewport
     lengths. Hanging *down* from the line rather than straddling it is
     what keeps the wordmark and the full-width CTA clear of the badge:
     on a 320x568 screen the type block already reaches y~308 while the
     artwork only starts at y~354, so a badge rising above the line would
     run straight into the button. */
  .badge-magnet{
    top: calc(86% - 44.55vw);
    bottom: auto;
    left: 1.25rem;
    right: auto;
    width: clamp(9.5rem, 42vw, 12rem);
  }
  /* Ring text needs no override: it is in the SVG's 500-unit user space,
     so it scales with the badge automatically — and the centre label is
     now cqw-based against that same badge, so the two stay locked in
     proportion without any per-breakpoint font-size tuning. */

  /* Wordmark drops out from under the nav into the empty upper band, so
     the yellow top of the hero stays open and the type sits closer to the
     portrait it belongs to. Scales with viewport height rather than width
     so the drop stays proportionate on short phones. */
  .hero-copy{
    max-width: 100%;
    padding-top: clamp(2.5rem, 8vh, 6rem);
    padding-right: 1.5rem;
    text-align: left;
  }

  /* CTA goes full width on phones, using the wordmark's own left indent
     and right margin so the pill lines up with the type above it. */
  .hero-cta{
    padding-left: clamp(1.5rem, 8vw, 11rem);
    padding-right: 1.5rem;
  }

  .hero-cta .magnetic{
    display: block;
    width: 100%;
    /* No magnet on a phone-width layout either: the catch radius would
       only add dead space around a pill that now spans the screen. */
    padding: 0;
    margin-left: 0;
  }

  .hero-cta .contact-btn{
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
  }

  .hero{ padding-bottom: 0; }
}

/* No type overrides needed at small sizes: the wordmark is SVG, so it
   scales with its container and the two lines stay locked to the same
   width automatically. */

/* ===================================================================
   Case studies — filter chips, image cards with a progressive blur band,
   and the "View more" pager (see js/case-studies.js)
=================================================================== */
.case-head{
  margin-bottom: clamp(0.75rem, 1.5vw, 1.25rem);
}

.case-head .section-lead{ margin: 0; }

/* Chips: outline pills at rest, solid ink with yellow type once chosen, so
   the active filter reads at a glance against the yellow section. */
.case-filters{
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: clamp(1.5rem, 3vw, 2.25rem) 0 clamp(1.75rem, 3.5vw, 2.75rem);
}

.filter-chip{
  appearance: none;
  font-family: inherit;
  font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
  font-weight: 600;
  line-height: 1;
  padding: 0.85rem 1.35rem;
  border: 1.5px solid rgba(20, 19, 15, 0.28);
  border-radius: 999px;
  background: transparent;
  color: var(--ink);
  cursor: pointer;
  transition: background-color 220ms ease, border-color 220ms ease, color 220ms ease;
}

.filter-chip:hover{
  border-color: var(--ink);
  background: rgba(20, 19, 15, 0.07);
}

.filter-chip.is-active{
  background: var(--ink);
  border-color: var(--ink);
  color: var(--yellow);
}

.filter-chip:focus-visible{
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* On phones the four labels stack into three rows and push the grid far
   down the page. A single swipeable row keeps the section compact; the
   strip is padded out to the screen edge so a chip isn't half cut off at
   the start of the scroll. */
@media (max-width: 560px){
  .case-filters{
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    padding-right: var(--edge);
    margin-right: calc(-1 * var(--edge));
    padding-bottom: 0.25rem;
  }

  .case-filters::-webkit-scrollbar{ display: none; }
  .filter-chip{ flex: 0 0 auto; }
}

.case-grid{
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1rem, 2vw, 1.75rem);
}

@media (min-width: 720px){
  .case-grid{ grid-template-columns: repeat(2, 1fr); }
}

/* Filtered out / not-yet-paged cards. Explicit !important because the
   card layout below sets display on the same element. */
.case-grid [hidden],
.case-more[hidden]{ display: none !important; }

.case-card{
  position: relative;
  border-radius: 1.5rem;
  overflow: hidden;
  background: var(--ink);
  /* 4:3 landscape gives the image room to read as a picture while the
     blurred band still has a generous home for two lines of type. */
  aspect-ratio: 4 / 3;
  isolation: isolate;
}

@media (max-width: 520px){
  .case-card{ aspect-ratio: 5 / 4; border-radius: 1.25rem; }
}

.case-card__link{
  display: block;
  position: relative;
  height: 100%;
  text-decoration: none;
  color: inherit;
}

.case-card__link:focus-visible{
  outline: 3px solid var(--yellow);
  outline-offset: -3px;
  border-radius: inherit;
}

/* The photo is the whole box. */
.case-card__img{
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.01);
  transition: transform 900ms cubic-bezier(0.22, 1, 0.36, 1);
}

.case-card:hover .case-card__img,
.case-card:focus-within .case-card__img{
  transform: scale(1.06);
}

/* ---- Progressive blur band ----
   Six stacked layers, each blurred harder than the one before and each
   masked to fade in lower down the card. Where the ramps overlap the
   blurs compound, so the image melts into the caption area instead of
   hitting a visible haze line. The dark gradient underneath keeps the
   white type legible even over a bright screenshot. */
.case-card__blur{
  position: absolute;
  inset: auto 0 0 0;
  height: 62%;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgba(20, 19, 15, 0) 0%,
    rgba(20, 19, 15, 0.18) 38%,
    rgba(20, 19, 15, 0.5) 72%,
    rgba(20, 19, 15, 0.66) 100%
  );
}

.case-card__blur-layer{
  position: absolute;
  inset: 0;
  -webkit-backdrop-filter: blur(0.5px);
  backdrop-filter: blur(0.5px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 100%);
}

.case-card__blur-layer:nth-child(2){
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 10%, #000 42%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 10%, #000 42%, #000 100%);
}

.case-card__blur-layer:nth-child(3){
  -webkit-backdrop-filter: blur(2.5px);
  backdrop-filter: blur(2.5px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 24%, #000 56%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 24%, #000 56%, #000 100%);
}

.case-card__blur-layer:nth-child(4){
  -webkit-backdrop-filter: blur(5px);
  backdrop-filter: blur(5px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 40%, #000 70%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 40%, #000 70%, #000 100%);
}

.case-card__blur-layer:nth-child(5){
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 56%, #000 84%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 56%, #000 84%, #000 100%);
}

.case-card__blur-layer:nth-child(6){
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  -webkit-mask-image: linear-gradient(to bottom, transparent 68%, #000 96%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 68%, #000 96%, #000 100%);
}

/* Caption sits on the blurred part of the photo: title + subtitle left,
   arrow right. */
.case-card__body{
  position: absolute;
  inset: auto 0 0 0;
  z-index: 2;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  padding: clamp(1.15rem, 2.4vw, 1.9rem);
}

.case-card__text{
  display: block;
  min-width: 0;
}

.case-card__title{
  display: block;
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 1.4vw + 0.9rem, 1.7rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.15;
  color: #ffffff;
}

.case-card__subtitle{
  display: block;
  margin-top: 0.35rem;
  font-size: clamp(0.82rem, 0.35vw + 0.75rem, 0.95rem);
  font-weight: 400;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.74);
  max-width: 34ch;
}

.case-card__arrow{
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: clamp(2.5rem, 3.2vw, 3rem);
  aspect-ratio: 1;
  border: 1.5px solid rgba(255, 255, 255, 0.55);
  border-radius: 50%;
  color: var(--yellow);
  background: rgba(255, 255, 255, 0.06);
  transition: background-color 260ms ease, color 260ms ease, border-color 260ms ease,
              transform 260ms ease;
}

.case-card__arrow svg{
  width: 42%;
  height: auto;
}

.case-card:hover .case-card__arrow,
.case-card:focus-within .case-card__arrow{
  background: var(--yellow);
  border-color: var(--yellow);
  color: var(--ink);
  transform: translate(3px, -3px);
}

@media (prefers-reduced-motion: reduce){
  .case-card__img,
  .case-card__arrow{ transition: none; }
  .case-card:hover .case-card__img,
  .case-card:focus-within .case-card__img{ transform: none; }
}

/* Screen-reader-only status line announcing how many studies are shown. */
.case-status{
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.case-more{
  display: flex;
  justify-content: center;
  margin-top: clamp(2.25rem, 4vw, 3.25rem);
}

/* Same outline pill language as the Contact button. */
.case-more__btn{
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  min-height: 44px;
  padding: clamp(0.95rem, 0.7vw + 0.75rem, 1.15rem) clamp(2rem, 2vw + 1.2rem, 2.75rem);
  background: transparent;
  border: 2px solid var(--ink);
  border-radius: 999px;
  color: var(--ink);
  font-family: inherit;
  font-size: clamp(1rem, 0.35vw + 0.9rem, 1.15rem);
  font-weight: 700;
  cursor: pointer;
  transition: background-color 200ms ease, color 200ms ease, box-shadow 200ms ease;
}

.case-more__btn:hover:not(:disabled){
  background: var(--ink);
  color: var(--yellow);
  box-shadow: 0 10px 24px rgba(20, 19, 15, 0.22);
}

.case-more__btn:focus-visible{
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.case-more__btn:disabled{
  cursor: progress;
  opacity: 0.75;
}

.case-more__spinner{
  display: none;
  width: 1.05rem;
  height: 1.05rem;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: case-spin 700ms linear infinite;
}

.case-more__btn.is-loading .case-more__spinner{ display: block; }

@keyframes case-spin{
  to{ transform: rotate(360deg); }
}

@media (max-width: 1120px){
  .h3.service-card__title{
      align-items: center;
  }
}

