/* Fonts are self-hosted. Loading them from Google costs a DNS lookup, a TLS
   handshake and a second round trip before a single glyph exists, which is why the
   page painted in the fallback and then reflowed into Instrument Sans on every
   refresh.

   font-display: optional is the part that removes the flash: the browser gives the
   font a short window, and if it misses it the fallback is used for that page view
   and the real font is swapped in on the next navigation. Nothing ever reflows
   mid-view. Preloaded from the same origin, it makes that window comfortably. */

@font-face {
  font-family: 'Instrument Sans';
  src: url('../fonts/instrument-sans-latin.woff2') format('woff2');
  font-weight: 400 700;
  font-style: normal;
  font-display: optional;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Geist Mono';
  src: url('../fonts/geist-mono-latin.woff2') format('woff2');
  font-weight: 400 700;
  font-style: normal;
  font-display: optional;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ============================================================================
   Umer Ansari — portfolio
   Built from the Figma frame 887:2075, a 1920x1080 slide system.
   Everything scales off one root size so the 1920 design holds its proportions
   at any width instead of being re-guessed per breakpoint.
   ========================================================================== */

:root {
  --ink: #ffffff;
  --ink-60: rgba(255, 255, 255, 0.6);
  --ink-70: rgba(255, 255, 255, 0.7);
  --rule: rgba(255, 255, 255, 0.24);
  --black: #000000;
  --cyan: #4cb3e6;
  --blue: #2050df;

  --shell: 6.25rem;   /* 100px at design width */
  --gap: 125px;       /* black between slides, so each one reads as its own frame */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

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

html {
  /* 16px at 1920 wide, floored so nothing collapses on small screens. */
  font-size: clamp(9px, 0.8333vw, 15px);
  background: var(--black);
  overflow-x: hidden;
}

body {
  margin: 0;
  background: var(--black);
  font-family: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
  font-variation-settings: 'wdth' 100;
  color: var(--ink);
  background: var(--black);
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; }

/* --- The WebGL layer -------------------------------------------------------
   One fixed canvas draws every image on the page. The DOM owns layout; each
   [data-gl] element is an invisible anchor whose rect the shader draws into,
   so the art can float, refract and lag behind the scroll without any of it
   affecting the document flow. */
/* --- Art -------------------------------------------------------------------
   Each picture is a background on a pseudo element rather than on the anchor
   itself, which keeps two motions from fighting over one transform: the anchor
   carries the scroll parallax, the pseudo element carries the drift and the lean.
   Float rides on `translate` and the velocity squeeze on `scale`, so all three
   compose instead of overwriting each other. */

[data-gl] { position: relative; }

[data-gl]::before {
  content: '';
  position: absolute;
  inset: 0;
  background-image: var(--img);
  background-position: center;
  background-repeat: no-repeat;
  background-size: contain;
}

/* Idle drift, cursor lean and the scroll squeeze are paused. They were competing
   with the authored entrance, and an accurate playback of the Figma timeline matters
   more than ambient motion. Add the `extras` class to the root to bring them back. */
html.extras [data-gl]::before {
  transform: translate3d(
    calc(var(--mx, 0) * var(--lean, 1.4rem)),
    calc(var(--my, 0) * var(--lean, 1.4rem)),
    0);
  animation: drift var(--drift-dur, 15s) ease-in-out infinite;
  animation-delay: var(--drift-delay, 0s);
}

[data-gl][data-gl-fit="cover"]::before,
[data-gl][data-gl-fit="bleed"]::before { background-size: cover; --lean: 0.6rem; }

/* Scroll speed squeezes the art very slightly, which is what ties it to the wheel. */
html.extras .pillar__art::before { scale: calc(1 + var(--vel, 0) * 0.03); }

@keyframes drift {
  0%,  100% { translate: 0 0; }
  25%       { translate: 0.45rem -0.7rem; }
  50%       { translate: -0.3rem 0.35rem; }
  75%       { translate: 0.6rem 0.5rem; }
}

/* --- Fixed chrome ---------------------------------------------------------
   The wash and the two vertical rules are properties of the window, so they sit
   outside the transformed scroll wrapper: a transform ancestor makes position:
   fixed resolve against it and quietly stop being fixed.

   The horizontals are the opposite case. They mark where one section ends and the
   next begins, so they travel with their slide. */

/* The hands live below the gradient, so they cannot sit inside the scroll wrapper:
   everything in there is one layer above the fixed chrome. They get their own fixed
   layer beneath the wash instead, which also keeps them pinned like the gradient. */
.hero-art {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  will-change: translate;
}

/* Nothing is drawn while its slide is nowhere near the viewport. */
.hero-art[hidden] { display: none; }

/* The contact slide keeps its art and its gradient inside itself. Fixed to the
   viewport the hand cannot be clipped by the frame, and the gradient tracks the window
   instead of the section, which is wrong for a section you arrive at rather than
   scroll through. */
.contact-art {
  position: absolute;
  /* Inside the frame on all four sides. The rules are the section's edge, so nothing
     belonging to it runs past them to the window. */
  inset: 3.125rem;
  overflow: hidden;
  pointer-events: none;
  z-index: 1;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
}

/* Contact does not animate. Everything above it moves, so the page arriving somewhere
   that simply is, rather than performing one more entrance, is what makes it land. */
.contact-wash {
  position: absolute;
  left: 3.125rem;
  right: 3.125rem;
  bottom: 3.125rem;
  height: 55%;
  z-index: 2;
  pointer-events: none;
  transform-origin: 50% 100%;
  background: linear-gradient(
    180deg in oklab,
    rgba(32, 80, 223, 0) 0%,
    rgba(32, 80, 223, 0.82) var(--wash-a),
    rgba(32, 80, 223, 0.82) var(--wash-a),
    rgba(45, 138, 226, 0.94) calc((var(--wash-a) + var(--wash-b)) / 2),
    rgba(45, 138, 226, 0.94) calc((var(--wash-a) + var(--wash-b)) / 2),
    var(--cyan) var(--wash-b),
    #7fc8ee calc(var(--wash-b) + (100% - var(--wash-b)) * 0.28),
    #a6dbf4 calc(var(--wash-b) + (100% - var(--wash-b)) * 0.52),
    #d2ecfa calc(var(--wash-b) + (100% - var(--wash-b)) * 0.76),
    #ffffff 100%
  );
}


/* The two hands are separate layers so each can be placed, turned and animated on
   its own. Every number is a variable, because these are the values that get moved
   a dozen times before the composition is right.

   The entrance below is the Figma Motion timeline, translated. Its deltas are read
   off the authored keyframes and applied relative to the pose on screen, so the
   timing and easing are yours while the final composition stays where it is. The
   values are expressed in vw and vh because the source frame was 1920 by 1080. */
.hand {
  position: absolute;
  width: var(--w);
  aspect-ratio: var(--ratio);
  left: var(--x);
  top: var(--y);
  rotate: var(--rot, 0deg);
  /* The grayscale and exposure are baked into the webp. A full-screen filter is
     re-rasterised every frame the element moves, which is the single most expensive
     thing you can put on a layer that animates. */
}

.hand--left  { --ratio: 1600 / 889; --w: 50%; --x: 10%; --y: -30%; --rot: 33deg; }
.hand--right { --ratio: 1800 / 993; --w: 60%; --x: 51%; --y: 25%; --rot: 36deg; }

/* Where each hand starts, as an offset from where it ends. Taken from the Figma
   keyframes originally, and now its own set of numbers, because the resting pose has
   moved a long way since and a delta only composes against the pose it was authored
   for. */
.hand--left  { --from-x: -5.8vw;  --from-y: -22.5vh; --from-s: 0.81; --from-r: -4deg;  --from-o: 0.2; }
.hand--right { --from-x: 21.6vw;  --from-y: 41.9vh;  --from-s: 0.81; --from-r: -23deg; --from-o: 0.2; }

/* Figma's easing, on every track. */
:root {
  --motion-ease: cubic-bezier(0.5, 0, 0.15, 1);
  /* One clock for the whole hero. The hands and the gradient are the same event, so
     they share this multiplier and keep the relative timing Figma authored. The text
     is deliberately not part of it: it waits for the group to land. */
  --motion-scale: 1.7;
  --hero-in: 240ms;          /* the gradient's hold behind the hands, tightened from 400 */
  --text-in: 150ms;          /* the sentence goes first: it is what the visitor came for */
  --hero-delay: 230ms;       /* just a stagger behind the sentence, not a queue */
  --halo-scale: 2.2;         /* how slowly the Expertise rings travel */
  /* A softer landing than the hero's curve, which flattens so hard at the end that a
     long duration reads as the motion dying rather than arriving. */
  --halo-ease: cubic-bezier(0.16, 1, 0.3, 1);
  --halo-travel: 1;          /* share of the authored distance; 1 is the Figma value */
  --pillar-ms: 1124ms;       /* the offering timeline, 85ms hold then 1124ms of travel */
  --pillar-hold: 85ms;
  --pillar-scale: 1.9;       /* one knob for how slowly the whole offering plays */
}

.hand {
  transition:
    translate var(--enter-ms, 1410ms) var(--motion-ease),
    scale var(--enter-ms, 1410ms) var(--motion-ease),
    rotate var(--enter-ms, 1410ms) var(--motion-ease),
    opacity var(--enter-ms, 1410ms) var(--motion-ease);
  /* No delay by default, which is what the exit uses: a transition reads its timing
     from the state it is heading towards, so leaving must not wait for the entrance's
     head start. */
  transition-delay: 0s;
}

.hero-art.hands-in .hand { transition-delay: var(--hero-delay); }

.hand--right { --enter-ms: calc(1410ms * var(--motion-scale)); }
.hand--left  { --enter-ms: calc(1097ms * var(--motion-scale)); }

/* The start pose. Because it is expressed as the absence of a class, dropping that
   class plays the whole timeline backwards on the same curve, which is how the hands
   leave when the hero does. */
.hero-art:not(.hands-in) .hand {
  translate: var(--from-x) var(--from-y);
  scale: var(--from-s);
  rotate: calc(var(--rot) + var(--from-r));
  opacity: var(--from-o, 0.2);
}

/* Registering the stops makes them animatable: an unregistered custom property has
   no type, so the browser cannot interpolate it and the gradient would jump. With
   these declared the colour bands themselves travel, which is what stops the
   entrance reading as a flat sheet fading in. */
@property --wash-a { syntax: "<percentage>"; inherits: false; initial-value: 34%; }
@property --wash-b { syntax: "<percentage>"; inherits: false; initial-value: 85%; }

/* A third layer, purely for the ambient breath. It has to be its own element because
   an animation beats a transition on the same property: run this on the entrance
   layer and the entrance stops playing, run it on the wash and the scroll-driven
   growth is overridden. Separating them lets all three compose. */
.wash-breathe {
  position: absolute;
  inset: 0;
  /* Scaling from the floor keeps the bottom edge pinned, so no amount of breath can
     lift the layer and uncover a strip of black. Nothing here translates. */
  transform-origin: 50% 100%;
  animation: washBreathe 19s ease-in-out infinite;
}

@keyframes washBreathe {
  0%, 100% { scale: 1 1;    }
  50%      { scale: 1 1.13; }
}

/* Two layers on purpose. The outer one plays the authored entrance once; the inner
   one carries the scroll-driven opacity every frame. Nesting them multiplies the two
   instead of letting the scroll value overwrite the entrance mid-flight. */
.wash-enter {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 1;
  translate: 0 0;
  scale: 1 1;
  transform-origin: 50% 100%;
  /* Scaled by the same multiplier as the hands, so slowing the hero slows all of it
     together rather than pulling the gradient out of step. */
  transition:
    opacity calc(1141ms * var(--motion-scale)) var(--motion-ease) calc(var(--hero-delay) + var(--hero-in) * var(--motion-scale)),
    translate calc(1400ms * var(--motion-scale)) var(--motion-ease) calc(var(--hero-delay) + var(--hero-in) * var(--motion-scale)),
    scale calc(1400ms * var(--motion-scale)) var(--motion-ease) calc(var(--hero-delay) + var(--hero-in) * var(--motion-scale));
}

/* Starts low and rises, so the light comes up from below the fold rather than
   switching on in place. */
html.js:not(.has-entered) .wash-enter { opacity: 0; translate: 0 14%; scale: 1 0.55; transition: none; }

.wash {
  opacity: 0;
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 55%;
  pointer-events: none;
  /* Interpolating in oklab spreads the steps evenly through the ramp instead of
     bunching them where sRGB moves slowly, which is where the banding showed. The
     blue also carries slightly less alpha, so there is less contrast to quantise
     across, and an extra stop shortens the longest slow stretch. */
  /* The pale end is where eight bits show most, because the colour changes least per
     pixel there. Four hand-placed stops along the cyan to white run shorten every
     segment, which is what breaks the steps up without laying anything over the top. */
  background: linear-gradient(
    180deg in oklab,
    rgba(32, 80, 223, 0) 0%,
    rgba(32, 80, 223, 0.82) var(--wash-a),
    rgba(45, 138, 226, 0.94) calc((var(--wash-a) + var(--wash-b)) / 2),
    var(--cyan) var(--wash-b),
    #7fc8ee calc(var(--wash-b) + (100% - var(--wash-b)) * 0.28),
    #a6dbf4 calc(var(--wash-b) + (100% - var(--wash-b)) * 0.52),
    #d2ecfa calc(var(--wash-b) + (100% - var(--wash-b)) * 0.76),
    #ffffff 100%
  );
  transition:
    opacity 220ms linear,
    --wash-a calc(1500ms * var(--motion-scale)) var(--motion-ease) calc(var(--hero-delay) + var(--hero-in) * var(--motion-scale)),
    --wash-b calc(1500ms * var(--motion-scale)) var(--motion-ease) calc(var(--hero-delay) + var(--hero-in) * var(--motion-scale));
  will-change: opacity;

  /* --wp runs 0 on the hero to 1 on Expertise. The band grows upward across that
     travel and rises slightly, so Expertise gets more light rather than less. No
     dimming, no desaturation: the colour stays exactly as it is. */
  /* --wp runs 0 to 1 as Expertise arrives. The band travels down the screen and gets
     taller at once. Both are in vh so the movement is a share of the window rather
     than of the band itself, which is what makes it legible. Downward travel is safe
     where upward was not: it pushes the ramp past the floor instead of lifting it off
     and exposing black. */
  /* Origin at the top edge, so the extra height extends downward past the floor
     rather than upward over the section. Scaling from the bottom made it grow up and
     swallow the slide, which buried the travel underneath it. */
  transform-origin: 50% 0%;
  translate: 0 calc(var(--wp, 0) * 16vh);
  scale: 1 calc(1 + var(--wp, 0) * 0.7);
}

/* No looping animation on the stops. Animating them repaints a full-screen gradient
   on every frame forever; the breath below does its work on transform alone, which
   the compositor handles without repainting anything. */

/* The bands begin compressed against the floor and open upward as it enters. */
html.js:not(.has-entered) .wash { --wash-a: 88%; --wash-b: 97%; transition: none; }

/* --- Index and the veil -------------------------------------------------------
   Twenty-four sections is more than a five-link bar can carry, and easing across all
   of them to reach one is a long look at everything in between. The index lists the
   lot and the veil covers the jump, so arriving somewhere costs a blink. */

.veil {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: var(--black);
  opacity: 0;
  pointer-events: none;
  /* The way back is slower than the way out, and the page is already there before it
     starts, so arriving reads as coming up out of the black rather than as a frame of
     black dropped in and taken away again. */
  transition: opacity 520ms var(--motion-ease);
}
.veil.is-on {
  opacity: 1;
  transition-duration: 220ms;
}

/* --- Topbar -------------------------------------------------------------------
   Above the frame rules and above the scrolling wrapper, since both are fixed and
   would otherwise sit over it. A long scroll with no way back is the thing that makes
   a site like this annoying rather than considered. */

.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 2rem;
  /* On the content shell, not the frame rules. At the rules the first and last words
     sat flush against the line. */
  padding: 2.25rem 6.25rem 3.5rem;
  font-size: 1.25rem;
  letter-spacing: -0.01rem;
  background: linear-gradient(180deg in oklab, var(--black) 46%, rgba(0, 0, 0, 0));
}
.topbar a {
  color: var(--ink-70);
  text-decoration: none;
  transition: color 300ms var(--ease);
}
.topbar a:hover,
.topbar a.is-here { color: var(--ink); }
/* Written as a compound so it beats `.topbar a`, which is one class heavier than a
   lone `.topbar__name` and was holding the name at the same dimmed grey as the links. */
.topbar a.topbar__name {
  color: #fff;
  font-size: 1.375rem;
  font-weight: 500;
  letter-spacing: -0.045rem;
}
.topbar__nav { display: flex; align-items: center; gap: 2.5rem; }

/* Above the topbar. The rules are the page's frame, so a bar sitting inside them
   should not be the thing that interrupts them. */
.grid { position: fixed; inset: 0; z-index: 6; pointer-events: none; }
.grid i { position: absolute; background: var(--rule); }
.grid i:nth-child(1) { left: 3.125rem; top: 0; width: 1px; height: 100%; }
.grid i:nth-child(2) { right: 3.125rem; top: 0; width: 1px; height: 100%; }

/* --- Slides --------------------------------------------------------------- */

#scroll {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

/* Pieces inside one bucket sit closer than the buckets do. The full gap between every
   slide made three pieces of the same work read as three unrelated ones. A chapter
   breaks the run, so adjacency is enough to tell them apart. */
.slide--work + .slide--work { margin-top: calc(var(--gap) * -0.62); }

/* The four offering sections are one stack. They share a frame, a hand and a cube, so
   any black between them reads as four separate pages of the same idea. */
.slide--pillar + .slide--pillar { margin-top: calc(var(--gap) * -1); }

/* The transformed wrapper is taken out of flow while smooth scrolling is on; a
   spacer in the body carries the height so the scrollbar still behaves. */
html.smooth #scroll {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 3;
  will-change: transform;
}

