/* ═══════════════════════════════════════════════════════════════════
   The Transformation — scroll-scrubbed build sequence.
   Inherits every token from style.css; this file only adds the page.
   ═══════════════════════════════════════════════════════════════════ */

.tf-body{ background:var(--black) }

/* .sr-only used to be declared here. It moved to style.css when
   contact.html was added: that page uses it in the shared footer and
   does not load this file, so the heading it hides was rendering as a
   visible line of text across the foot of the page. A utility every
   page needs belongs in the file every page loads. */

/* ═══ the scrub section ═══════════════════════════════════════════ */
/* .tf__track supplies the scroll budget; .tf__stage sticks inside it.
   Native sticky keeps the pin on the compositor — no pin-spacer, no
   measured offsets, so nothing can shift when the video loads. */

/* The one section on the site that is a photograph edge to edge, so it
   sets its own colour context rather than inheriting the page's. Without
   this every heading inside it takes body's deep brown and disappears
   into the footage — the explicit rules further down would still be
   right, and the ones that inherit would all be wrong. The ground stays
   the video's own black for the moment before it loads. */
.tf{
  position:relative;
  background:#0E0E10;
  color:var(--on-media);
}
.tf__track{ position:relative; height:520vh }

.tf__stage{
  position:sticky; top:0;
  height:100vh; height:100dvh;
  overflow:hidden;
  display:grid;
  isolation:isolate;
}

/* ── media ────────────────────────────────────────────────────────── */
.tf__media{ position:absolute; inset:0; margin:0; z-index:0 }

.tf__video{
  width:100%; height:100%;
  object-fit:cover;
  object-position:center;
  /* nudge the framing so the corridor's vanishing point sits clear of
     the caption column on tall/narrow viewports */
  will-change:auto;
  background:#0E0E10;
}

/* ── the hand-off ─────────────────────────────────────────────────── */
/* Paper, wiped up over the footage in the last tenth of the scroll. The
   site opens every framed thing on it with an upward inset wipe — the
   figures, the plates, the portraits — so the page arriving over the
   film is the same gesture at the scale of a whole screen.

   Above the captions rather than under them: a paper veil beneath light
   type would leave the last caption white on white for the length of
   the dissolve. It rides over everything, and the caption is on its own
   way out underneath it. */
/* .tf__out is gone — see the note where it used to sit in index.html.
   It painted var(--black), which on this palette is #FDFBF7: the token
   kept its name through the move to a light page and the panel went on
   wiping to what it thought was black. */

.tf__scrim{
  position:absolute; inset:0; pointer-events:none;
  background:
    linear-gradient(180deg, rgba(14,14,16,.80) 0%, rgba(14,14,16,.20) 26%,
                            rgba(14,14,16,.16) 58%, rgba(14,14,16,.88) 100%),
    linear-gradient(90deg,  rgba(14,14,16,.72) 0%, rgba(14,14,16,.10) 56%,
                            rgba(14,14,16,.22) 100%);
}

/* ── opening title ───────────────────────────────────────────────── */
.tf__intro{
  position:relative; z-index:2;
  align-self:center; justify-self:start;
  max-width:min(46rem, 88vw);
  padding:0 var(--gut);
  will-change:transform, opacity;
}

