/* ==========================================================================
   SHIFT — Motion
   One idea, applied at every scale: things move by discrete offsets and lock
   into place. Deliberate, never floaty. Only transform and opacity animate.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Reveal on scroll. Content arrives from below behind a clip, so it reads as
   a panel sliding up into its slot rather than a fade.

   The hidden state is opt-in, gated behind .js-anim, which the inline script
   in <head> only adds once it knows the browser can drive the animation. The
   default state of every element is therefore *visible*: if a script fails,
   an observer never fires, or the CSS arrives without the JS, the page still
   renders in full instead of going blank.
   -------------------------------------------------------------------------- */
[data-reveal] { --rv-y: 24px; }

.js-anim [data-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--rv-y), 0);
  transition: opacity var(--d-sect) linear,
              transform var(--d-sect) var(--e-shift);
  transition-delay: calc(var(--i, 0) * var(--stagger));
}
.js-anim [data-reveal].is-in { opacity: 1; transform: translate3d(0, 0, 0); }

.js-anim [data-reveal="left"]  { --rv-y: 0; transform: translate3d(-28px, 0, 0); }
.js-anim [data-reveal="right"] { --rv-y: 0; transform: translate3d(28px, 0, 0); }
.js-anim [data-reveal="scale"] { transform: translate3d(0, var(--rv-y), 0) scale(0.985); }
.js-anim [data-reveal="scale"].is-in { transform: none; }

/* Masked line reveal — used on headlines. Each line is its own clipped row,
   so the type shifts up out of a hard edge. Works for Latin and Japanese
   because the split is by line, not by word. */
.reveal-lines { display: grid; }
.reveal-lines > span { display: block; padding-bottom: 0.08em; margin-bottom: -0.08em; }
.js-anim .reveal-lines > span { overflow: hidden; }
.js-anim .reveal-lines > span > span {
  display: block;
  transform: translateY(105%);
  transition: transform var(--d-sect) var(--e-shift);
  transition-delay: calc(var(--i, 0) * 70ms);
}
.js-anim .reveal-lines.is-in > span > span { transform: translateY(0); }

/* --------------------------------------------------------------------------
   Scroll displacement. Columns travel at different rates and converge to
   alignment as the section reaches the middle of the viewport — panels
   sliding into register. `--p` is written by motion.js (-1 → 1).
   -------------------------------------------------------------------------- */
[data-shift] {
  transform: translate3d(0, calc(var(--p, 0) * var(--shift-amp, 64px)), 0);
}

/* --------------------------------------------------------------------------
   The background system.
   Technical line work sitting behind the content — a circuit board, a data
   matrix, a network, a pipeline, storage layers, a signal, a dependency
   tree. Built and driven by machinery.js.

   Opacity is set per layer rather than on the whole group, so the accent can
   carry more weight than the static line work. That matters: the accent only
   ever marks what is *moving*, and at a single group opacity it would sink
   into the drawing instead of reading as live.
   -------------------------------------------------------------------------- */
.machine {
  --mc-line: 0.17;    /* the static drawing            */
  --mc-frame: 0.10;   /* boards, rails, guides         */
  --mc-sig: 0.5;      /* whatever is currently moving  */
  --mc-glow: 0.15;    /* the light it spills           */
  --mc-lamp: 0.62;    /* indicator lamps               */
  position: absolute;
  z-index: var(--z-gear);
  pointer-events: none;
  color: var(--c-ink);
}
.machine svg { width: 100%; height: auto; overflow: visible; }

.machine .mc-line   { stroke-opacity: var(--mc-line); fill-opacity: var(--mc-line); }
.machine .mc-frame  { stroke-opacity: var(--mc-frame); fill-opacity: var(--mc-frame); }
.machine .mc-signal { color: var(--c-accent); stroke-opacity: var(--mc-sig); fill-opacity: var(--mc-sig); }
/* The bloom under a live stroke, and the halo around a lamp. Wide and faint —
   it should read as light spilling, never as a second line. */
.machine .mc-glow   { color: var(--c-accent); stroke-opacity: var(--mc-glow); fill-opacity: var(--mc-glow); }
.machine .mc-lamp   { color: var(--c-accent); fill-opacity: var(--mc-lamp); }

/* One drawing only, behind the hero, sitting to the right of the column. */
.machine--circuit { top: 50%; right: -3%; width: 48%; translate: 0 -50%; }

/* On a phone the rails carry the drawing entirely; the board drops below the
   text rather than sitting behind it. */
