/* ============================================================
   MOTION — layered over styles.css. Every hidden state here is
   applied by JS via a data attribute, never as a CSS default, so
   a blocked script leaves the page complete rather than empty.
   ============================================================ */

@media (prefers-reduced-motion: reduce) {
  [data-motion] { opacity: 1 !important; transform: none !important; filter: none !important; }
  [data-motion] *, [data-motion] *::before, [data-motion] *::after { animation: none !important; transition: none !important; }
}

/* --- Hero layout: headline left, pixel heart right ----------------------
   1.05fr/0.95fr gives the headline column the larger share, since it is
   the column carrying the type. The heart's own max-width (520px) only
   ever matters once the column itself has room to grant it — below that
   the grid track is already the binding constraint, so the two never
   fight. Below 900px the columns stack; the heart drops to a fixed
   320px cap so a 350px viewport (390px minus gutters) still clears it
   with room either side. */
.hero__grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: var(--s-10); align-items: start; }

/* Two things keep the heart on the first screen, which centring against the
   copy column did not: it is capped against the viewport's own height, so a
   short laptop shrinks it rather than pushing it off the bottom; and it tops
   out with the headline instead of centring against a column half again its
   height, which on a 1512x850 MacBook had it hanging 93px below the fold.
   The small negative offset is optical — the headline's line-height is 0.95,
   so its caps sit a little above its own box, and matching box tops exactly
   left the heart looking dropped next to it. */
.hero__heart {
  position: relative;
  aspect-ratio: 1;
  width: 100%;
  max-width: min(520px, 58vh);
  margin-inline-start: auto;
  margin-top: calc(var(--s-3) * -1);
}
.hero__heart canvas { width: 100%; height: 100%; display: block; }
@media (hover: hover) and (pointer: fine) {
  .hero__heart { cursor: pointer; }
}
@media (max-width: 900px) {
  .hero__grid { grid-template-columns: 1fr; }
  /* Above the copy on a narrow screen, not below it. Stacked underneath a
     full headline, lead, CTA pair and fact row it started ~950px down the
     page — off the bottom of every phone made, and off the end of the
     arrival sequence, which converges on wherever this actually is. */
  .hero__heart {
    order: -1;
    max-width: min(330px, 42vh);
    margin: 0 auto var(--s-8);
  }
}

/* --- Hero entrance ------------------------------------------------------ */
.hero__title .word { display: inline-block; }

[data-motion="hero"] .word {
  will-change: transform, opacity, filter;
  opacity: 0;
  transform: translateY(0.5em);
  filter: blur(6px);
}

[data-motion="hero"][data-motion-in] .word {
  opacity: 1;
  transform: none;
  filter: none;
  transition:
    opacity 420ms cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i) * 32ms),
    transform 480ms cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i) * 32ms),
    filter 420ms linear calc(var(--i) * 32ms);
}

/* The rust italic lands last and a beat later — the eye should end on it. */
[data-motion="hero"][data-motion-in] em.word {
  transition-delay: calc(var(--i) * 32ms + 120ms);
}

/* --- Process rail --------------------------------------------------------- */

/* The fade must anchor to the element that actually clips the cards. On the
   section it rendered 152px clear of the cut-off, inside the container's
   margin, which is why the rail still looked like a layout bug. */
.rail__viewport { position: relative; }
.rail__viewport::after {
  content: "";
  position: absolute; top: 0; right: 0; bottom: 0;
  width: 96px; pointer-events: none;
  background: linear-gradient(to right, transparent, var(--bg-2));
  opacity: 0; transition: opacity 240ms ease-out;
}
.rail[data-scroll="start"] .rail__viewport::after,
.rail[data-scroll="mid"] .rail__viewport::after { opacity: 1; }

/* position:relative here is load-bearing, not decorative: .sr-only below is
   position:absolute and, unpositioned, its containing block would be the
   initial containing block (the viewport) rather than this element — and
   text-align:right on an absolutely-positioned, unconstrained-width box
   then pushes it past the *container's* right edge (not the readout's),
   which is real, page-level horizontal overflow between ~721px and 1350px
   where this element renders but the container is narrower than the
   viewport. Giving this its own containing block keeps .sr-only's box
   inside it, where text-align:right has nothing wider than the readout
   itself to escape into. */