/* Touch keeps native scrolling, and there it gets the browser's own snapping, which
   the compositor runs. Nothing snaps on desktop: being pulled somewhere you did not
   ask for is what felt broken. */
html:not(.smooth) { scroll-snap-type: y proximity; }
html:not(.smooth) .slide { scroll-snap-align: start; }

.slide {
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  min-height: 40rem;
  overflow: hidden;
}

/* No slide paints a background. The page is black on the body and the fixed chrome
   sits above it, so every piece of content declares its own layer: the wash at 1,
   the hero art at 2, text at 3, the rules at 4. A slide with its own background
   would bury all of it. */
.halos, .hero__line, .contact, .expertise__inner,
.pillar, .case, .slide--title h2 { z-index: 3; }

/* Per-slide horizontals, inset from each edge as in the source frame. Two of them
   meet across every boundary, which is what reads as a division between sections. */
.slide::before,
.slide::after {
  content: '';
  position: absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background: var(--rule);
  z-index: 4;
  pointer-events: none;
}
.slide::before { top: 3.125rem; }
.slide::after { bottom: 3.125rem; }

/* --- Type scale ----------------------------------------------------------- */

.display-128 { font-size: 8rem;   line-height: 1.2; letter-spacing: -0.32rem;  margin: 0; font-weight: 400; opacity: 0.9; }
.display-72  { font-size: 4.5rem; line-height: 1.2; letter-spacing: -0.0675rem; margin: 0; font-weight: 400; }
.display-64  { font-size: 4rem;   line-height: 1.2; letter-spacing: -0.08rem;  margin: 0; font-weight: 400; }
.display-48  { font-size: 3rem;   line-height: 1.2; letter-spacing: -0.06rem;  margin: 0; font-weight: 400; }
.body-28     { font-size: 1.75rem; line-height: 1.5; margin: 0; font-weight: 400; }
.body-24     { font-size: 1.5rem;  line-height: 1.5; letter-spacing: 0.0075rem; margin: 0; font-weight: 400; }