@media (max-width: 47.99em) {
  .machine { --mc-line: 0.13; --mc-frame: 0.07; --mc-sig: 0.38; --mc-glow: 0.1; --mc-lamp: 0.45; }
  .machine--circuit { top: auto; bottom: -7%; right: -26%; left: auto; width: 74%; translate: none; }
}
/* Content always sits above the machinery. */
.section > .wrap, .phero > .wrap { position: relative; z-index: var(--z-body); }

/* Each machine stays inside its own bay. Without this a tall machine would
   spill into the next section and, at the foot of the page, add scroll height
   to a document that has nothing left to show. */
.has-machine { overflow: hidden; }

/* --------------------------------------------------------------------------
   Hero.
   The board is the hero image here, not background texture, so it gets room
   and enough contrast to carry the composition on its own.
   -------------------------------------------------------------------------- */
.hero { min-height: min(78vh, 740px); display: grid; align-items: center; }
.hero .machine--circuit { --mc-line: 0.17; --mc-frame: 0.10; --mc-sig: 0.5; --mc-glow: 0.15; --mc-lamp: 0.6; width: 46%; }
@media (max-width: 47.99em) {
  .hero { min-height: auto; }
  /* Only the width is overridden — the corner it hangs off comes from the
     phone placement above, so the hero follows the same scatter as the rest. */
  .hero .machine--circuit { --mc-line: 0.17; --mc-sig: 0.48; width: 88%; }
}

/* The hero headline runs a hair under the global display scale, because its
   longest English line overshoots the measure by about one percent at some
   widths — enough to wrap it onto an extra line.

   Written as a multiplier on --t-display, NOT as its own clamp. Japanese
   redefines --t-display to a much smaller scale (every glyph is full-width, so
   a headline at the Latin size runs three times as wide); replacing the value
   here instead of scaling it forced the Latin size onto Japanese and wrapped
   the JA headline at 768 and 1024. */
.hero .t-display { font-size: calc(var(--t-display) * 0.96); }

/* Interior page headers are much shorter than the home hero, so the board is
   sized to fit inside them rather than being clipped by the section and
   running up under the fixed header. */
.phero .machine--circuit { width: 40%; }
@media (max-width: 61.99em) { .phero .machine--circuit { width: 58%; } }
@media (max-width: 47.99em) { .phero .machine--circuit { width: 76%; } }

/* The drawing's caption block. */
.hero__fig {
  align-self: end;
  display: grid;
  justify-items: end;
  gap: var(--s-1);
  text-align: right;
  font-family: var(--f-mono);
  font-size: var(--t-label);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  color: var(--c-muted);
}
.hero__fig::before {
  content: "";
  width: 4.5rem;
  height: var(--hair);
  background: var(--c-line);
  margin-bottom: var(--s-2);
}
@media (max-width: 47.99em) {
  .hero__fig { justify-items: start; text-align: start; margin-top: var(--s-6); }
}

/* --------------------------------------------------------------------------
   Page transition. Vertical panels sweep across, staggered, on leave — and
   clear the other way on arrival. Same displacement idea at full-screen size.
   -------------------------------------------------------------------------- */
.wipe {
  position: fixed;
  inset: 0;
  z-index: var(--z-wipe);
  display: grid;
  grid-template-columns: repeat(var(--panels, 6), 1fr);
  pointer-events: none;
  visibility: hidden;
}
.wipe__panel {
  background: var(--c-ink);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform var(--d-page) var(--e-mech);
  transition-delay: calc(var(--i) * 30ms);
}
/* Leaving: panels grow down and cover. */
[data-wipe="out"] .wipe { visibility: visible; }
[data-wipe="out"] .wipe__panel { transform: scaleY(1); }
/* Arriving: panels start covered and clear upward. */
[data-wipe="in"] .wipe { visibility: visible; }
[data-wipe="in"] .wipe__panel { transform: scaleY(1); transform-origin: bottom; transition: none; }
[data-wipe="in-run"] .wipe { visibility: visible; }
[data-wipe="in-run"] .wipe__panel { transform: scaleY(0); transform-origin: bottom; }

/* --------------------------------------------------------------------------
   Marquee — a continuous horizontal shift. Duplicated track, translated by
   exactly -50% so the loop is seamless.
   -------------------------------------------------------------------------- */
.marquee { overflow: hidden; border-top: var(--hair); border-bottom: solid; padding-top: var(--s-4); padding-bottom: var(--s-4); }
.marquee__track {
  display: flex;
  width: max-content;
  gap: var(--s-6);
  animation: marquee 42s linear infinite;
}
.marquee:hover .marquee__track { animation-play-state: paused; }
.marquee__item {
  display: inline-flex; align-items: center; gap: var(--s-6);
  font-family: var(--f-mono); font-size: var(--t-label);
  letter-spacing: var(--tr-label); text-transform: uppercase;
  color: var(--c-muted); white-space: nowrap;
}
.marquee__item::after { content: "»"; color: var(--c-accent); }
@keyframes marquee { to { transform: translateX(-50%); } }