.tf__title{
  /* Sized so each line of the headline sits on ONE line at every width.
     At the old 8.2vw it wrapped to four, which stacked with the brand
     mark to overflow the stage — and two full lines read far stronger
     than four broken ones. */
  font-size:var(--fs-hero);
  line-height:.94;
  margin-bottom:clamp(1rem,2.4vh,1.6rem);
}
.tf__title em{
  font-style:normal;
  background:linear-gradient(180deg,#EFC49B 0%,var(--on-media-accent) 52%,#B8845A 100%);
  -webkit-background-clip:text; background-clip:text; color:transparent;
}

.tf__lede{
  max-width:34ch;
  font-size:var(--fs-body);
  color:var(--on-media-dim);
}

/* ── stage captions ──────────────────────────────────────────────── */
.tf__stages{
  position:absolute; z-index:2;
  left:var(--gut); bottom:clamp(4.5rem,13vh,8rem);
  width:min(30rem, calc(100% - 2*var(--gut)));
}

.tf__stage-item{
  grid-area:1/1;
  display:grid;
  grid-template-columns:auto 1fr;
  grid-template-areas:"num name" ". desc";
  column-gap:clamp(.9rem,2vw,1.4rem);
  row-gap:.5rem;
  opacity:0;
  will-change:transform, opacity;
}
.tf__stages{ display:grid }

.tf__num{
  grid-area:num;
  font-family:var(--display);
  /* --fs-fine, not --fs-body, though the old value rounded nearer body.
     This is an ordinal marker standing beside a --fs-major word; at body
     size it stops labelling the stage and starts competing with it.
     Four steps down is what keeps it a marker. */
  font-size:var(--fs-fine);
  letter-spacing:.16em;
  color:var(--on-media-accent);
  /* optical baseline match to the cap-height of .tf__name, which is
     smaller now that the numeral is */
  padding-top:.62em;
}
.tf__name{
  grid-area:name;
  font-family:var(--display); font-weight:400;
  font-size:var(--fs-major);
  line-height:1; letter-spacing:.01em;
  text-transform:uppercase;
  color:var(--on-media);
}
.tf__desc{
  grid-area:desc;
  max-width:34ch;
  font-size:var(--fs-body);
  line-height:1.6;
  color:var(--on-media-dim);
}

/* ── progress rail ───────────────────────────────────────────────── */
.tf__rail{
  position:absolute; z-index:2;
  right:var(--gut); top:50%; transform:translateY(-50%);
  display:flex; align-items:center; gap:.9rem;
}
.tf__rail-line{
  position:relative; display:block;
  width:1px; height:clamp(9rem,26vh,15rem);
  background:var(--hair);
  order:2;
}
.tf__rail-line i{
  position:absolute; inset:0 0 auto 0; display:block;
  height:0%;
  background:linear-gradient(180deg,var(--bronze-lt),var(--bronze));
  will-change:height;
}
.tf__ticks{
  order:1;
  list-style:none; margin:0; padding:0;
  display:flex; flex-direction:column;
  justify-content:space-between;
  height:clamp(9rem,26vh,15rem);
  text-align:right;
}
.tf__ticks li{
  font-size:var(--fs-micro); letter-spacing:.2em;
  color:rgba(244,241,236,.5);
  transition:color .45s var(--ease-out);
}
.tf__ticks li.is-on{ color:var(--on-media-accent) }

/* ── scroll cue ──────────────────────────────────────────────────── */
.tf__cue{
  position:absolute; z-index:2;
  left:50%; bottom:clamp(1rem,2.6vh,1.8rem);
  transform:translateX(-50%);
  display:flex; flex-direction:column; align-items:center; gap:.55rem;
  will-change:opacity;
}
.tf__cue-track{
  position:relative; display:block; overflow:hidden;
  width:1px; height:clamp(24px,4.4vh,40px);
  background:var(--on-media-hair);
}
.tf__cue-thumb{
  position:absolute; inset:0; display:block; background:var(--on-media-accent);
  animation:tfCue 2s var(--ease-in-out) infinite;
}
.tf__cue-label{
  font-size:var(--fs-micro); letter-spacing:.28em; text-transform:uppercase;
  color:rgba(244,241,236,.55);
}
@keyframes tfCue{
  0%{ transform:translateY(-100%) }
  55%,100%{ transform:translateY(100%) }
}

/* ═══ responsive ══════════════════════════════════════════════════ */

/* tablet & below: rail off, captions get the full width */
@media (max-width:900px){
  .tf__rail{ display:none }
  .tf__track{ height:460vh }
  .tf__stages{ bottom:clamp(3.5rem,11vh,6rem) }
}

@media (max-width:640px){
  .tf__track{ height:420vh }
  .tf__cue{ display:none }
  .tf__stages{
    left:var(--gut); right:var(--gut); width:auto;
    /* lifted clear of the floating contact button. These captions are
       pinned, not scrolling, so an overlap here would sit on the text for
       the whole section rather than passing by. */
    bottom:clamp(5.75rem,15vh,7.5rem);
  }
  .tf__stage-item{
    grid-template-columns:auto 1fr;
    column-gap:.8rem;
  }
  .tf__desc{ font-size:var(--fs-fine) }
  /* The corridor reads better slightly off-centre on portrait — but
     only when the film being shown is the wide one, most of which is
     off both edges at this shape. The portrait cut was taken around
     this very point, so nudging it a second time would walk the
     fireplace out of frame. js/transformation.js sets .is-cut when it
     swaps that file in. */
  .tf__video{ object-position:62% center }
  .tf__video.is-cut{ object-position:center }
  .tf__scrim{
    background:
      linear-gradient(180deg, rgba(14,14,16,.82) 0%, rgba(14,14,16,.28) 24%,
                              rgba(14,14,16,.34) 52%, rgba(14,14,16,.93) 100%);
  }
}

/* landscape phones — the stage is short, so shrink the type */
@media (max-height:520px) and (orientation:landscape){
  .tf__title{ font-size:var(--fs-head) }
  .tf__lede{ display:none }
  .tf__name{ font-size:var(--fs-sub) }
  .tf__desc{ display:none }
  .tf__cue{ display:none }
}

/* ═══ reduced motion ══════════════════════════════════════════════ */
/* No scrub, no pin: show the finished room and stack the captions as a
   plain readable list. */
@media (prefers-reduced-motion:reduce){
  .tf__track{ height:auto }
  .tf__stage{ position:relative; height:auto; min-height:0; display:block }
  .tf__media{ position:relative; aspect-ratio:16/9 }
  .tf__intro{ padding-block:clamp(3rem,10vh,6rem) }
  .tf__stages{
    position:relative; left:auto; bottom:auto; width:auto;
    display:grid; gap:2.5rem;
    padding:clamp(3rem,8vh,5rem) var(--gut);
  }
  .tf__stage-item{ grid-area:auto; opacity:1 !important; transform:none !important }
  .tf__cue, .tf__rail{ display:none }
  .tf__cue-thumb{ animation:none }
}

/* ═══════════════════════════════════════════════════════════════════
   THE ARRIVAL — the other half of the loading screen.

   css/loader.css takes the screen off. This puts the page on. They are
   one movement, and the two files are joined by two classes on <html>:

     .is-loading   set by the inline script in the head of index.html,
                   before the first paint. It holds everything below
                   parked. The reader never sees the parked state — the
                   loading screen is on top of it the whole time it is
                   set — which is the only reason a page may be parked
                   at all.

     .is-revealed  added by js/loader.js in the same statement block
                   that removes .is-loading. It carries no values, only
                   transitions. That division is the whole mechanism:
                   remove the class holding the parked values and the
                   element's natural style applies, and .is-revealed is
                   what supplies the curve to get there by. Both happen
                   in one style recalculation, so the browser sees a
                   changed value and a means of changing it, and the
                   page arrives instead of appearing.

   It is here rather than in style.css because the landing page is the
   only page with a loading screen, and this file is the only sheet the
   landing page has to itself. The brand and burger rules are the two
   exceptions — they belong to the shared header — but they are scoped
   to classes that exist on no other page, so they are inert everywhere
   else, and keeping the whole gesture in one file is worth more than
   filing two rules where their elements live.

   ─────────────────────────────────────────────────────────────────
   WHY THE DELAYS ARE THE NUMBERS THEY ARE
   The loading screen does not fade any more. It leaves as a curtain —
   the same rising clip-path wipe the project pages open with — so the
   page underneath is not revealed evenly. It is uncovered from the
   bottom up, and the edge is somewhere specific at every moment:

     405ms  the scroll cue, at the very bottom
     498    the lede
     519    the second line of the headline
     524    halfway up — the rail, and the middle of the screen
     538    the first line of the headline
     560    the eyebrow
     695    the brand mark
     753    the burger
     940    the top edge, and the screen is gone

   (Those are the wipe's own curve solved for each element's height on a
   812px viewport — cubic-bezier(.77,0,.175,1) over 800ms after a 140ms
   lead. They move a little with viewport height; the ordering does not,
   because the ordering is just what is above what.)

   Every delay below is that number minus about sixty. Each thing is
   already moving by the time the edge clears it, so nothing is ever
   uncovered sitting still — which is the one thing that would give the
   game away, because a static element under a moving edge reads as a
   photograph being unveiled rather than as a page arriving.

   The consequence is that the whole page starts inside a 345ms band
   rather than being read out top to bottom over a second. That is
   correct now. The wipe is the transition; this is what the wipe
   uncovers, and it should not be performing a second show underneath.

   ─────────────────────────────────────────────────────────────────
   WHY EVERY RULE TARGETS A CHILD
   js/transformation.js writes inline opacity and transform onto
   [data-intro], [data-cue] and .tf__media on every frame the section is
   on screen — including at scroll 0, where it writes the identity
   values. An inline style beats a stylesheet, so anything animated on
   those three elements would be overwritten within a frame and never
   move. Nothing here touches them. It animates what is inside them:
   the eyebrow, the line masks, the lede, the cue's track and label, the
   video itself. Move a rule up onto its parent and it stops working —
   silently, and only once the scrub script has loaded.

   The rail and the two chrome elements are transformed in their own
   right (.tf__rail is translateY(-50%), .tf__cue is translateX(-50%)),
   so where a transform would collide the rule uses opacity alone.

   ─────────────────────────────────────────────────────────────────
   THE ORDER
   Bottom to top, following the edge. It is not the order a page is
   read in and it is not meant to be — it is the order a curtain
   uncovers a stage, which is the only order that can look right
   underneath one.

     0ms     the film settles out of 1.09           1600ms
     120     the scrim deepens                       800
     345     the scroll cue                          700
     440     the lede                                800
     460     the progress rail                       700
     470     the eyebrow rule draws                  700
     470     "Every client" up through its mask     1000
     500     "has a story"                          1000
     510     the eyebrow                             640
     640     the brand mark                          700
     690     the burger                              700

   Two places where the edge and the sentence disagree, both resolved
   in favour of the sentence and both by less than 40ms, which is under
   the threshold at which anyone can say which of two things moved
   first: the headline's second line is uncovered 19ms before its
   first, and the eyebrow is uncovered after the headline it introduces.
   Ordering those by the wipe would be technically faithful and would
   read as the page being assembled backwards.

   The last of it lands at 1600ms — the film, which is the slowest thing
   here and the only one still moving after the type has settled.
   js/loader.js takes .is-revealed off at REVEAL (1900ms); if any delay
   here grows past that, the element it belongs to will snap into place
   instead of arriving. That is the one way this can break, and the two
   numbers are the only thing holding it, so neither may move without
   the other.
   ═══════════════════════════════════════════════════════════════════ */

/* ── the film ─────────────────────────────────────────────────────── */
/* 1.09 down to 1, over a second and a half — the longest thing here by
   far and the only one still moving after the type has landed. It is
   the ambient layer: a room does not stop being a room when you have
   finished reading the words in front of it, and a hero that arrives
   and then holds perfectly still reads as a photograph rather than as a
   place. Slow enough that it is never seen as a zoom, only felt as the
   picture settling.

   Nine per cent, not more: .tf__stage is overflow:hidden so any amount
   is safely clipped, but the cost is real — this scales a decoding
   video, and every per cent is more pixels being sampled during the one
   moment on this page where the main thread is busiest. */
.is-loading .tf__video{ transform:scale(1.09) }
.is-revealed .tf__video{
  transition:transform 1.6s var(--ease-out);
  will-change:transform;
}

/* The scrim from .35, not from 0. It is what holds the light type off
   the footage, and the curtain uncovers the headline at about 520ms —
   so it has to be most of the way in before that or the first frame
   anyone sees of the hero is bone type on a lit ceiling. On the page's
   decelerate curve it is past 95% by 500ms and full at 920. Starting it
   part-way rather than at zero keeps it from being the first thing that
   moves; the film should be. */
.is-loading .tf__scrim{ opacity:.35 }
.is-revealed .tf__scrim{ transition:opacity .8s var(--ease-out) .12s }

/* ── the chrome ───────────────────────────────────────────────────── */
/* Down from above, because that is where the bar is: the header is the
   one part of the page that has an edge to come from. Ten pixels, which
   at this duration is a settle rather than a slide.

   The two halves are 50ms apart rather than together. A bar whose two
   ends arrive on the same frame reads as an image being switched on;
   the same bar with the name a beat ahead of the button reads as being
   set down. It is the smallest stagger on the page and it is the one
   that does the most for how finished the whole thing feels.

   Last of everything, because the top of the screen is the last thing
   the curtain clears — at 695 and 753ms, with the wipe still running
   until 940. The chrome is more than half arrived by the time it is
   uncovered at all. */
.is-loading .brand,
.is-loading .burger{ opacity:0; transform:translateY(-10px) }
.is-revealed .brand,
.is-revealed .burger{
  transition:opacity .7s var(--ease-out) var(--in-delay),
             transform .7s var(--ease-out) var(--in-delay);
}
.is-revealed .brand { --in-delay:640ms }
.is-revealed .burger{ --in-delay:690ms }

/* ── the eyebrow ──────────────────────────────────────────────────── */
/* The rule draws from its left edge before the words beside it fade up,
   40ms ahead — the gesture of a line being ruled and then written
   against, which is the same gesture the eyebrow is a picture of.

   The pair sits above the headline, so the curtain reaches it after —
   560ms against 538. Ordered by the sentence rather than by the edge;
   see the note about the two disagreements in the block at the top. */
.is-loading .tf__intro .eyebrow{ opacity:0; transform:translateY(8px) }
.is-loading .tf__intro .eyebrow__rule{ transform:scaleX(0) }
.tf__intro .eyebrow__rule{ transform-origin:left center }
.is-revealed .tf__intro .eyebrow{
  transition:opacity .64s var(--ease-out) .51s,
             transform .64s var(--ease-out) .51s;
}
.is-revealed .tf__intro .eyebrow__rule{
  transition:transform .7s var(--ease-out) .47s;
}

/* ── the headline ─────────────────────────────────────────────────── */
/* The .line / .line__i pair in style.css has been in this markup from
   the start and nothing has ever animated it. This is what it was cut
   for: the mask is overflow:hidden, the inner is a block, and the words
   rise out of nothing rather than fading up out of the picture.

   110%, not 100%. The mask's padding-bottom of .06em is there so
   descenders are not clipped at rest, and a line parked at exactly 100%
   leaves its ascenders sitting in that padding — visible, upside down
   from what you expect, for the whole of the delay. The extra ten per
   cent puts the line properly out of sight.

   A full second each, which is long for type. It is the slowest thing
   on the page apart from the film, on purpose: this is the sentence the
   site is built around, and a headline that snaps into place has been
   delivered rather than said.

   30ms apart, so the second line is still coming as the first lands.
   Two lines is not enough to be a stagger — it is a comma. It used to
   be 90, which was right when the screen above was dissolving and the
   headline had the whole reveal to itself; under a wipe that crosses
   both lines in 19ms, 90 would have the second line visibly waiting its
   turn in plain sight. */
.is-loading .tf__title .line__i{ transform:translateY(110%) }
.is-revealed .tf__title .line__i{
  transition:transform 1s var(--ease-out) var(--in-delay);
  will-change:transform;
}
.is-revealed .tf__title .line:nth-child(1) .line__i{ --in-delay:470ms }
.is-revealed .tf__title .line:nth-child(2) .line__i{ --in-delay:500ms }

/* ── the lede ─────────────────────────────────────────────────────── */
/* Under the headline, and now slightly before it: the curtain reaches
   the lede at 498ms and the headline at 519, because the lede is lower
   on the screen. Fourteen pixels — the small print moves the short
   distance and the display type moves the long one, which is the
   proportion the eye already expects from their sizes, and it is what
   keeps the lede from reading as the lead just because it moved
   first. */
.is-loading .tf__lede{ opacity:0; transform:translateY(14px) }
.is-revealed .tf__lede{
  transition:opacity .8s var(--ease-out) .44s,
             transform .8s var(--ease-out) .44s;
}

/* ── the rail and the cue ─────────────────────────────────────────── */
/* Last, and both by opacity alone — .tf__rail and .tf__cue each carry a
   centring transform of their own, so the cue moves by its children and
   the rail does not move at all.

   They are first now, and they were last before. The cue sits at the
   very bottom of the screen, which is where the curtain starts, so it
   is uncovered at 405ms — a full third of a second before the headline.
   Under a fade it could be held back until the sentence had been read;
   under a wipe, holding it back means uncovering a blank strip at the
   foot of the page and then putting something in it, which is worse
   than the instruction arriving early. The edge decides.

   It is still quiet enough not to compete: a hairline, a two-second
   loop and six letters of 0.56rem tracking, against a headline at 6vw
   that lands a second later. Being first here is not the same as being
   read first. */
.is-loading .tf__rail{ opacity:0 }
.is-revealed .tf__rail{ transition:opacity .7s var(--ease-out) .46s }

.is-loading .tf__cue-track,
.is-loading .tf__cue-label{ opacity:0; transform:translateY(10px) }
.is-revealed .tf__cue-track,
.is-revealed .tf__cue-label{
  transition:opacity .7s var(--ease-out) .345s,
             transform .7s var(--ease-out) .345s;
}

/* ── reduced motion ───────────────────────────────────────────────── */
/* None of it. Not slowed, not shortened — removed, and the parked
   values with it. This is the case the global rule in style.css cannot
   handle on its own: it zeroes every duration, which would leave the
   parked values applying instantly and then being corrected instantly,
   so the whole page would flash through its start positions in one
   frame. That is worse than no animation and much worse than the thing
   it is trying to prevent. So the parked state is switched off at
   source and the page is simply there when the screen goes.

   !important because the parked rules above are two classes deep and
   these are one; the alternative is repeating .is-loading on every
   selector here, which would be nine more lines saying nothing. */
@media (prefers-reduced-motion:reduce){
  .is-loading .tf__video,
  .is-loading .tf__scrim,
  .is-loading .brand,
  .is-loading .burger,
  .is-loading .tf__intro .eyebrow,
  .is-loading .tf__intro .eyebrow__rule,
  .is-loading .tf__title .line__i,
  .is-loading .tf__lede,
  .is-loading .tf__rail,
  .is-loading .tf__cue-track,
  .is-loading .tf__cue-label{
    opacity:1 !important;
    transform:none !important;
  }
}
