/* ==========================================================================
   VDV ENGINE — v0.1.0
   Shared motion and surface layer for every build in this workspace.
   Plain CSS. No build step, no dependency, no runtime.

   Targets: Chrome and Safari. Firefox is not a target.
   Everything here has been measured in Chrome 150 and Safari 26.5.2 — see
   Experiment motionsites/research/ for the evidence behind each rule.

   THE ONE PERFORMANCE RULE, measured at 4x CPU throttle:
     A STATIC SVG filter rasterises once and is essentially free.
     A SMIL-ANIMATED filter re-rasterises every frame, FOREVER — three test
     pieces sat at 21fps while completely stationary because of it.
   So: use filters freely, animate them almost never.
   ========================================================================== */

@layer engine.reset, engine.tokens, engine.motion, engine.surface, engine.guards;

/* ==========================================================================
   TOKENS — the contract each site fills in.
   A site overrides these and inherits everything below.
   ========================================================================== */

@layer engine.tokens {
  :root {
    /* --- the two a site MUST set ------------------------------------------ */
    --brand: #0A5FE0;          /* the one accent */
    --ink: #14171A;            /* body text */

    /* --- derived, so contrast stays predictable across 200 sites ---------- */
    --paper: #FAF9F7;
    --surface: color-mix(in oklab, var(--paper) 96%, var(--ink));
    --rule: color-mix(in oklab, var(--paper) 82%, var(--ink));
    --muted: color-mix(in oklab, var(--ink) 62%, var(--paper));

    /* Contrast flip: light text on dark brands, ink on light ones. The clamp
       turns lightness into a 0/1 switch, so this needs no per-site thought. */
    --on-brand: oklch(from var(--brand)
                      clamp(0, (0.62 - l) * 100, 1) 0 h);

    /* --- motion ----------------------------------------------------------- */
    --dur-fast: 180ms;
    --dur: 320ms;
    --dur-slow: 620ms;
    --ease-out: cubic-bezier(0.22, 1, 0.36, 1);

    /* Spring easings, generated from the damped-harmonic-oscillator solution
       and sampled into linear(). Ours — no library, no licence, no runtime.
       Regenerate with .pipeline/engine/springs.py if you want other curves. */
    --ease-snap: linear(0, 0.03, 0.1069, 0.2131, 0.3344, 0.4596, 0.5805, 0.6914,
                 0.7888, 0.8709, 0.9374, 0.9888, 1.0264, 1.052, 1.0674, 1.0747,
                 1.0757, 1.0721, 1.0654, 1.0568, 1.0475, 1.0381, 1.0293, 1.0214,
                 1.0146, 1.009, 1.0045, 1.0009, 0.9982, 0.9963, 0.995, 0.9942,
                 0.9939, 0.9939, 0.9942, 0.9947, 0.9953, 0.9959, 0.9966, 0.9972,
                 0.9978, 0.9983, 1);
    --ease-settle: linear(0, 0.0219, 0.0781, 0.1569, 0.2485, 0.3455, 0.4425,
                   0.5354, 0.6216, 0.6992, 0.7676, 0.8264, 0.876, 0.9168, 0.9496,
                   0.9753, 0.9948, 1.0091, 1.019, 1.0253, 1.0287, 1.0299, 1.0294,
                   1.0277, 1.0253, 1.0224, 1.0194, 1.0164, 1.0136, 1.011, 1.0087,
                   1.0067, 1.005, 1.0035, 1.0023, 1.0013, 1.0005, 0.9999, 0.9994,
                   0.9991, 0.9989, 0.9988, 1);
    --ease-gentle: linear(0, 0.017, 0.061, 0.123, 0.1959, 0.2743, 0.3542, 0.4325,
                   0.5072, 0.5769, 0.6407, 0.6982, 0.7494, 0.7942, 0.8332, 0.8666,
                   0.895, 0.9188, 0.9387, 0.955, 0.9683, 0.9789, 0.9873, 0.9938,
                   0.9987, 1.0023, 1.0049, 1.0066, 1.0077, 1.0082, 1.0083, 1.0081,
                   1.0077, 1.0071, 1.0064, 1.0057, 1.005, 1.0043, 1.0036, 1.003,
                   1.0024, 1.0019, 1);
  }
}