/* ==========================================================================
   Reduced motion.
   Everything still arrives and every state is still legible — it just stops
   travelling. The machinery stops turning and the wipe is skipped entirely
   (both handled in JS).
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  [data-reveal] { opacity: 1; transform: none; }
  [data-shift] { transform: none; }
  .reveal-lines > span > span { transform: none; }
  .marquee__track { animation: none; }
  .wipe { display: none; }
}

/* ==========================================================================
   The edge bus
   Two rails pinned to the sides of the window, carrying current the whole
   time. The text column keeps the middle; the machine lives in the margins.
   z-index -1 puts it behind everything, so the footer's own background still
   covers it and nothing ever sits on top of a word.
   ========================================================================== */
.railbus {
  position: fixed;
  top: 0;
  bottom: 0;
  z-index: -1;
  pointer-events: none;
  color: var(--c-ink);
  --mc-line: 0.12;
  --mc-frame: 0.07;
  --mc-sig: 0.38;
  --mc-glow: 0.10;
  --mc-lamp: 0.45;
}
.railbus--left  { left: 0; }
/* Mirrored rather than drawn twice, so both sides stay in step by construction. */
.railbus--right { right: 0; transform: scaleX(-1); }
.railbus svg { display: block; }

.railbus .mc-line   { stroke-opacity: var(--mc-line); fill-opacity: var(--mc-line); }
.railbus .mc-frame  { stroke-opacity: var(--mc-frame); }
.railbus .mc-signal { color: var(--c-accent); stroke-opacity: var(--mc-sig); }
.railbus .mc-glow   { color: var(--c-accent); stroke-opacity: var(--mc-glow); }
.railbus .mc-lamp   { color: var(--c-accent); fill-opacity: var(--mc-lamp); }

/* Current, always running. Everything else on the page moves only when you
   scroll, which is right for content — but a rail that sits still reads as
   dead, and this is the one thing that should look powered.

   It travels by transform, not by stroke-dashoffset. Dashoffset makes the
   browser repaint the whole stroke every frame; a transform is handed to the
   compositor and costs nothing while the page scrolls. */
@keyframes rail-pulse { to { transform: translateY(var(--travel, 900px)); } }
.rail-pulse { animation: rail-pulse 6s linear infinite; will-change: transform; }

/* The board's two live traces. Only two paths animate this way, over a small
   area, so the repaint is negligible. */
@keyframes board-flow { to { stroke-dashoffset: -320; } }
.board-flow { animation: board-flow 7s linear infinite; }

@keyframes mc-blink { 0%, 46% { opacity: 0.1; } 52%, 96% { opacity: 1; } 100% { opacity: 0.1; } }
.mc-blink { animation: mc-blink 5s steps(1, end) infinite; }

/* Keep the text clear of the rails. The gutter already scales with the
   viewport; this only guarantees a minimum that clears the rail width. */
.wrap { padding-left: max(var(--gutter), 4.25rem); padding-right: max(var(--gutter), 4.25rem); }

@media (max-width: 47.99em) {
  .railbus { --mc-line: 0.12; --mc-sig: 0.36; --mc-glow: 0.09; --mc-lamp: 0.42; }
  .wrap { padding-left: max(var(--gutter), 3rem); padding-right: max(var(--gutter), 3rem); }
}

@media (prefers-reduced-motion: reduce) {
  .rail-pulse, .board-flow, .mc-blink { animation: none; }
  /* The ground needs no rule here. Its swapping squares are built by
     machinery.js, which returns before building anything under reduced
     motion, so the sheet is simply never given any. */
}

