/* =========================================================================
   Silence Acoustic — motion layer
   ---------------------------------------------------------------------------
   Two engines, one vocabulary.

   1. Native scroll-driven animations (`animation-timeline: view()/scroll()`).
      Runs entirely off the main thread, needs no JavaScript at all, and is
      what Chrome, Edge and Safari 26 use. Everything here is transform,
      opacity or clip-path only — no layout, no filters, no paint churn.

   2. IntersectionObserver, added by motion.js *only* when engine 1 is missing
      (Firefox today). It reproduces the reveals; the scroll-linked pieces
      (parallax, pinned rail) simply sit still there, which looks deliberate
      rather than broken.

   With JavaScript blocked AND no native support, no hidden state is ever
   applied — the page is just a static page. Nothing can strand content.

   The whole file is inert under `prefers-reduced-motion: reduce`.
   ========================================================================= */

:root {
  --m-in:  var(--ease);                       /* entrances: expo-out */
  --m-out: cubic-bezier(0.4, 0, 0.2, 1);      /* state changes */
  --m-fast: 200ms;
  --m-mid:  420ms;
}

/* Anchors must clear the sticky header. */
html { scroll-padding-top: 7rem; }

/* ============================ scroll progress ===========================
   A hairline of logo cyan pinned to the top of the viewport. It is the only
   always-visible piece of motion on the site, so it stays a hairline. */
.progress {
  position: fixed; left: 0; right: 0; top: 0; height: 2px; z-index: 120;
  overflow: hidden; pointer-events: none;
}
.progress > i {
  display: block; height: 100%; width: 100%;
  background: linear-gradient(90deg, var(--brand-deep), var(--brand) 60%, var(--brand-lift));
  transform: scaleX(0); transform-origin: 0 50%;
  will-change: transform;
}

/* ======================= header on scroll direction =====================
   The header retracts once you commit to reading downward and comes back the
   moment you reverse — the nav is there when you reach for it, gone when you
   are not. */
.site-head {
  transition: transform 380ms var(--m-out), background-color 300ms var(--m-out);
}
html.head-up .site-head { transform: translate3d(0, -101%, 0); }
html.head-solid .site-head { background: rgba(250, 251, 251, 0.94); }
/* Never hide the header while something inside it has focus, or while the
   mobile menu is open — both would strand a keyboard user. */
.site-head:focus-within { transform: none !important; }
html:has(.nav[data-open="true"]) .site-head { transform: none !important; }

/* ============================== vocabulary ==============================
   Seven moves, used consistently:
     fade     quiet copy and small blocks           34px + opacity
     rise     cards and panels                      68px + 0.972 scale
     reveal   headings, lifted out of a mask        clip + 0.5em
     frame    material surfaces, uncovered          clip + 1.055 scale
     open     photographs, split open from centre   clip, both sides
     line     hairline rules, drawn left to right   scaleX
     tile     grid items that assemble              20px + 0.965 scale
     slide-l  a half that enters from the left      -72px + opacity
     slide-r  a half that enters from the right     +72px + opacity
   Anything that needs a different feel does not get motion at all.

   The travels were all raised once. The originals (18/34px, and a range that
   finished by cover 30%) were tuned to be barely perceptible, and the reveals
   read as a page that simply appeared rather than one that moved. `fade` and
   `rise` carry most of the site, so they are where depth is worth spending;
   `reveal` stays a mask because a heading that also travels far reads as
   sliding rather than being uncovered. */

@keyframes mFade   { from { opacity: 0; transform: translate3d(0, 34px, 0); } }
@keyframes mRise   { from { opacity: 0; transform: translate3d(0, 68px, 0) scale(0.972); } }
@keyframes mReveal {
  from { opacity: 0; clip-path: inset(0 0 100% 0); transform: translate3d(0, 0.5em, 0); }
  to   { opacity: 1; clip-path: inset(-0.3em -100% -0.35em 0); transform: none; }
}
@keyframes mFrame {
  from { clip-path: inset(0 0 100% 0); transform: scale(1.055); }
  to   { clip-path: inset(0 0 0 0);    transform: scale(1); }
}
@keyframes mLine   { from { transform: scaleX(0); } to { transform: scaleX(1); } }
/* `open` is a port of a framer-motion `useScroll` + `useTransform` pair
   (by request): clip-path `inset(0 50% 0 50%)` -> `inset(0)` across
   offset ["start end", "center center"]. In view() terms that is
   `entry 0%` (top edge meets the bottom of the viewport) to `cover 50%`
   (centre meets centre), and useTransform is linear, so the timing is
   too. Clip only — no opacity, no travel — which is also why the photograph
   inside can keep its own hover scale. */
@keyframes mOpen   { from { clip-path: inset(0 50% 0 50%); } to { clip-path: inset(0 0 0 0); } }

/* The two halves of a diptych arrive from the outside in, so the pair reads as
   one gesture closing rather than two blocks appearing. Horizontal travel is
   only ever used on something that is genuinely a half of a two-up — on a
   single column it fights the reading direction. */
@keyframes mSlideL { from { opacity: 0; transform: translate3d(-72px, 0, 0); } }
@keyframes mSlideR { from { opacity: 0; transform: translate3d( 72px, 0, 0); } }

/* Stagger, shared by both engines: each child starts a little further into the
   scroll than the one before it. Cycling in threes means a row of cards
   cascades, and a single mobile column barely notices the offset. */
[data-stagger] > :nth-child(3n + 1) { --i: 0; }
[data-stagger] > :nth-child(3n + 2) { --i: 1; }
[data-stagger] > :nth-child(3n + 3) { --i: 2; }

/* The long variant, for a single column that reads top to bottom rather than
   as a grid — a spec table's rows, an index's entries. Cycling in threes is
   wrong there: rows 1 and 4 would start together and the table would appear
   in three chunks instead of filling in. This runs a true sequence and then
   holds at 9, so an unusually long table finishes rather than trailing off.
   Values stay small: at 2% of the scroll range each, ten rows span 18%, which
   is still well inside the element's own pass through the viewport. */
[data-stagger="long"] > :nth-child(1) { --i: 0; }
[data-stagger="long"] > :nth-child(2) { --i: 1; }
[data-stagger="long"] > :nth-child(3) { --i: 2; }
[data-stagger="long"] > :nth-child(4) { --i: 3; }
[data-stagger="long"] > :nth-child(5) { --i: 4; }
[data-stagger="long"] > :nth-child(6) { --i: 5; }
[data-stagger="long"] > :nth-child(7) { --i: 6; }
[data-stagger="long"] > :nth-child(8) { --i: 7; }
[data-stagger="long"] > :nth-child(9) { --i: 8; }
[data-stagger="long"] > :nth-child(n + 10) { --i: 9; }

