/* LIAISON motion kit - the CSS half.
 * Pair with liaison-motion.js. Everything here is scoped to lm-* so it can drop
 * into any client kit without colliding with that kit's own class vocabulary.
 *
 * ================================================================ REQUIRED
 * PUT THIS LINE IN THE <head>, BEFORE THIS STYLESHEET. IT IS NOT OPTIONAL.
 *
 *   <script>document.documentElement.classList.add('lm-js')</script>
 *
 * Why it is required, and what it cost (2026-09-24). Every start state below
 * used to be written unconditionally: `[data-lm-reveal]{opacity:0}`, the line
 * masks at `translate 105%`, the media crop at `scale(1.08)`. With JavaScript
 * running, the observer adds `.lm-in` and everything opens. WITHOUT JavaScript,
 * nothing ever adds it, so the page keeps its start state and the text is
 * simply GONE. Measured on two prospect mockups the same morning: 220 words and
 * 34 blocks hidden on one, 167 words and 15 blocks on the other. Names, section
 * titles, numbers. The console stays silent, the layout looks correct, and
 * `prefers-reduced-motion` and the mobile breakpoint both looked fine, because
 * they carry their own final-state block. Only a browser with no script sees it.
 *
 * That breaks the hard rule of the house: NOTHING IS HIDDEN BY CSS THAT ONLY A
 * LIVING SCRIPT CAN BRING BACK. So the start states are now armed by `html.lm-js`
 * and nothing arms that class except a script that actually ran.
 *
 * It goes in the <head>, inline, not at the foot of <body> and not in the
 * deferred file: the class must exist BEFORE first paint, or the visitor sees
 * the final content flash and then collapse into the start state.
 *
 * Failure mode if you forget it: the page renders fully, in its final state,
 * with no animation. Visible and dull, never invisible. That is the safe
 * direction, and it is deliberate.
 *
 * `gate-kit.py` measures this (section "JavaScript coupe"). It walks text nodes
 * and computes the area surviving each clip-path, because a fully clipped block
 * keeps a non-zero bounding box and an opacity of 1, and an inset that closes a
 * line often reads 108%, not 100%.
 * ==========================================================================
 */

/* ---------------------------------------------------------------- root
 * `clip` and not `hidden`. `overflow-x: hidden` on html/body turns the root
 * into a scroll container, which silently disables every `position: sticky`
 * on the page. This single line is the difference between a pinned section
 * that works and one that scrolls away mid-animation.
 */
html, body { overflow-x: clip; }

:root {
  --lm-ease: cubic-bezier(0.16, 1, 0.3, 1);       /* the slow-out luxury curve */
  --lm-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --lm-dur: 900ms;
  --lm-dur-fast: 420ms;
  --lm-rise: 22px;
}

/* ---------------------------------------------------------------- reveal */
html.lm-js [data-lm-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--lm-rise), 0);
  transition: opacity var(--lm-dur) var(--lm-ease),
              transform var(--lm-dur) var(--lm-ease);
  will-change: opacity, transform;
}
/* End state armed by html.lm-js too, 2026-09-28. The start state above reads
 * `html.lm-js [data-lm-reveal]` (specificity 0,2,1) and the old end state read
 * `[data-lm-reveal].lm-in` (0,2,0): the start state won, so a page that followed
 * the REQUIRED head line kept every revealed block at opacity 0 for good, .lm-in
 * set and nothing visible. Proved on a test page before and after. Whatever arms
 * a start state must arm its end state with the same prefix, or the end loses. */
html.lm-js [data-lm-reveal].lm-in {
  opacity: 1;
  transform: none;
  will-change: auto;                 /* drop the hint once the work is done */
}

/* Variants: set data-lm-reveal="fade" / "left" / "scale" */
html.lm-js [data-lm-reveal="fade"]  { transform: none; }
html.lm-js [data-lm-reveal="left"]  { transform: translate3d(calc(var(--lm-rise) * -1.4), 0, 0); }
html.lm-js [data-lm-reveal="right"] { transform: translate3d(calc(var(--lm-rise) * 1.4), 0, 0); }
html.lm-js [data-lm-reveal="scale"] { transform: scale(1.06); }

/* ---------------------------------------------------------------- line split
 * .lm-line is the mask, .lm-line-inner is what travels. The 0.14em bottom
 * padding is not decoration: without it the descenders of p, j, g, y, q are
 * sheared by the overflow clip. Check it on a screenshot, never in the CSS.
 */
.lm-line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
html.lm-js .lm-line-inner {
  display: block;
  transform: translate3d(0, 105%, 0);
  transition: transform var(--lm-dur) var(--lm-ease);
  will-change: transform;
}
[data-lm-split].lm-in .lm-line-inner { transform: none; }