/* ==========================================================================
   Ground
   A sheet of engineering paper under everything. The sheet never moves.
   Instead, two neighbouring squares of it trade places — one travels a whole
   module, the other the same distance back, sideways or up and down. A square
   is one module: six by six of the fine grid.

   Two earlier attempts are worth not repeating. Sliding the entire sheet was
   one gesture that just pushed the page to the right. Nudging blocks a few
   pixels out of register was too quiet to register as movement at all.

   How a square hides the sheet under it. It is drawn ON TOP, so if it were
   transparent you would see both and every line would double. It carries an
   opaque --c-base fill instead, and its lines are drawn at the colour the
   sheet's lines have ALREADY been blended to. Get that colour wrong by even
   one level and the squares sit on the page as visible rectangles.

   Register, which is the whole idea. The sheet is inset by a whole number of
   modules, so its pattern is in phase with the viewport origin, and squares
   sit on that same lattice. A module is also exactly one period of the
   pattern. So a square at rest is pixel-identical to the sheet beneath it,
   AND a square that has travelled one module is pixel-identical to the sheet
   at its new home. Both ends of a swap are therefore invisible: the travel is
   the only evidence, and once it lands there is nothing left to point at.
   That is what makes it read as "did something just move?" rather than as an
   animation. It is also what lets a finished pair be silently reset and
   reused. --bg-module is the single source for all of it, and JS reads it
   rather than hard-coding 168.

   Cost. Each swap is a transform on two small elements — no repaint, nothing
   on the scroll thread, and the sheet is still painted exactly once.
   ========================================================================== */
/* The two grid sizes, and the ONLY place either is written. A square is always
   6 x 6 of the fine grid — the swap distance, the tile size and both background
   patterns are all derived from --bg-module, so changing these two numbers
   rescales the whole ground and JS picks it up through the same property. */
:root { --bg-module: 168px; --bg-fine: 28px; }

body::before {
  content: "";
  position: fixed;
  /* A whole number of modules (2 × 168, and 12 × 28), so the pattern's phase
     matches the viewport origin. That is what lets a tile sit in register. */
  inset: -336px;
  z-index: -3;
  pointer-events: none;
  background-image:
    linear-gradient(to right,  var(--c-line-set) 1px, transparent 1px),
    linear-gradient(to bottom, var(--c-line-set) 1px, transparent 1px),
    linear-gradient(to right,  var(--c-line-soft-set) 1px, transparent 1px),
    linear-gradient(to bottom, var(--c-line-soft-set) 1px, transparent 1px);
  background-size:
    var(--bg-module) var(--bg-module), var(--bg-module) var(--bg-module),
    var(--bg-fine) var(--bg-fine), var(--bg-fine) var(--bg-fine);
  /* No opacity: 0.5 here any more. The sheet used to be full-strength lines
     dimmed by a layer opacity, and a tile matched it with color-mix — two
     different rounding paths to the same intended colour, which left every
     line inside a resting tile off by up to 3 levels and the tile faintly
     visible as a rectangle. Both now draw the SAME already-dimmed colour at
     full strength, so they cannot disagree. The page looks exactly as it did:
     50% of --c-line over --c-base is what the opacity was producing. */
}

/* The swapping squares. machinery.js builds and schedules them, and skips the
   whole thing under reduced motion — so there is nothing to switch off here. */
.ground {
  position: fixed;
  inset: 0;
  /* Sheet -3, squares -2, rails -1, content 1. The squares are opaque, so this
     order is load-bearing: .railbus is also negative, and at an equal z-index
     the later element in the DOM wins — the ground is appended last, so it
     painted straight over both rails. Caught by diffing a render against one
     with the ground removed; the only pixels that differed were the rails. */
  z-index: -2;
  pointer-events: none;
  overflow: hidden;       /* a square near the edge cannot paint outside */
}
.ground__tile {
  position: absolute;
  background-color: var(--c-base);
  background-image:
    linear-gradient(to right,  var(--c-line-set) 1px, transparent 1px),
    linear-gradient(to bottom, var(--c-line-set) 1px, transparent 1px),
    linear-gradient(to right,  var(--c-line-soft-set) 1px, transparent 1px),
    linear-gradient(to bottom, var(--c-line-soft-set) 1px, transparent 1px);
  background-size:
    var(--bg-module) var(--bg-module), var(--bg-module) var(--bg-module),
    var(--bg-fine) var(--bg-fine), var(--bg-fine) var(--bg-fine);
  /* 780ms with a strong ease-in-out. Long for UI, but this is not UI — it is
     ambient, it must be slow enough to catch out of the corner of an eye, and
     it is the full width of a module rather than a nudge. The curve makes it
     leave and arrive rather than slide at a constant speed.

     A transition rather than a keyframe, so machinery.js can cancel one
     mid-travel by setting transition: none and the square lands immediately
     instead of finishing a fixed animation. That cancel is how a pair gets
     handed back to the pool. Kept in sync with SWAP_MS there.

     No will-change: a square is idle almost all of the time, and the
     transition promotes it for the moment it runs. */
  transition: transform 780ms cubic-bezier(0.77, 0, 0.175, 1);
}
/* The tell. Just before two squares trade places, ONE of them — not both —
   lights up around all four of its edges, holds for a beat, and fades out as
   they travel. It exists so the eye has somewhere to be: without it the
   movement is genuinely hard to catch, which was the charm and also the
   frustration.

   Only one of the pair glows on purpose. Lighting both would draw a symmetric
   pair of boxes and read as a diagram of what is about to happen; lighting one
   just says "here", and you find the movement yourself.

   An inset shadow rather than a border or a gradient: it hugs the perimeter
   and falls away inward, so all four edges are lit with no hard line anywhere
   and nothing spills onto the neighbour. The box is exactly one module, so the
   lit edges land on the four coarse grid lines that already bound that square
   — it reads as those lines brightening, not as a rectangle appearing.

   Only opacity animates, so this costs a composite and never a repaint. At
   rest it is opacity 0 and contributes nothing, which is what keeps a resting
   page pixel-identical to one with no ground at all.
   ========================================================================== */