/* ==========================================================================
   MOTION — reuses the data-reveal hook all 204 sites already have,
   so adopting this deletes JS with zero markup changes.
   ========================================================================== */

@layer engine.motion {

  /* Finished state is the DEFAULT. A browser without scroll timelines gets the
     final layout, never a blank box. Motion is layered on below. */
  [data-reveal] { opacity: 1; }

  @supports (animation-timeline: view()) {
    [data-reveal] {
      animation: eng-reveal linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 26%;
    }
    @keyframes eng-reveal {
      from { opacity: 0; translate: 0 22px; }
      to   { opacity: 1; translate: 0 0; }
    }
    /* Stagger by shifting the RANGE, not by adding delays — a delay on a
       progress timeline does nothing useful. */
    [data-reveal="2"] { animation-range: entry 14% cover 30%; }
    [data-reveal="3"] { animation-range: entry 18% cover 34%; }
    [data-reveal="4"] { animation-range: entry 22% cover 38%; }

    /* ABOVE THE FOLD: view() is wrong here. Its range starts when the element
       ENTERS the viewport, so something already on screen begins ~75% through
       its range and looks frozen. Use scroll() with an absolute range. */
    [data-reveal-hero] {
      animation: eng-reveal linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 40vh;
    }
  }

  /* Scroll progress bar — replaces the last scroll listener. */
  .eng-progress { position: fixed; inset-block-start: 0; inset-inline: 0;
                  height: 3px; z-index: 90; }
  .eng-progress__fill { height: 100%; background: var(--brand);
                        transform-origin: left; scale: 0 1; }
  @supports (animation-timeline: scroll()) {
    .eng-progress__fill {
      animation: eng-grow linear both;
      animation-timeline: scroll(root block);
    }
    @keyframes eng-grow { to { scale: 1 1; } }
  }
  /* Hide it entirely where unsupported rather than showing an empty track. */
  @supports not (animation-timeline: scroll()) { .eng-progress { display: none; } }

  /* STRUCTURAL PATTERN — full-bleed backdrop that content scrolls over.
     A sticky viewport-height backdrop, then the content pulled back up over it
     with a negative margin. Cheaper and better-behaved than position:fixed,
     and it composites on the GPU. Put a <video muted loop playsinline> or an
     image inside .eng-backdrop__media. */
  .eng-backdrop { position: relative; z-index: 0; }
  .eng-backdrop__pin { position: sticky; top: 0; height: 100svh;
                       overflow: clip; }   /* clip, NOT hidden — see guards */
  .eng-backdrop__media { width: 100%; height: 100%; object-fit: cover;
                         display: block; }
  .eng-backdrop__fade { position: absolute; inset-inline: 0; inset-block-end: 0;
                        height: 45%; pointer-events: none;
                        background: linear-gradient(to bottom, transparent, var(--backdrop-out, #000)); }
  .eng-backdrop__over { position: relative; z-index: 1; margin-block-start: -100svh; }
}

/* ==========================================================================
   SURFACE — treatments that make flat CSS read as material.
   All static. Nothing here animates a filter.
   ========================================================================== */

@layer engine.surface {

  /* GLASS. Three things stacked, which is what separates this from a plain
     translucent panel:
       1. the blur, plus a saturation/brightness lift so it reads as glass
          rather than as frosted plastic;
       2. a GRADIENT BORDER — a padding-box/border-box mask difference, so the
          edge is bright where light would catch it and dim where it wouldn't.
          `mask-composite: exclude` is what carves the ring;
       3. a faint radial sheen for the specular fall.
     Recreated here from the mechanism, with our own values. */
  .eng-glass {
    position: relative;
    overflow: hidden;
    border-radius: var(--glass-radius, 16px);
    background: linear-gradient(160deg,
                  rgb(255 255 255 / 0.06) 0%,
                  rgb(255 255 255 / 0.02) 45%,
                  rgb(255 255 255 / 0.01) 100%);
    backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
    -webkit-backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
    box-shadow: inset 0 0 14px rgb(255 255 255 / 0.12);
    border: none;
  }
  .eng-glass::before {          /* the lit edge */
    content: ""; position: absolute; inset: 0; pointer-events: none;
    border-radius: inherit; padding: 1.5px;
    background: linear-gradient(180deg,
                  rgb(255 255 255 / 0.34) 0%,
                  rgb(255 255 255 / 0.10) 18%,
                  rgb(255 255 255 / 0.03) 45%,
                  rgb(255 255 255 / 0.03) 58%,
                  rgb(255 255 255 / 0.12) 86%,
                  rgb(255 255 255 / 0.28) 100%);
    -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
            mask-composite: exclude;
  }
  .eng-glass::after {           /* the sheen */
    content: ""; position: absolute; inset: 0; pointer-events: none;
    border-radius: inherit;
    background:
      radial-gradient(ellipse at 50% 0%, rgb(255 255 255 / 0.05) 0%, transparent 55%),
      radial-gradient(ellipse at center, transparent 55%, rgb(255 255 255 / 0.02) 100%);
  }

  /* LIT MATERIAL. A static heightmap shaded by the renderer's own lighting —
     the fastest thing we measured (143fps median, zero janky frames) and the
     best-looking. Set --eng-height to a greyscale heightmap.

     CRITICAL: lighting filters read the ALPHA channel as the height field, not
     luminance. A greyscale PNG is fully opaque, so feeding it in directly gives
     a perfectly flat surface with a bare hotspot. The feColorMatrix in
     engine-filters.svg moves luminance into alpha. Do not remove it. */
  .eng-material { filter: url(#eng-lit); background: #000; }

  /* Typography defaults that separate "set" from "defaulted". */
  .eng-type h1, .eng-type h2, .eng-type h3 { text-wrap: balance; }
  .eng-type p, .eng-type li { text-wrap: pretty; }
  .eng-type :is(td, th).num { font-variant-numeric: tabular-nums; }
}

/* ==========================================================================
   GUARDS — invariants only, never a look.

   IMPORTANT, and counter-intuitive: being the LAST layer does not make these
   win against a site's CSS. Unlayered styles outrank every layered style, and
   site stylesheets are unlayered. So a guard that MUST hold — the reduced-motion
   block — carries `!important`. The rest are strong defaults the site is free to
   override, which is the behaviour we want.
   ========================================================================== */

@layer engine.guards {

  /* Reduced motion, done correctly. The three usual lines DO NOT stop a
     scroll-driven animation: animation-duration is ignored on a progress-based
     timeline. Measured — a test element kept running 0 -> 0.54 while scrolling.
     Killing the timeline is what actually honours the setting. */
  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
      animation-timeline: none !important;
    }
    ::view-transition-group(*),
    ::view-transition-old(*),
    ::view-transition-new(*) { animation: none !important; }
    /* SMIL ignores the setting entirely, so any moving filter must be dropped. */
    .eng-material { filter: none !important; }
  }

  /* Text escaping its box is the most common review failure in this workspace.
     `min-width: auto` on a flex/grid child is what allows it.

     Applied to EVERY element on purpose. For a block child `min-width: 0` is
     already the default, so this is a no-op there and only changes flex/grid
     items — which is exactly the intent. `:where()` contributes zero
     specificity, so any site rule that genuinely wants a minimum still wins.

     Earlier versions scoped this to `.eng-grid`/`.eng-card`, which no site in
     the fleet uses — so the guard would never have fired. Guards have to match
     the markup that actually exists. */
  :where(body *) { min-width: 0; box-sizing: border-box; }

  /* And the text itself. Long unbroken strings — a full email address, a URL —
     are what actually overflow. Targeted at elements that hold them rather
     than everything, so ordinary prose keeps normal breaking. */
  :where(p, li, dd, dt, td, th, address, blockquote, figcaption,
         h1, h2, h3, h4, h5, h6) { overflow-wrap: anywhere; }

  /* overflow:hidden makes an element a SCROLL CONTAINER, which silently freezes
     any scroll-driven animation inside it at one frame. Nothing errors; it just
     sits still. Prefer clip. This catches the common accidental case. */
  :where(.eng-backdrop__pin, .eng-clip) { overflow: clip; }

  /* Keyboard focus must always be visible. */
  :where(a, button, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 3px;
  }

  /* Anchors must clear a sticky header. */
  :where([id]) { scroll-margin-top: var(--eng-header-h, 5rem); }
}