@media (prefers-reduced-motion: no-preference) {

  /* ---------------------- engine 1: native, no JS --------------------- */
  @supports (animation-timeline: view()) {

    [data-anim] {
      animation-duration: auto;
      animation-timing-function: var(--m-in);
      animation-fill-mode: both;
      animation-timeline: view();
      /* Was `entry 4% cover 30%`, and that is why the reveals were invisible
         rather than subtle. At entry 4% the animation starts the instant the
         element's top edge crosses the bottom of the viewport and is finished
         by cover 30% — so the whole movement happened in the bottom sliver of
         the screen, below where anyone is reading, and the element was already
         static by the time it reached the middle. Starting later keeps it at
         opacity 0 for longer (fill-mode: both holds the from-state), so it
         reveals in the lower-middle of the screen instead of at the edge.

         The end was then pushed from cover 48% to cover 56%. The start is
         where the reader is, and that was already right; the end is how long
         the movement lasts, and finishing at 48% meant the travel was over
         before the element reached the middle of the screen. Later gives the
         same distance more scroll to cross, which is what makes it read as
         motion rather than as a state change. */
      animation-range: entry 25% cover 56%;
    }
    [data-anim="fade"]    { animation-name: mFade; }
    [data-anim="rise"]    { animation-name: mRise; }
    [data-anim="reveal"]  { animation-name: mReveal; }
    [data-anim="frame"]   { animation-name: mFrame; }
    [data-anim="open"]    { animation-name: mOpen; }
    [data-anim="line"]    { animation-name: mLine; transform-origin: 0 50%; }
    [data-anim="slide-l"] { animation-name: mSlideL; }
    [data-anim="slide-r"] { animation-name: mSlideR; }
    /* `.diptych` is two columns only from 48rem. Below that it is a single
       stacked column, where two blocks arriving from opposite sides is not a
       pair closing — it is one column jittering sideways against the reading
       direction. So the horizontal travel is desktop-only and the halves fall
       back to the vertical word everything else uses. 72px of horizontal
       travel on a 375px viewport would also be the one reveal on the site
       that leans on `body { overflow-x: hidden }` to not show a scrollbar. */
    @media (max-width: 47.9375rem) {
      [data-anim="slide-l"],
      [data-anim="slide-r"] { animation-name: mFade; }
    }

    /* 7% a step rather than 5%: three cards across now span 14% of the range
       between first and last instead of 10%, so the row cascades visibly
       instead of arriving almost together. The long variant keeps its own
       smaller step below, because ten rows at 7% would span 63%. */
    [data-stagger] > * {
      animation-range: entry calc(22% + var(--i, 0) * 7%) cover calc(48% + var(--i, 0) * 7%);
    }
    [data-stagger="long"] > * {
      animation-range: entry calc(22% + var(--i, 0) * 3%) cover calc(48% + var(--i, 0) * 3%);
    }
    /* After the stagger rules on purpose: `open` keeps its own range, and a
       row of them still cascades by the same 7% step. */
    [data-anim="open"] { animation-range: entry 0% cover 50%; animation-timing-function: linear; }
    [data-stagger] > [data-anim="open"] {
      animation-range: entry calc(var(--i, 0) * 7%) cover calc(50% + var(--i, 0) * 7%);
    }

    /* Progress bar — driven by the document's own scroll. */
    .progress > i {
      animation: mLine linear both;
      animation-timeline: scroll(root block);
    }
  }

  /* -------------- engine 2: IntersectionObserver fallback ------------- */
  html.io [data-anim] {
    transition: opacity 700ms var(--m-in), transform 700ms var(--m-in),
                clip-path 700ms var(--m-in);
    transition-delay: calc(var(--i, 0) * 90ms);
  }
  html.io [data-anim]:not(.in) { opacity: 0; }
  html.io [data-anim="fade"]:not(.in)    { transform: translate3d(0, 34px, 0); }
  html.io [data-anim="rise"]:not(.in)    { transform: translate3d(0, 68px, 0) scale(0.972); }
  html.io [data-anim="slide-l"]:not(.in) { transform: translate3d(-72px, 0, 0); }
  html.io [data-anim="slide-r"]:not(.in) { transform: translate3d( 72px, 0, 0); }
  html.io [data-anim="reveal"]:not(.in) { clip-path: inset(0 0 100% 0); transform: translate3d(0, 0.5em, 0); }
  html.io [data-anim="reveal"].in       { clip-path: inset(-0.3em -100% -0.35em 0); }
  html.io [data-anim="frame"]:not(.in)  { clip-path: inset(0 0 100% 0); transform: scale(1.055); opacity: 1; }
  html.io [data-anim="frame"].in        { clip-path: inset(0 0 0 0); }
  html.io [data-anim="open"]:not(.in)   { clip-path: inset(0 50% 0 50%); opacity: 1; }
  html.io [data-anim="open"].in         { clip-path: inset(0 0 0 0); }
  html.io [data-anim="line"]            { transform-origin: 0 50%; opacity: 1; }
  html.io [data-anim="line"]:not(.in)   { transform: scaleX(0); }
}

/* =============================== the hero ===============================
   On load: the headline is lifted out of a mask line by line, and the last
   word throws a reverb tail that dissipates — two ghost repeats trailing off
   to the right of the real word. It is the one literal gesture on the site
   and it earns its place, because the tail is exactly the thing the company
   removes.

   The tails must never land on the word. An earlier version had them decay
   *into* it (ending at `transform: none`), which meant that for most of the
   animation two partly-opaque copies of "place." sat on top of the real one
   and the headline read as an illegible blur — the single most common
   "the text is not visible" report on the site. They now travel outward and
   fade where they started from, so the word itself is never overprinted. */

.hero { view-timeline-name: --hero; view-timeline-axis: block; }

.hl { display: block; clip-path: inset(-0.32em -100% -0.34em 0); }

.hero-title .decay { position: relative; display: inline-block; }
@keyframes mHeroLine { from { opacity: 0; transform: translate3d(0, 105%, 0); } }
@keyframes mHeroIn   { from { opacity: 0; transform: translate3d(0, 22px, 0); } }

}

}
/* Scrolling out of the hero: the wall keeps moving after the copy has gone,
   so the section has depth rather than being one flat plate. */
/* scale 1.16 gives 8% of overflow on each edge against a 7% end translate, so
   the photograph keeps ~1% of headroom instead of landing exactly flush. At
   1.14 the two figures were identical by construction and the top edge met the
   container's at 0.00px — covered, but with nothing left for sub-pixel
   rounding to eat. */
@keyframes mWall  { from { transform: translate3d(0, -5%, 0) scale(1.16); }
                    to   { transform: translate3d(0,  7%, 0) scale(1.16); } }
@keyframes mDrift { from { transform: none; opacity: 1; }
                    to   { transform: translate3d(0, -52px, 0); opacity: 0.25; } }