.eyebrow {
  font-size: 2rem;
  line-height: 1.3;
  letter-spacing: -0.03rem;
  margin: 0;
  color: var(--ink);
}

/* --- 1 & 11. Hero and contact --------------------------------------------- */





/* The wash is the whole art direction: void at the crown, daylight at the floor. */

.hero__line {
  position: absolute;
  left: var(--shell);
  top: 39%;
  width: 44rem;
  margin: 0;
  font-size: 2.5rem;
  line-height: 1.16;
  letter-spacing: -0.01rem;
  font-weight: 400;
}
.hero__line .dim { color: var(--ink-60); }

.contact {
  position: absolute;
  left: var(--shell);
  top: 50%;
  transform: translateY(-50%);
}
.contact__label { margin: 0 0 0.5rem; font-size: 2.5rem; color: var(--ink-70); }
.contact__mail {
  display: inline-block;
  font-size: 4.5rem;
  line-height: 1.1;
  letter-spacing: -0.03rem;
  text-decoration: none;
  background-image: linear-gradient(currentColor, currentColor);
  background-size: 0% 1px;
  background-repeat: no-repeat;
  background-position: 0 100%;
  transition: background-size 500ms var(--ease);
}
.contact__mail:hover { background-size: 100% 1px; }

/* --- 2. Expertise --------------------------------------------------------- */