/* ---------------------------------------------------------------- pin
 * A pinned stage is a tall wrapper whose height IS the scroll budget, with a
 * sticky viewport inside it. 340vh of wrapper gives you 240vh of travel.
 */
.lm-pin { position: relative; }
.lm-pin-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;                    /* mobile browser chrome moves; svh does not */
  overflow: hidden;
  display: flex;
  align-items: center;
}
.lm-pin-track {
  display: flex;
  will-change: transform;
}
.lm-pin-panel {
  flex: 0 0 100vw;
  height: 100vh;
  height: 100svh;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Depth variant: the track and panels live in 3D so panels can recede and
 * angle as they pass the centre of the screen. */
.lm-pin-3d .lm-pin-stage { perspective: 1400px; }
.lm-pin-3d .lm-pin-track,
.lm-pin-3d .lm-pin-panel { transform-style: preserve-3d; }

/* ---------------------------------------------------------------- parallax */
.lm-parallax-frame {
  position: relative;
  overflow: hidden;                  /* the crop that makes the move legible */
}
.lm-parallax-frame > [data-lm-parallax] {
  position: absolute;
  inset: -8%;                        /* travel room, or an edge sliver shows */
  width: 116%;
  height: 116%;
  object-fit: cover;
  will-change: transform;
}

/* ---------------------------------------------------------------- marquee */
.lm-marquee {
  display: flex;
  width: max-content;
  will-change: transform;
}
.lm-marquee-wrap { overflow: hidden; }

/* ---------------------------------------------------------------- underline
 * The origin flip is the whole trick: the rule wipes in from the left on
 * hover and wipes out to the right on leave, instead of retracting the way
 * it came. One extra line, and the link stops feeling like a checkbox.
 */
.lm-underline {
  position: relative;
  text-decoration: none;
}
.lm-underline::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -0.12em;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right center;
  transition: transform var(--lm-dur-fast) var(--lm-ease);
}
.lm-underline:hover::after,
.lm-underline:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left center;
}

/* ---------------------------------------------------------------- media reveal
 * Scale the image slightly beyond its frame and let it settle. Reads as a
 * camera settling rather than a box appearing.
 */
.lm-media { overflow: hidden; }
.lm-media img,
.lm-media video {
  display: block;
  width: 100%;
  height: auto;                      /* never omit this next to a width, or an
                                        img carrying width/height attributes
                                        gets stretched out of ratio */
}
/* The 1.08 start scale applies ONLY where the reveal is actually wired.
 * Paid on valorvento, 2026-09-08: a page used .lm-media WITHOUT
 * data-lm-reveal, so .lm-in was never added, and nine photographs stayed
 * zoomed at 1.08 for the life of the page. Nothing failed: the render gate
 * measures rendered width against native and a 1.08 crop does not exceed it,
 * the opacity probe sees 1, and the page looks deliberate. It is not.
 * Scoping the start state to the attribute that undoes it makes the trap
 * impossible: no reveal, no zoom. */
html.lm-js .lm-media[data-lm-reveal] img,
html.lm-js .lm-media[data-lm-reveal] video {
  transform: scale(1.08);
  transition: transform 1400ms var(--lm-ease);
}
html.lm-js .lm-media[data-lm-reveal].lm-in img,
html.lm-js .lm-media[data-lm-reveal].lm-in video { transform: scale(1); }

@media (prefers-reduced-motion: reduce) {
  .lm-media[data-lm-reveal] img,
  .lm-media[data-lm-reveal] video { transform: none; transition: none; }
}

/* ---------------------------------------------------------------- mobile
 * Linearise, do not shrink. A pinned 3D corridor at 380px wide is unreadable;
 * the same content as stacked full-width blocks is not a downgrade, it is the
 * correct layout for the device.
 */
@media (max-width: 760px) {
  .lm-pin { height: auto !important; }
  .lm-pin-stage {
    position: static;
    height: auto;
    display: block;
    overflow: visible;
  }
  .lm-pin-track {
    display: block;
    transform: none !important;
  }
  .lm-pin-panel {
    flex: none;
    width: 100%;
    height: auto;
    transform: none !important;
    padding: 12vh 6vw;
  }
  .lm-pin-3d .lm-pin-stage { perspective: none; }
}

/* ---------------------------------------------------------------- reduced motion
 * Show the final state. Never hide content behind a preference.
 */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
  [data-lm-reveal] { opacity: 1 !important; transform: none !important; }
  .lm-line-inner { transform: none !important; }
  .lm-media img, .lm-media video { transform: none !important; }
  .lm-pin-track, .lm-pin-panel { transform: none !important; }
}