@media (prefers-reduced-motion: no-preference) {
  /* ---- the banner cross-fade -------------------------------------------
     Three photographs, 5s each on a 15s loop (asked for: "the images should
     change within 5 seconds"). The percentages below are shares of the loop,
     not seconds, so they did not move when the period did — but the four
     numbers that ARE seconds did, and they live in two files: this one and
     the `.hero-slide` / `.hero-say` overrides in theme.css, which restate
     the period to hang gKen and gSay off it. All four change together or a
     headline outlives its photograph. Deliberately NOT inside the
     scroll-timeline @supports block further down: this is a plain time-based
     animation and Firefox should get it too. It IS inside the reduced-motion
     gate, because content that changes on its own is exactly what that
     setting asks not to happen — under Reduce Motion the first photograph
     simply stays.

     The delays are NEGATIVE so all three slides run one shared cycle that is
     already in progress, and 0% is the visible state. Positive delays would
     need `fill-mode: backwards`, which paints the 0% state throughout the
     wait — all three slides opaque at load, the last sibling winning. That is
     the bug this shape avoids.

     EVERY PHOTOGRAPH HERE IS MEASURED, AT BOTH LAYOUTS. The scrim guarantees
     nothing on its own (see the banner note in site.css), so a fourth image
     cannot just be appended. Method: composite it under both scrim gradients
     on a canvas at the hero's real aspect, then sample the DARKEST pixel
     under the WORDS and score it against --on-banner.

     Two things about that method are load-bearing, and both were got wrong
     first time round here:
       - Minimum, not a percentile. A 5th-percentile sample reads about twice
         as forgiving and will pass an image that fails.
       - Per WORD, not per element box. The h1 and lead line boxes run the
         full column width while the glyphs do not, so a box-level minimum
         reads the bare photograph past the end of the text and condemns
         images that are fine. Range over each word and union those rects.

     Mobile is the binding case, not desktop: below 64rem the copy runs the
     full width, so the lead reaches ~91% across the hero, where the 102deg
     scrim has already fallen to alpha 0. Only 9 of the 170 project
     photographs clear AA there. Measured worst case per element:

                          DESKTOP 1440x801            MOBILE 375x682
       adani-bkc-mumbai   eye 10.92 h1 5.49 lead 5.63  eye 12.34 h1 8.40 lead 3.80
       atlas-pune         eye  8.75 h1 6.87 lead 7.18  eye  6.41 h1 5.60 lead 6.62
       meraki-arean       eye  6.51 h1 6.44 lead 7.70  eye  5.69 h1 6.61 lead 6.84

     NOTE THE FIRST ROW: the incumbent banner's lead is 3.80:1 on mobile,
     under the 4.5 floor. That is pre-existing — the scrim was shaped around
     the desktop type column — and it is the reason the two images added
     beside it were held to passing at BOTH widths rather than matching it.
     Fixing it is a scrim change, not an image change. --mark-banner carries
     .decay and is LARGE TEXT ONLY: 3.33 / 4.36 / 6.52 desktop. */
  @keyframes mHeroFade {
    0%     { opacity: 1; }
    30%    { opacity: 1; }
    33.33% { opacity: 0; }
    96.67% { opacity: 0; }
    100%   { opacity: 1; }
  }
  .hero-slide { animation: mHeroFade 15s linear infinite; }
  .hero-slide:nth-child(2) { animation-delay: -10s; }
  .hero-slide:nth-child(3) { animation-delay: -5s; }

  /* The client's headline for each banner, on the SAME keyframes and the SAME
     negative delays as the photograph it belongs to. That is not a stylistic
     echo — it is the synchronisation. Change the duration or a delay here and
     you have to change it on `.hero-slide` in the same edit, or a headline
     will outlive its picture and describe the wrong room for nine seconds.

     Note `.hero-say:nth-child(1)` is stated rather than left to the base
     rule: the first block ships with `.is-on`, which makes it opaque, so
     without an explicit animation it would stay opaque under the other two
     as they faded in. The three are peers here; `.is-on` only decides the
     pre-animation and no-JS state. */
  .hero-say { animation: mHeroFade 15s linear infinite; }
  .hero-say:nth-child(2) { animation-delay: -10s; }
  .hero-say:nth-child(3) { animation-delay: -5s; }

  /* Load-time entrance, NOT a scroll reveal. Both the hero and the page-head
     are above the fold, so there is no scroll for a view() timeline to run
     against — and .page-head is overflow: hidden, where view() never advances
     at all and a [data-anim] would leave its h1 invisible for good. This is a
     plain timed animation, so it is safe inside the clip. */
  .hero [data-in],
  .page-head [data-in] {
    animation: mHeroIn 780ms var(--m-in) both;
    animation-delay: calc(var(--d, 0) * 1ms);
  }
  .hero .hl > span {
    display: block;
    animation: mHeroLine 900ms var(--m-in) both;
    animation-delay: calc(var(--d, 0) * 1ms);
  }

  /* Inner page headers land the same way the hero does, without needing an
     attribute on every one of them — they are always the first thing on the
     page, so a plain load animation is right and a scroll trigger is not. */
  .page-head .wrap > * {
    animation: mHeroIn 760ms var(--m-in) both;
  }
  .page-head .wrap > :nth-child(1) { animation-delay:  60ms; }
  .page-head .wrap > :nth-child(2) { animation-delay: 160ms; }
  .page-head .wrap > :nth-child(3) { animation-delay: 260ms; }
  .page-head .wrap > :nth-child(4) { animation-delay: 350ms; }
  .page-head .wrap > :nth-child(5) { animation-delay: 430ms; }

  @supports (animation-timeline: view()) {
    /* The walls sit inside `overflow: hidden` sections, so they cannot resolve
       their own `view()` timeline — they borrow the named one their section
       publishes instead. */

    /* The banner keeps moving after the copy has gone, so the hero has depth
       rather than being one flat plate. `.hero-top` is `overflow: hidden`, so
       this takes the named `--hero` timeline; `view()` would never advance
       inside a scroll container. Only the photograph moves — the scrim is
       `.hero-media::after` and stays put, so the banner's contrast guarantee
       is untouched by the drift. That guarantee is now 7.1:1 over a pure-black
       image (the white scrim inverted it; it was 7.6:1 over pure white when
       the scrim was dark), and the drift cannot change it either way. */
    .hero-media img {
      animation: mWall linear both;
      animation-timeline: --hero;
      animation-range: cover 0% cover 100%;
      will-change: transform;
    }
    /* The hero always sits at the top of the document, so the page's own
       scroll offset is the hero's progress — no element timeline needed. */
    /* The STATEMENT drifts. The spec rail does not.
       .hero-rail is also a .hero .wrap, so an unscoped rule faded it to
       opacity 0.25 across the first 78vh — which is exactly the scroll
       position at which the reader arrives at it. The four figures and both
       call-to-action buttons ghosted out at the moment they were being read.
       Whatever drifts here has to be the copy above the rail, never the rail. */
    .hero .wrap:not(.hero-rail) {
      animation: mDrift linear both;
      animation-timeline: scroll(root block);
      animation-range: 0px 78vh;
    }
    .page-head { view-timeline-name: --phead, --box; }
    .cta-band { view-timeline-name: --cta, --box; }
  }
}

/* ======================== the pinned family rail ========================
   The homepage used to list all nineteen products. It now holds five family
   panels that travel sideways while the section is pinned — a catalogue you
   move through rather than scroll past. Below 48rem, without native scroll
   timelines, or under reduced motion, the identical markup is a plain
   snap-scrolling rail. That is the default state; the pin is layered on top. */

/* `section` carries a global vertical rhythm; this one hands that job to
   .rail-sticky, which is the box that has to fit the viewport exactly when the
   section is pinned. Leaving both in place added 230px the pin could not see. */
.rail-sec { background: var(--ombre-dark); color: var(--on-dark); padding-block: 0; }
.rail-sticky { padding-block: var(--pad-y); }
.rail-head {
  display: grid; gap: clamp(1rem, 2.5vw, 2rem); align-items: end;
  margin-bottom: clamp(2rem, 4vw, 3rem);
}
@media (min-width: 56rem) { .rail-head { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); } }
.rail-head h2 { font-size: var(--t-h2); margin-top: 0.85rem; }

.rail-viewport {
  overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* Without this the snapport ignores the padding, so the first card snaps
     flush to the screen edge and the gutter is scrolled away on load. */
  scroll-padding-inline: var(--gut);
  scrollbar-width: thin;
  scrollbar-color: rgba(16, 23, 27, 0.287) transparent;
  padding-inline: var(--gut);
  padding-bottom: 1rem;
}
.rail-track {
  display: flex; gap: clamp(1rem, 2vw, 1.5rem);
  width: max-content; max-width: none;
}

/* THE RAIL CARRIES PRODUCT CARDS NOW, and they are the same `.card` the
   products page uses — not a copy of it. Only what a flex track needs is
   restated: don't grow, don't shrink, take a fixed width, snap. Everything
   else — the drawn material surface, the NRC bar, the material marker, the
   hover lift, the dark-ground fill — is inherited from the card rules in
   site.css and stays in step with them.

   This rule lives in motion.css rather than site.css for the same reason
   `.fam` does: the rail is a structural section that only exists because of
   the pin, and it is kept next to the pin that drives it.

   The width is a notch under `.fam`'s. A family card carried a two-line note
   held open whether the family needed it or not; a product card carries a
   name and a one-line tag, so it reads cramped at 23rem and settled at 21rem
   — and eleven cards at 21rem is the difference between a rail that pins in
   2100px and one that hits the 2600px ceiling. */
.rail-track .card {
  scroll-snap-align: start;
  flex: none; width: clamp(15rem, 58vw, 21rem);
}

.fam {
  scroll-snap-align: start;
  flex: none; width: clamp(16rem, 60vw, 23rem);
  display: flex; flex-direction: column;
  background: var(--ink-panel);
  border: 1px solid rgba(16, 23, 27, 0.149);
  border-radius: var(--r); overflow: hidden;
  text-decoration: none; color: inherit;
  transition: border-color 240ms var(--m-out), transform 240ms var(--m-out);
}
.fam:hover { border-color: var(--brand); transform: translateY(-3px); }
.fam-surface { aspect-ratio: 16 / 9; flex: none; }
.fam-body { padding: 1.4rem 1.4rem 1.5rem; display: flex; flex-direction: column; gap: 0.6rem; flex: 1; }
.fam-n {
  font-family: var(--mono); font-size: var(--t-mono); font-weight: 500;
  letter-spacing: 0.14em; color: var(--mark);
}
.fam h3 { font-size: var(--t-h3); }
/* Two lines held open whether the note needs them or not — three of the five
   notes wrap and two do not, and without this the surfaces above them ended up
   at two different heights across the rail. */