/* Clipped to the frame rather than to the slide, so nothing crosses the rules. The
   halo offsets below are measured from this box, which starts at the top rule. */
.halos {
  position: absolute;
  inset: 3.125rem 0;
  overflow: hidden;
  pointer-events: none;
}

.halo {
  position: absolute;
  pointer-events: none;
  /* This box only carries position and the entrance. The ring is painted by the
     child so the two motions cannot collide. */
}
.halo--near { left: 8.3rem;  top: 10.125rem; width: 37.5rem; height: 37.4rem; --ring: rgba(255, 255, 255, 0.24); }
.halo--far  { left: -19.2rem; top: 12.625rem; width: 62.4rem; height: 62.25rem; --ring: #ffffff; }

/* Expertise entrance, from the authored Figma timeline. Both rings sweep up and to
   the right into place; the near one also grows and turns through a half circle.
   The deltas are read off the keyframes and applied relative to the resting
   composition, and expressed in vw and vh because the source frame was 1920 by 1080.
   As with the hero, the delay lives on the entering state only, so leaving does not
   wait before it reverses. */
.halo {
  transition:
    translate calc(1191ms * var(--halo-scale)) var(--halo-ease),
    scale calc(1191ms * var(--halo-scale)) var(--halo-ease),
    rotate calc(1191ms * var(--halo-scale)) var(--halo-ease);
  transition-delay: 0s;
}

/* The ring itself lives on a child so it can keep moving after the entrance lands.
   An animation overrides a transition on the same property, so the two cannot share
   an element: the parent travels once, the child never stops. */
.halo::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid var(--ring, rgba(255, 255, 255, 0.24));
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
  animation: haloDrift 46s ease-in-out infinite;
}

.halo--far::before { animation-duration: 61s; animation-direction: reverse; }

@keyframes haloDrift {
  0%, 100% { translate: 0 0;        scale: 1;     }
  33%      { translate: 1.6% -1.2%; scale: 1.025; }
  66%      { translate: -1.1% 0.9%; scale: 0.985; }
}

.slide--expertise.is-centre .halo--near { transition-delay: calc(200ms * var(--halo-scale)); }
.slide--expertise.is-centre .halo--far  { transition-delay: calc(396ms * var(--halo-scale)); }
.halo--far { transition-duration: calc(1115ms * var(--halo-scale)); }

/* Direction, proportion and curve are the authored ones; the distance is not. Figma
   animated these across an unclipped 1920 by 1080 canvas, but here they are clipped
   to the frame, so at full distance the whole journey happens off-box and they only
   appear in the last moments. --halo-travel scales both rings together. */
.slide--expertise:not(.is-centre) .halo--near {
  translate: calc(-55.6vw * var(--halo-travel)) calc(81.9vh * var(--halo-travel));
  scale: 0.697;
  rotate: -90deg;
}
.slide--expertise:not(.is-centre) .halo--far {
  translate: calc(-40.5vw * var(--halo-travel)) calc(54.2vh * var(--halo-travel));
}


.expertise__inner {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  gap: 4rem;
  padding: 6.25rem var(--shell) 5.625rem;
}
/* Title rides the top of the section, the paragraph sits on the baseline, and the
   halos fill the space between them. */
.expertise__inner .display-72 { flex: 0 0 auto; align-self: flex-start; }
.expertise__inner .body-28 { align-self: flex-end; }
.expertise__inner .body-28 { width: 61.5rem; max-width: 55%; }

/* --- 3, 7, 9. Section titles ---------------------------------------------- */

/* A chapter opens a bucket of work. Without it nine sections of artefacts read as a
   pile of collateral, because the argument for why the pieces belong together was
   sitting in a caption. */
/* A chapter is a divider, not a page: the bucket's name over a smaller line naming
   the run it belongs to. It replaces both the lone "Work" slide and the left-hand
   block that restated the same thing a screen later. */
.slide--chapter { padding: 5rem 6.25rem; }
.chapter {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  text-align: center;
  max-width: 60rem;
}
/* Both lines at 100px, exactly as in the reference. The only difference between them
   is that Work sits at 60 per cent. */
.slide--chapter h2 { font-size: 6.25rem; letter-spacing: -0.125rem; }
.chapter__over {
  margin: 0;
  font-size: 6.25rem;
  line-height: 1.2;
  letter-spacing: -0.125rem;
  color: #fff;
  opacity: 0.6;
}

/* One word does not need a viewport. */
.slide--title {
  display: grid;
  place-items: center;
  height: auto;
  min-height: 0;
  padding: 5rem 0;
}
.slide--title h2 { position: relative; }

/* The offering slides own their black and isolate themselves, because mix-blend-mode
   blends against the backdrop inside the nearest isolated group. The scroll wrapper
   is transformed, which already forms a stacking context, so without this the overlay
   composites against transparency and lands at full strength instead of vanishing
   into the black the way it does in Figma. */
.slide--pillar {
  background: var(--black);
  isolation: isolate;
}

/* --- 4, 5, 6. Offering ------------------------------------------------------
   Built from the Figma slide: a hand descending from the top at 86 degrees, a blend
   overlay across the whole section, the glass cube sitting in the content row where
   the fingers land, and the copy to its right. */

/* Clipped to the band between the two horizontal rules, so nothing crosses either
   line. The percentages below are measured from this box, not from the slide. */
.pillar-frame {
  position: absolute;
  /* Inside the frame on all four sides. The rules are the section's edge, so nothing
     belonging to it runs past them to the window. */
  inset: 3.125rem;
  overflow: hidden;
  pointer-events: none;
  z-index: 1;
  /* Clipping this box made it a stacking context of its own, which cut the slide's
     black out of the blend group and put the overlay back at full strength. It has
     to carry the black and the isolation itself. */
  background: var(--black);
  isolation: isolate;
  /* Softens the cut. A hard clip slices the hand mid-tone and leaves a bright orange
     edge sitting exactly on the rule, which reads as a seam rather than a boundary.
     Fading the last few percent lets the art dissolve into the line instead. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
}

.pillar-hand {
  position: absolute;
  /* The Figma wrapper is the rotated bounding box; this is its centre, which is the
     one measurement that survives the rotation. */
  /* Positioned from its corner rather than centred with a transform. Individual
     properties compose as translate, rotate, scale, then transform, all about the
     same origin, so a centring translate(-50%, -50%) would run first and leave the
     rotation spinning the box around a point that is no longer its centre. Placing it
     by the corner removes the transform entirely, which frees rotate for the
     entrance. */
  /* Every number is a variable so a section can override only what differs from the
     first one. Section 1's values are the defaults. */
  left: var(--hand-x, 6.9%);
  top: var(--hand-y, 1.2%);
  width: var(--hand-w, 45%);
  aspect-ratio: var(--hand-ratio, 864 / 480);
  transform-origin: 50% 50%;
  rotate: var(--hand-rot, 86.13deg);
  z-index: 1;
}