.ground__glow {
  position: absolute;
  opacity: 0;
  pointer-events: none;
  /* Two layers, one colour. The hairline lands exactly on the four coarse grid
     lines that already bound this square, so those lines simply deepen — which
     is what makes it read as "these edges are lit" rather than as a rectangle
     someone drew on top. The wide inset behind it is the bloom; a negative
     spread keeps it hugging the edge instead of shading the whole square, which
     is what a single wide inset did and why it looked like a grey box. */
  box-shadow:
    inset 0 0 0 1px var(--c-glow),
    inset 0 0 12px -1px var(--c-glow);
  /* Two transitions, because the tell does two different things. It fades on
     its own curve, and it TRAVELS on the squares' curve and duration — it is
     pinned to one of the pair and goes with it, so those two numbers have to
     match .ground__tile exactly or the glow would drift off its square.

     machinery.js replaces this shorthand with an opacity-only one when it
     starts the fade. That is deliberate: setting transitionDuration alone
     would retime BOTH properties, and by then the travel is over anyway. */
  transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1),
              transform 780ms cubic-bezier(0.77, 0, 0.175, 1);
}


/* The sheet's lines, pre-blended. --c-line at 50% over --c-base is what the
   sheet's 0.5 opacity actually produces. */
:root {
  --c-line-set:      color-mix(in srgb, var(--c-line) 50%, var(--c-base));
  --c-line-soft-set: color-mix(in srgb, var(--c-line-soft) 50%, var(--c-base));
}

/* The fine grid alone would shimmer on a phone, where it lands near the
   pixel grid. Coarse only below the breakpoint — and the module shrinks,
   which the tiles pick up through the same variable. */
/* The grid scales down with the screen, in two steps.

   It used to jump straight from a 168px module to 108px below 48em and drop the
   fine grid entirely. That left a phone with barely three squares across the
   viewport, so a swapping square was a third of the screen wide and read as a
   slab moving rather than a detail — Guto's note, and he was right. Desktop
   gets about eight and a half squares across; these steps keep a phone near
   four and a half and a tablet near six, which is the same character rather
   than the same absolute size.

   The 6:1 ratio is preserved at every step, because a square has to stay six by
   six of the fine grid for the pattern to land back on itself when one travels
   a module. */
@media (max-width: 74.99em) {
  :root { --bg-module: 132px; --bg-fine: 22px; }
}
@media (max-width: 47.99em) {
  :root { --bg-module: 84px; --bg-fine: 14px; }
}

/* ==========================================================================
   The rails as a map of the page
   A node rides down them at your scroll position, and each section has a tick
   that lights as you pass it. Positioned entirely from --page-progress, so
   scrolling writes one property and the browser does the rest.
   ========================================================================== */
.rail-map { position: absolute; inset: 0; pointer-events: none; }

.rail-cursor {
  position: absolute;
  left: 50%;
  top: 0;
  width: 7px;
  height: 7px;
  margin-left: -3.5px;
  border-radius: 50%;
  background: var(--c-accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 22%, transparent);
  transform: translateY(calc(var(--page-progress, 0) * (100vh - 7px)));
  will-change: transform;
}

.rail-tick {
  position: absolute;
  left: 50%;
  top: calc(var(--at, 0) * 100%);
  width: 13px;
  height: 1px;
  margin-left: -6.5px;
  background: var(--c-ink);
  opacity: 0.16;
  transition: opacity var(--d-comp) linear, background-color var(--d-comp) linear;
}
/* A section you have reached. */
.rail-tick.is-passed {
  background: var(--c-accent);
  opacity: 0.65;
  width: 19px;
  margin-left: -9.5px;
}

@media (prefers-reduced-motion: reduce) {
  .rail-cursor { transition: none; }
}