.fam-note {
  font-size: 0.9375rem; line-height: 1.55; color: var(--on-dark-mute);
  min-height: calc(2 * 1.55em);
}
/* Pinned to the bottom of the body, so the five "See the family" links line
   up across the rail whatever is above them.

   This is prevention, not a fix: measured at 500, 820 and 1280 the five cards
   are currently identical to the pixel, because the .fam-items list that was
   removed was removed from all of them equally. But what used to guarantee
   that alignment was a reservation — four rows held open whether the family
   needed them or not — and nothing replaced it. Today every family name fits
   one line and every note fits the two that --fam-note reserves, so the rows
   happen to agree. Add a family whose name wraps, or whose note runs to three
   lines, and the note and the link below it drop out of step with the other
   four, with nothing absorbing the difference.

   margin-top: auto costs nothing now (the foot is already last, and every
   body is the same height) and means a longer name can only ever push the
   note, never the link. */
.fam .card-foot { color: var(--brand-ink); margin-top: auto; }
.fam:hover .btn-arrow { transform: translateX(3px); }

/* the closing panel: not a product family, the way out of the rail.
   This is the ONE saturated fill on the site, so every colour inside it has to
   be named — the rail's own text tokens are dark now and would sit on cyan at
   3.6:1. The opacity: 0.92 that used to soften the note is gone too: it
   composited --paper-hi down to 4.02:1, under AA. */
/* --brand, NOT --brand-deep. `--brand-deep` is the raw logo cyan now and it
   is reserved for BUTTONS, where a dark label on it reads 6.74:1. This is a
   card, it carries white, and white on the cyan is 2.62:1 — so it takes the
   text-safe darkened cyan instead, where white reads 7.72:1. */
.fam-all { background: var(--brand); border-color: var(--brand); color: var(--paper-hi); }
.fam-all .fam-body { justify-content: center; gap: 1rem; padding: 2rem 1.6rem; }
.fam-all h3 { font-size: var(--t-h2); line-height: 0.98; color: var(--paper-hi); }
.fam-all .fam-n,
.fam-all .fam-note,
.fam-all .card-foot { color: var(--paper-hi); }
.fam-all:hover { background: var(--brand-lift); border-color: var(--on-light); }

/* a hint that the rail is not the end of the row */
.rail-hint {
  display: flex; align-items: center; gap: 0.6rem; margin-top: 1.25rem;
  font-family: var(--mono); font-size: var(--t-mono); letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--on-dark-mute);
}
.rail-hint::after {
  content: ''; flex: 1; max-width: 8rem; height: 1px;
  background: linear-gradient(90deg, var(--mark), transparent);
}

@keyframes mRail {
  to { transform: translate3d(min(0px, calc(100vw - 100% - var(--gut) * 2)), 0, 0); }
}

@media (min-width: 48rem) and (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    /* The pin is measured in px rather than as a multiple of the viewport
       height, because the distance the track has to travel is set by viewport
       *width*, not height. A `vh` figure that felt 1:1 on a laptop turned into
       a long slow drag on a tall display.

       IT IS PER-CARD NOW instead of a flat 1200px. `mRail` already scales its
       travel to the track (`100vw - 100%`), but the scroll it was driven over
       did not scale with it, so changing how many cards are on the rail
       silently changed how fast it moved. The rail carried six items and
       1200px, which is the 200px per item this reproduces; `--rail-n` is set
       on the section in src/build.js from the actual card count, and the
       fallback keeps a hand-written rail working.

       Bounds, so a data change cannot produce an absurd section: never
       shorter than 900px (below that the travel outruns the scroll and the
       cards blur past), never longer than 2600px (past that a homepage
       section has stopped being a rail and become a detour). All nineteen
       products would want ~4800px and hit the ceiling — which is the reason
       the rail shows ten, not because the clamp would break. */
    .rail-sec {
      height: calc(100vh + clamp(900px, var(--rail-n, 6) * 200px, 2600px));
      view-timeline-name: --rail, --box;
      view-timeline-axis: block;
    }
    .rail-sticky {
      position: sticky; top: 0;
      height: 100vh; display: flex; flex-direction: column; justify-content: center;
      overflow: hidden;
      /* the top pad clears the site header, which can come back over the pin
         at any moment if the reader scrolls up inside it */
      padding: 6rem 0 1.5rem;
    }
    .rail-sticky .rail-head { margin-bottom: clamp(1.25rem, 2.5vw, 2rem); }
    /* the pin drives the track, so the element must stop scrolling itself */
    .rail-viewport {
      overflow: visible; scroll-snap-type: none; padding-bottom: 0;
      min-height: 0; /* let this flex item actually shrink */
    }
    /* The height constraint has to reach the card for the surface to know it
       must give way. On a short window the surface is what shrinks — the copy
       is the part that has to stay readable, so it never does. */
    .rail-track { height: 100%; }
    .rail-sticky .fam,
    .rail-sticky .card { height: 100%; }
    .rail-sticky .fam-body,
    .rail-sticky .card-body { flex-shrink: 0; }
    /* The surface is what gives way on a short window — the copy is the part
       that has to stay readable, so it never does. `.card-surface` ships with
       `flex: none`, which would refuse to shrink; inside the pin it has to be
       allowed to.

       THE ASPECT RATIO STAYS. An earlier version of this rule also set
       `aspect-ratio: auto`, on the reasoning that a fixed ratio cannot
       shrink. It can — `flex: 0 1 auto` shrinks a ratio-derived basis
       perfectly well — and removing it broke the rail in a way that only
       showed on half the cards:

         · An `.is-cut` surface centres its sample as an in-flow flex child,
           so it has intrinsic content height and survived at 262px.
         · A photo surface's `<img>` is `position: absolute`, so the box has
           no in-flow content at all. With the ratio gone it had no height
           source left and collapsed to `min-height` — 64px — leaving a
           sliver of photograph above a card of empty space.

       Measured at 1440x900: cut-outs 262px, photographs 64px, in the same
       row. Do not take the ratio off again. */
    .rail-sticky .fam-surface { flex: 0 1 auto; min-height: 4rem; }
    .rail-sticky .card-surface { flex: 0 1 auto; min-height: 4rem; }
    .rail-track {
      animation: mRail linear both;
      animation-timeline: --rail;
      animation-range: contain 0% contain 100%;
      will-change: transform;
    }
    .rail-hint { display: none; }
  }
}

/* ====================== the scroll choreography =========================
   REMOVED. A 300vh section with a sticky 100vh child, in which four project
   photographs traded places, stacked at the centre and the last opened to
   full bleed — a port of a framer-motion component to this engine, driven by
   a named `--choreo` timeline because `.choreo-pin` was `overflow: hidden`.

   It was deleted by request, not disabled, and everything it owned went with
   it: `.choreo` / `.choreo-pin` / `.choreo-copy` / `.choreo-stage` /
   `.choreo-plate` / `.choreo-surface` / `.choreo-foot` / the `.at-*`
   positions, the `--choreo` timeline, and the `mChoreoTL` / `mChoreoBR` /
   `mChoreoBL` / `mChoreoHero` / `mChoreoCopy` / `mChoreoCap` keyframes. The
   `choreoBand()` builder and the `CHOREO` slug list are gone from
   src/pages.js in the same pass. There is no dead code left to revive; if a
   pinned piece is wanted here again, write it fresh.

   The family rail above is now the only pinned section on the site, which
   means the note in CLAUDE.md about "a second pin" and about `.rail-sec`
   being the control to check when a pin looks broken no longer has a second
   subject. `.rail-sec` is the only one. */

/* ========================= the process spine ===========================
   Seven steps that gate each other, so the rule between them is drawn as you
   descend rather than printed all at once. */