.pillar-overlay {
  position: absolute;
  /* Full bleed, as the design has it. The texture's bright area is painted where the
     hand is, so its placement comes from the image rather than from any box I choose.
     That is exactly what a generated field cannot know, which is why this layer is a
     still and the cube is the shader. */
  left: 0;
  top: 0;
  width: 100%;
  height: 88%;
  mix-blend-mode: overlay;
  z-index: 2;
  pointer-events: none;
  transform-origin: 50% 50%;
}

/* Figma stretches this to the full box rather than fitting it, and the default
   contain sizing was letterboxing it into a square in the middle of the section. */
.pillar-overlay::before { background-size: 100% 100%; }

/* The hand sways once it has arrived, never during. It rides the image layer rather
   than the element, because the element carries the entrance and an animation
   overrides a transition on the same property. The origin sits at the shoulder end,
   so the arm pivots from where it leaves the frame and the movement reads as weight
   at the hand rather than the whole limb sliding. */
.slide--pillar.is-centre .pillar-hand__layer {
  transform-origin: 0% 50%;
  /* Held back by the length of the entrance, so the drift picks up exactly where the
     travel stops rather than fighting it on the way in. */
  animation: handSway 13s linear infinite;
  animation-delay: calc((var(--pillar-hold) + var(--pillar-ms)) * var(--pillar-scale));
}

/* Linear on purpose, with the turn traced through enough stops to read as a curve.
   Easing in and out makes the hand pause at each end of its swing, which is what made
   it feel like a loop restarting rather than something that never stops moving. */
@keyframes handSway {
  0%   { rotate: 0deg;      translate: 0 0; }
  12%  { rotate: 1.35deg;   translate: 0.5% -1.2%; }
  25%  { rotate: 2.3deg;    translate: 0.9% -2.3%; }
  37%  { rotate: 2.05deg;   translate: 0.8% -1.6%; }
  50%  { rotate: 0.6deg;    translate: 0.2% 0.3%; }
  62%  { rotate: -1.1deg;   translate: -0.4% 1.4%; }
  75%  { rotate: -2deg;     translate: -0.6% 1.7%; }
  87%  { rotate: -1.2deg;   translate: -0.35% 0.9%; }
  100% { rotate: 0deg;      translate: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .pillar-hand::before { animation: none; }
}

/* --- The offering entrance, from the authored timeline ----------------------
   Hand, overlay and cube all hold 85ms then travel for 1124ms on the same curve.
   Deltas are read off the keyframes and applied relative to the resting pose, in vw
   and vh because the source frame was 1920 by 1080. Keyed to the slide reaching the
   centre, so it replays each time you come back. */

.pillar-hand,
.pillar-overlay,
.pillar__art {
  transition:
    translate calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease),
    scale calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease),
    rotate calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease),
    transform calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease),
    opacity calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease);
  transition-delay: 0s;
}

.slide--pillar.is-centre .pillar-hand,
.slide--pillar.is-centre .pillar-overlay,
.slide--pillar.is-centre .pillar__art {
  transition-delay: calc(var(--pillar-hold) * var(--pillar-scale));
}

/* Where the hand comes from, again as overridable values. The first section's data
   is the default: it swings down and right into place, growing and turning through
   the last 19.55 degrees of its 86. */
.slide--pillar:not(.is-centre) .pillar-hand {
  translate: var(--hand-from-x, -8.27vw) var(--hand-from-y, -40.3vh);
  scale: var(--hand-from-s, 0.9185);
  rotate: calc(var(--hand-rot, 86.13deg) + var(--hand-from-r, -19.55deg));
  opacity: var(--hand-from-o, 1);
}

/* The overlay spreads: it widens far more than it grows tall, which is what makes the
   light look like it is opening out across the section rather than zooming. */
.slide--pillar:not(.is-centre) .pillar-overlay {
  translate: -1.03vw 1.68vh;
  scale: 0.7234 0.9592;
  rotate: 11.56deg;
  /* Not in the timeline, but it belongs: the light should arrive with the spread
     rather than be there waiting for it. Same duration, same hold, same curve. */
  opacity: 0;
}

/* The cube arrives colourless and gains its colour as it settles. The turn is in
   three dimensions rather than flat: a 2D rotation on an object with obvious depth
   reads as a sticker being spun, because nothing about it agrees with the perspective
   already drawn into the image. Turning it about its own vertical axis instead makes
   the same seven degrees read as the object moving.

   Saturation replaces the black veil from the fill track: a veil dims the whole thing,
   where desaturation drains it and lets the glass keep its highlights. */
.slide--pillar:not(.is-centre) .pillar__art {
  transform: perspective(1400px) rotateY(-16deg) rotateX(7deg) scale(0.88);
}

.pillar__art { transform: perspective(1400px) rotateY(0deg) rotateX(0deg) scale(1); }

/* On the image layer, so the drop shadow on the parent is not re-rendered with it. */
.pillar__art::before {
  filter: saturate(1);
  transition: filter calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease);
  transition-delay: 0s;
}

.slide--pillar.is-centre .pillar__art::before {
  transition-delay: calc(var(--pillar-hold) * var(--pillar-scale));
}

.slide--pillar:not(.is-centre) .pillar__art::before { filter: saturate(0); }

.pillar {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4rem;
  padding: 6.25rem 12.5rem;
  opacity: 0.9;
}

.pillar__art {
  width: 22.8rem;
  aspect-ratio: 256 / 213;   /* the clip path's own proportions */
  height: auto;
  flex: none;
  position: relative;
  transform-style: preserve-3d;
  filter: drop-shadow(0 2rem 2rem rgba(0, 0, 0, 0.6));
}

/* The live shader sits on top of the static art rather than replacing it, so the
   image is what shows wherever the shader is not run: phones, tablets, reduced
   motion, or no WebGL2. Nothing is ever missing. */