.rail__readout {
  position: relative;
  margin: 0 0 var(--s-4);
  text-align: end;
  font-variant-numeric: tabular-nums;
}

/* Not the classic "1px + overflow:hidden" trick: position:absolute makes a
   span's used display block-level, and forcing that block to 1px wide while
   its text can't wrap (white-space:nowrap) makes the text overflow the box —
   which registers, to a scrollWidth/clientWidth check, as a real horizontal
   scroller (confirmed empirically: overflow:clip does NOT avoid this —
   clip still reports the full un-clipped content width via scrollWidth
   even though nothing is actually scrollable, so a fixed 1px box here would
   itself become a second "scroller" alongside .rail__track). Leaving the
   box unsized (shrink-to-fit its own text, ~100px, via the default
   overflow:visible) keeps scrollWidth == clientWidth for THIS element, so
   it can never itself look like a scroller. clip-path: inset(50%) alone
   then clips that box to nothing VISUALLY without touching its layout size.

   Two things had to be true for this box to stay on the page, not one:
   .rail__readout above is position:relative so this box's containing block
   is the (correctly narrow) readout rather than the viewport — but with no
   offset properties of its own, an absolutely-positioned element's location
   is its *static position*: roughly where it would have rendered in normal
   flow. This span sits right after the visible "01 / 05" text, which
   text-align:right has already pushed flush against the readout's right
   edge — so the static position starts AT that edge and this box's own
   shrink-to-fit width (~100px) then runs straight past it, off the page,
   regardless of the containing block being correct. `right: 0` anchors the
   box's own right edge to the containing block's right edge instead, so it
   grows leftward, inward, from a fixed point — never past it. */
.sr-only {
  position: absolute;
  top: 0;
  right: 0;
  clip-path: inset(50%);
  white-space: nowrap;
}

[data-motion="rail"] .rail__item { opacity: 0; transform: translateY(18px); }
[data-motion="rail"][data-motion-in] .rail__item {
  opacity: 1; transform: none;
  transition: opacity 420ms ease-out calc(var(--i) * 80ms),
              transform 520ms cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i) * 80ms);
}

/* --- Hover craft ---------------------------------------------------------
   .card / .work-card / .engagement / .person also carry .reveal, whose own
   base rule (styles.css) governs opacity + transform at --d-reveal (360ms)
   for the one-time entrance. An unscoped `transition: transform ...` rule
   here would sit at equal specificity with .reveal's and — because motion.css
   loads after styles.css — would win outright for the whole transition-
   property list, silently dropping opacity's transition and truncating the
   reveal's own transform run from 360ms to whatever this rule used, i.e. the
   reveal would pop in instead of rising. Scoping to .is-in (the class the
   reveal applies once, permanently, the moment it completes) gives this rule
   higher specificity (two classes) than .reveal's bare one, so it only takes
   over transform — and, as a bonus, actually gets the pre-existing
   border-color/background hovers animating too — once the entrance has
   already finished. Hovering mid-reveal is never left without a transition,
   so it blends into the still-running entrance rather than snapping. */
.card.is-in, .work-card.is-in, .engagement.is-in, .person.is-in {
  transition: transform var(--d-hover) var(--ease-out),
              border-color var(--d-hover) var(--ease-out),
              background var(--d-hover) var(--ease-out);
}
@media (hover: hover) {
  .card:hover, .work-card:hover, .engagement:hover, .person:hover {
    transform: translateY(-3px);
  }
  /* Press mirrors the lift: down instantly, back on release. */
  .card:active, .work-card:active, .engagement:active, .person:active {
    transform: translateY(-1px);
    transition-duration: 70ms;
  }
}
@media (prefers-reduced-motion: reduce) {
  .card:hover, .work-card:hover, .engagement:hover, .person:hover,
  .card:active, .work-card:active, .engagement:active, .person:active { transform: none; }
}