.steps-spine { position: relative; }
.steps-spine::before,
.steps-spine::after {
  /* IN THE GUTTER BETWEEN THE NUMBER AND THE TITLE, NOT THROUGH THE NUMBER.
     4.25rem is derived, not chosen: .step is a 3.5rem numeral column and a
     1.5rem gap, so the gutter runs 3.5rem to 5rem and this is its centre.

     It was 2.4rem, which was correct while the numeral was 0.8125rem mono.
     The restyle that set the numeral in the display face at
     clamp(1.5rem, 1.2rem + 1.2vw, 2.25rem) did not move the line with it: at
     a 1280 viewport the numeral renders 34.56px and "05" measures 42.7px
     wide, so it crossed the rule by 4.3px and the line struck through the
     figures.

     If the numeral grows again, this value does not need to change -- but the
     3.5rem column does. Check the numeral still fits its column rather than
     nudging the line. */
  content: ''; position: absolute; left: 4.25rem; top: 0; bottom: 0; width: 1px;
}
.steps-spine::before { background: var(--paper-edge); }
.dark .steps-spine::before { background: rgba(16, 23, 27, 0.161); }
/* The bright overlay exists only where it can actually be drawn by scrolling.
   Anywhere else it would be a zero-height line that never grows, so the plain
   hairline above is left to do the job on its own. */
.steps-spine::after { display: none; background: var(--mark-ink); }
.dark .steps-spine::after { background: var(--mark); }
@media (max-width: 34rem) { .steps-spine::before, .steps-spine::after { display: none; } }

@media (min-width: 34.0625rem) and (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .steps-spine::after {
      display: block;
      transform: scaleY(0); transform-origin: 50% 0;
      animation: mSpine linear both;
      animation-timeline: view();
      animation-range: entry 60% exit 40%;
    }
  }
}
@keyframes mSpine { to { transform: scaleY(1); } }

/* ============================= micro-motion =============================
   Hover and press feedback, kept to two properties so nothing here can ever
   cost a layout pass. */
.card, .sector, .fam, .quote { will-change: auto; }
a.card:active, .sector:active, .fam:active {
  transform: translateY(-1px);
  transition-duration: 120ms;
}

/* Cards lift their own surface a touch on hover — the panel catching light
   as you lean toward it. */
.card-surface, .fam-surface { transition: transform 240ms var(--ease); }
a.card:hover .card-surface, .fam:hover .fam-surface { transform: scale(1.03); }

/* A quiet loading state for the sector tiles: they scale up from 97% so the
   grid assembles rather than blinks. */
@keyframes mTile { from { opacity: 0; transform: scale(0.965) translate3d(0, 20px, 0); } }
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    [data-anim="tile"] {
      animation: mTile auto var(--m-in) both;
      animation-timeline: view();
      animation-range: entry calc(2% + var(--i, 0) * 4%) cover calc(30% + var(--i, 0) * 4%);
    }
  }
  html.io [data-anim="tile"] {
    transition: opacity 700ms var(--m-in), transform 700ms var(--m-in);
    transition-delay: calc(var(--i, 0) * 90ms);
  }
  html.io [data-anim="tile"]:not(.in) { opacity: 0; transform: scale(0.965) translate3d(0, 20px, 0); }
}

/* =========================================================================
   PORTED FROM replit.com
   ---------------------------------------------------------------------------
   Read off their own stylesheets rather than guessed at. What they actually
   run on the landing page is:

     LogoBlock__scroll        an infinite logo marquee, translateX(0 → -100%),
                              linear, four duplicated tracks
     landing-page__sweep-*    a light sweep across a surface, animating a
                              registered custom property, --sweep-pos: -30% → 100%
     landing-page__glow-fade-in   a soft bloom behind the hero input
     PlatformCards__blink     a typing caret, steps(1), 50% { opacity: 0 }
     MarketingHeader__header  sticky head, transition: all .3s (.4, 0, .2, 1)
     SimpleIntro__stickyCta   a sticky bottom CTA under a gradient-to-ground mask

   The lesson from that list is the opposite of what it looks like: their
   entrance travels are TINY — 4px, 16px, 20px. What makes their page feel
   alive is CONTINUOUS ambient motion (marquee, sweep, blinking caret), not
   large scroll reveals. So what is ported here is the ambient layer, not
   bigger reveals.

   Two of their six are deliberately not ported. The caret has nothing to type
   on this site, so it would be decoration pretending to be an interface. And
   the sticky bottom CTA is a layout and consent decision, not a motion one —
   it belongs in a conversation about the page, not in a motion pass.

   Their reveals below the fold are rAF-driven, which is why every scrolled
   screenshot of replit.com taken during this pass came back blank: no paint,
   no rAF, no reveal, no content. This site's CSS-first path is what avoids
   that, and nothing here moves it back onto JavaScript.
   ========================================================================= */

/* ---------------------------- the --box timeline -------------------------
   Most of what follows animates something INSIDE an `overflow: hidden` box —
   a photograph inside `.surface`, a dash inside `.dip`. `view()` cannot
   resolve there (an overflow: hidden ancestor is a scroll container, so the
   timeline never advances and the element stays at its start state), which is
   the trap documented in CLAUDE.md. The fix is the one the hero already uses:
   the enclosing box publishes a NAMED timeline and the descendant borrows it.

   `--box` is that name, published broadly and looked up by nearest ancestor,
   so a consumer inside a card gets the card's own pass through the viewport
   and a consumer loose in a section gets the section's. Sections that already
   publish a name take `--box` as a second entry in the list rather than
   losing the first. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    section, footer,
    .card, .gal-item, .rdx, .note, .dip, .pd-hero, .panel, .review {
      view-timeline-name: --box;
      view-timeline-axis: block;
    }

    /* A CARD ON THE FAMILY RAIL MUST NOT PUBLISH ITS OWN `--box`, and this is
       the one exception to the rule above. `--box` is looked up by nearest
       ancestor, so a `.card` publishing it means every consumer inside that
       card — the NRC bar, the card's own reveal — measures against the
       CARD's pass through its scrollport. Everywhere else on the site that is
       exactly right. Inside `.rail-viewport` it is broken: that element is
       `overflow-x: auto / overflow-y: hidden`, so it is a scroll container on
       the block axis as well, and a block-axis ViewTimeline measured against
       a port that never scrolls vertically FREEZES at whatever value it
       resolved to and never moves again.

       Measured at 375x700 before this rule existed: every bar on the rail sat
       at `scaleX(0.967)` with its timeline pinned at 50.8131%, at the top of
       the page, in the middle of the rail, and 9,728px past it at the bottom
       of the document. The card reveals were frozen at the same 50.8131%, and
       only rendered because that number happens to fall past the end of
       `mRise`'s range. A different viewport resolves a different constant,
       and a low one would have held the cards at `opacity: 0`.

       THE NRC BAR IS WHY THIS IS URGENT RATHER THAN COSMETIC. It is the one
       fail-unsafe animation on the site (see the note beside `mLine`): a fill
       that does not reach `scaleX(1)` UNDERSTATES a real published absorption
       figure, so 0.967 was drawing 0.85 NRC as roughly 0.82. That is a
       content error, not a visual one.

       `none` here makes the lookup walk past the card to `.rail-sec`, which
       publishes `--box` — from the `section` rule above at every width, and
       from `view-timeline-name: --rail, --box` inside the pin. `.rail-sec` is
       not inside the horizontal scroller, so its timeline resolves against
       the document and advances normally. The consumers then read the
       SECTION's pass through the viewport, which is what `.fam` always did.

       Verify after touching this: scroll past the rail and check that every
       `.rail-track .nrc-track > i` reads `matrix(1, 0, 0, 1, 0, 0)`. */
    .rail-track .card { view-timeline-name: none; }
  }
}