.pillar__art[data-shader="heatmap"] {
  -webkit-clip-path: url(#cube-clip);
  clip-path: url(#cube-clip);
}

/* The still has to frame the cube exactly as the shader does. Left to contain, the
   image sits letterboxed inside the box while the shader fills the silhouette, and
   the cross-fade between them reads as the cube suddenly growing. */
.pillar__art::before { background-size: cover; }

/* The shader lights the hand rather than replacing it, on overlay, exactly as the
   section's static overlay does. No mask: overlay against black stays black, so the
   shader's glow falls away on its own wherever there is no hand, and there is no cut
   edge for it to pile up against. Masking it is what produced the bright rim. */
/* Sections alternate hands, left then right then left then right. There is one hand
   photograph, so the other one is it mirrored: --hand-flip lives on this layer rather
   than the element above, which is already carrying the entrance. */
.pillar-hand__layer { transform: scaleX(var(--hand-flip, 1)); }

/* One layer holding both the photograph and its canvas, so every transform applies to
   the pair. Drifting the image alone slid the hand out from under its own light.

   The shader lives here rather than in a separate box above the section, which is what
   makes it cover the whole arm by construction: same element, same rotation, same
   travel, nothing to aim. The black is what lets the blend work, since rotating the
   hand makes this a stacking context and shuts the section's black out of the group. */
.pillar-hand__layer {
  position: absolute;
  inset: 0;
  background: var(--black);
}

/* The fallback, and it is a real one rather than an absence. Wherever the shader is
   not running, this texture lights the hand instead: same element, same box, same
   blend, so the section looks composed rather than unfinished. It costs one image and
   no frames, which is the whole point of stepping down to it. */
.pillar-hand__layer::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--lit-img) center / cover no-repeat;
  mix-blend-mode: overlay;
  opacity: 0.9;
  transition: opacity 400ms var(--motion-ease);
  pointer-events: none;
}

/* Never shown where a shader is going to do the same job. */
html.shaders .pillar-hand__layer::after { display: none; }

/* ?raw shows the canvas unblended, which is the only unambiguous test of whether the
   shader is drawing at all. */
html.raw .pillar-hand__layer canvas { mix-blend-mode: normal !important; }

.pillar-hand__layer canvas {
  /* The photograph is an absolutely positioned pseudo element, so it paints above a
     static child and was covering the shader completely. Positioning alone was not
     enough, since equal-weight positioned siblings fall back to DOM order and ::before
     comes first: the canvas needs to say explicitly that it is on top. */
  position: absolute;
  inset: 0;
  z-index: 1;

  /* overlay leaves black as black, so the heat stops at the hand's edge on its own
     rather than needing a mask. */
  mix-blend-mode: overlay;
}

[data-shader="heatmap"] canvas {
  display: block;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 500ms var(--motion-ease);

  /* The outer pixel row is dropped. The layer is rotated and scaled on the way in, so
     its canvas lands on fractional device pixels and that edge is covered partially;
     under overlay a half-covered pixel over black reads as a lit hairline that flickers
     until the transform settles on whole pixels. Two pixels of mask cost nothing and
     the seam has nothing to draw. */
  -webkit-mask-image:
    linear-gradient(to right, transparent 0, #000 2px, #000 calc(100% - 2px), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 2px, #000 calc(100% - 2px), transparent 100%);
  mask-image:
    linear-gradient(to right, transparent 0, #000 2px, #000 calc(100% - 2px), transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 2px, #000 calc(100% - 2px), transparent 100%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}

/* The hand's shader rises from nothing as the section arrives, on the section's own
   clock, so the light appears to come up with the hand rather than switching on when
   the module happens to finish loading. */
.slide--pillar.is-centre .pillar-hand__layer.is-live canvas {
  opacity: 1;
  transition: opacity calc(var(--pillar-ms) * var(--pillar-scale)) var(--motion-ease)
    calc(var(--pillar-hold) * var(--pillar-scale));
}

.slide--pillar:not(.is-centre) .pillar-hand__layer canvas { opacity: 0; }

[data-shader="heatmap"].is-live canvas { opacity: 1; }

/* The still is not cross-faded with the shader, it is removed after it. Cross-fading
   two things that frame the same object differently reads as the object changing
   size, which is what the jump was. The canvas fades in on top while the still holds
   position, and the still only goes once the canvas is fully opaque, by which point
   nothing is visible underneath to notice. */
[data-shader="heatmap"]::before { transition: opacity 1ms linear 520ms; z-index: 0; }
[data-shader="heatmap"].is-live::before { opacity: 0; }

/* Except the hand: its photograph is the thing being lit, so it stays. Placed after
   the rule above, which carries equal weight and would otherwise win. */
.pillar-hand__layer.is-live::before { opacity: 1; }

/* And it is lit for its form, not its colour. Overlay multiplies and screens against
   whatever sits under it, so a skin-toned photograph dragged every section back towards
   orange whichever palette its shader was handed, while the cube beside it sat on black
   and came out clean. Stripped of its own colour the hand takes the section's. */
.pillar-hand__layer::before { filter: grayscale(1); }



.pillar__copy {
  width: 46.875rem;
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 4rem;
}

.pillar__head { display: flex; flex-direction: column; gap: 1rem; }

.display-52 {
  margin: 0;
  font-size: 3.25rem;
  line-height: 1.2;
  letter-spacing: -0.049rem;
  font-weight: 400;
}

.pillar__body {
  color: var(--ink-70);
  letter-spacing: 0.0375rem;
}

/* Mirrored sections put the copy first and the art second, so the composition
   alternates rather than repeating four times. */
.slide--mirror .pillar { flex-direction: row-reverse; }

/* Per-section overrides. Only the values that differ from section 1 appear here, so
   the hand's motion reads as a short list of differences rather than four copies of
   the same rule.

   Sections 2, 3 and 4 currently reuse section 1's motion; their own timelines land
   here as they arrive. */
/* Section 2. The hand rises from the lower right instead of descending: no size
   change anywhere in the timeline, 53.5vh of climb, 14.2vw of drift left, and 65.85
   degrees unwinding into a 90 degree rest. Its own duration too, since the authored
   travel is 1215ms with no hold. */
#pillar-2 {
  /* Straight off the layout: the wrapper sits at 1410.2, 433 and measures 549.8 by
     997, which puts the unrotated image's corner at 1186.6, 656.6 on a 1920 by 1080
     canvas, less the frame's 50px inset. */
  --hand-x: 61.8%;
  --hand-y: 61.9%;
  --hand-w: 51.9%;
  --hand-ratio: 997 / 549.8;
  --hand-rot: 90deg;

  /* And off the timeline: no size change, 53.5vh of climb, 14.2vw of drift left,
     65.85 degrees unwinding, 1215ms with no hold. */
  --hand-from-x: -14.2vw;
  --hand-from-y: 53.5vh;
  --hand-from-s: 1;
  --hand-from-r: 65.85deg;

  --pillar-ms: 1215ms;
  --pillar-hold: 0ms;
}
/* Section 3. Same layout as section 1, so only the travel differs: it comes in from
   the left across 35.7vw and 18.1vh, growing from 0.9331 and turning back through
   45.72 degrees, on section 1's clock. */
#pillar-3 {
  --hand-from-x: -35.7vw;
  --hand-from-y: -18.1vh;
  --hand-from-s: 0.9331;
  --hand-from-r: -45.72deg;
}
/* Section 4. Same layout as section 2, the right hand rising from the lower right,
   with a bigger version of the same move: 45.9vh of climb, 16vw of drift, growing
   from 0.85 and unwinding 68.74 degrees over 1215ms with no hold. */
#pillar-4 {
  --hand-x: 61.8%;
  --hand-y: 61.9%;
  --hand-w: 51.9%;
  --hand-ratio: 997 / 549.8;
  --hand-rot: 90deg;

  --hand-from-x: -16vw;
  --hand-from-y: 45.9vh;
  --hand-from-s: 0.85;
  --hand-from-r: 68.74deg;

  --pillar-ms: 1215ms;
  --pillar-hold: 0ms;
}

