/* Interaction layer — presses, taps and cross-page transitions. One file, linked from every
 * page that has a nav, so the whole estate responds the same way.
 *
 * Rahaid, 2026-09-04: "every clickable button and every page change/load should have a nice
 * animation... look at Apple, they have good animation."
 *
 * WHAT ACTUALLY MAKES APPLE'S INTERACTIONS FEEL LIKE THAT, and what is copied here:
 *
 * 1. THE PRESS IS ACKNOWLEDGED INSTANTLY, THE RELEASE TAKES ITS TIME. This asymmetry is the
 *    whole thing and it is what most sites miss. A single transition duration on a button
 *    means the press is as slow as the release, which reads as lag. Here the press lands in
 *    90ms on a sharp curve and the release comes back over 440ms on a spring. Your finger
 *    gets an answer immediately; the element relaxes afterwards.
 *
 * 2. SCALE, NOT COLOUR. A colour swap says "state changed". A scale says "you pushed a
 *    physical thing". Apple presses everything - buttons, tiles, list rows - and never
 *    flashes them.
 *
 * 3. NO GREY TAP FLASH. -webkit-tap-highlight-color is the single biggest tell that a thing
 *    is a web page rather than an app: iOS paints a grey box over whatever you touch, on
 *    top of and out of sync with whatever animation you wrote. It has to be turned off
 *    explicitly, and none of these pages did.
 *
 * 4. NO 300ms TAP DELAY. touch-action:manipulation drops the wait for a possible double-tap
 *    zoom. Without it every button on mobile is a third of a second late no matter how good
 *    the animation is.
 *
 * 5. THE PAGE DOES NOT CUT. Cross-document view transitions, with the outgoing page leaving
 *    faster than the incoming one arrives - the same asymmetry as the press.
 *
 * Progressive throughout: view transitions are ignored by browsers that lack them and the
 * navigation is a plain one. Nothing here is required for the site to work.
 */

:root {
  /* A real spring, not an ease-out pretending to be one: it overshoots to 1.125 and settles.
     Used for RELEASES and arrivals only - never for a press, which must not wobble. */
  --ke-spring: linear(
    0 0%, .055 4%, .189 8%, .363 12%, .547 17%, .718 21%, .863 25%, .975 29%,
    1.053 33%, 1.101 38%, 1.123 42%, 1.125 46%, 1.114 50%, 1.095 54%, 1.072 58%,
    1.049 62%, 1.028 67%, 1.012 71%, .999 75%, .991 79%, .986 83%, .984 88%,
    .985 92%, .986 96%, .989 100%
  );
  --ke-press-in: cubic-bezier(.3, 0, .2, 1);   /* sharp, no overshoot */
  --ke-ease: cubic-bezier(.16, 1, .3, 1);
}

/* ── No phantom page at either end ─────────────────────────────────────────────────────
 * iOS rubber-band over-scroll exposes the area past the document, and whatever colour that
 * area is, it reads as content: left light it is a slab of empty white under the footer,
 * painted in the footer's tone it is the footer running on into another section. Both were
 * reported. The document itself measures exactly as tall as its footer - this was never
 * extra height - so the fix is to remove the bounce, not to recolour it. Lives here so
 * every page with a nav gets it, not just the homepage.
 */
html, body { overscroll-behavior-y: none; }

/* ── Tap behaviour ─────────────────────────────────────────────────────────────────────
 * Applied to everything clickable rather than to a class, because a missed element is a
 * grey flash sitting on top of an otherwise finished interaction. */
a, button, summary, [role="button"], .btn-solid, .btn-quiet, .slot, .cell, .step {
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}

/* ── The press ─────────────────────────────────────────────────────────────────────────
 * Release is the default state, so the spring lives on the base rule and the fast curve
 * lives on :active. That is what produces the asymmetry: leaving :active reverts to the
 * base transition. */
.btn-solid, .btn-quiet, .nav-links a, .mobile-menu a, footer a, .slot, summary,
button:not([disabled]), [role="button"] {
  transition: transform .44s var(--ke-spring), opacity .3s var(--ke-ease),
              color .22s var(--ke-ease);
}