/* -------------------------------- the sweep ------------------------------
   Their `--sweep-pos`, driven by scroll position instead of by a timer, so
   the light rakes across a panel as you pass it rather than pulsing at it.

   Where it is allowed to go is constrained by two existing rules. It never
   goes on a photograph — `.surface::after` is the light wash and it sits at
   z-index 2, BELOW `.surface > img` at 4, precisely so a moving highlight
   lands on a drawn material and not on someone's photograph.

   That rule is why this ends up on exactly ONE object. The roomdex rows were
   tried and dropped: `.rdx-swatch` is a `.surface` carrying a project
   photograph, and because it isolates, an overlay on the row above it paints
   straight over the picture. `.panel` was in the list behind a
   `:not(.surface)` guard until the templates were checked — the only `.panel`
   on the site is `class="surface s-slat panel"`, so the guard excluded the
   sole match and the selector was dead. It is gone rather than left in
   looking load-bearing.

   So: `.review`, and nothing else. It is the right one anyway — it is already
   the site's one sanctioned gradient-bearing object, so a moving highlight is
   the same idea it is already built on. And on `.review`,
   whose text is scored against the deepest stop of its gradient, the sweep
   only ever ADDS white — it raises the local luminance under dark text, so it
   moves contrast the safe way. It cannot lower any measured pair.

   This is the one thing in this file that repaints a gradient per frame
   rather than moving a compositor layer. Thirteen review cards on one page is
   the whole budget for it. Do not spread it. */
@property --sweep-pos {
  syntax: '<percentage>';
  inherits: false;
  initial-value: -30%;
}
@keyframes mSweep { from { --sweep-pos: -30%; } to { --sweep-pos: 130%; } }

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .review { position: relative; }
    .review::after {
      content: ''; position: absolute; inset: 0;
      pointer-events: none; z-index: 6;
      background: linear-gradient(100deg,
        transparent calc(var(--sweep-pos) - 20%),
        rgba(255, 255, 255, 0.40) var(--sweep-pos),
        transparent calc(var(--sweep-pos) + 20%));
      animation: mSweep linear both;
      animation-timeline: --box;
      animation-range: entry 28% cover 72%;
    }
  }
}

/* --------------------------------- the glow ------------------------------
   Their `glow-fade-in`, on the one block that is asking for an action. It is
   NEUTRAL, not tinted: the accent is the client's blue and CLAUDE.md is
   explicit that it is not to be spent on decoration, so this is a white bloom
   in the band's own light. It sits at z-index 2 under `.cta-band .wrap` at 3,
   and being white under dark text it raises contrast rather than lowering it. */
@keyframes mGlow { from { opacity: 0; } to { opacity: 1; } }

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .cta-band::after {
      content: ''; position: absolute; inset: -30% -12% auto -12%; height: 150%;
      background: radial-gradient(44% 56% at 50% 8%,
        rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.24) 46%, transparent 72%);
      pointer-events: none; z-index: 2; opacity: 0;
      animation: mGlow linear both;
      animation-timeline: --cta;
      animation-range: entry 8% cover 55%;
    }
  }
}

/* ------------------------------ photo parallax ---------------------------
   The hero banner has drifted against its frame since `mWall`. This gives
   every other photograph on the site the same depth, on the same mechanism:
   the surface's enclosing box publishes `--box`, the image borrows it.

   Both endpoints are FAIL-SAFE, which is the whole reason for the numbers.
   At scale 1.14 there is 7% of overflow on each edge against a 5% end
   translate, so the frame is fully covered at every point in the range — and
   also at the start state, which is what an unresolved or inactive timeline
   holds. A dead timeline here costs a little depth; it can never uncover an
   edge or strand an image.

   `.fam-surface` is excluded on purpose: it lives in a horizontally-scrolling
   rail where vertical drift means nothing. `.rail-track .card-surface` has to
   be excluded for the same reason, and cannot be by leaving it out of the
   list — `.card-surface > img` matches it — so it is switched off explicitly
   in the rule that follows the parallax block. */
@keyframes mPara {
  from { transform: translate3d(0, calc(var(--para-y) * -1), 0) scale(var(--para-s)); }
  to   { transform: translate3d(0, var(--para-y), 0) scale(var(--para-s)); }
}

/* This was gated at 40rem, which meant a phone — the majority of the traffic —
   got no parallax on any photograph on the site. There is no reason for that:
   a scroll-linked transform is composited, and it costs a phone no more than
   it costs a laptop. What a phone does need is a SMALLER figure. The overdraw
   is the scale, and on a 375px-wide card a 1.14 scale is throwing away 14% of
   a photograph that was already the smallest copy of it; the drift is also
   proportionally more noticeable on a short viewport.

   The two variables are set on the image itself, never inherited from a
   parent — a custom property on an ancestor that a child's transform reads
   recalculates styles for every child on every frame. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .card-surface > img,
    .gal-surface > img,
    .note-surface > img,
    .rdx-swatch > img,
    .pd-hero > img {
      --para-y: 3.5%;
      --para-s: 1.09;
      animation: mPara linear both;
      animation-timeline: --box;
      animation-range: cover 0% cover 100%;
      /* NO `will-change` here. It was set once and measured: the projects page
         matches 180 of these, and will-change would promote all 180
         photographs to their own compositor layer at once. A scroll-driven
         transform is composited anyway, so the hint bought nothing and cost
         memory on the longest page on the site. The micro-motion section at
         the top of this file resets `will-change: auto` for the same reason. */
    }
    @media (min-width: 40rem) {
      .card-surface > img,
      .gal-surface > img,
      .note-surface > img,
      .rdx-swatch > img,
      .pd-hero > img { --para-y: 5%; --para-s: 1.14; }
    }
    /* `.gal-item:hover > .gal-surface > img` scales the IMAGE, and an
       animation beats a transition on the same property — so the gallery's
       hover would silently stop working. Move that hover onto the frame,
       where the card and family panels already put theirs. */
    .gal-item:hover > .gal-surface > img { transform: none; }
    .gal-surface { transition: transform 240ms var(--ease); }
    .gal-item:hover > .gal-surface { transform: scale(1.035); }

    /* The product cards on the family rail take no parallax, for the reason
       `.fam-surface` takes none: the rail travels sideways, so a vertical
       drift keyed to the section's pass through the viewport is motion with
       nothing to read it against. It cannot simply be left out of the list
       above — `.card-surface > img` matches these — so it is switched off. */
    .rail-track .card-surface > img { animation: none; transform: none; }

    /* A MOUNTED SPECIMEN DOES NOT DRIFT. Parallax is for photographs — it
       works because a `cover` image has overflow to spend, which is exactly
       what the scale is for. An `.is-cut` sample is `object-fit: contain`
       inside padding, so there is no overflow: the 1.14 scale pushes the
       sample past its mount and `.surface`'s overflow clips the edges off the
       product. It also reads wrong — the specimen slides around on its card
       while the card itself is still. */
    .surface.is-cut > img { animation: none; transform: none; }
  }
}

/* ------------------------- the two orphaned words ------------------------
   `line` and `tile` were both defined in the vocabulary above and never used
   by a single template — dead motion. `tile` now belongs to the project
   gallery, which is what its own comment said it was for. `line` is wired up
   here instead of through an attribute, because the thing it should draw is
   already a pseudo-element: the 2.25rem dash every eyebrow on the site
   carries. That is one rule for roughly fifty call sites.

   The attribute selector is doing real work. `.eyebrow[data-anim]` matches
   only eyebrows that are themselves scroll-revealed, which excludes the two
   that are not: the hero's and the page-head's, both of which are animated on
   LOAD by `mHeroIn` and sit inside `overflow: hidden` boxes where this could
   not resolve anyway. Widen this selector and those two dashes disappear. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .eyebrow[data-anim]::before {
      transform-origin: 0 50%;
      animation: mLine linear both;
      animation-timeline: --box;
      animation-range: entry 30% cover 58%;
    }
  }
}

/* ------------------------------- the NRC bar -----------------------------
   The signature, and the one place on the site where a bar states a measured
   proportion. Drawing the absorbed run left to right as you arrive is the
   reading the component already wants: filled = absorbed, empty = what comes
   back off the wall.

   `scaleX` on the fill, NOT `width` — the width is `var(--v)` from the data
   and animating it would be a layout pass per frame on up to nineteen cards.
   The transform scales the fill inside the width the figure sets, so the
   published number is still what determines where the bar ends.

   If this ever renders empty, the cause is `--box` failing to resolve, and
   the bar would then be understating a real published figure. That is the one
   fail-unsafe animation on the site, which is why the range closes early — by
   cover 52% the bar is full and stays full for the rest of the scroll. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .nrc-track > i {
      transform-origin: 0 50%;
      animation: mLine linear both;
      animation-timeline: --box;
      animation-range: entry 32% cover 52%;
    }
  }
}

/* =========================================================================
   A SECOND SCROLL PASS
   ---------------------------------------------------------------------------
   Five places that still had no scroll motion at all. Each one is transform
   and opacity only, and none of them fades anything while it is being read —
   see the `mDrift` note in the hero section, where a drift to opacity 0.25
   ghosted out the spec figures at exactly the scroll position a reader
   arrives at them. That is the failure mode to design against here.
   ========================================================================= */