/* --- 8, 10. Work ------------------------------------------------------------
   The section is as tall as its shots, the page does the scrolling, and the column on
   the left sticks while they pass. Nothing here is its own scroll container: making
   the gallery scroll internally trapped the wheel and gave the section a second,
   competing scrollbar. */

/* A section hugs what it holds. Shots take the gallery's full width and their height
   follows from their own ratio, so nothing is padded out to a viewport it does not
   need. */
.slide--work {
  position: relative;
  height: auto;
  /* .slide carries a 40rem floor, which is right for a slide built around one image
     and wrong for a section that hugs its shots: it was reserving 160px this section
     never filled. */
  min-height: 0;
  overflow: visible;
}
.slide--work .work__aside { height: auto; }

.work {
  position: static;
  display: flex;
  gap: 3.125rem;
  /* Inside the frame on both sides. Letting the gallery run to the edge under a fade
     read as continuing past the frame with a wide screenshot, and as a cut-off clip
     with anything narrower. */
  padding: 6.25rem;
  z-index: 3;
}

.work__aside {
  /* Pinned from the scroll loop rather than by position: sticky, which cannot work
     against a transformed wrapper. */
  position: relative;
  align-self: flex-start;
  will-change: translate;
  width: 28.125rem;
  flex: none;
  height: calc(100dvh - 12.5rem);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.work__head { display: flex; flex-direction: column; gap: 1.5rem; }
/* The number and the category are one label: a piece of work belongs to 01, and the
   heading beneath says which piece. */
.work__kicker {
  margin: 0;
  font-family: 'Geist Mono', ui-monospace, monospace;
  font-size: 1.125rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-60);
}
.work__head h2 { font-size: 2.75rem; letter-spacing: -0.055rem; }
.work__head .work__body { font-size: 1.25rem; }
.work__body { color: var(--ink-70); letter-spacing: 0.0075rem; }

/* The list is read, never opened. An accordion put four closed labels and one open
   panel in a column that rewrote itself while you were looking at the gallery beside
   it, which is churn with nothing gained: there are only ever a few rows and they all
   fit. On a work section the rows double as the gallery's index, so the one you are
   looking at is lit and the rest sit back. */
.work--notes .work__aside {
  height: auto;
  justify-content: flex-start;
  gap: 3rem;
}
.notes { display: flex; flex-direction: column; gap: 2rem; }
.note { display: flex; flex-direction: column; gap: 0.5rem; }
.note__title {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 1.375rem;
  line-height: 1.3;
  letter-spacing: -0.02rem;
  text-align: left;
}
.note__body {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.5;
  color: var(--ink-70);
}

/* --- Gallery ----------------------------------------------------------------- */

.work__gallery {
  flex: 1 1 auto;
  min-width: 0;
  /* One shot to a row. Paired two up they halved the page's length and halved the
     screenshots with it: each capture carries its own black margin, so at half width
     the site inside it was a panel floating in a frame rather than a page. Length is
     bought by carrying fewer shots, not smaller ones. */
  display: flex;
  flex-direction: column;
  /* Close enough to read as one run, far enough that two bright screenshots do not
     bleed into each other. At zero the Pitch Studio captures shared an edge. */
  gap: 2rem;
}


.work__shot {
  margin: 0;
  flex: none;
  /* One viewport each, less the section's padding, so a shot fills the frame when the
     page settles on it. */
  height: calc(100dvh - 12.5rem);
  border-radius: 0.75rem;
  background: linear-gradient(160deg, #14161c, #0a0c11);
  border: 1px solid rgba(255, 255, 255, 0.07);
  display: grid;
  place-items: center;
  color: rgba(255, 255, 255, 0.28);
  font-size: 0.875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* Screenshots run at the gallery's full width and set their own height, so a wide
   capture does not float in a viewport-tall frame with dead space above and below. */
.work__shot--image {
  height: auto;
  background: none;
  border: 0;
  display: block;
  overflow: hidden;
}
.work__shot--image img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 0.75rem;
}

/* --- Video blocks -------------------------------------------------------------
   Ported from the old portfolio, values intact. Dimmed until hovered, which is what
   tells a visitor the thing is playable before they have touched it. */

/* A clip sizes to its own aspect ratio rather than being cropped into a
   viewport-tall frame, which is what was cutting the heads and captions off. */
.work__shot--video { background: none; border: 0; padding: 0; height: auto; }
/* Each block carries the ratio of the file inside it. object-fit is cover, so a
   mismatch here is not a letterbox, it is a crop: the setup loops are 1080x1896 and
   were being poured into a 16:9 box, which threw away most of every frame. */
.video-block.landscape { width: 100%; height: auto; aspect-ratio: 16 / 9; }
.video-block.wide      { width: 100%; height: auto; aspect-ratio: 1280 / 800; }

/* Two portrait clips share one frame: a vertical video alone leaves most of the width
   empty, and side by side they read as the pair they are. */
.work__shot--pair {
  display: flex;
  gap: 2rem;
  justify-content: center;
  height: calc(100dvh - 12.5rem);
}
/* The three setup loops are one shot: a few seconds each, and they only make sense
   read together. Full width apiece put a quarter of a screen of empty product UI on
   the page three times over. */
.work__shot--stack {
  display: flex;
  gap: 0.5rem;
  /* Not stretch. A stretched item takes its height from the line rather than from its
     own ratio, and the video inside is absolutely positioned, so the line had no
     content to measure and all three collapsed to nothing. */
  align-items: flex-start;
  height: auto;
}
.work__shot--stack .video-block {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
  height: auto;
  aspect-ratio: 1080 / 1896;
}

.video-block {
  width: auto;
  aspect-ratio: 9 / 16;
  height: 100%;
}

.video-block {
  width: auto;
  aspect-ratio: 9 / 16;
  height: 100%;
}

.video-block {
  flex: 1 1 0;
  min-width: 0;
  height: auto;
  aspect-ratio: 16 / 9;
}

.video-block {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border-radius: 0.75rem;
  background: #1a1a1a;
  opacity: 0.5;
  transition: opacity 300ms var(--ease);
}
.video-block:hover { opacity: 1; }
/* A cover loop is never dimmed: it is the section, not an invitation to press play. */
.video-block.loop { opacity: 1; }

.video-block video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  max-width: none;
  max-height: none;
  display: block;
}

.video-duration {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 2;
  display: flex;
  padding: 4px 7px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.7);
  font-family: 'Geist Mono', ui-monospace, monospace;
  font-size: clamp(9px, 0.7vw, 14px);
  line-height: 1;
  color: var(--ink);
  pointer-events: none;
}

/* The elapsed half only appears on hover, so the tag reads as a length at rest and as
   a position while playing. */
.video-elapsed {
  display: inline-block;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  transition: max-width 300ms var(--ease), opacity 300ms var(--ease);
}
.video-block:hover .video-elapsed { max-width: 5em; opacity: 1; }