@media (max-width: 720px) {
  .rail__viewport::after,
  .rail__viewport::before { display: none; }  /* vertical timeline, nothing to scroll */
  .rail__readout { display: none; }
}

/* ============================================================
   THE ARRIVAL SEQUENCE
   Built entirely by intro.js and never present in the markup, so a blocked
   script cannot leave this sheet stranded over the page. It is cream on
   cream, carrying the same 32px graph rule the page ground does, so what fades at the
   end is the drawing on it and not the ground — the page reads as having
   been underneath the whole time rather than as a curtain going up.
   ============================================================ */
.intro {
  position: fixed;
  inset: 0;
  z-index: 90;
  background-color: var(--bg);
  background-image:
    linear-gradient(to right, var(--grid) 1px, transparent 1px),
    linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
  background-size: var(--gs) var(--gs);
  background-position: -1px -1px;
  /* Decoration never intercepts a click. The escape hatches listen on
     window instead, so a visitor who reaches for a button during the
     sequence dismisses it and hits the button. */
  pointer-events: none;
  opacity: 1;
  transition: opacity 280ms linear;
}
.intro[data-leaving] { opacity: 0; }
.intro[data-fast]    { transition-duration: 180ms; }

.intro__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* Three seconds is short, but not so short that there shouldn't be a way
   out that doesn't require guessing there is one. Fades up at 700ms — late
   enough not to compete with the opening, early enough to be there before
   anyone wants it. */
.intro__skip {
  position: absolute;
  right: calc(var(--gutter) + env(safe-area-inset-right, 0px));
  bottom: calc(var(--s-7) + env(safe-area-inset-bottom, 0px));
  pointer-events: auto;
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: 0;
  padding: var(--s-2) var(--s-3);
  font-family: "JetBrains Mono", ui-monospace, monospace;
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-dim);
  cursor: pointer;
  opacity: 0;
  animation: intro-skip-in 400ms var(--ease-out) 700ms forwards;
}
.intro__skip:hover { color: var(--ink); }
/* A focused control must be visible immediately — it cannot wait 700ms for
   an animation to reveal where the keyboard already is. */
.intro__skip:focus-visible {
  opacity: 1;
  animation: none;
  color: var(--ink);
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
@keyframes intro-skip-in { to { opacity: 1; } }

/* intro.js never mounts under reduced motion. This is the second lock on
   the same door. */
@media (prefers-reduced-motion: reduce) { .intro { display: none; } }


/* ============================================================
   THE ECG RULE
   The intro's heartbeat, carried through the page as a divider:
   a flatline that spikes once. It draws itself on entry via the
   same .reveal/.is-in observer the cards use — no extra JS.
   Hidden state is gated on data-motion-ready="on" (set by
   motion.js), so no-JS and reduced-motion visitors get the line
   already drawn, never a blank strip.
   ============================================================ */
.ecg { color: var(--accent); }
.ecg svg { display: block; width: 100%; height: 40px; }
.ecg path { stroke: currentColor; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }

html[data-motion-ready="on"] .ecg path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}
html[data-motion-ready="on"] .ecg.is-in path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 1400ms var(--ease-soft) 120ms;
}

/* The footer's is quieter — an end-of-record read-out, not a headline. */
.ecg--footer { color: var(--accent-deep); opacity: 0.55; }

/* Proof tiles ride the shared reveal but start a touch lower and later —
   a heavier object should land more deliberately than a card. */
.tile.reveal { transform: translateY(24px); transition-duration: 480ms; }
.tile.reveal.is-in { transform: none; }


/* The rail's scrub line — motion.js already maintains --progress on .rail;
   this is the first thing that actually renders it. */
.rail__viewport::before {
  content: "";
  position: absolute; left: 0; bottom: 0; z-index: 1;
  height: 2px; width: 100%;
  background: var(--accent);
  transform: scaleX(var(--progress, 0));
  transform-origin: left center;
  opacity: 0.7;
  pointer-events: none;
}



/* The rail reads as draggable to a mouse. */
@media (hover: hover) and (pointer: fine) {
  .rail__track { cursor: grab; }
}