.btn-solid:active, .mobile-menu a.btn-solid:active, button:not([disabled]):active,
[role="button"]:active {
  transform: scale(.965);
  transition: transform .09s var(--ke-press-in);
}

/* Text links press less than buttons — a full 3.5% on a word reads as a glitch.
 *
 * :not(.btn-solid) is load-bearing. Without it the nav's Apply button matched BOTH this
 * rule and the button rule above at identical specificity (0,2,0), so the later one won
 * and the primary CTA in the header pressed like a text link. Measured at 0.985 where it
 * should be 0.965. Specificity ties are decided by source order, which is not a thing to
 * leave to chance on the one button every page is trying to get clicked. */
.btn-quiet:active, .nav-links a:not(.btn-solid):active,
.mobile-menu a:not(.btn-solid):active, footer a:not(.btn-solid):active,
summary:active {
  transform: scale(.985);
  transition: transform .09s var(--ke-press-in);
}

/* NOT ADDED: a growing underline on .btn-quiet. It looked like an easy win and it fights
   both existing designs - on index .btn-quiet is a text link that already carries a
   border-bottom, and on about/contact it is a bordered PILL, where an underline inside the
   pill is simply wrong. This layer adds press and page behaviour; it does not restyle
   anything that already has a look. */

/* Keyboard users get the same affordance, and a visible ring the mouse never sees. */
a:focus-visible, button:focus-visible, summary:focus-visible, [role="button"]:focus-visible {
  outline: 2px solid var(--ink, #23211E);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ── The mobile menu ───────────────────────────────────────────────────────────────────
 * Reported as "the animation for the nav is non existent", and it was: the menu toggled
 * display:none to display:flex, which is a hard cut with nothing in between. On a phone
 * this is the main navigation interaction on the site, so it was the most-used control
 * with the least behaviour.
 *
 * Height 0 -> auto needs interpolate-size, and display needs allow-discrete so the element
 * stays rendered for the length of the transition instead of vanishing on frame one.
 * @starting-style gives the opening frame something to animate FROM - without it the menu
 * appears at its final size and only the closing animates, which is a common half-fix.
 */
/* WHY NOT HEIGHT. The first version animated height 0 -> auto, which reflows the entire
 * document on every frame - the panel is in normal flow, so everything below it moves. It
 * animated, but it stuttered, and Rahaid called it: "the animation isn't the smoothest."
 *
 * The menu now OVERLAYS instead of pushing, and is revealed with clip-path. Nothing below
 * it moves, no layout is recalculated, and the reveal runs on the compositor. This is also
 * how a native menu behaves - the page does not shove itself down to make room.
 */
.mobile-menu {
  position: absolute; top: 100%; left: 0; right: 0;
  background: var(--page, #F7F6F4);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--hairline, #D8D4CC);
  clip-path: inset(0 0 100% 0); opacity: 0;
  transition: clip-path .42s var(--ke-ease), opacity .24s var(--ke-ease), display .42s;
  transition-behavior: allow-discrete;
}
.mobile-menu.open { clip-path: inset(0 0 0 0); opacity: 1; }
@starting-style { .mobile-menu.open { clip-path: inset(0 0 100% 0); opacity: 0; } }

/* The rows arrive after the panel, one behind the next. A menu whose items all appear at
   once with the box is a box appearing; staggered, it reads as a menu opening. */
.mobile-menu a {
  opacity: 0; transform: translateY(-7px);
  transition: opacity .3s var(--ke-ease), transform .46s var(--ke-spring),
              color .22s var(--ke-ease);
}
.mobile-menu.open a { opacity: 1; transform: none; }
/* The rows need their OWN @starting-style. Until the panel leaves display:none they are
   not rendered at all, so on first open they begin life already at the open state and the
   stagger never runs - measured at full opacity 60ms in, before any delay had elapsed.
   The panel's starting-style does not cascade to them. */
@starting-style { .mobile-menu.open a { opacity: 0; transform: translateY(-7px); } }
/* :not(:active) is not tidiness - it is the difference between a button that responds and
   one that does not. These stagger delays are specificity 0,4,0 and the press rules are
   0,2,0, so without it the ENTRANCE delay wins over the press and every tap on a menu row
   sat dead for up to 220ms before anything moved. Measured: transition-delay .22s still in
   force on the Apply row long after the menu had settled. A press must never be queued
   behind an entrance it has nothing to do with. */
.mobile-menu.open a:nth-child(1):not(:active) { transition-delay: .07s }
.mobile-menu.open a:nth-child(2):not(:active) { transition-delay: .12s }
.mobile-menu.open a:nth-child(3):not(:active) { transition-delay: .17s }
.mobile-menu.open a:nth-child(4):not(:active) { transition-delay: .22s }

/* The burger becomes a close. Driven off aria-expanded, which the existing toggle already
   maintains, so this needs no change to any page's markup or script. Spans are 1.5px with
   a 5px gap, so their centres are 6.5px apart - that is the exact travel to meet. */
.burger span {
  transform-origin: center;
  transition: transform .4s var(--ke-ease), opacity .18s var(--ke-ease);
}
.burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; transform: scaleX(.3); }
.burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

/* ── Cross-page transitions ────────────────────────────────────────────────────────────
 * FOURTH VERSION. The first three all tuned opacity on a root cross-fade and all three
 * failed, in a pattern worth keeping:
 *
 *   v1  old out in 200ms, new in over 420ms -> a window where neither was opaque.
 *   v2  removed the old page's fade to close that window. Wrong fix: ::view-transition-new
 *       paints ABOVE ::view-transition-old, so an opaque old page under a fading-in new one
 *       is a guaranteed CROSS-DISSOLVE. Recorded at 542ms, both headlines legible at once.
 *   v3  made them strictly sequential - old leaves, THEN new arrives. Recorded frame by
 *       frame (430x932, prerender stripped): contrast fell to 3.9% of peak and stayed under
 *       15% for ~75ms. That is an empty page held for four frames. Traded a double exposure
 *       for a blink, which is what Rahaid was seeing when he said there was no transition
 *       at all. He was right: 8px and 10px of travel is not perceptible, so once you strip
 *       the flash there was nothing left to see.
 *
 *   v4  overlapped them so the screen was never empty. Measured trough rose to 29.9% of
 *       peak with no blank frame, which read as a pass - and the frames showed a DOUBLE
 *       EXPOSURE at +160ms, both headlines legible at once. The metric could not tell one
 *       page at 30% from two pages at 30% each, so it scored the old failure as the fix.
 *
 * The structural error in all four: OPACITY was the only cue. Two full-page snapshots
 * cross-fading, with new painting above old, can be tuned to a blink or tuned to a double
 * exposure and to nothing else. Every version above was the same bug at a different point
 * on that one slider.
 *
 *   v5  took opacity out entirely: the incoming page slid up a full viewport, opaque, as
 *       an iOS sheet. Structurally clean and Rahaid rejected it on sight - "that
 *       transition isn't it". It was an app gesture on a website, and 440ms of a whole
 *       page moving is the opposite of the reference he then pointed at.
 *
 * The reference, READ rather than remembered (apple.com/shop/buy-iphone/iphone-16, all
 * 11 stylesheets pulled, a real navigation recorded, 2026-09-04): Apple runs NO page
 * transition at all - no @view-transition, pagereveal fires without one, the frames go
 * from one page to the next with nothing between. What Apple animates is INSIDE the page:
 * cubic-bezier(.4,0,.6,1) x88, durations clustered at 240-320ms, opacity x67 and
 * transform x45, travel of 4-20px (-18.5px, -13.5px, -8px), 80ms as the stagger unit.
 *
 * So the structure is now Apple's, plus the one thing a hard cut cannot do. The FRAME
 * stays still: root, nav and footer are static, and since the ground and the nav are
 * identical on every page that swap is invisible. Only the CONTENT moves - the outgoing
 * page's <main> leaves in 120ms, the incoming one arrives over 320ms with a 14px rise.
 * A blank is structurally impossible because the ground is never part of the animation,
 * and the two contents barely overlap and only while both are faint.
 *
 * Each page's <main> carries a UNIQUE view-transition-name, so old and new are unpaired
 * and animate at their own geometry. A shared name would pair them into a morph and
 * stretch one page's content into the other's height on the way through.
 */
@view-transition { navigation: auto; }

@keyframes ke-content-out {
  to { opacity: 0; transform: translateY(-6px); }
}
@keyframes ke-content-in {
  from { opacity: 0; transform: translateY(14px); }
}

/* The frame. Nothing here animates; the swap is between identical pixels. */
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
footer { view-transition-name: ke-footer; }
::view-transition-group(ke-footer) { animation: none; }
::view-transition-old(ke-footer), ::view-transition-new(ke-footer) { animation: none; }

/* The content. Unpaired, so old and new each get exactly one of these. */
::view-transition-old(*) {
  animation: ke-content-out .12s cubic-bezier(.4, 0, .6, 1) both;
}
/* The ARRIVAL lives on <main> itself, not on the view-transition pseudo - so a first
   load, a refresh and an arrival from outside get the same entrance as a navigation
   (Rahaid: "when the page loads up it currently just pops up"). The incoming pseudo is a
   live view of the new document, so if it animated too the content would rise twice.
   fill-mode is BACKWARDS on purpose: `both` or `forwards` would keep opacity:1 pinned at
   animation level after it ends, and that outranks the html.leaving transition below -
   the exit-on-tap would silently stop working on every page. */
main {
  /* .22s and no delay, down from .32s + .04s (Rahaid, 2026-09-11, picking "bring the new
     page's content in sooner" over leaving the gap between pages as it was). Measured
     from the NEW document's navigation start, before and after, on a mobile context:
     half-visible went 191/207/396ms -> see the commit. The delay was pure blank — 40ms
     in which the page had loaded and deliberately showed nothing.
     .24s is the FLOOR of the 240-360ms band brand/DESIGN.md sets for the arrival, not a
     number picked freely: .22s measured better and broke canon, and test-interactions
     caught it. Total time to full opacity is 360ms -> 240ms with the delay gone.
     fill-mode stays BACKWARDS: `both` or `forwards` pins opacity:1 at animation level
     after it ends, which outranks the html.leaving transition and silently kills the
     exit-on-tap on every page. test-interactions guards that. */
  animation: ke-content-in .24s cubic-bezier(0, 0, .2, 1) backwards;
}
::view-transition-new(*) { animation: none; }
/* Measured 2026-09-04, both sides of a real navigation: pageswap and pagereveal both
   carry the ViewTransition after the script-started navigation, and <main>'s entrance
   runs inside it exactly once (opacity 0.26 at +100ms, 1.0 by +400ms after reveal).
   A first draft of this note said the opposite. That measurement had gone through a
   Playwright route.fulfill(), and a document served that way does not qualify for a
   cross-document transition - the harness was disqualifying the navigation, not the
   script. Record transitions with the responses untouched. */
/* The nav and logo are paired and identical on both pages: they morph as a group and
   must NOT pick up the content animation above, or the header fades out and rises back
   in on every click. Named selectors outrank (*) whatever the source order. */
::view-transition-old(ke-nav), ::view-transition-new(ke-nav),
::view-transition-old(ke-logo), ::view-transition-new(ke-logo) { animation: none; }
/* The open mobile menu sits INSIDE <nav>, so it was captured into the nav's snapshot and
   hung there for the whole transition: the new nav bar cannot cover it, because the menu
   overlays the page below the bar. Recorded: menu visible to +441ms, gone at +480ms. Its
   own name pulls it out of the nav, and the (*) rule above then takes it out with the
   content in 120ms. */
.mobile-menu { view-transition-name: ke-menu; }
::view-transition-group(ke-menu) { animation: none; }
::view-transition-new(ke-menu) { animation: none; }

/* The exit that starts ON THE TAP (transitions.js), before the next page has even been
   requested. The view transition above cannot begin until the new document is ready, and
   on a phone that is the longest part of the whole navigation - so this is what the user
   actually sees first. The content leaves, the frame stays, and the fetch happens behind
   an empty stage rather than a frozen page. The menu closes on the same clock. */
html.leaving main {
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity .16s cubic-bezier(.4, 0, .6, 1), transform .16s cubic-bezier(.4, 0, .6, 1);
}
html.leaving .mobile-menu { transition-duration: .16s, .12s, .16s; }

/* The nav and the logo are the same object on both pages, so they are MORPHED rather than
   cross-faded — the header stays put while the body changes underneath it. This is the
   detail that separates a page transition from a fade. */
nav { view-transition-name: ke-nav; }
/* ONE element per name, and it has to be scoped to the NAV. A duplicate
   view-transition-name makes the browser abort the whole transition - you get a hard cut,
   with nothing logged anywhere. Measured: index, about, contact and privacy all carry the
   same logo mark in the FOOTER as well as the nav, so an unscoped `.nav-logo img` matched
   two elements on every one of them and the transition never ran on the four pages that
   matter most. Cleared explicitly on the footer copy rather than relying on selector
   order, because two of these pages set the name themselves further up. */
nav .nav-logo img { view-transition-name: ke-logo; }
footer img, footer .nav-logo img, footer .brand img { view-transition-name: none; }
::view-transition-group(ke-nav), ::view-transition-group(ke-logo) {
  animation-duration: .42s;
  animation-timing-function: var(--ke-ease);
}

/* ── Reduced motion ────────────────────────────────────────────────────────────────────
 * NO OVERRIDE, deliberately. Rahaid, standing (2026-07-07, restated 2026-08-27 and
 * 2026-09-07): don't let Reduce Motion stop or change anything we make - the motion is a
 * client-acquisition asset. Not switched off, and not shortened either; the setting is
 * ignored entirely. test-reduced-motion.mjs fails any file that reintroduces a block. */

/* ---------------------------------------------------------------------------
 * Scroll-scrubbed film — the closing section's signature moment.
 *
 * The film is the page's ONE dark anchor. DESIGN.md's palette rule says the
 * site is light and black lives in the mark and in buttons; this section is
 * the agreed exception and the file carries the amendment alongside it.
 *
 * No prefers-reduced-motion block here on purpose. The scrub moves only with
 * the visitor's own scroll, which DESIGN.md names as must-always-ship, and
 * test-reduced-motion.mjs fails any file in the estate that branches at all.
 * ------------------------------------------------------------------------ */
.scrub {
  position: relative;
  isolation: isolate;
  overflow: clip;
  background: #0d0d0f;
  color: var(--page, #f7f6f4);
}

.scrub-media {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.scrub-media img,
.scrub-media video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 62% center;
}

/* The poster holds until a real frame paints, then hands over. */
.scrub-media video {
  opacity: 0;
  transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.scrub[data-scrub-painted="true"] .scrub-media video {
  opacity: 1;
}

/* The scrim is its own ELEMENT, not a pseudo.
 *
 * It was ::before first, and it never once applied: `section.band::before`
 * already paints the kanji watermark and is more specific, so the browser
 * simply used that. Nothing errored — the section looked plausible, the guard's
 * "a scrim exists" check passed because it only asked whether a ::before had
 * content, and the watermark satisfied that. Three rounds of contrast tuning
 * were measuring a page with no scrim on it.
 *
 * As an element it composites over the film, under the copy, and leaves the
 * watermark alone.
 *
 * The copy column here is CENTRED, so the scrim is centred too. An earlier
 * angled version was drawn for a left-aligned column and cleared exactly where
 * the words sit.
 *
 * Two layers, two jobs: a flat veil that lowers the whole film, and a wide
 * centred pool over the copy that still lets the form read at the edges. Both
 * stay inside the neutral ramp with no hue shift across stops, per the
 * gradient rule in brand/DESIGN.md.
 */
.scrub-scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(
      86% 64% at 50% 48%,
      rgba(13, 13, 15, 0.9) 0%,
      rgba(13, 13, 15, 0.84) 42%,
      rgba(13, 13, 15, 0.5) 76%,
      rgba(13, 13, 15, 0.12) 100%
    ),
    linear-gradient(
      180deg,
      rgba(13, 13, 15, 0.34) 0%,
      rgba(13, 13, 15, 0.2) 45%,
      rgba(13, 13, 15, 0.34) 100%
    );
}

.scrub > .container {
  position: relative;
  z-index: 2;
}

.scrub h2,
.scrub .lede,
.scrub .closing-sub {
  color: var(--page, #f7f6f4);
}
/* The sub and the note sit back from the heading on a dark ground the same way
   --muted sits back from --ink on a light one. */
.scrub .closing-sub {
  color: rgba(247, 246, 244, 0.74);
}
.scrub .closing-note,
.scrub .btn-quiet {
  color: rgba(247, 246, 244, 0.6);
}
.scrub .smallcaps {
  color: var(--stone, #8a857c);
}

@media (max-width: 860px) {
  .scrub-media img,
  .scrub-media video {
    object-position: 68% center;
  }
  /* Portrait crops the film hard and the copy runs nearly edge to edge, so
     the centred pool has nowhere to clear to. A flat vertical veil instead —
     the form still reads through it, the copy no longer competes with it. */
  .scrub-scrim {
    background: linear-gradient(
      180deg,
      rgba(13, 13, 15, 0.8) 0%,
      rgba(13, 13, 15, 0.9) 40%,
      rgba(13, 13, 15, 0.93) 100%
    );
  }
}

/* ---------------------------------------------------------------------------
 * The mono — added 2026-09-10 (Rahaid).
 *
 * Third face in the system, and it earns its place by doing exactly ONE job:
 * every LABEL and every small NUMERAL. Eyebrows, the 01-04 markers, the rail
 * counter. Nothing else. It is the move that gives a page an instrument-panel
 * character without a second display face, and it was the largest character
 * gap between our estate and the reference (MKT-BRD-005).
 *
 * JetBrains Mono over IBM Plex Mono on a rendered comparison, not a
 * description: Plex's numerals sit too heavy beside a 300-weight headline.
 *
 * Self-hosted, per brand/DESIGN.md — no Google @import anywhere in the estate.
 *
 * It lives HERE rather than in each page's inline <style> because every public
 * page loads this file AFTER its own block, so one rule reaches all eleven and
 * they cannot drift apart. Doing it per page is how the eyebrow ended up 12px
 * on index and 11px everywhere else.
 *
 * NOT applied to .step .n — that is a display numeral at 56-104px, not a
 * label, and the reference uses its SANS for figures at that size, never the
 * mono. Newsreader keeps its display moments.
 * ------------------------------------------------------------------------ */
@font-face {
  font-family: "JetBrains Mono";
  src: url("/mono-118cffcc.woff2") format("woff2");
  font-weight: 100 800;
  font-style: normal;
  font-display: swap;
}

:root {
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

.smallcaps,
.cell .n,
.kz-count,
.kz-num {
  font-family: var(--mono);
  font-style: normal;
}

/* A mono is already wide; the sans eyebrow's 0.18em becomes a gap at this
 * size, so it comes back to 0.12em. Measured on the render, not assumed. */
.smallcaps {
  letter-spacing: 0.12em;
  font-weight: 500;
}

/* These carried Newsreader italic. Reverting the italic with the family, or
 * the numerals inherit a slant the mono was never drawn for. */
.kz-count { font-size: 22px; letter-spacing: 0.02em; }
.kz-num { font-size: 13px; letter-spacing: 0.06em; }
.cell .n { letter-spacing: 0.06em; }

/* ── Reveal: the mechanism and services pages ──────────────────────────────
 *
 * Added 2026-09-11. Both pages shipped with NO entrance motion at all - 8 .reveal
 * blocks on the home page, zero on either of them - which is why they read as
 * flatter than the rest of the estate however good the layout is. DESIGN.md's
 * standing instruction is that motion is the default state of anything we ship,
 * and these went out without it.
 *
 * These selectors are the .lp-* families, which exist only on kaizen-loop.html
 * and what-we-run.html, so they cannot collide with the home page's own inline
 * block. Living here rather than inline on each page is the same reasoning the
 * mono rule is here for: one rule reaches every page that uses the pattern, and
 * two copies cannot drift apart.
 *
 * Gated on html.motion, which transitions.js adds as its first action. With no
 * JS nothing is ever hidden, so a failed script costs the animation and never
 * the content. No reduced-motion branch anywhere: Rahaid's standing ruling is
 * that the setting must not stop or change what we make.
 */
.motion .lp-block .lp-eyebrow,
.motion .lp-block h2,
.motion .lp-block h3,
.motion .lp-block .lp-no,
.motion .lp-body > p,
.motion .lp-body > ul,
.motion .lp-card,
.motion .lp-tile,
.motion .lp-do li,
.motion .wr-crit li,
.motion .lp-next .lp-eyebrow,
.motion .lp-next h2,
.motion .closing-sub,
.motion .closing-ctas,
.motion .closing-note,
.motion .lp-block .sect-head > p,
.motion .lp-block figure.duo,
.motion .lp-block .team-note { will-change: opacity, transform; }

/* Track one: scrubbed by the scroll itself, so the page resolves as the visitor
   moves and holds wherever they stop. Same approach as the home page. */
@supports (animation-timeline: view()) {
  /* Each block publishes ONE timeline and its children ride it. Anchoring every
     beat to the element's OWN view() was the defect: `entry` is measured against
     the subject's height, so a 20px eyebrow had a 20px scroll range and snapped
     0 -> 1 in a single wheel notch. The block is 309-803px tall, so offsets given
     in px below are a real scrub and read identically in every section. */
  .motion .lp-block,
  .motion .lp-next { view-timeline-name: --lp; }

  .motion :is(.lp-block, .lp-next) .lp-eyebrow { animation: ke-lp-fade linear both;
    animation-timeline: --lp; animation-range: entry 0 entry 150px; }
  .motion :is(.lp-block, .lp-next) .lp-eyebrow::before { animation: ke-lp-draw linear both;
    animation-timeline: --lp; animation-range: entry 20px entry 215px; }
  .motion :is(.lp-block, .lp-next) h2,
  .motion .lp-block h3 { animation: ke-lp-mask linear both;
    animation-timeline: --lp; animation-range: entry 40px entry 245px; }

  /* The closer had .lp-next on it and nothing reading it, so the most important
     block on the page was the one that did not move. Same beats as any section. */
  .motion .closing-sub { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 90px entry 290px; }
  .motion .closing-ctas { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 120px entry 300px; }
  .motion .closing-note { animation: ke-lp-fade linear both;
    animation-timeline: --lp; animation-range: entry 150px entry 300px; }

  /* about.html's own shapes. Scoped under .lp-block so they reach nothing on a
     page that has not opted in - index runs its own reveal system. */
  .motion .lp-block .sect-head > p { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 70px entry 270px; }
  .motion .lp-block figure.duo { animation: ke-lp-card linear both;
    animation-timeline: --lp; animation-range: entry 110px entry 300px; }
  .motion .lp-block .team-note { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 150px entry 300px; }
  .motion .lp-block .lp-no { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 30px entry 215px; }
  .motion .lp-body > p,
  .motion .lp-body > ul { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 90px entry 290px; }
  .motion .lp-body > p:nth-of-type(2) { animation-range: entry 112px entry 300px; }
  .motion .lp-body > p:nth-of-type(3) { animation-range: entry 132px entry 300px; }
  .motion .lp-card { animation: ke-lp-card linear both;
    animation-timeline: --lp; animation-range: entry 80px entry 285px; }
  .motion .lp-card:nth-child(2) { animation-range: entry 120px entry 300px; }
  .motion .lp-do li,
  .motion .wr-crit li { animation: ke-lp-rise linear both;
    animation-timeline: --lp; animation-range: entry 120px entry 300px; }
  .motion .lp-do li:nth-child(2),
  .motion .wr-crit li:nth-child(2) { animation-range: entry 148px entry 300px; }
  .motion .lp-do li:nth-child(3),
  .motion .wr-crit li:nth-child(3) { animation-range: entry 176px entry 300px; }
  .motion .lp-do li:nth-child(4) { animation-range: entry 204px entry 300px; }

  /* The hero tiles sit OUTSIDE every block, so there is no --lp to ride and they
     keep their own subject timeline. They are above the fold on load anyway. */
  .motion .lp-tile { animation: ke-lp-card linear both;
    animation-timeline: view(); animation-range: entry 6% entry 60%; }
  .motion .lp-tile:nth-child(2) { animation-range: entry 16% entry 70%; }
  .motion .lp-tile:nth-child(3) { animation-range: entry 26% entry 80%; }
}

@keyframes ke-lp-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes ke-lp-draw { from { transform: scaleX(0) } to { transform: scaleX(1) } }
/* A heading is MASKED, never faded - type rising out of an edge reads made, a
   fade reads generated. The house move, carried over from the home page. */
@keyframes ke-lp-mask {
  from { clip-path: inset(0 0 104% 0); transform: translateY(13px) }
  to   { clip-path: inset(-14% -8% -22% 0); transform: none }
}
@keyframes ke-lp-rise { from { opacity: 0; transform: translateY(9px) } to { opacity: 1; transform: none } }
@keyframes ke-lp-card { from { opacity: 0; transform: translateY(14px) } to { opacity: 1; transform: none } }

/* Track two: browsers without view timelines (Firefox, older Safari) get the
   same beats driven by an IntersectionObserver instead, so nobody lands on a
   page that does not move. */
@supports not (animation-timeline: view()) {
  .motion :is(.lp-block, .lp-next) .lp-eyebrow { opacity: 0; transition: opacity .55s var(--ke-ease) }
  .motion :is(.lp-block, .lp-next) .lp-eyebrow::before { transform: scaleX(0); transform-origin: left center;
                                          transition: transform .8s var(--ke-ease) }
  .motion :is(.lp-block, .lp-next) h2,
  .motion .lp-block h3 { clip-path: inset(0 0 104% 0); transform: translateY(13px);
                         transition: clip-path .95s var(--ke-spring), transform .95s var(--ke-spring) }
  .motion .lp-block .lp-no,
  .motion .lp-body > p,
  .motion .lp-body > ul,
  .motion .lp-card,
  .motion .lp-tile,
  .motion .lp-do li,
  .motion .wr-crit li,
  .motion :is(.lp-block, .lp-next) .closing-sub,
  .motion :is(.lp-block, .lp-next) .closing-ctas,
  .motion :is(.lp-block, .lp-next) .closing-note,
  .motion .lp-block .sect-head > p,
  .motion .lp-block figure.duo,
  .motion .lp-block .team-note { opacity: 0; transform: translateY(11px);
                        transition: opacity .8s var(--ke-ease), transform .8s var(--ke-ease) }
  .motion :is(.lp-block, .lp-next).in .lp-eyebrow { opacity: 1 }
  .motion :is(.lp-block, .lp-next).in .lp-eyebrow::before { transform: scaleX(1) }
  .motion :is(.lp-block, .lp-next).in h2,
  .motion .lp-block.in h3 { clip-path: inset(-14% -8% -22% 0); transform: none }
  .motion :is(.lp-block, .lp-next).in .closing-sub,
  .motion :is(.lp-block, .lp-next).in .closing-ctas,
  .motion :is(.lp-block, .lp-next).in .closing-note,
  .motion .lp-block.in .sect-head > p,
  .motion .lp-block.in figure.duo,
  .motion .lp-block.in .team-note { opacity: 1; transform: none }
  .motion :is(.lp-block, .lp-next).in .closing-ctas { transition-delay: .09s }
  .motion :is(.lp-block, .lp-next).in .closing-note { transition-delay: .16s }
  .motion .lp-block.in figure.duo { transition-delay: .08s }
  .motion .lp-block.in .team-note { transition-delay: .16s }
  .motion .lp-block.in .lp-no,
  .motion .lp-block.in .lp-body > p,
  .motion .lp-block.in .lp-body > ul,
  .motion .lp-block.in .lp-card,
  .motion .lp-block.in .lp-tile,
  .motion .lp-block.in .lp-do li,
  .motion .lp-block.in .wr-crit li { opacity: 1; transform: none }
  .motion .lp-block.in .lp-body > p:nth-of-type(2) { transition-delay: .07s }
  .motion .lp-block.in .lp-body > p:nth-of-type(3) { transition-delay: .14s }
  .motion .lp-block.in .lp-body > ul { transition-delay: .18s }
  .motion .lp-block.in .lp-card:nth-child(2) { transition-delay: .09s }
  .motion .lp-block.in .lp-tile:nth-child(2) { transition-delay: .07s }
  .motion .lp-block.in .lp-tile:nth-child(3) { transition-delay: .14s }
  .motion .lp-block.in .lp-do li:nth-child(2),
  .motion .lp-block.in .wr-crit li:nth-child(2) { transition-delay: .06s }
  .motion .lp-block.in .lp-do li:nth-child(3),
  .motion .lp-block.in .wr-crit li:nth-child(3) { transition-delay: .12s }
}