.video-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 400ms var(--ease), transform 400ms var(--ease), filter 400ms var(--ease);
  pointer-events: none;
}
.video-overlay.is-hidden { opacity: 0; transform: scale(0.9); filter: blur(4px); }

.video-sound-btn {
  position: relative;
  width: 11%;
  aspect-ratio: 1;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(10px);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  pointer-events: auto;
  transition: background 200ms ease, opacity 400ms var(--ease),
    transform 400ms var(--ease), filter 400ms var(--ease);
}
.video-sound-btn:hover { background: rgba(255, 255, 255, 0.22); }
.video-overlay.is-hidden .video-sound-btn { opacity: 0; transform: scale(0.9); filter: blur(4px); }

.video-sound-btn svg {
  position: absolute;
  width: 44%;
  height: 44%;
  transition: opacity 250ms ease, transform 250ms ease;
}
.video-sound-btn .icon-muted { opacity: 1; transform: scale(1); }
.video-sound-btn .icon-unmuted { opacity: 0; transform: scale(0.8); }
.video-sound-btn.is-unmuted .icon-muted { opacity: 0; transform: scale(0.8); }

/* The unmuted icon lands with a small overshoot, which is the whole feedback that the
   click did anything. */
.video-sound-btn.is-unmuted .icon-unmuted {
  opacity: 1;
  animation: btnPop 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}

@keyframes btnPop {
  0%   { transform: scale(0.6); opacity: 0; }
  60%  { transform: scale(1.1); opacity: 1; }
  100% { transform: scale(1);   opacity: 1; }
}

/* The fade the design has along the right edge, so the gallery reads as continuing
   past the frame rather than stopping at it. */
/* Out of the flex row: as a flex item it was taking 16rem of width away from the
   gallery, which is why the shots stopped short of the edge. */
/* --- Scroll-driven transitions -------------------------------------------
   Each slide publishes --p (1 below, 0 centred, -1 above) and --away (0 centred,
   1 a full viewport out). Every transition below is a function of those two, so
   the slides hand off continuously as you scroll rather than firing once when a
   threshold is crossed. Both default to 0, which means the page reads correctly
   with JavaScript switched off. */

.slide { --p: 0; --away: 0; }

/* Nothing is displaced by scroll position. Parallax moved every element against
   the page as you scrolled, which reads as the layout sliding around under you.
   The entrances below still fire when a slide reaches the centre, and the art still
   drifts and leans on its own clock, but scrolling no longer pushes anything. */

/* The headline arrives a word at a time, each one resolving out of a blur. Blur is
   the part that makes it read as coming into focus rather than merely sliding. */
html.js [data-words] .w {
  display: inline-block;
  opacity: 0;
  filter: blur(12px);
  transform: translateY(0.32em);
  /* No delay and a short duration on the way out, so leaving a slide resets every
     word together instead of unwinding the stagger in reverse. */
  transition:
    opacity 220ms var(--ease),
    filter 220ms var(--ease),
    transform 220ms var(--ease);
  transition-delay: 0s;
}
.slide--hero [data-words] .w { --hold: var(--text-in); }

html.js.is-ready .slide.is-centre [data-words] .w {
  opacity: 1;
  filter: blur(0);
  transform: none;
  transition-duration: 900ms;
  transition-delay: calc(var(--hold, 0ms) + var(--d, 0ms));
}

/* The Expertise paragraph is long, so its words carry a much tighter stagger than
   the hero headline, and the whole block still lands inside a second. */
.slide--expertise [data-words] .w { --hold: 500ms; }

/* Big type rises letter by letter as its slide reaches the middle. */
[data-split] { display: flex; overflow: hidden; }
[data-split] .ch { display: inline-block; }
[data-split] .ch--space { width: 0.28em; }

html.js [data-split] .ch {
  transform: translateY(110%);
  transition: transform 260ms var(--ease);
  transition-delay: 0s;
}
html.js.is-ready .slide.is-centre [data-split] .ch {
  transform: none;
  transition-duration: 1100ms;
  transition-delay: var(--d, 0ms);
}

/* The frame rules draw themselves in as a slide arrives. */


/* ?pose freezes everything at its resting state, which is how the composition gets
   judged without waiting on the entrance. */
html.pose *, html.pose *::before, html.pose *::after {
  transition: none !important;
  animation: none !important;
}

@media (prefers-reduced-motion: reduce) {
  html:not(.smooth) { scroll-snap-type: none; }
  .halo::before { animation: none; }
  .wash-breathe, .wash { animation: none; }
  .slide { --p: 0 !important; --away: 0 !important; }
  html.js [data-split] .ch { transform: none; transition: none; }
  html.js [data-words] .w { opacity: 1; filter: none; transform: none; transition: none; }
}

/* --- Small screens -------------------------------------------------------- */

@media (max-width: 900px) {
  :root { --shell: 1.5rem; --gap: 45px; }
  .topbar { padding: 1.25rem 2.5rem 2.25rem; font-size: 1rem; }
  .topbar__name { font-size: 1.0625rem; }
  .topbar__nav { gap: 1.25rem; }
  .topbar__nav a:not([href="#work"]):not([href="#contact"]) { display: none; }
  html { font-size: 13px; }

  .slide { height: auto; min-height: 100svh; padding: 6rem 0; }
  .slide::before { left: 1rem; }
  .slide::after { right: 1rem; }

  .hero__line { position: static; width: auto; padding: 0 var(--shell); font-size: 2rem; }
  .slide--hero { display: grid; align-items: center; }

  .expertise__inner { position: static; flex-direction: column; align-items: flex-start; gap: 2rem; padding: 0 var(--shell); }
  .expertise__inner .body-28 { width: auto; max-width: none; font-size: 1.25rem; }
  .halos { display: none; }

  .display-128 { font-size: 3.5rem; }
  .display-72 { font-size: 2.75rem; }
  .display-64 { font-size: 2.25rem; }

  .pillar { position: static; flex-direction: column; align-items: flex-start; gap: 2.5rem; padding: 0 var(--shell); }
  .pillar__copy { width: auto; gap: 2rem; }
  .pillar__art { width: 70%; height: 12rem; }
  .display-52 { font-size: 2.25rem; }
  .pillar-hand { width: 90%; top: 20%; left: 40%; }

  .work { position: static; flex-direction: column; padding: 0 var(--shell); gap: 2rem; }
  .work__aside { position: static; width: auto; height: auto; gap: 2rem; }
  .work__gallery { height: auto; gap: 1rem; }
  .work__shot { height: auto; aspect-ratio: 16 / 10; }
  .work__shot--image, .work__shot--pair { aspect-ratio: auto; }
  .work__shot--pair .video-block { width: 100%; height: auto; }
    .contact { position: static; transform: none; padding: 0 var(--shell); }
  .contact__mail { font-size: 2rem; word-break: break-word; }
}

/* ?pose freezes everything at its resting state, which is how the composition gets
   judged without waiting on the entrance. */
html.pose *, html.pose *::before, html.pose *::after {
  transition: none !important;
  animation: none !important;
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal], [data-split] .ch { transition: none; opacity: 1; transform: none; }
}