/* ------------------------ 1. the diptych photographs ---------------------
   The two largest photographs on the homepage were the only ones left out of
   `mPara`, and for a real reason: `.dip:hover .dip-surface img` scales the
   IMAGE, and an animation beats a transition on the same property, so adding
   parallax would have silently killed the hover.

   Same fix the gallery got — the hover moves to the frame. `.dip-surface` is
   the clipping box at `inset: 0`, and `.dip` is itself `overflow: hidden`, so
   scaling the frame is clipped by the panel exactly as scaling the image was. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .dip:hover .dip-surface img { transform: none; }
    .dip-surface { transition: transform 900ms var(--ease); }
    .dip:hover .dip-surface { transform: scale(1.03); }
    .dip-surface img {
      --para-y: 3.5%;
      --para-s: 1.09;
      animation: mPara linear both;
      animation-timeline: --box;
      animation-range: cover 0% cover 100%;
    }
    @media (min-width: 40rem) {
      .dip-surface img { --para-y: 5%; --para-s: 1.14; }
    }
  }
}

/* ---------------------- 2. inner page heads drift away -------------------
   The hero's copy drifts up and dims as you leave it; the seven inner page
   heads landed on load and then sat still. This gives them the same exit.

   It is on `.page-head .wrap`, not on its children: those already carry the
   `mHeroIn` load animation and a second `animation` shorthand on the same
   element would replace it.

   THERE IS NO OPACITY IN THIS ONE, AND THAT IS A MEASURED DECISION.

   It was written as a fade first, to match the hero. At a 0.45 floor the head
   measured 0.478 opacity at 400px of scroll while 135px of it was still on
   screen. Delaying the fade to `exit 20%` and raising the floor to 0.6 fixed
   the timing but not the arithmetic — text at 0.6 alpha composites toward its
   own ground, and against `--ombre-light`'s deepest stop `#C2CCD8` that
   measures:

     h1      96px   10.99:1 -> 3.93:1   passes, but only on the large-text allowance
     lead    20px    5.37:1 -> 2.50:1   FAILS 4.5
     eyebrow 13px    5.37:1 -> 2.50:1   FAILS 4.5

   An opacity fade is a contrast change wearing a costume: it cannot be
   verified by the DOM walker, which reads declared colours and never sees the
   composite. So the fade is gone and the drift stays. A transform cannot move
   a contrast ratio, so this animation is free.

   (The hero's `mDrift` does fade its copy, to 0.25, and the same arithmetic
   applies to it. That one is pre-existing, deliberate and documented — the
   hero is content you have already left, and it is excluded from the
   automated sweep and verified separately. It is not a precedent for
   introducing a fade anywhere new.) */
@keyframes mHeadOut {
  from { transform: none; }
  to   { transform: translate3d(0, -12%, 0); }
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .page-head .wrap {
      animation: mHeadOut linear both;
      animation-timeline: --phead;
      animation-range: exit 0% exit 100%;
    }
  }
}

/* ------------------------ 3. the vertical rail labels --------------------
   "ROOMS", "WORK", "METHOD" — the rotated labels down the left rail. They
   were the one piece of the section furniture that never moved.

   The base transform is `rotate(90deg) translateX(0.1rem) translateY(-0.62rem)`
   with `transform-origin: 0 0`, so the entry translate is PREPENDED. Order is
   load-bearing: after the rotate, a translate moves along the rotated axis, so
   the same value appended would slide the label sideways across the rule
   instead of up along it. And the rest of the base transform has to be
   repeated verbatim in both keyframes — an animation replaces the property,
   it does not add to it, so dropping the tail would un-rotate the label.

   Resting opacity is 0.5, not 1, which is why the keyframe ends there. */
@keyframes mLabel {
  from { opacity: 0;
         transform: translateY(1.6rem) rotate(90deg) translateX(0.1rem) translateY(-0.62rem); }
  to   { opacity: 0.5;
         transform: rotate(90deg) translateX(0.1rem) translateY(-0.62rem); }
}

@media (min-width: 62rem) and (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .rail-label {
      animation: mLabel var(--m-in) both;
      animation-timeline: --box;
      animation-range: entry 20% entry 95%;
    }
  }
}

/* ----------------------- 4. the statement drifts slowly ------------------
   The one section that is a single sentence at h1 size. Its heading already
   arrives on `reveal`; this is the block continuing to move after it has
   arrived, so the largest type on the page is not the stillest thing on it.

   TRANSFORM ONLY. No opacity anywhere in this one. It is mid-page, so it is
   on screen and being read for its entire range — the hero can afford to dim
   because you have already left it, and this cannot. ±26px across the whole
   pass, which is slow enough to read as depth rather than as movement.

   It goes on `.statement .wrap`, whose children carry their own reveals; a
   transform on the parent composes with those, and `.wrap` is not a scroll
   container, so the children's own `view()` timelines still resolve. */
@keyframes mSettle {
  from { transform: translate3d(0,  var(--settle), 0); }
  to   { transform: translate3d(0, calc(var(--settle) * -1), 0); }
}

/* Also un-gated from 48rem. ±26px of drift across a 900px viewport is a
   gentle depth cue; across an 812px phone it is the same distance over a
   shorter pass, so it reads faster. ±16px there instead. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .statement .wrap {
      --settle: 16px;
      animation: mSettle linear both;
      animation-timeline: --box;
      animation-range: cover 0% cover 100%;
    }
    @media (min-width: 48rem) { .statement .wrap { --settle: 26px; } }
  }
}

/* --------------------- 5. the gallery captions, a beat later -------------
   `.gal-item` arrives on `tile`. The caption inside it arrived at the same
   instant, so a project's picture and its name were one event. This gives the
   name its own beat just after the frame settles — the caption of a
   photograph, not part of the photograph.

   It takes `--box` because `.gal-item` is `overflow: hidden`; `view()` on
   anything inside it would never advance. The range starts after `tile`'s
   ends (`cover 30%` at worst) so the two never overlap. */
@keyframes mCap { from { opacity: 0; transform: translate3d(0, 14px, 0); } }

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .gal-cap {
      animation: mCap var(--m-in) both;
      animation-timeline: --box;
      animation-range: entry 45% cover 40%;
    }
  }
}

/* =========================================================================
   HOVER IS FOR DEVICES THAT HOVER
   ---------------------------------------------------------------------------
   The reasoning was already written on `.review` above, and then applied to
   `.review` alone: unscoped, a tap on a phone latches `:hover` and the element
   stays in its hovered state until you tap something else. Eighteen other
   rules on this site move something on hover and none of them were scoped, so
   on a phone a tapped card stays lifted, a tapped panel stays zoomed and a
   tapped link keeps its underline after you have navigated away and come back.

   This resets the MOVEMENT only. Colour, border and shadow feedback stays —
   those do not read as broken when they persist, and dropping them would take
   away the only confirmation a tap got through. Same division the
   reduced-motion variant of `.review` makes.

   One block rather than eighteen relocated rules: moving them into a gated
   block would reorder the cascade in a 2200-line stylesheet, and several of
   these share a selector list with a non-hover state (`.nav a:hover::after`
   sits with `[aria-current="page"]::after`, which must keep its underline).
   A reset at the end of the last-loaded file cannot disturb either.

   IT HAS TO BE THIS FILE, and that was a live bug for one revision. The block
   was written at the end of `site.css` first, which reads correctly and is
   wrong: the shell links `site.css` and then `motion.css`, and five of these
   hover rules live in `motion.css` — `.fam:hover`, `.fam:hover .btn-arrow`,
   `a.card:hover .card-surface, .fam:hover .fam-surface`,
   `.gal-item:hover > .gal-surface` and `.dip:hover .dip-surface`. At equal
   specificity the later sheet wins, so the five product-family and gallery
   hovers went on latching on touch while the other thirteen were fixed. If
   this block ever moves back, check the link order in `src/build.js` first.

   The gate is `(hover: hover) and (pointer: fine)`, so its complement is
   `(hover: none), (pointer: coarse)` — a laptop with a touchscreen keeps its
   hovers, because its primary pointer is still the mouse. */
@media (hover: none), (pointer: coarse) {
  .btn:hover,
  .btn:hover .btn-arrow,
  .btn-ghost:hover::before,
  .nav a:hover::after,
  .tlink:hover::after,
  .tlink:hover .btn-arrow,
  a.card:hover .btn-arrow,
  a.card:hover .card-surface,
  .sector:hover,
  .fam:hover,
  .fam:hover .btn-arrow,
  .fam:hover .fam-surface,
  .gal-item:hover > .gal-surface,
  .gal-item:hover > .gal-surface > img,
  .dip:hover .dip-surface,
  .dip:hover .dip-surface img,
  .dip:hover .btn-arrow,
  .rdx > a:hover .rdx-swatch,
  .note > a:hover .btn-arrow {
    transform: none;
  }
  /* `.tlink`'s underline fades as well as draws, so resetting the transform
     alone would leave a full-opacity zero-width rule behind. */
  .tlink:hover::after { opacity: 0; }
}

/* -------------------- the set-piece, on a phone --------------------------
   `.fam` had no reveal at all, and the reason was invisible from the markup:
   on a wide screen ALL of its motion comes from the pin, so nobody noticed
   that below the pin breakpoint the section was completely static. A phone
   got a horizontal rail of six panels, none of which moved.

   Anything inside the rail needs `--box` rather than the `view()` that
   `[data-anim]` sets up. `.rail-viewport` is `overflow-x: auto` at every
   width — it is the snap rail on a phone and the pinned track on a desktop —
   so it is a scroll container either way, and a `view()` timeline inside one
   never advances. The cards would hold their start state and read as MISSING
   CARDS, which is `[data-anim="rise"]` at opacity 0. The attribute selector
   raises specificity above the base `[data-anim]` rule; the pin is untouched
   by this because the pin animates `.rail-track`, the parent.

   `.rail-track .card[data-anim]` IS THE SAME BUG, ONE COMPONENT LATER. The
   rail carried five `.fam` panels when this rule was written and carries ten
   `.card`s now, and `.card` is the shared product card — it takes plain
   `[data-anim="rise"]` everywhere else on the site, where `view()` resolves
   fine because nothing above it is a scroll container. Inside the rail it is
   not fine. Scoping to `.rail-track` is deliberate: this must not reach the
   cards on the products page, which are in an ordinary grid and want
   `view()`. The second entry that `.rail-sec` publishes on
   `view-timeline-name: --rail, --box` is what these resolve against — drop
   it and every card in the rail goes invisible. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .fam[data-anim],
    .rail-track .card[data-anim] {
      animation-timeline: --box;
      animation-range: entry 12% cover 42%;
    }
  }
}

/* =========================================================================
   ENGINE 2, THE REST OF IT
   ---------------------------------------------------------------------------
   Everything added to this file after the original reveals went inside
   `@supports (animation-timeline: view())` and stopped there. That is correct
   for a scroll-LINKED effect and wrong for a one-shot one, and nobody noticed
   because the browser this was built in supports scroll timelines.

   The result on an iPhone running anything before iOS 26: `html.io` is
   applied, `motion.js` drives the core reveals with an IntersectionObserver,
   and the eyebrow dashes, gallery captions and photographs all sit still. The
   page reads as "no animations" because the only motion left is a fade.

   What can be reproduced here is anything that happens ONCE, on arrival.
   `motion.js` puts `.in` on every `[data-anim]` element, so any of those can
   hang a transition off it. What cannot be reproduced is anything linked to
   scroll POSITION — parallax, the statement drift, the page-head drift —
   because there is no scroll loop in `motion.js` and adding one for 180
   photographs on a phone would cost more than the effect is worth. Those
   three degrade to nothing, which is their designed fallback: no transform,
   natural size, no missing content.

   Deliberately NOT reproduced, beyond those three:

     the NRC bar   It is the one fail-unsafe animation on the site. Not
                   animating it renders it full, which is the true figure.
                   A fallback here could only make it wrong.
     the sweep     It animates `--sweep-pos`, which needs `@property` to be
                   animatable at all. There is no clean `@supports` test for a
                   registered property, and on a browser that lacks it the
                   gradient referencing that value is invalid — so the safe
                   move is to leave the sweep on the native path only.
     the CTA glow  `.cta-band` is the one element here with no `[data-anim]`
                   to hang off. It is shown outright instead of animated.
   ========================================================================= */

@media (prefers-reduced-motion: no-preference) {

  /* The eyebrow dash. `.eyebrow[data-anim]` is itself a revealed element, so
     it gets `.in` directly — the same selector the native path uses. */
  html.io .eyebrow[data-anim]::before {
    transform-origin: 0 50%;
    transition: transform 700ms var(--m-in) 120ms;
  }
  html.io .eyebrow[data-anim]:not(.in)::before { transform: scaleX(0); }

  /* The gallery caption, a beat after its frame. The delay is what makes it a
     second event rather than part of the photograph. */
  html.io .gal-cap {
    transition: opacity 600ms var(--m-in) 170ms, transform 600ms var(--m-in) 170ms;
  }
  html.io .gal-item:not(.in) .gal-cap {
    opacity: 0; transform: translate3d(0, 14px, 0);
  }

  /* Photographs settle instead of drifting. Parallax needs a scroll position;
     this needs only the moment of arrival, and it is the same idea — the
     picture moving inside a frame that is not moving. 1.06 rather than the
     native 1.09/1.14, because this one comes to rest at 1 and any overdraw
     left at the end would show as a crop. */
  html.io .card-surface > img,
  html.io .gal-surface > img,
  html.io .note-surface > img,
  html.io .rdx-swatch > img,
  html.io .pd-hero > img,
  html.io .dip-surface img {
    transition: transform 900ms var(--m-in);
  }
  html.io .card:not(.in) .card-surface > img,
  html.io .gal-item:not(.in) .gal-surface > img,
  html.io .note:not(.in) .note-surface > img,
  html.io .rdx:not(.in) .rdx-swatch > img,
  html.io .pd-hero:not(.in) > img,
  html.io .dip:not(.in) .dip-surface img {
    transform: scale(1.06);
  }

  /* No `[data-anim]` on the band, so the bloom is simply present. */
  html.io .cta-band::after {
    content: ''; position: absolute; inset: -30% -12% auto -12%; height: 150%;
    background: radial-gradient(44% 56% at 50% 8%,
      rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.24) 46%, transparent 72%);
    pointer-events: none; z-index: 2;
  }
}

/* =========================================================================
   REDUCED MOTION IS NOT NO MOTION
   ---------------------------------------------------------------------------
   Every animation block in this file is gated on
   `prefers-reduced-motion: no-preference`, which meant a reader with Reduce
   Motion switched on — a single OS toggle, and a common one on iOS — got a
   completely inert page. That is defensible and it is also more than the
   setting asks for. Reduce Motion asks for no vestibular triggers: no travel,
   no parallax, no scale, no marquee, nothing sliding or zooming. A cross-fade
   is none of those things.

   So this is the whole tier: OPACITY, and nothing else. No transform, no
   clip-path, no `--sweep-pos`, no drift, no pins, no ticker, no count-up.
   If you extend this block, the test is not "is it subtle" — it is "does
   anything move". If something moves, it does not belong here.

   It is driven by `html.iomin`, which the inline boot script adds only when
   JavaScript is running and strips again after six seconds unless motion.js
   claims it. Identical contract to `io`: nothing can strand content, because
   the hidden state never exists unless something is there to undo it.
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  html.iomin [data-anim] {
    transition: opacity 520ms linear;
    transition-delay: calc(var(--i, 0) * 60ms);
  }
  html.iomin [data-anim]:not(.in) { opacity: 0; }
}
