/* ==========================================================================
   Unstealth — beta landing page
   Design tokens sampled directly from the app (web/src/index.css):
   near-black canvas, spring-green brand, indigo "uncharted" accent,
   Jost display / Inter body / Space Mono for on-chain ids.
   ========================================================================== */

:root {
  /* Text */
  --ink: #ededf2;
  --muted: #9aa1b4;
  --faint: #5c6478;
  --starlight: #cdd6f4;

  /* Brand — spring green, sampled from the logo gradient */
  --brand: #00ff89;
  --brand-dim: #0f3d2a;
  --brand-glow: rgba(0, 255, 137, 0.35);

  /* "Uncharted" — indigo/purple, the unknown */
  /* Violet. The app's tint for stealth fleets and entity chips (violet-500
     with violet-300 type), and the only true purple here — deliberately not
     --uncharted below, which is the periwinkle the eyebrow pill wears and
     which reads blue over this artwork. */
  --violet: #8b5cf6;
  --violet-light: #c4b5fd;
  --violet-glow: rgba(139, 92, 246, 0.38);

  --uncharted: #818cf8;
  --uncharted-dim: #312e6b;
  --uncharted-glow: rgba(129, 140, 248, 0.3);

  /* Surfaces — Aceternity-style depth */
  --canvas: #05060d;
  --panel: #0f1220;
  --panel-2: #0a0c15;
  --edge: #1f2434;
  --edge-strong: #2c3346;

  --radius-card: 1rem;
  --radius-sm: 0.625rem;

  --font-display: 'Jost', ui-sans-serif, system-ui, sans-serif;
  --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --font-mono: 'Space Mono', ui-monospace, 'SF Mono', 'Menlo', monospace;

  /* Full-bleed: the page fills the viewport and only the gutter holds content
     off the edge. Individual blocks cap their own line length where prose
     needs it — the container no longer does that job for them. */
  --wrap: 100%;
  /* Grows with the viewport so a wide screen gets margin proportional to it,
     rather than the same 24px it gives a phone. */
  --gutter: clamp(24px, 4vw, 72px);
  /* The space above and below every section after the hero. One number, so
     the sections cannot drift apart from each other again. */
  --section-pad: clamp(6rem, 14vw, 11rem);
  /* The headline's measure — wide, because display type at 65px reads fine
     across it. Sized to where the headline was already landing (~1116px at
     1600px wide). */
  --hero-measure: 70rem;
  /* The measure for the hero's body copy, which cannot take the headline's
     width: at 70rem the lead ran past 100 characters a line. Narrower, but
     still wide enough that the block steps down from the headline rather than
     collapsing to the middle of it. */
  --hero-text-measure: 52rem;
  /* The header's exact height, shared with the hero so the first screen can
     subtract it and land on precisely one viewport. */
  --header-h: 84px;
  /* And its height once it has shrunk. Anchored sections clear THIS one, not
     the one above: by the time a jump lands, the page is scrolled and the bar
     has already shrunk, so measuring the gap against its full height left
     every section sitting 22px lower than the bar it was clearing. */
  --header-h-stuck: 62px;
  /* The hairline that closes a section: brand green out at the shoulders,
     periwinkle through the middle, nothing at either end. Shared, so every
     section that draws one draws the same one. */
  --seam: linear-gradient(
    90deg,
    transparent 0%,
    rgba(0, 255, 137, 0.42) 28%,
    rgba(129, 140, 248, 0.6) 50%,
    rgba(0, 255, 137, 0.42) 72%,
    transparent 100%
  );
}

/* --------------------------------------------------------------------------
   Reset / base
   -------------------------------------------------------------------------- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
  /* The hero fan's outer cards bleed a little past the viewport by design;
     clip that here so it never produces a horizontal scrollbar. `clip` rather
     than `hidden` for the same reason as on body below — see the note there.
     `hidden` stays first as the fallback for browsers without `clip`. */
  overflow-x: hidden;
  overflow-x: clip;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* The showcase cards bleed past the viewport by design, so sideways
     overflow has to be cut off. It must be `clip` and not `hidden`: `hidden`
     computes overflow-y to `auto`, which makes the body a scroll container,
     and a scroll container silently breaks `position: sticky` inside it — the
     hero deck's stage simply scrolls away instead of pinning. `clip` cuts the
     same overflow without creating one. `hidden` is left first as the fallback
     for anything too old to know `clip`; there the deck degrades to sliding
     past, which is ugly but not broken. */
  overflow-x: hidden;
  overflow-x: clip;
}

img {
  max-width: 100%;
  display: block;
}

a {
  color: inherit;
  text-decoration: none;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.08;
  margin: 0;
}

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* --------------------------------------------------------------------------
   Buttons
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: 0.95rem;
  padding: 0.8rem 1.4rem;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease,
    border-color 0.2s ease, color 0.2s ease;
  white-space: nowrap;
  /* For the sheen below: a stacking context of its own so the pseudo-element
     can sit behind the label, and a clip so the band stops at the corners. */
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

/* The sheen.
   --------------------------------------------------------------------------
   A narrow band of light that crosses the face once on hover — the same
   gesture as a reflection passing over something physical, which is what
   stops a flat rectangle reading as a flat rectangle.

   z-index: -1 against the button's own isolation puts it above the background
   and below the label: within a stacking context the paint order is
   background, then negative-z children, then inline content. No filter is
   involved, which matters — .btn-ghost is lit by backdrop-filter, and a
   filter anywhere on it would make it a backdrop root and flatten the glass.

   `translate` rather than `transform` so a button that also wants a transform
   on hover (the lift) is not fighting it. */
.btn::after {
  content: '';
  position: absolute;
  z-index: -1;
  /* Wider and taller than the button, and skewed, so the band crosses as a
     diagonal rather than sweeping as a wall. */
  top: -50%;
  bottom: -50%;
  left: 0;
  width: 55%;
  rotate: 18deg;
  translate: -220% 0;
  opacity: 0;
  transition: translate 0.62s cubic-bezier(0.32, 0.08, 0.24, 1), opacity 0.2s ease;
}

.btn:hover::after,
.btn:focus-visible::after {
  translate: 420% 0;
  opacity: 1;
}

/* A visible focus ring, which none of these had. Offset inward because the
   button clips its own overflow. */
.btn:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: -4px;
}

/* The sheen and the lift are both movement. The colour and the lit edge are
   not, and they stay — asking not to be moved is not asking for a flat
   rectangle. */
@media (prefers-reduced-motion: reduce) {
  .btn::after {
    display: none;
  }

  .btn:hover,
  .btn:active {
    transform: none;
  }
}

.btn-sm {
  padding: 0.55rem 1rem;
  font-size: 0.9rem;
}

.btn-block {
  width: 100%;
}

.btn-primary {
  /* Lit rather than filled: a light edge at the top left falling to a deeper
     green at the bottom right, so the face has a direction to it. The stops
     are close together on purpose — this should read as one green under a
     light, not as a two-colour gradient. */
  background: linear-gradient(140deg, #6bffbb 0%, var(--brand) 42%, #00d977 100%);
  color: #04210f;
  box-shadow:
    /* The lit top edge that sells it as a raised surface. */
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    0 0 0 1px rgba(0, 255, 137, 0.35),
    0 12px 40px -12px var(--brand-glow);
}

.btn-primary::after {
  background: linear-gradient(
    90deg,
    transparent,
    rgba(255, 255, 255, 0.55),
    transparent
  );
}

.btn-primary:hover {
  transform: translateY(-1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    0 0 0 1px rgba(0, 255, 137, 0.6),
    0 18px 52px -12px var(--brand-glow);
}

/* Pressed: back down onto the page, and the glow it was floating on goes
   with it. Without this the lift has no answer and the button feels stuck. */
.btn-primary:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 2px 6px rgba(0, 80, 40, 0.45),
    0 0 0 1px rgba(0, 255, 137, 0.5),
    0 6px 18px -10px var(--brand-glow);
}

.btn-ghost {
  background: rgba(139, 92, 246, 0.08);
  color: var(--violet-light);
  border-color: rgba(139, 92, 246, 0.38);
}

.btn-ghost::after {
  background: linear-gradient(
    90deg,
    transparent,
    rgba(196, 181, 253, 0.35),
    transparent
  );
}

.btn-ghost:hover {
  border-color: var(--violet);
  background: rgba(139, 92, 246, 0.18);
  color: #ede9ff;
  transform: translateY(-1px);
  box-shadow: 0 14px 40px -16px var(--violet-glow);
}

.btn-ghost:active {
  transform: translateY(1px);
  box-shadow: none;
}

/* --------------------------------------------------------------------------
   Header
   -------------------------------------------------------------------------- */
/**
 * The header floats. It is out of the flow entirely, so the hero runs full
 * bleed to the top of the screen and the bar sits ON it — you read the city
 * through the glass. Nothing below has to make room for it, and shrinking it
 * moves nothing, because it never occupied any space to begin with.
 *
 * --header-h stays the layout token: the hero pads its top by it so the
 * eyebrow clears the bar, and anchors scroll clear of it. --bar-h is what the
 * glass is actually drawn at, and only that shrinks.
 */
.site-header {
  --bar-h: var(--header-h);
  position: fixed;
  inset-inline: 0;
  top: 0;
  z-index: 50;
  height: var(--bar-h);
  transition: height 0.32s cubic-bezier(0.32, 0.08, 0.24, 1);
}

/* The glass. Deliberately thin: a high blur and a high saturation over very
   little tint, so the hero reads through it in colour rather than being
   covered by a dark bar. The white gradient and the inset top highlight are
   the light catching the near edge of the pane — that, more than the tint, is
   what makes it look like glass rather than a translucent rectangle.
   Saturation matters here: a plain blur turns the green and the violet
   passing underneath grey. */
.site-header::before {
  content: '';
  position: absolute;
  inset: 0;
  /* Behind the logo and nav. A positioned pseudo-element at z-index auto
     paints ABOVE its parent's in-flow content, which put the glass over the
     logo and dimmed it; the header is its own stacking context, so -1 keeps
     this inside it. */
  z-index: -1;
  -webkit-backdrop-filter: blur(16px) saturate(165%);
  backdrop-filter: blur(16px) saturate(165%);
  background:
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.055),
      rgba(255, 255, 255, 0.014) 45%,
      rgba(255, 255, 255, 0) 100%
    ),
    linear-gradient(180deg, rgba(6, 8, 16, 0.3), rgba(6, 8, 16, 0.1));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09);
  transition: background 0.32s ease, box-shadow 0.32s ease,
    -webkit-backdrop-filter 0.32s ease, backdrop-filter 0.32s ease;
}

/* The bar's bottom edge, in the same brand gradient as the section seams and
   fading out the same way — so the header is edged like the page rather than
   boxed off from it. */
.site-header::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(0, 255, 137, 0.3) 22%,
    rgba(129, 140, 248, 0.45) 50%,
    rgba(0, 255, 137, 0.3) 78%,
    transparent 100%
  );
}

/* How far down the page you are, riding on that edge. Above the hairline
   rather than under it — ::after is the last thing in the box tree, so this
   needs the z-index to come out on top of it. Width is a scale rather than a
   width so it never lays out; script.js just writes the number. */
.header-progress {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  z-index: 1;
  height: 1px;
  background: linear-gradient(90deg, var(--brand), var(--uncharted));
  transform-origin: 0 50%;
  scale: var(--scroll-progress, 0) 1;
}

/* Scrolled past the header: shorter, denser, and lifted off the page. */
.site-header.is-stuck {
  --bar-h: var(--header-h-stuck);
}

/* Denser once it is over the body of the page rather than the hero — still
   glass, just enough tint to hold the type against whatever scrolls under it.
   The drop shadow is what lifts the pane off the page. */
.site-header.is-stuck::before {
  -webkit-backdrop-filter: blur(22px) saturate(175%);
  backdrop-filter: blur(22px) saturate(175%);
  background:
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.07),
      rgba(255, 255, 255, 0.018) 45%,
      rgba(255, 255, 255, 0) 100%
    ),
    linear-gradient(180deg, rgba(6, 8, 16, 0.58), rgba(6, 8, 16, 0.34));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.11), 0 16px 38px -26px rgba(0, 0, 0, 0.95);
}

.header-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  height: 100%;
}

/* The logo keeps its size however tight the bar gets. As a flex item it would
   otherwise be the first thing squeezed — to a sliver, since the image scales
   down with its box. */
.brand {
  flex: none;
}

.brand-logo {
  height: 40px;
  width: auto;
  transition: height 0.32s cubic-bezier(0.32, 0.08, 0.24, 1);
}

.site-header.is-stuck .brand-logo {
  height: 31px;
}

.header-nav {
  display: flex;
  align-items: center;
  gap: 1rem;
}

/* The section links, held in a pane of their own — a smaller piece of the
   same glass the bar is made of, so the two read as one object. No
   backdrop-filter here: the bar's glass already sits behind it, and a second
   one nested inside the first buys nothing but a backdrop root. */
.nav-links {
  display: flex;
  align-items: center;
  gap: 0.2rem;
  padding: 0.22rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.015));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.nav-links a {
  position: relative;
  /* So the fill below can sit at z-index -1 and stay behind the label without
     escaping to the back of the whole bar. */
  isolation: isolate;
  padding: 0.42rem 0.95rem;
  border-radius: 999px;
  font-size: 0.88rem;
  letter-spacing: 0.005em;
  color: var(--muted);
  white-space: nowrap;
  transition: color 0.2s ease;
}

/* The lit state: a green pane that swells into place rather than switching
   on. Drawn as a pseudo-element so hover and the current-section mark are the
   same object and cannot fight over the background. */
.nav-links a::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: linear-gradient(180deg, rgba(0, 255, 137, 0.17), rgba(0, 255, 137, 0.05));
  box-shadow: inset 0 0 0 1px rgba(0, 255, 137, 0.3), 0 0 20px -6px var(--brand-glow);
  opacity: 0;
  scale: 0.86;
  transition: opacity 0.22s ease, scale 0.3s cubic-bezier(0.32, 0.08, 0.24, 1);
}

.nav-links a:hover,
.nav-links a:focus-visible,
.nav-links a.is-current {
  color: var(--ink);
}

.nav-links a:hover::before,
.nav-links a:focus-visible::before,
.nav-links a.is-current::before {
  opacity: 1;
  scale: 1;
}

.nav-links a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The section you are actually in reads as selected rather than hovered:
   paler fill, no glow, and the label in full. */
.nav-links a.is-current::before {
  background: linear-gradient(180deg, rgba(0, 255, 137, 0.12), rgba(0, 255, 137, 0.035));
  box-shadow: inset 0 0 0 1px rgba(0, 255, 137, 0.22);
}

.nav-links a.is-current:hover::before {
  background: linear-gradient(180deg, rgba(0, 255, 137, 0.17), rgba(0, 255, 137, 0.05));
  box-shadow: inset 0 0 0 1px rgba(0, 255, 137, 0.3), 0 0 20px -6px var(--brand-glow);
}

/* ---- The narrow-screen section menu ----
   Only below the width the row of links needs (see the 820px rule at the end
   of the file). The pill is the same glass as .nav-links; the panel drops out
   of it as a larger piece of the same material. */
.nav-menu {
  position: relative;
  display: none;
}

.nav-menu-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 40px;
  padding: 0 0.85rem 0 1rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
  color: var(--ink);
  font: 500 0.88rem/1 var(--font-sans);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.nav-menu-chevron {
  color: var(--muted);
  transition: rotate 0.25s cubic-bezier(0.32, 0.08, 0.24, 1);
}

.nav-menu-toggle[aria-expanded='true'] .nav-menu-chevron {
  rotate: 180deg;
}

.nav-menu-toggle:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The label changes as you scroll. The pill grows and shrinks leftward into
   the free space beside the logo, so nothing else moves when it does. */
.nav-menu-label {
  white-space: nowrap;
}

.nav-menu-panel {
  position: absolute;
  top: calc(100% + 0.6rem);
  right: 0;
  width: max-content;
  min-width: 13rem;
  display: grid;
  gap: 0.15rem;
  padding: 0.4rem;
  border-radius: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.09);
  background: linear-gradient(180deg, rgba(20, 24, 40, 0.92), rgba(9, 11, 20, 0.94));
  -webkit-backdrop-filter: blur(18px) saturate(160%);
  backdrop-filter: blur(18px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 30px 60px -24px rgba(0, 0, 0, 0.95);
  transform-origin: top right;
  animation: menu-drop 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}

.nav-menu-panel[hidden] {
  display: none;
}

@keyframes menu-drop {
  from {
    opacity: 0;
    scale: 0.96;
    translate: 0 -4px;
  }
}

/* Rows a thumb can hit: 48px, the whole width of the panel. */
.nav-menu-panel a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  min-height: 48px;
  padding: 0 1rem;
  border-radius: 0.7rem;
  color: var(--muted);
  font-size: 0.98rem;
  -webkit-tap-highlight-color: transparent;
}

.nav-menu-panel a:hover,
.nav-menu-panel a:focus-visible {
  color: var(--ink);
  background: rgba(255, 255, 255, 0.05);
  outline: none;
}

.nav-menu-panel a.is-current {
  color: var(--ink);
  background: linear-gradient(180deg, rgba(0, 255, 137, 0.12), rgba(0, 255, 137, 0.035));
  box-shadow: inset 0 0 0 1px rgba(0, 255, 137, 0.22);
}

/* The dot that says "you are here", in the brand green. */
.nav-menu-panel a.is-current::after {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: 0 0 8px var(--brand-glow);
}

/* The button loses a little weight with the bar, so the two shrink together
   rather than the button staying put and crowding it. */
/* The `scale` PROPERTY, not a transform: .btn-primary already translates on
   hover and press, and a transform here would replace those outright rather
   than compose with them. Its own transition has to be respelled in full,
   since declaring `transition` again would drop .btn's. */
.site-header .btn-sm {
  transition: scale 0.32s cubic-bezier(0.32, 0.08, 0.24, 1), transform 0.15s ease,
    box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.site-header.is-stuck .btn-sm {
  scale: 0.94;
}

/* --------------------------------------------------------------------------
   Hero
   -------------------------------------------------------------------------- */
/**
 * The first screen: exactly one viewport tall, with everything in it centred.
 *
 * svh rather than vh because on a phone vh is the *large* viewport — the height
 * the page has once the browser's address bar has scrolled away — so a vh-sized
 * hero is taller than the screen at the one moment it matters, when the page
 * first loads. The header sits above this in the flow, so its height comes off
 * the top; both read the same token and cannot drift apart.
 */
.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  /* The whole screen. The header floats over the top of it rather than
     sitting above it, so nothing comes off this. */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding-block: clamp(1rem, 3vh, 2.5rem);
  /* …and the type starts below the bar. The backdrop still fills the whole
     hero: an absolutely positioned child resolves `inset` against the PADDING
     box, which includes the padding, so inset: 0 reaches the border edge. */
  padding-top: calc(var(--header-h) + clamp(1rem, 3vh, 2.5rem));
  text-align: center;
}

/* --------------------------------------------------------------------------
   The deck
   --------------------------------------------------------------------------
   One screen of type at a time, advanced by scrolling rather than by a timer:
   the reader controls the pace, and nothing moves unless they move it.

   The mechanism is a tall outer section with a sticky stage inside it. The
   stage pins under the header and stays there while the page scrolls past the
   section's height; how far through that height you are is the position in the
   deck. Once the section ends the stage unpins and the page carries on to the
   showcase, so leaving the deck needs no special handling — it is just the end
   of an ordinary section.

   .deck-on is added by script.js, and everything that takes the slides out of
   normal flow hangs off it. That is deliberate: with no script, or with
   reduced motion, the slides are plain blocks stacked down the page in order,
   which is the same content in the same sequence and needs no fallback of its
   own. The stage height is set from script.js too, because it has to be
   measured rather than declared — svh in a calc cannot know what the sticky
   run actually came to.
   -------------------------------------------------------------------------- */
.hero-deck {
  position: relative;
}

.slide {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

/* Stacked reading order, for the no-script and reduced-motion cases. */
.slide + .slide {
  margin-top: clamp(3rem, 9vh, 6rem);
}

.hero-deck.deck-on .hero {
  position: sticky;
  top: 0;
  height: 100svh;
  min-height: 0;
}

.hero-deck.deck-on .hero-inner {
  position: relative;
  flex: 1;
  width: 100%;
}

/* Every slide sits centred in the stage, stacked on the others, and script.js
   places each one per frame in 3D: where it is in the run decides how far it
   has tipped back and gone into the distance, how soft and how faint. The
   front one is centred, upright and sharp; each one before it lies back on
   the plane behind it, receding.

   Centred by `top` plus a -50% translate rather than by filling the stage and
   flex-centring, so that a slide's box IS its content. script.js reads that
   height to work out where the one behind has to sit, and tips the box about
   its own middle. Moving it away rather than changing font-size is what keeps
   the line breaks: the type shrinks with distance as it was set.

   --deck-foot is the strip kept clear for the dots. The centring line sits in
   the middle of what is left, raised by --deck-lift: the slide area starts
   under the eyebrow, so its own middle falls below the screen's, and type
   centred exactly there reads low. Lifted, it sits a touch above the middle
   of the screen, where the eye puts the centre.

   --deck-centre is that line, as a distance from the top of the slide area.
   The slides centre on it, the camera looks down it, and script.js measures
   it to know how much room a receding slide has above — one definition, so
   the three cannot disagree. */
.hero-deck.deck-on .hero-inner {
  --deck-foot: clamp(3.5rem, 9vh, 5.5rem);
  --deck-lift: clamp(1.75rem, 6vh, 4rem);
  --deck-centre: calc((100% - var(--deck-foot)) / 2 - var(--deck-lift));
  /* The camera. The vanishing point sits on the centring line, straight
     behind the front slide, so everything receding heads for it. Shorter is
     more dramatic; script.js reads this value to place the slides. */
  perspective: 1000px;
  perspective-origin: 50% var(--deck-centre);
  /* A layer of its own, beneath the eyebrow: slides climbing away pass BEHIND
     the pill rather than over it, whatever z-index the run gives them. */
  z-index: 1;
}

.hero-deck.deck-on .slide {
  position: absolute;
  left: 0;
  right: 0;
  top: var(--deck-centre);
  margin: 0;
  opacity: 0;
  transform: translate3d(0, -50%, 0);
  transform-origin: 50% 50%;
  backface-visibility: hidden;
  /* An absolutely positioned child resolves left/right against its containing
     block's PADDING box, so the slides span straight through the gutter that
     .wrap puts on .hero-inner. They have to hold themselves off instead. */
  padding-inline: var(--gutter);
  will-change: transform, opacity, filter;
}

/* The eyebrow belongs to the deck, not to a slide: it states where things
   stand (private beta, and when) and that is true of every slide, so it holds still
   while they change under it. It sits above .hero-inner in the stage's flex
   column, and because .hero-inner takes the remaining space the eyebrow ends
   up pinned near the top with the slides centred in what is left. */
.hero-deck.deck-on > .hero > .eyebrow {
  position: relative;
  z-index: 2;
  margin: 0 0 clamp(0.75rem, 2.5vh, 1.5rem);
  flex: none;
}

/* The claim is alone on its slide, so the margin that normally separates a
   headline from its lead would only push it off centre. */
.slide--claim .hero-title {
  margin-bottom: 0;
}

/* The tweet slide carries a card rather than a line of type, so it wants a
   measure of its own — the card has its own internal scale and blowing it up
   to headline width would make the quote look like a banner. */
.slide--tweet .tweet-card {
  max-width: min(42rem, 100%);
  width: 100%;
  margin-inline: auto;
  /* The slide is a centred column flex container, so the card is a flex item
     and flex items shrink below their content by default. Combined with the
     card's own overflow: hidden that silently ATE the quote. It has to keep
     its intrinsic height. */
  flex: none;
  /* Tied to viewport height as well as to text size, so on a short screen the
     card tightens rather than running off the bottom of the stage. */
  padding: clamp(1rem, 2.6vh, 1.9rem) clamp(1.1rem, 3.5vw, 2rem);
}

.slide--tweet .tweet-avatar {
  width: 42px;
  height: 42px;
}

.slide--tweet .tweet-body {
  margin-top: clamp(0.8rem, 2vh, 1.35rem);
  font-size: clamp(1.1rem, 2.2vh + 0.55rem, 1.9rem);
}

/* Deeper than the card's own shadow, because it is casting onto the headline
   receding a few centimetres behind it rather than onto the page. The blur on
   the headline says "further away"; the shadow says "and this is in front". */
.hero-deck.deck-on .slide--tweet .tweet-card {
  box-shadow:
    0 50px 110px -40px rgba(0, 0, 0, 0.95),
    0 0 0 1px rgba(255, 255, 255, 0.04);
}

/* How deep an opaque slide — the card — cuts across the one receding behind
   it, as a fraction of that one's height as seen — tipped back and in the
   distance, which is far less than its upright height. Only the card overlaps: a
   slide of bare type in front of another would put words over words, so
   those keep a gap instead (see render() in script.js). A token rather than a
   constant in script.js because it is a judgement about the composition, and
   the composition changes with the viewport. */
.slide--tweet {
  --deck-overlap: 0.05;
}

/* Desktop gets a bigger card, and the headline gives up more of itself to it.
   A min-width query in a sheet written max-width first, deliberately: this is
   an enlargement for big screens, not a base the small ones walk back. */
@media (min-width: 861px) {
  .slide--tweet {
    --deck-overlap: 0.07;
  }

  .slide--tweet .tweet-card {
    max-width: min(46rem, 100%);
    padding: clamp(1.3rem, 3.2vh, 2.3rem) clamp(1.4rem, 3.2vw, 2.4rem);
  }

  .slide--tweet .tweet-avatar {
    width: 48px;
    height: 48px;
  }

  /* Bigger type as well as a bigger box. Widening alone would have made the
     quote SHORTER — two lines becoming one. */
  .slide--tweet .tweet-body {
    font-size: clamp(1.3rem, 2.8vh + 0.6rem, 2.15rem);
  }
}

/* Where you are in the deck.
   --------------------------------------------------------------------------
   Built by script.js from the slides it finds, so the count cannot drift out
   of step with the markup. They are buttons, not decoration: a reader who can
   see there are four can also jump to the fourth, and the one that matters is
   the last one, which holds the call to action. */
.deck-dots {
  position: absolute;
  left: 50%;
  bottom: clamp(1.25rem, 4vh, 2.25rem);
  transform: translateX(-50%);
  z-index: 3;
  display: flex;
  gap: 0.6rem;
  padding: 0.5rem 0.7rem;
  border-radius: 999px;
  background: rgba(10, 12, 21, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
}

.deck-dot {
  width: 8px;
  height: 8px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  /* Legible enough to be counted at a glance — an indicator nobody can read
     the total off is not doing its job. */
  background: rgba(237, 237, 242, 0.5);
  cursor: pointer;
  transition: background 0.25s ease, transform 0.25s ease;
}

.deck-dot:hover {
  background: rgba(237, 237, 242, 0.8);
}

/* The current one is brand green and a little larger — two signals rather than
   one, so it does not rely on colour alone. */
.deck-dot[aria-current='true'] {
  background: var(--brand);
  transform: scale(1.5);
}

.deck-dot:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 3px;
}


/* --------------------------------------------------------------------------
   The hero backdrop
   --------------------------------------------------------------------------
   A lone watcher above a neon city — the page's argument as a picture: someone
   looking at a network nobody else can read.

   Two layers, and both are doing real work. The photograph needs grading, and
   the type needs a scrim; neither is optional, because the raw image is a
   mid-value field with a bright moon in it and white type laid straight over
   that is unreadable at the top of the page.

   Behind the content (z-index) and inert (pointer-events), so it can never
   intercept a click on the buttons.
   -------------------------------------------------------------------------- */
.svg-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.hero-backdrop {
  /* Held in one place: the settle keyframes restate the grade in both of
     their stops, and three copies of it would drift apart. */
  --grade: saturate(0.74) brightness(0.8) hue-rotate(-14deg) contrast(1.03);
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

/* The photograph, graded into the palette.
   --------------------------------------------------------------------------
   The image ships hot magenta. The page's accent is --uncharted, an indigo,
   and its brand is a spring green — so the art is pulled back toward the
   indigo rather than left to shout in a colour the palette does not hold.
   saturate() takes the neon down to something ambient, hue-rotate() walks the
   magenta toward that indigo, and brightness() drops the whole plate so the
   type has somewhere to sit.

   object-position is off-centre on both axes. The frame is 4:3 and the hero is
   a wide letterbox, so cover crops top and bottom: biasing downward keeps the
   watcher's silhouette and the skyline, and loses sky. Horizontally it is
   biased LEFT, because the watcher stands hard against the image's left edge
   and a centred crop cuts him in half — which costs the picture its subject. */
/* The plate's box: the painting's own aspect, sized to cover the hero, and
   offset the way object-position would offset it.
   --------------------------------------------------------------------------
   left/top at the object-position percentages with a matching negative
   translate reproduces cover's arithmetic exactly — cover puts the overflow
   (cw - W) * 0.40 to the left, and 0.40 * cw - 0.40 * W is the same number.

   Doing it with a real box rather than with object-fit is what lets a second
   element — the figure — be positioned in percentages of the PAINTING rather
   than of the viewport, so the two stay registered at every size. The height
   term is the hero's own height restated, because a width calc cannot ask its
   container how tall it is. */
.hero-plate {
  /* The plate is square now, which crops hard: on a 16:9 hero only about 57%
     of its height is in frame. 45% down the frame is what holds the figure,
     the rock he stands on and the city behind him; the moon is above the cut
     and cannot be had as well. */
  --plate-ar: 1;
  position: absolute;
  left: 40%;
  top: 45%;
  translate: -40% -45%;
  width: max(100%, calc(100svh * var(--plate-ar)));
  aspect-ratio: var(--plate-ar);
  filter: var(--grade);
}

.hero-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* The box is already the image's exact aspect, so there is nothing to crop
     and nothing to letterbox. */
  object-fit: fill;
}

/* PROOF OF CONCEPT: the moving figure.
   --------------------------------------------------------------------------
   Percentages of the plate, from fitting the clip's figure to the painted one:
   1150/1896 wide, 1150/1451 tall, at -98/1896 and 310/1451. He therefore
   tracks the painting through every viewport without a line of script.

   The clip has no alpha channel — it is the figure on black — so #figure-key
   computes his alpha from his own brightness. The bottom of the frame is
   masked off because the generator burns its watermark there; the plate's own
   rock shows through instead, and the rock does not move, so the join is
   invisible. */
/* The clip covers the whole plate, because it was generated at the plate's own
   frame: inset 0 IS the alignment. Nothing to fit and nothing to drift. */
.hero-figure,
.hero-figure-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: fill;
  pointer-events: none;
}
.hero-figure {
  filter: url(#figure-key);
}

/* Safari does not apply an SVG filter to a <video> at all — it paints the clip
   as it is, a black square over the plate. So where WebGL is available,
   script.js keys the clip in a shader onto a canvas laid exactly over it (the
   same matrix as #figure-key) and the video itself only feeds it frames. It
   stays in layout, merely unseen, because a video that is not rendered is one
   some browsers stop decoding. */
.has-figure-canvas .hero-figure {
  filter: none;
  opacity: 0;
}


/* The scrim.
   --------------------------------------------------------------------------
   Three gradients in one layer, each with a separate job:

     1. a vertical wash, heaviest at the top where the header and eyebrow sit
        and heaviest again at the bottom, so the hero dissolves into the
        canvas instead of ending on a cut edge against the next section;
     2. a soft pool centred on the type, which is what actually buys the
        headline its contrast — a flat overlay dark enough to do that would
        kill the picture everywhere else;
     3. a breath of brand green from below, so the first screen still belongs
        to this site and not only to the artwork.

   Note the order: the pool is listed first because CSS paints the first
   background layer on top. */
.hero-veil {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(50% 44% at 50% 50%, rgba(5, 6, 13, 0.8), transparent 78%),
    radial-gradient(70% 40% at 50% 100%, rgba(0, 255, 137, 0.08), transparent 72%),
    linear-gradient(
      to bottom,
      rgba(5, 6, 13, 0.82) 0%,
      rgba(5, 6, 13, 0.3) 22%,
      rgba(5, 6, 13, 0.24) 58%,
      rgba(5, 6, 13, 0.72) 86%,
      var(--canvas) 100%
    );
}

/* The two outlined objects, over artwork.
   --------------------------------------------------------------------------
   Everywhere else on the page these sit on flat canvas, where a 2% white fill
   and a hairline border are enough. Over a lit skyline they vanish — the
   border falls on detail of its own contrast and stops reading as an edge.

   The fix is to make them glass rather than to make them opaque: a dark
   translucent fill plus a blur, so the picture still shows through but does so
   as a wash instead of as competing detail. Scoped to .hero, so the buttons in
   the rest of the page are untouched. */
.hero .eyebrow,
.hero .btn-ghost {
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
}

.hero .eyebrow {
  background: rgba(10, 12, 21, 0.55);
  border-color: rgba(129, 140, 248, 0.45);
}

/* Over the artwork the ghost button was a slab of rgba(10, 12, 21, 0.55) —
   the canvas colour, which is a blue-black — inside a plain white hairline.
   Against a hero lit violet and green it read as a cold blue rectangle
   belonging to nothing. It is still glass, but violet glass now, and the
   hairline does some work.

   Two background layers doing two jobs: the fill is clipped to the PADDING
   box, the gradient to the BORDER box, so the gradient shows only through the
   1px of transparent border. That gives a hairline that travels from solid
   violet at the top left, through almost nothing, to a pale violet at the
   bottom right — an edge that catches light along its length instead of
   sitting at one flat value the whole way round. */
.hero .btn-ghost {
  border-color: transparent;
  background:
    linear-gradient(140deg, rgba(26, 17, 48, 0.62), rgba(18, 13, 36, 0.55)) padding-box,
    linear-gradient(
        140deg,
        var(--violet) 0%,
        rgba(139, 92, 246, 0.25) 55%,
        var(--violet-light) 100%
      )
      border-box;
  color: var(--violet-light);
}

.hero .btn-ghost:hover {
  border-color: transparent;
  background:
    linear-gradient(140deg, rgba(60, 38, 110, 0.7), rgba(38, 24, 74, 0.62)) padding-box,
    linear-gradient(140deg, var(--violet-light) 0%, var(--violet) 55%, var(--violet-light) 100%)
      border-box;
  color: #f1edff;
}

/* On a phone the hero is taller than it is wide, and the crop is severe: a
   4:3 frame in a portrait letterbox shows barely a third of the image's width.
   A centred crop lands in the haze between the watcher and the tower and shows
   neither, so the frame is pulled hard left onto the figure — he is the
   subject, and on a phone he is most of what there is room for.

   The scrim comes up a little, because the type is far larger relative to the
   frame, but only a little: the first pass here was heavy enough that the
   picture went to flat black and there was no point loading it at all. */
@media (max-width: 640px) {
  /* Framed so the watcher stands in the BOTTOM half, bigger, and the type
     has the top half. Fitted to the height alone, he stood in the middle of
     the screen, exactly where the centred headline goes, with an empty third
     of dark rock beneath him. So the plate is drawn at 150% of the screen's
     height: that is what gives the frame room to drop him until his feet sit
     just above the deck's dots, and it makes him larger as it does.

     Percentages with the object-position arithmetic, as on desktop, because
     the rain's depth mask below has to repeat exactly this framing and can
     only say it in those terms. --plate-x and --plate-y are shared with it. */
  .hero-backdrop {
    --plate-scale: 1.5;
    --plate-x: 4%;
    --plate-y: 0%;
  }

  .hero-plate {
    width: max(100%, calc(100svh * var(--plate-scale) * var(--plate-ar)));
    left: var(--plate-x);
    top: var(--plate-y);
    translate: calc(-1 * var(--plate-x)) calc(-1 * var(--plate-y));
  }

  /* The scrim's dark pool moves up to where the type now is. Over the
     watcher it stays light: he is a silhouette, and a silhouette is only as
     visible as the haze behind it is bright. */
  .hero-veil {
    background:
      radial-gradient(80% 34% at 50% 30%, rgba(5, 6, 13, 0.7), transparent 80%),
      radial-gradient(80% 36% at 50% 100%, rgba(0, 255, 137, 0.07), transparent 72%),
      linear-gradient(
        to bottom,
        rgba(5, 6, 13, 0.85) 0%,
        rgba(5, 6, 13, 0.4) 20%,
        rgba(5, 6, 13, 0.14) 52%,
        rgba(5, 6, 13, 0.3) 86%,
        var(--canvas) 100%
      );
  }

  /* And the type rises to meet it: the deck's centring line, which the slides,
     the vanishing point and the script all share, comes up into the top half. */
  /* The type does not shrink with the screen's height, so a shorter phone
     lifts it less. The limit is the tallest beat, the call to action, whose
     heading must stay clear of the shrunken header bar above it. */
  .hero-deck.deck-on .hero-inner {
    --deck-lift: clamp(2rem, 30svh - 90px, 15svh + 20px);
  }

  /* The call to action is the tallest beat, so centred on the same line as
     the rest it climbs right up under the header. On a phone it drops the
     second button — the page below says what Unstealth is anyway — and sits
     lower than the others, clear of the header. */
  .hero-deck .slide:last-child .hero-cta .btn-ghost {
    display: none;
  }

  .hero-deck.deck-on .slide:last-child {
    top: calc(var(--deck-centre) + 3rem);
  }

  /* No "Private beta" pill on a phone: the screen's top band is the only
     room the type has above the watcher, and the call to action says the
     same thing in full. The slide area takes the space it leaves. */
  .hero > .eyebrow {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   The fall, and the traffic
   --------------------------------------------------------------------------
   Two moving things over the plate: a fall of ones and zeros between the
   viewer and the city, and the city's own light shifting behind it. Both are
   slow and both are faint — the picture should look alive when you rest on
   it, not busy when you are trying to read the headline over it.

   The fall is drawn on a canvas (script.js); the city's light is CSS, and
   animates transform and opacity only. Nothing here animates
   background-position or filter: those repaint the layer every frame, and
   this is a full-screen surface.
   -------------------------------------------------------------------------- */

/* The fall, and where it is allowed to land.
   --------------------------------------------------------------------------
   It is kept off the foreground by a depth map of the plate, read as
   LUMINANCE: white lets the glyphs through, black holds them back. The map is
   the photograph desaturated, softened and levelled hard, so the watcher and
   his rock fall to solid black while the sky and the haze go to white. The
   towers land in between and keep some rain, dimmer, which is right: they are
   behind it but nearer than the sky. Levels were taken from the plate's own
   histogram — a black point of 50 and a white point of 100, where 78% of its
   pixels sit inside a thirty-value band around 96.

   The map is only STORED here, in --depth-map. script.js reads it and applies
   it inside the canvas, laid over the plate's own box, together with the fade
   toward the bottom of the frame. It used to be a CSS mask (mask-mode:
   luminance, a second gradient layer, mask-composite: intersect), and Safari
   drew the rain straight over the watcher with it; masking the pixels in
   script behaves the same in every browser.

   It is inline rather than a file so there is no fetch to fail — opening
   index.html straight off disk included. If it cannot be read at all, the
   script lets the glyphs fall everywhere: a fall in the wrong place is a far
   better failure than no fall.

   A hand-drawn silhouette was the other option for the shape and it would
   have been wrong the moment the viewport changed: the plate is
   object-fit: cover, so the crop moves with the window and a fixed shape
   would slide off the figure. This map is derived from the plate and shares
   its aspect ratio, so drawn over the plate's box it tracks at every size.

   To rebuild it after changing the hero image, with sharp:
     greyscale -> resize 420 -> blur 2.2 -> linear(255/50, -255) -> jpeg q72
   then base64 the result back into the url() below. */
.rain-field {
  /* How loud the fall is. It is faint by design, which makes its strength
     partly a matter of the screen you are on — turn this down to fade the
     glyphs back, up to bring them forward. Pitch, speed and tail length are
     constants at the top of the glyph rain block in script.js. */
  --rain-strength: 1;
  /* Read by script.js, which masks the rain with it. */
  --depth-map: url('data:image/jpeg;base64,/9j/2wBDAAkJCQkKCQoLCwoODw0PDhUTERETFR8WGBYYFh8wHiMeHiMeMCozKScpMypMOzU1O0xXSUVJV2pfX2qFf4Wurur/2wBDAQkJCQkKCQoLCwoODw0PDhUTERETFR8WGBYYFh8wHiMeHiMeMCozKScpMypMOzU1O0xXSUVJV2pfX2qFf4Wurur/wgARCAGQAZADASIAAhEBAxEB/8QAHAAAAQUBAQEAAAAAAAAAAAAAAAECAwQFBgcI/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAA6yevYHRzNISRo0cDRwNHg0eDFcDElaYtO7QOlGyDVUBr2iDgaOBo4GioKIDWyBGPBsg4aOUYPBg5RXtUoWIJyUABUAAAAAEFAABQMLI1cU6fQ5fpR4AIoAqjRQABg8Ea9xFJLeKEuzaOfOlac1Hv55mpajISSUrE0RUmsPICyhCk6lYnaREoRvfMQF15nGkhmpptOLzdvHNPVxd4eaEpllthXWVSuTqV1tzma/asmHZ25DLv2AAAACKUM2vq1So23EZ0GjENWQI1V4ws2DMNeMzV14SCSZ5WZoBRdaYJKkZxfN9JzZZ7XjOwNtjIRWtaTMYD1ag6zVeaUufbLAigAAAAAAANFZDETRMQa62pSW0hXLTik29EVEtsKzLIVXyoRjkGxz4Zk5cEpZ3+a6Q2klCIfIQLMojm3CIsvKk8rhHgAAAAAAAMfGQskCuToWwBBQAARQYyYKzLcJWLalRZ6ZJh9JzxwszbBFPOG7rc1slyzkboxtjFL1nB3ByyuInqorgAAAAAAAGuCMkCNJQAAAAAAAACtZqk740FyM6+bmTrYRws0dknSWIW9WlJ+p5TUOh5jqObK+1l6xoggoAAAAAAAAAEE9IuKigAIuVqlKtJXNiOTlTrCnbFOagOsxNjnzdr14DKtR2CbJ1c45q9BfFiv1COVsg6/UtHSYz3le2sRqZMsBqXKNc1itZAAAAAAAKV2gXlbSL41xRuMeQPlUq/J31v8+Gx2fzjKZTWB7P6988fShSr6NUz7tWUsU54DDdO8YxJRr0sj555R8zZBKluEWSDQIqE0Jcs5shqLBMNciCuYo4IyShLWNDC08815s7RGujkAAT5v7z55IwAAPQfpP5A+vB1e7WMyRXlqloUjEkSQgbI4Zbq3y7LJOQq5g2GwFS3C4ibYYQyEw+eJxUY4NWg+kSTU7BarLCSQSKLo5t8prXC6+iw8++fO94EAAAJvrH5I9/PWqlpDISbPJawwSSNS1NmhpUp5yv0fO9GNr24SuoorZGCI+UhdOpETtKkzlIqdyEZIyQrslYOWScdYFMothV8W9J+XCBAAAAA3cLZPruVqFOnowGezStmK6zCVFtoRvVSruZdgvyZN8svcpA6RRjlAAAAGuCJJghisqQMthDI4AAQVh84+Y9Bz4AAAAAb+B159WqqkRKFF9iMya2w0zbF1SsyzCMdNKUr7bA8AAAAAAAAAAARQAAAAAAA8T8R+0vnA83O+4EAAAD03zL2k92AAAAAAAARHAAAAU8qfmDqa+BMa2pysp2EmVqgAAAAAAAAAACCkEg8QPnzyT688COADfMqr9H/ADoR+++BesH0OAAAAAAAAAAAABmZmlkksMEg8rSHRXsrTEKLi8sEg/OvZJro2qRNzGG1pcd1hLWfzx8yXeVD0TtfBlPrPxXz+Mh9m8Z6g+tPlrT5c536I+d/bT3I5xToiCcAAAAAAAABrq5n4evkCPjeRihsT04yR8IaMuTIa2ZE02c640zzVlMLXtVSajehPjAAAAAAAAAVAesYdP7N85Sn2yvzB3h7Gc+pvmLrjwAGPClbqFLI3s0oyyWCnLM8ektsqWrUpnQ6lQrQaKDZ3TDHOCOCeuIIh8XgAAAAAAAAAAAACoHsPZ/O3sJ03ofnfoZZZKCKAQToZNbZeUnX0KJbQjnHDXAJHnYh1cvPdCKZtU3BMwuQcdZOpw0oHy0AAAAAAAAAAAAAAonSM9cNX0Piu1LQAAAAAAAGNl6OIT9bxfSmkcEh0HKcNonoXCdn4oWfQ/Ae+Pa5/nbvS7fgvDq+BiHloAAAAAAAAAAAAB6R5v6Ue13cOwN2M6Q6grQGgQSjivlGyeb6h3AgYWPewRKzg86zbWASWcyc7jnqtAb0nMaZPvcztnpVJeBJaLpjzQAAAAAAAAAAAAA6/kOjPo7F1Moj3ua6o16OxkFvNKJVyNHkyZ2fUPcK/B6hayZccuRUsUwOX18IlliCrdzdUXexZB8tqqbeP0CFW7G48xAAAAAAAAAAAAANrFefYnP+X9MX9fLadLzPGB6JFlWBnG2eeNCnAw6y1xVo6uOhEaXGXcgoQTVzQgnsnMXa9gkexDYglrGpo42iTtqwnFgAAAAAAAAAAAAABpy0bB0PdcFsElGesS2suma9KF4iEYskSGpWo2wo7eSUW34ylq4+iZl/WkMGK88qPcpPLJGA1TjwAAAAAAAAAAAAAAJGXSezRqHQbHNdQZ1aakWZKko4jkEgdXNexnuNeKh0BNy3TYxV04ZBK9GkW7NiMz43YZ0Ojx/TlmvcqnHAAAAAAAAAAAAAAAu3hhtQ51wbv83sF/D16IlukpfZWtkMV/UMnTz5BthqFx1eA0Is6uR0ErnW0+erGlntAc0OqzsdD//EADcQAAICAQMCAwYFAgYDAQAAAAECAAMRBBIhBTEGEyIQFCAwQVEHMkBhcSNCFTM0UnKBNUSRU//aAAgBAQABPwBYJkw/MEI9nVPyidNH9X4T8rPsOfZj2AczkTcZuM3GbjMmZMUmGL3i+zEKzbNs2zbNsxNvxbZ1UYVZoH22mI24ZmJiAQiYmJiYmJtm34sTE2xUMKGbDNk2QpNk2QLBEJimAzMz83E6ufSs0pAsyZRqaiAoPMHwH4yIB7CIBAmYKSYunJnu5nkGeWYyQjHxKIo9gEI+DHyMQzrHZZQDlpo+bBFPA9vMwTMTHyAhMWokyuj9pXpMxdKogoT7Q6dT9JbpOOJZQw+kaow1NPLaLR9WjlRwohMCwCD2bZj4QMwKJ5c2TZNk2TbOtf2zQqpZ9/bE0C6drHwO0qO/JE2zEAmJiEfBibIKotBP0lelJiaMyugKIAB8L1qY9SD6Qqn2hCf7Yat3YRqSD2jVRaTPK/aeXPLgSeWYazCh+0CzbAhiVkQJmCgme7P9p7s/+2e6uf7Z7nZ9p4gpNTJmaXGHnTQBZZNBQzK3P1g0v3M92rWPUM8Q1meW08pp5JnkmLpiYmlJiaERdCkXT1j6QVqPp8l0H2hQ5/LGJUfkhtb/AGxyx+kI/aY9mJsgrhQzYftNv7TyT9oKGPYRNITDozPdmA4hSxYHtH0m+77RGcDmbj9p4qGbK5pgMNNDza3M6RQbKmbP1nun7x9MAOTCiCYrmEm0GBMw1fvB6ZXaq9xFvSCxT8zAhUGeWkepftBSv1m2FZtMCmBYnljuIWp+0HkkzNOIHVSZ54g1KwX1n6w3VTzqobAZ5iqJ70PtPE1gtsrigAGdP4azj6To2p2UMP3nvjR7yxhf2ZmZziczaYqxVWVqsHy2OIXaF3+83t94Wb7wVTyhPLH2gVftMD7TGfpCJtE2TZNphWAYmQJu+03tN7R7ggyxwJ1a9L7lAjhAABNGoQuSfpOkHKP/ADMTbMTExNs2HZmIk8sGeVAhEQEQfLaEQpmFJt4gWFYVmyBIEhrENc2QoRCJiFRCk2TaYUM65ldLASccxRjMpfBadF2qjAsMmYB7TAEDKWxNsCQJFsc2bCvEXEBWZWAr9oAPmPMGYMKwCYExNsx8BUTZCsKQ1wqfZgGbIUnXlHuk29sRFPOYFAlTWKfSxE6ZqiT5TcmXnaJpwrHJHM2wLiOyoMmJar9oFmJz7ATFz8szn2f9THy8CXcLkRVyohrE8sS2/ZeteO82zxCp9zlQJImMGUadbDycQ1BG4M0DrVfubtNRqVceiafVBWG6Jh1BHYzbNdbl9oMquNY47zTuLUBgSbZiBf1mqJFcq/y1/iE4htrH1E1POtqIgE61Ur6J8/SUrzGHqmkz6sx+WgyB24lJ9MxxNEP6CQjgzUj+s0Czp44P6C+3y8fcxTkA/P1bqqYMqOa1l5xU/wDE89y+CfrG/wBRRCcDM61qtulZR2Mp5M27mmmBG4GOMNBjZiUsB3hO7tNJrLNyV/SHtLwfOaAATQdj7CQPrAQfm6r8yRfyj4Ayn6+y/UmtsAT359wGIjblBj311kBjzAQRkfAe01J3sWLdj2mnI8pZr3K0nEwc5lLs165+kt1VxZlzxOoMzUsCZpwSwAgG15WrEkiEc+xRBxNEMXKzR7lC8GWepyZtxNNYaziXuRVkTzLG7tNIzMmD83VH1JF/KPg05bzBk+zVpl4lPIi4CidT6x0qrUhLNSoZZodXp9VQr0WB1+4jMFBZjgCa3xb0XRZFmpUkTR+M+h6y0VpqADEsSxNyMGBHcS4Hzm+2ZVf5VeZq9SbFC4irNOpNwllY3tNag8h5o1w4hXLkzT/3fxCpZjibCDEGYFlXBEBJi18xquIPSZZc9gwIobM0x2oxMfU2EkAzTOzpz8Gefj1AzYsHCiPrQrYxEYOob2Km1pmWpuIMrrGJqCQjY+xnil7h1vV7nb80/C/rn59Da88ddbTQdJsRLttzy2+252ax2Yk5OTEsetgyMVI+on4d+KdS2p9w1LlwZbXl2mPRtmoXbj2aUqLB95c6ozExzXf6OwMekae8hTmbiTmUWhC276xGwxMZsmUDLTZk8SuvmIkNc+hEZeZSIlQZ4SqqwzCJpX29+0ssBX0mL2HsHeExmxB7SQJa2bRCfR/1LB6z/Mo/yl9gXMxMex1yZ+JvRjRrV1aJhHnSOq39K1Q1FPcTrPXNZ1i/zdQ/t/DvTvb11GXssZAWMKzVAYE4lPpsBmoRmycRaTuUmaivGobmJWmfzR1HmEAxK/3grJcgcyitlchhFrgSIDMmKecSxfVEswZSwI7S8gWGc8xQdveUckx2ACgGDkQkCFlPYwgmD2OeRNTniYlxYVDBhBmlsYtt+nsX4GZVBZjgCfiH4is1utbRKQak+H8Otemk6yqN/fAuQTEWatAFgXiKuHXEYnbjEt3HaBxNQh8+IqgjKyzYSdolYhba2RNKWdiTEWBYgn1nYy1SeYqfUxbQojKzMWxCpEOcSniYik7ZrHbgAzRE7zk+zV3FMBTK9RYRgxHG/ky5hgGCxTLHJG2YOMzTnDRWJgPwfiT13W9NoSrTPjfLbXtdndiWJySfh6Le1HU9LYvcOJonNmjpc92QRBNYPTEAIiKS4liNgYmpQ4HMtqs82Vo5bkRq3LkKsNDrPKcfmmjUAmIo+82QiZweYMMTGAwZt4m0QORxiMAe8ZftK0Ex9Ju2iXjzcSpSnM81iJac95XFRgcx2JGIAoMY8zukoHqgBnnmDUHEXUGG+fib0xtVo01O8KEh+HT2mm+uwf2sDPCnU6+pdHosU8qoBid5riQsS1FXtzLNRtAZV5h6jYRyJbrHfAnmWOwga4fWVak1k5UGLq0sOTXLr0PGyeY2fTxK3vLrye8UZRf4hXiMIDtmcmEcewYzGUseIKuIikGfWEZE2wLhYMASweoxBiFmAjEwZ+swcZglVa4yIBiAewS/UVaalrrWARRkzxp4pu6rq2pqsPkJ8f4W6/Te4vpjYPMig7pqK96x6/S2BMMR6o9agTbSRyeYFH0m1u4MIb6mLkShBacGHRoOd0Zyrqq/eVHNa/xG5BgEdfVAphAxMEzyyIo4gUwLNs2cQJG7Rh6IUzChEByuMQj2fQCVVq3eBQBgezbAhm3HJnj/AMUks3T9M/H93yPD3UL9B1PTvSxGXAM0bm3TU2t3ZATLmwhitkNGyRGVm4MejA5WJXWV9UC6cZnlqzkAxqMdmlQNYljlccxvzKZXq0WtYNZUREdLOVjKIEGIy8xVxMEwLj4igMZeIEjLx2iL9CIagYahBUMRVC+0L+02zxb1lOkdMsfPrYYE1WofU32XOcs5z8joFC39W0lbdi4mmrFempQdggloJiJ34hr/AGi1jIzGrUjkcS4Uow+0LacngRkpxwOYlKtPJIbAMarMWsk4xNgAIioJo1IBmPYR83AmJgfDgQgDmfiT1j3vqXuyPlK/k+GP/N6P/nKv8qv/AIiFQYFwYVjJN2UmoUHE2j6CKpirAo+sGBmAerIhRzmKCJphgH54Hybf8qz/AImeKP8Azes/5/J8EaZdR17TBvoYo2qB9h8BQGGgEd4+jz2MGjInu0FGJdVgZEWkmLQREpPPMFERAn6gjIIniz8PbtRqb9dpn47mX1Gm6yo91bHyPwxort6yWbuvyyAYFA+HWapdMm4xOsEntD1ZAO0bqpLcdp7+5Od0TX07RubmV3JaMqf0WrqN2muqHdkInU/w/wCuHXXMleVZ51bwr/gfTPN1h/rv2Hx/hPRW119v6HrK76gItDr2aeU5PLRNOzdjNhBxmCmzvmdLUqjA/PJwPh/FXU2nqFVOfR8Fuj1FNa2WVlVbsfb+FFVA0tzhvX+h6ku5BEor28mWV6dVyJUF3NgxmoAwOTN64AE0OzZx7Nwm8QMD7dZq3pdFUdzEOVUx3CqTD1AZOBPfz9pVr1dwvszH1VQYqTPedP8A/qn/ANi21t+VwYSB3MDKexnVfDHS+rXC3Uplp4+6Po+ldRSrSrhfZ4c6RZ1XqVNCrlcjdPFXg2m/oqCnhqEltbV2OjDBUkez8K7rB1OxA3p/Q9Qf0QsNneMSZVzu/iMv1is32nTXyGBjEBTFfLHJgRnbhuIiGv8Aebm+0Baa/AurLRG9A/iXWgqygxcISWGZbapEotAtSA+kH9p9Jqw3mkxfEfVx/wC08o8Y9aoORqGifiN1kDDnM8MfiDVe7Va1tko67otQ58rVIZ+JFyX9RR1sDez8LNMBZdeUjottbIwyGGDPHfSF6b1mwJ+Vz7Pwz6boBoPe6+bv0BYTX42cxiPpMyonLQkwOQZoGwpmqubaApnrxnMqudTndF1ZHJOYmr3HGIb+ZrrNz1ytxsGftNRkscRaHZYNISOZXoWW0NLbsVbcc4mm/wAoZmpQM54+AEjsZTrdVQc13Ov/AHLr7r23WuWP7+zpvinW9M0wp03E0v4mdZp4fmeIfEF3XNSt9q4I9n4c+I+naDRPRqbQhieK+hv21aweKuiFwo1aSnU0XqGqtVgfsfm2AKczXHgR8faZ4lOSWh+sCEzThhWxh3seZhjDU2IBtWVli0Y2Bu8dizLEGUENfqzFXA7Ra5hRLlB7So4UCMuWJ+YCR2JE8yz/AHt/9gtsByHbP8zpPizq3TLFKXsyD6GeHfxG03ULK9PqV2WNFYMAQeD8oywktNXXkCWpgz04xiVKATiBMmLVKl2oQDNkrrJGAImmzyZZpB9IKcGNVPJGeZWuFAgSBZiMBGEz+hpusosWys4ZTkGdP/ETrWk2hn3qJ0z8U6LrEr1VWInWU1KK9JGxhwZ70+c74vUGGMkSp96BvaGy2PY2YR6pfggS9VXB7xV3HgRagIFBbibTK0MSqV1qvss7Q5g7QIXPaJXiYHtYiEzsMxWVhkMD+j8H9ZF2jGmZ/WsNt33MSywuuWPeaL/TV/xCTAXgHtsX6y4FodKzyrT7AOI1AOeImkCjM8iJSBAoEx7DCi/cQKvYGAAfDZjkxGD9peB5D/uJpiaKnQvnJ/R6TWX6O1bamIInh/xFR1GtarGxdMFbVH7zQ/6av+PiIBENAi1qB7COIGwPUYHr+4gZT2I+Dqt71INhxF1to7uZ03UvZfgniMyqMsQJq9ZWqYRxmaXW2u4U9punU9W+m05dO8br9rcETTdY2JzG6yllewrzG1A/SeGNLdf1Gs1kgLErbKZmi/01f8fL1bsLSMyzV7DjmJrnLqFP1lZJrUn7e3rPYSy5asbp0Zs3ZnibxVrtNrrqA2EWVeNNS1gBzOneItK9SFrdrTS9f0RAB1CmdV1FWo0BatwwmPVKUjWIkfWJ9/0QBJwJ03w5r9bYn9Iqh+s6J4aTpiB1HJgUgjImj/06fL1g/qMZeuZ7t5JSxm4lPUdKVRd4zidT8SXVay3ToRs28GdM66U6dqXvvHmDO2azxdr9ReVZ/SGmq6gHooYsCZ051HTveEcbgs8Q2Pfrb2buZWje8on3aW9LWihW3tuK5mnOpssKVu06drL10YoZycTTVl+SI4CJxNbrGViMy3Wv9/0Xgnw1T1BveriCq/SabR6IIECqm2XaejyNqMCY+kvxmdJNgDK7fGbFB7zzBuC+3V8XMx7CazVh12quJbqLXQITxCzJyc8CdS1Nraq0hz3j6i7aRvOIpBYmNc+0DccTovU7V0jIbDtmpNV2tfzGwpj6ce/jyTnByIut6k1VlTqmMdzOmbRbaz2bSBOhvbdqWUnIJioEWai3CNzNdaz2HAMKnGf0X4fdRevVPps8NHLgnvNMfUN7TUXnJCtNBaykkx79lYae/D7Si7zVzGsRPzMBDq6B3cTqHV66UBrOTNT1fUbiwczpfWbL7UVu8zwIWA7mdRtHnEAy18Ewv9ZdYHpbj6TWEedZ/M1moZCFEqs4hs9Jmg6jVRUweX6gXuzrwJUQtgcN6hNRqrXK8kHHM0NNVjWmx8YE8NOF15y3E1GrUB8NNT1m5bbF7jMq1L6juI6+n9F4Kdl6zViXW1hSCvMe9wTPe33BTNFyJcMULODNLcEISdYsdbQATN57kmavVIx2g8iXOSJ03VGjV1sThcxOoUvWjq/GJfeWG4WCXNuLMWj6gZORDYFbMuuBRv4mucC+yaizzLIGxN/oMawnIlA/pypM2qT2zNSenFV8tjuxKwxJ2Ayq22mxShIbMs6hq61AtUgkRmLuzmaMYSN2/ReFb/I6xpzjuZcu/afuI9Ds5Aj0Ot6KcTpiAnaZaqCr1dhLr6F7GLqashszXXC5wwMY+ky2zF7/AMzU6gKsOoyqkTpGtNun2faPYf8AdGbKk5llgLHmFzOraw1oqo3M1lhwxJ5M2ktGXAEJ9MY4JlVoNIGJSz22LUgGTLqLKbSj950tLHe3aOy8xiRZkdw002NW9bajBAGJ1LQadL18g5Q95TXt4HaOfp+i8PuqdX0jN23iJpKb6K3RgSUE14FbFMeqOHLAknM6bqfKtwzYE6t13S0VBN4LGL1qmzgjErsDV5HaW2bSDNf1FdPSWCw6o22M/wB5qbcpBaQBOjdRrpyjdzOs9TZa18psTTax206lm5Ihc5zHtwpJmruNl5OcjM17ncBKFLQ6VysbSuBLRhyJQfREdq33KcGWXO75ZiTNBrLNMLNv9wwYz92+5mmuc45gc47wvDaB+irdkdWU4IM8P+L9ZpPK820sgl3irSau8NkDMFtVwVkcGXYX6zqFze88kkSjUVk+qaTqelWgIz8y+9fL3g5E6rrRbSVAiWES22FsASq/yrQ81Ot94CjE01irQuTiXa1ACEOWj6u+wMpOBAfUczXDLCUuydompLDvKUs1DbE5JmsrarUOjjkGUfkExkxgciaFKmS3zGxxxDgL/wByhgoEFoxGsjMT+jrs/pgAytxniw5ml6jq6QNtpxNB1EX6TNjZedQcm4mI5zPN5zmV9SuxtL8TVW7lgaPM9oTkxWGRNdr1tpprrXaR3Mep1FexsloadQhIdGmTumpBZhPKws01wpsyy7hP8QJw1KbCJqNPqL7WsIJLSjQXLXkiFMHEdbM8CVqSDkzHAEUEAZHtBz+jqtVRhpSlbNkGUU1EctKGFCf05bcrcHvMEZ4hOMwuQTPNLKBEKgcyxlK/vFbBBjMC3aKwEsJONsR7FFbL3WJ1G3FnmqCWExyTCqspMxwZsLMcCabTuVGREsrpIUiW6oFcKJ5ibzkQ2LjhYuTulddmc7YtVrDJEIIPacwfpEcoeImu2gROrPnEp1Rs1C8x7WLFcgcR3PIj5xK2GBmFxM59hbmX2bcTSV703xHqyd083St3Eq0QvtxWeCZd4ddKz6xnGZZWUcpK1WvuolfrUYAEt0bMQ0aor9IMebyvEtekD0rE1KkkBZqNa9aDYRNF1Cx+HaAq2dxj0gAMHE7fowMkCJo7GGdph0TASypqzzOlVo1pLvtI7Q6NbaWtDYIhT95q7fLAH1Mqs9AmRBYsHMLAWYmq+k01jrSFBhewdkJlZZicjE6MP6resDiarWXszqbD3xDShbcZ5Nf3nCrgGX6zUIWCtKeoX2WhXbiJ5J5EtrR84E2Km6Wkl25lFvluDKNdoXUK35oa6yODCoB7/owcEGVdWCIFKCP1FX/t5jjzHVjA4F4K/SU6l2TaWwJ5YxnM1zA2gCUDNWYroEww5l4odF8pcN9YvAAhry+6XruYYmn7KDNRaqoFRRyOTFxK2ZeUDCJXa4yQYtZY4j17JjIPM1H5nmhQNq8GPbpqm2kgGanqFKVkJyZ77uByIxyxPsU4YGafVU+SN78gTVa3Fg8s8fpaxlhL8rtAMqGH5lFdlhAQZji6tTuGJdoLHsHqHIzEY1VMPsYpLCAMvcxK6zjLxKA1m1DmDQUJXlz6o4VWIWGwGsAjmG+tUA8vme/lRwgxK+rcEMI+tTeMNLtaO26HWqAeZbrAS2BKb2qt8wTUXtc+8nn4sn2f/8QAFBEBAAAAAAAAAAAAAAAAAAAAkP/aAAgBAgEBPwAcf//EABQRAQAAAAAAAAAAAAAAAAAAAJD/2gAIAQMBAT8AHH//2Q==');
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

/* The fall itself is drawn on a canvas by script.js — see there for how the
   columns work. All this has to do is place it and set how loud it is.

   The glyphs are held off the foreground in the script itself (see the depth
   map above). */
.glyph-rain {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  opacity: calc(0.38 * var(--rain-strength));
  pointer-events: none;
}

/* The city, lit from within.
   --------------------------------------------------------------------------
   Three pools sat over the neon band in the lower half of the plate, blended
   with `screen` so they brighten what is already there instead of laying
   colour on top of it — the difference between a city whose lights are moving
   and a coloured shape drifting across a photograph.

   They drift on different periods and never line up, so nothing about the
   movement is countable. The colours are the page's own: the magenta the image
   ships is not in the palette, so what brightens it is brand green and
   --uncharted indigo. */
.city-pulse {
  position: absolute;
  inset: 0;
  mix-blend-mode: screen;
  pointer-events: none;
}

.city-pulse::before,
.city-pulse::after {
  content: '';
  position: absolute;
  inset: 0;
}

.city-pulse::before {
  background:
    radial-gradient(22% 16% at 30% 72%, rgba(0, 255, 137, 0.16), transparent 70%),
    radial-gradient(18% 12% at 62% 80%, rgba(129, 140, 248, 0.16), transparent 72%);
  animation: city-drift-a 19s ease-in-out infinite;
}

.city-pulse::after {
  background: radial-gradient(26% 14% at 78% 68%, rgba(94, 234, 212, 0.14), transparent 72%);
  animation: city-drift-b 26s ease-in-out infinite;
}

@keyframes city-drift-a {
  0%,
  100% {
    transform: translate3d(-2%, 0, 0);
    opacity: 0.55;
  }
  50% {
    transform: translate3d(3%, -1%, 0);
    opacity: 1;
  }
}

@keyframes city-drift-b {
  0%,
  100% {
    transform: translate3d(2%, 0, 0);
    opacity: 1;
  }
  50% {
    transform: translate3d(-3%, -1.5%, 0);
    opacity: 0.5;
  }
}

/* Weather is the first thing to go when motion is unwelcome. The canvas is
   hidden rather than left standing, because a column of glyphs frozen
   mid-fall is not a quieter version of the effect — it is a grid of digits
   sitting on the picture. script.js never starts it either. The city keeps
   its light, held at the middle of its cycle. */
@media (prefers-reduced-motion: reduce) {
  .glyph-rain {
    display: none;
  }
  .city-pulse::before,
  .city-pulse::after {
    animation: none;
  }
}

/* --------------------------------------------------------------------------
   The hero resolving into focus
   --------------------------------------------------------------------------
   The product's whole job is turning something indistinct into something you
   can read, so the first screen performs that rather than sliding in: the
   backdrop and the type arrive out of focus and resolve. Blur is the carrier,
   not distance — a plain translate would be an entrance animation and would
   say nothing about what this page is for.

   The order is the argument. The pill settles first, then the headline's
   statement of the problem, then the leads — and the brand phrase, "let's fix
   it", sharpens LAST and on its own, a beat after the line it belongs to.
   That beat is the whole effect: the problem resolves, and then the answer
   does.

   CSS only, and on load only — nothing here waits on script or on scroll.
   -------------------------------------------------------------------------- */
/* Registered so they can be animated at all: a custom property is a string to
   the animation engine unless it has been given a type, and would jump from
   keyframe to keyframe. These exist because the headline's intro blur has to
   pass THROUGH a token — it shares `filter` with the deck and with the tweet
   slide's step back, and the last animation to write that property with
   `fill-mode: both` would otherwise own it forever and pin it at `none`. */
@property --intro-blur {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@keyframes decloak {
  0% {
    opacity: 0;
    filter: blur(18px);
    transform: translateY(14px);
  }
  /* The middle stop is the whole trick. On a single ease the blur is gone
     within a few hundred milliseconds and what is left reads as an ordinary
     fade — the focus pull is over before the eye has registered there was one.
     Holding the text still soft at not-quite-half way gives the resolve
     something to actually happen across. */
  45% {
    opacity: 0.78;
    filter: blur(5px);
  }
  100% {
    opacity: 1;
    filter: none;
    transform: none;
  }
}

/* The same move without the blur, for the two glass objects.
   --------------------------------------------------------------------------
   The pill and the ghost button are lit by backdrop-filter, and a filter on an
   element (or on any ancestor of it) makes that element a backdrop root — so
   it would sample its own empty background instead of the picture, and the
   glass would go flat. Even filter: blur(0) does it; the property only has to
   be present. These two ride the same timing on opacity and transform alone,
   which at this scale is indistinguishable. */
/* The headline's own version: same shape, but the blur leaves through
   --intro-blur so nothing else that wants `filter` on this element is lost. */
@keyframes decloak-title {
  0% {
    opacity: 0;
    --intro-blur: 22px;
    transform: translateY(14px);
  }
  /* The blur's middle stop and the opacity's are doing different jobs and no
     longer share a value. The blur holds soft to not-quite-half way, which is
     what makes the focus pull last. The opacity is pinned LOW here on
     purpose: the ease front-loads each segment, so a high value at 45% meant
     the line was most of the way present within the first half-second and the
     rest of the run was a creep nobody reads as a fade. Half way through,
     half there. */
  45% {
    opacity: 0.5;
    --intro-blur: 6px;
  }
  100% {
    opacity: 1;
    --intro-blur: 0px;
    transform: none;
  }
}

/* Light moving over the phrase, continuously. The band layer is TILED rather
   than a single pass, so there is always some of it on the letters and the
   loop has no seam: one cycle shifts the tiling by exactly one tile, which
   lands the pattern back on itself. Linear, because anything eased would give
   the loop a beat, and a beat is the thing that makes a loop audible.

   It travels the way the line is read, left to right, so it moves with the
   eye rather than against it. */
@keyframes brand-sheen {
  from {
    background-position: 0 0, 0 0;
  }
  to {
    /* One tile. The tile is half the phrase wide, so the offset a percentage
       buys is P x (100% - 50%) = half the phrase at 100% — one tile exactly. */
    background-position: 100% 0, 0 0;
  }
}

@keyframes decloak-plain {
  0% {
    opacity: 0;
    transform: translateY(14px);
  }
  45% {
    opacity: 0.78;
  }
  100% {
    opacity: 1;
    transform: none;
  }
}

/* The backdrop pulls focus and settles, very slowly and over a longer run than
   anything in front of it — it should be felt rather than watched. */
@keyframes backdrop-settle {
  from {
    opacity: 0;
    filter: var(--grade) blur(18px);
    transform: scale(1.06);
  }
  to {
    opacity: 1;
    filter: var(--grade);
    transform: none;
  }
}

.hero-plate {
  animation: backdrop-settle 2.6s cubic-bezier(0.32, 0.08, 0.24, 1) both;
}

/* A long, decelerating ease: most of the movement is over early and the last
   of the blur takes its time, which is what reading "coming into focus"
   rather than "fading in" depends on. */
/* A long, decelerating ease: most of the movement is over early and the last
   of the blur takes its time, which is what reading "coming into focus"
   rather than "fading in" depends on.

   Only the first slide gets an intro. The rest arrive by being scrolled to,
   and their opacity belongs to the deck — an entrance animation on top of that
   would be two things writing to the same property. */
.slide:first-child > * {
  animation: decloak 1s cubic-bezier(0.32, 0.08, 0.24, 1) both;
  animation-delay: 0.4s;
}

/* The headline waits, and the wait is the point. The backdrop has the screen
   to itself for two full seconds — long enough to actually look at the thing,
   which is most of what the first screen is selling — and by then it has all
   but finished resolving (its own run is 2.6s on a decelerating ease, so it
   is essentially sharp well before this starts). Only then does the line
   arrive, out of focus, resolving over a run long enough to watch.

   Held invisible until then by `fill-mode: both`, not merely transparent.
   A wait this long is also long enough to scroll through, so script.js ends
   the intro early if anyone does — see endIntro(). */
.slide:first-child .hero-title {
  animation-name: decloak-title;
  animation-duration: 2.4s;
  animation-delay: 2s;
  /* When the deck is off — no script, or before it starts — the composed
     filter on the deck rule never applies, so the intro needs a home here
     too. blur(0px) at rest costs the line nothing. */
  filter: blur(var(--intro-blur, 0px));
}

/* The eyebrow is glass, and a filter on it — even blur(0) — would make it a
   backdrop root and flatten it, so it rides the same timing without the blur.
   It leads, because it is the label the rest of the deck sits under. */
.hero > .eyebrow {
  animation: decloak-plain 1s cubic-bezier(0.32, 0.08, 0.24, 1) both;
  animation-delay: 0.15s;
}

.slide > .hero-cta {
  animation-name: decloak-plain;
}

/* The answer, arriving after the problem. Its own run, longer and later than
   the headline it sits inside, so it is still resolving once that line is
   sharp. It is already a background-clipped gradient, so it cannot take a
   filter without the clip going with it — opacity and a small rise carry it
   instead, which is enough given it is the only thing still moving.

   Scoped to the FIRST slide. It used to address every .grad-brand on the page,
   which meant the phrase on the last slide was still waiting out this delay —
   held invisible by animation-fill-mode — for the first couple of seconds. Sit
   through the intro and you would never notice; scroll straight to the call to
   action and its headline arrived with a hole in it. */
.slide:first-child .hero-title .grad-brand {
  display: inline-block;
  /* Two clipped layers instead of one, so the sheen has something to travel
     through: a soft rise and fall of light on top, the brand gradient
     underneath. Written here rather than on .grad-brand itself so the phrases
     on slides three and four stay still — one moving thing on the page, not
     three. */
  background-image:
    linear-gradient(
      100deg,
      transparent 0%,
      rgba(255, 255, 255, 0.52) 50%,
      transparent 100%
    ),
    linear-gradient(135deg, #00ff89 0%, #5eead4 60%, #18bb38 100%);
  /* The light layer TILES; the colour underneath does not. A tile half the
     phrase wide puts two swells on the line at any moment, which is what lets
     it shimmer continuously instead of flashing once and going dark. Each tile
     starts and ends at transparent, so neighbours meet invisibly. */
  background-repeat: repeat, no-repeat;
  background-size: 50% 100%, 100% 100%;
  background-position: 0 0, 0 0;
  /* The decloak's delay is a beat after the line it sits inside starts to
     sharpen, so the problem resolves and then the phrase that names it does.
     Measured from the headline's own run rather than from load: it starts a
     little past that run's half way and lands with it, so the slower fade
     above carries this with it instead of leaving it stranded.

     The sheen starts as that lands rather than before it: light moving across
     a phrase that is still arriving would read as part of the arrival rather
     than as a property of the phrase. */
  animation:
    decloak-plain 1.2s cubic-bezier(0.32, 0.08, 0.24, 1) 3.2s both,
    brand-sheen 7s linear 3.6s infinite;
}

/* Asked not to be moved: everything is simply present, at rest.
   The selectors are deliberately as specific as the rules they are cancelling
   — `animation: none` does not win on being inside a media query, and the two
   scoped intros above (the first slide's headline and its brand phrase) sat
   at a higher specificity than the plain ones this used to list, so they kept
   running for anyone who had asked them not to. */
@media (prefers-reduced-motion: reduce) {
  .hero-plate,
  .slide > *,
  .hero > .eyebrow,
  .slide:first-child .hero-title,
  .slide:first-child .hero-title .grad-brand {
    animation: none;
  }

  /* Stopping the sheen is not enough on its own: the light layer tiles, so a
     frozen one would leave the phrase with permanent bright patches in it.
     Asked for no motion, it gets the plain brand gradient back. */
  .slide:first-child .hero-title .grad-brand {
    background-image: linear-gradient(
      135deg,
      #00ff89 0%,
      #5eead4 60%,
      #18bb38 100%
    );
    background-size: 100% 100%;
  }
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--starlight);
  /* Stays --uncharted, the periwinkle, while the ghost button and the source
     link next to it are violet. Tried the other way and it was not wanted:
     the pill is a label rather than an accent, and it sits far enough from
     the button not to read as a second version of it. */
  background: rgba(129, 140, 248, 0.08);
  border: 1px solid rgba(129, 140, 248, 0.25);
  padding: 0.4rem 0.9rem;
  border-radius: 999px;
  margin: 0 0 1.75rem;
}

.eyebrow .dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brand);
  box-shadow: 0 0 10px 1px var(--brand-glow);
  animation: pulse 2.4s ease-in-out infinite;
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.35;
  }
}

h2.hero-title {
  /* Slides three and four are h2 — one h1 to a page — and the browser's own
     h2 sizing and margins must not show through a class that sets the look. */
  margin-top: 0;
}

.hero-title {
  /* A token because the first slide scales it as it settles — calc needs the
     base value, and the short-phone rule below overrides the token rather
     than the size so the scaling still applies there. */
  --title-size: clamp(2.3rem, 5.6vw, 4.05rem);
  font-size: var(--title-size);
  font-weight: 700;
  margin-bottom: 1.5rem;
  /* The page is full-bleed, so the headline caps itself. Without this it sets
     as one very long line on a wide screen and stops reading as a headline. */
  max-width: var(--hero-measure);
  margin-inline: auto;
  line-height: 1.07;
  color: var(--ink);
}

.grad-brand {
  background: linear-gradient(135deg, #00ff89 0%, #5eead4 60%, #18bb38 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.grad-uncharted {
  background: linear-gradient(135deg, #818cf8 0%, #a5b4fc 50%, #3884ff 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.hero-lead {
  max-width: var(--hero-text-measure);
  color: var(--ink);
  margin: 0 auto 1.1rem;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.35rem, 2.8vw, 1.75rem);
  line-height: 1.3;
}

/* On a slide the gap above it is the headline's own bottom margin, so it adds
   none of its own, and it steps down from the headline in colour as well as
   in size — the heading is the line, this is the reason for it. Sized against
   viewport height too, because on a slide it has a screen to fit, not a
   column. */
.slide .hero-lead {
  margin: 0 auto;
  flex: none;
  color: var(--starlight);
  font-size: clamp(1.05rem, 1.6vh + 0.6rem, 1.55rem);
}

.hero-cta {
  display: flex;
  /* Total gap above the buttons is this plus the lead's own bottom margin. In
     vh as well as rem so a short screen gives some of it back. */
  margin-top: clamp(1.9rem, 4vh, 2.9rem);
  gap: 0.85rem;
  justify-content: center;
  flex-wrap: wrap;
}

/* The section directly beneath the full-height hero, so it needs its own
   breathing room where it used to inherit the hero's.

   It also needs to not begin as flat canvas. The hero is a photograph with
   the city's light in it and ones and zeros falling through it; this is the
   first solid surface after that, so rather than a hard cut it takes a seam,
   a little of that light spilling past the fold, and what is left of the rain
   at this distance. Same palette, a fraction of the intensity. */
/* The FAQ wears the same ground, so the page's two quiet reading sections
   match: the panel, the seams and the lattice below all apply to both. */
.showcase,
.faq {
  position: relative;
  isolation: isolate;
  padding-block: var(--section-pad);
  background:
    /* each seam's own bloom, tight to its edge */
    radial-gradient(70% 20% at 50% 0%, rgba(129, 140, 248, 0.11), transparent 72%),
    radial-gradient(70% 18% at 50% 100%, rgba(129, 140, 248, 0.08), transparent 72%),
    /* the city's own glow, carrying over the fold */
    radial-gradient(90% 60% at 50% -12%, rgba(0, 255, 137, 0.09), transparent 66%),
    /* and the violet from the far side of the hero, answering it */
    radial-gradient(62% 55% at 88% 106%, rgba(139, 92, 246, 0.09), transparent 70%),
    /* a panel lifted just off the canvas, settling back into it by the end so
       the next section starts from the same black it always did */
    linear-gradient(180deg, #0b0e1a 0%, #080a13 58%, var(--canvas) 100%);
}

/* The seams, top and bottom. A hairline in the brand gradient that fades out
   well before the gutters, so it reads as a horizon rather than a border.

   One element paints both: the same gradient twice, each layer 1px tall and
   pinned to an opposite edge. A second pseudo-element was not available —
   ::after is the lattice — and this needs no extra markup. It covers the
   whole section, so it takes pointer-events: none to stay out of the way of
   the text underneath. */
.showcase::before,
.faq::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: var(--seam), var(--seam);
  background-size: 100% 1px;
  background-position: top left, bottom left;
  background-repeat: no-repeat;
}

/* The rain, resolved into a lattice. The hero's fall is a grid of columns of
   mono glyphs; this is what that grid looks like from far enough back that
   the characters are gone. Masked so it is densest just under the seam and
   has left entirely before the copy ends — it is a texture, not a pattern the
   eye should be able to count. */
.showcase::after,
.faq::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(
    circle at center,
    rgba(205, 214, 244, 0.22) 0.5px,
    transparent 1.2px
  );
  background-size: 30px 30px;
  -webkit-mask-image: radial-gradient(
    130% 85% at 50% 0%,
    #000 0%,
    rgba(0, 0, 0, 0.5) 40%,
    transparent 76%
  );
  mask-image: radial-gradient(
    130% 85% at 50% 0%,
    #000 0%,
    rgba(0, 0, 0, 0.5) 40%,
    transparent 76%
  );
}

.hero-showcase {
  margin-top: 0;
}

.hero-fan {
  position: relative;
  height: clamp(300px, 40vw, 560px);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Ambient glow pooling behind the fan */
.fan-glow {
  position: absolute;
  width: 70%;
  height: 70%;
  border-radius: 50%;
  filter: blur(70px);
  opacity: 0.6;
  background: radial-gradient(circle at 40% 40%, var(--brand-glow), transparent 60%),
    radial-gradient(circle at 65% 55%, var(--uncharted-glow), transparent 65%);
}

.fan-card {
  position: absolute;
  margin: 0;
  width: clamp(340px, 50%, 660px);
  border-radius: var(--radius-card);
  overflow: hidden;
  border: 1px solid var(--edge-strong);
  background: var(--panel-2);
  box-shadow: 0 30px 70px -28px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(255, 255, 255, 0.02);
  transform-origin: bottom center;
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.4s ease;
  will-change: transform;
}

.fan-card img {
  width: 100%;
  display: block;
}

/* Three-card fan: the network map (front) sits centre and largest, with two
   supporting views angled out behind it and dimmed slightly. */
.fan-card--left {
  transform: translateX(-42%) translateY(6%) rotate(-9deg) scale(0.88);
  z-index: 1;
  filter: brightness(0.82);
}
.fan-card--right {
  transform: translateX(42%) translateY(6%) rotate(9deg) scale(0.88);
  z-index: 1;
  filter: brightness(0.82);
}
.fan-card--front {
  transform: translateY(-2%) rotate(0deg);
  z-index: 2;
  box-shadow: 0 40px 100px -30px rgba(0, 255, 137, 0.25),
    0 30px 80px -30px rgba(129, 140, 248, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
}

/* Spread the fan a touch on hover — a subtle, deck-opening flourish */
.hero-fan:hover .fan-card--left {
  transform: translateX(-54%) translateY(4%) rotate(-12deg) scale(0.88);
}
.hero-fan:hover .fan-card--right {
  transform: translateX(54%) translateY(4%) rotate(12deg) scale(0.88);
}
.hero-fan:hover .fan-card--front {
  transform: translateY(-5%) rotate(0deg) scale(1.03);
}

/* --------------------------------------------------------------------------
   The problem, in one tweet — stylised tweet card
   -------------------------------------------------------------------------- */
.tweet-section {
  padding-top: clamp(3rem, 7vw, 5rem);
}

.tweet-wrap {
  max-width: 680px;
  text-align: center;
}

.tweet-card {
  position: relative;
  margin: 0;
  text-align: left;
  background:
    radial-gradient(80% 120% at 100% 0%, rgba(129, 140, 248, 0.08), transparent 60%),
    var(--panel);
  border: 1px solid var(--edge-strong);
  border-radius: calc(var(--radius-card) * 1.15);
  padding: clamp(1.5rem, 4vw, 2.25rem);
  box-shadow: 0 40px 90px -45px rgba(0, 0, 0, 0.9);
  overflow: hidden;
}

/* Ambient brand glow pooling behind the card */
.tweet-glow {
  position: absolute;
  inset: auto -20% -60% -20%;
  height: 70%;
  z-index: 0;
  filter: blur(70px);
  opacity: 0.5;
  background: radial-gradient(50% 100% at 30% 100%, var(--brand-glow), transparent 70%),
    radial-gradient(50% 100% at 80% 100%, var(--uncharted-glow), transparent 70%);
}

/* Lift the card's content above the glow behind it. The glow is EXCLUDED
   deliberately: it is a child too, and at (0,1,1) this selector outranks
   `.tweet-glow`'s own (0,1,0), so it was overriding that rule's
   `position: absolute` and dropping the glow back into flow — where its 70%
   height took real space at the top of the card and pushed the quote down.
   Harmless-looking extra padding in a section that could grow; in a slide
   with a fixed height it shoved the quote out of the card, which clips. */
.tweet-card > *:not(.tweet-glow) {
  position: relative;
  z-index: 1;
}

.tweet-head {
  display: flex;
  align-items: center;
  gap: 0.85rem;
}

.tweet-avatar {
  width: 52px;
  height: 52px;
  border-radius: 50%;
  flex: none;
  border: 1px solid rgba(0, 255, 137, 0.35);
  box-shadow: 0 0 24px -6px var(--brand-glow);
}

.tweet-id {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
  min-width: 0;
}

.tweet-name {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.05rem;
  color: var(--ink);
}

.tweet-verified {
  color: var(--brand);
  flex: none;
}

.tweet-handle {
  font-size: 0.9rem;
  color: var(--muted);
}

.tweet-bird {
  margin-left: auto;
  color: var(--faint);
  flex: none;
}

.tweet-body {
  margin: 1.35rem 0 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.4rem, 3.6vw, 2rem);
  line-height: 1.28;
  letter-spacing: -0.01em;
  color: var(--ink);
}

.tweet-mark {
  color: var(--brand);
  background: linear-gradient(135deg, #00ff89 0%, #5eead4 70%, #18bb38 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

.tweet-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 1.4rem;
  padding-top: 1.1rem;
  border-top: 1px solid var(--edge);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  color: var(--faint);
}

/* The footnote marker on the tweet card. Its note is in the page footer. */
.fn-ref {
  margin-left: 0.15em;
  font-size: 0.72em;
  line-height: 0;
  color: var(--violet-light);
}

/* The bar floats over the page, so an anchored section has to stop short of
   the top of the screen or it lands underneath it. Exactly short of it: the
   section's top edge meets the bar's bottom edge with nothing in between, so
   the bar reads as the lid on the section rather than as something hovering
   above it. No breathing space on purpose — the sections bring their own
   padding, which is where the breathing space belongs. */
section[id],
main[id] {
  scroll-margin-top: var(--header-h-stuck);
}

/* --------------------------------------------------------------------------
   Section headers (shared)
   -------------------------------------------------------------------------- */
.section-eyebrow {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--uncharted);
  margin: 0 0 0.85rem;
}

.section-title {
  font-size: clamp(1.9rem, 4vw, 2.9rem);
  font-weight: 600;
  max-width: 720px;
}

.section-lead {
  max-width: 620px;
  color: var(--muted);
  font-size: 1.08rem;
  margin-top: 1rem;
}

/* "What is Unstealth?" — the first thing under the hero. */
.intro-inner {
  text-align: center;
}

.intro-inner .section-title,
.intro-lead {
  margin-inline: auto;
}

/* Brighter and a touch larger than a normal lead: this paragraph is the
   definition, and it should read as the answer. */
.intro-lead {
  max-width: 44rem;
  font-size: clamp(1.08rem, 1.6vw, 1.22rem);
  color: var(--ink);
}

.intro-pair {
  --intro-split: clamp(1.4rem, 3.2vw, 2.75rem);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--intro-split);
  max-width: 68rem;
  margin: clamp(2.5rem, 5vw, 3.5rem) auto 0;
  text-align: left;
}

.intro-point {
  position: relative;
}

/* Hairlines rather than three cards — these should read as prose. The rule is drawn in the middle
   of the gap by a pseudo-element rather than as a border on the column, so
   the three tracks stay exactly equal in width. */
.intro-point + .intro-point::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--intro-split) / -2);
  border-left: 1px solid var(--edge);
}

.intro-kicker {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--brand);
  margin: 0 0 0.75rem;
}

.intro-point p + p {
  color: var(--muted);
  font-size: 0.99rem;
  line-height: 1.7;
  margin: 0;
}

/* Stacked, so each rule turns from a divider between columns into one between
   rows. Three columns run out of measure well before two did, so this happens
   at a wider breakpoint than it used to. */
@media (max-width: 900px) {
  .intro-pair {
    /* Generous: stacked, each point opens with its own vignette, and a tight
       gap ran one point's paragraph straight into the next picture. */
    --intro-split: clamp(4.5rem, 18vw, 6rem);
    grid-template-columns: 1fr;
    /* One column across the full 68rem would run to ~95 characters a line.
       Stacked, each point needs its own measure back. */
    max-width: 46rem;
  }

  .intro-point + .intro-point::before {
    left: 0;
    right: 0;
    bottom: auto;
    top: calc(var(--intro-split) / -2);
    border-left: 0;
    border-top: 1px solid var(--edge);
  }

  /* Label first, then its picture, then its words. With the picture on top a
     label sat between two pictures, and which one it named was a guess. */
  .intro-point {
    display: flex;
    flex-direction: column;
  }

  .intro-point .intro-kicker {
    order: -1;
    margin-bottom: 0.9rem;
  }
}

/* --------------------------------------------------------------------------
   The three vignettes
   --------------------------------------------------------------------------
   One account's way out of stealth, told across the three points: looked up,
   named, settled. Each is a small slab of the header's glass with a lit top
   edge, and the edge carries the page's colour story — periwinkle for the
   unknown on the first, green for the known on the last, the second halfway
   between. Read left to right, the row itself goes from uncharted to charted.

   Everything here is at rest in its END state. script.js adds .is-armed to
   wind a vignette back to its start and .is-played to run it, so with no
   script or with reduced motion none of the start states ever apply. */
.vg {
  --vg-edge: var(--uncharted);
  position: relative;
  height: 10.75rem;
  margin-bottom: 1.4rem;
  padding: 0.95rem 1rem;
  border-radius: calc(var(--radius-sm) * 1.3);
  border: 1px solid rgba(255, 255, 255, 0.07);
  background:
    radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--vg-edge) 9%, transparent), transparent 70%),
    linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 22px 40px -30px rgba(0, 0, 0, 0.9);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  line-height: 1.45;
  color: var(--muted);
  overflow: hidden;
  isolation: isolate;
}

/* The lit edge, fading out before the corners like the section seams do. */
.vg::before {
  content: '';
  position: absolute;
  inset: 0 12% auto;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--vg-edge), transparent);
  opacity: 0.8;
}

.vg--contribute {
  --vg-edge: #40c6c0;
}

.vg--curate {
  --vg-edge: var(--brand);
}

.vg p {
  margin: 0;
}

.vg-id {
  color: var(--starlight);
  letter-spacing: 0.02em;
}

/* ---- Research: the account, looked up ---- */
.vg-head {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  margin-bottom: 0.7rem;
}

.vg-name {
  font-family: var(--font-display);
  font-size: 1.02rem;
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--brand);
  white-space: nowrap;
  transition: color 0.5s ease, text-shadow 0.5s ease;
}

/* While it is still a stranger. The name's text is swapped by script.js; this
   is only the colour it wears until then. */
.vg-name.is-cloaked {
  color: var(--uncharted);
  font-family: var(--font-mono);
  font-size: 0.86rem;
  font-weight: 400;
}

.vg-name.is-decloaking {
  font-family: var(--font-mono);
  font-size: 0.86rem;
  font-weight: 400;
  color: var(--starlight);
}

.vg-name.is-named {
  text-shadow: 0 0 18px var(--brand-glow);
}

.vg-rows {
  margin: 0;
  display: grid;
  gap: 0.18rem;
}

.vg-row {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  border-bottom: 1px dashed rgba(205, 214, 244, 0.08);
  padding-bottom: 0.18rem;
}

.vg-row dt {
  color: var(--faint);
}

.vg-row dd {
  margin: 0;
  color: var(--starlight);
}

.vg-spark {
  position: absolute;
  inset: auto 1rem 0.85rem;
  height: 1.35rem;
  display: flex;
  align-items: flex-end;
  gap: 3px;
}

.vg-spark i {
  flex: 1;
  height: calc(var(--h) * 100%);
  border-radius: 2px 2px 0 0;
  /* A flat colour faded by a mask rather than a gradient, so the colour can
     be transitioned when the account is named. Green at rest — the end state
     — and periwinkle while script.js still has it down as a stranger. */
  background-color: var(--brand);
  -webkit-mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.25));
  mask-image: linear-gradient(180deg, #000, rgba(0, 0, 0, 0.25));
  opacity: 0.55;
  transform-origin: bottom;
  transition: background-color 0.8s ease;
}

.vg--research.is-stranger .vg-spark i {
  background-color: var(--uncharted);
}

/* The lookup: a scan passes down the slab and each row lands as it is read. */
.vg--research::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 2.5rem;
  background: linear-gradient(180deg, transparent, rgba(129, 140, 248, 0.14) 80%, rgba(129, 140, 248, 0.5));
  opacity: 0;
  pointer-events: none;
  z-index: -1;
}

.vg--research.is-armed .vg-row,
.vg--research.is-armed .vg-id {
  opacity: 0;
  translate: -6px 0;
}

.vg--research.is-armed .vg-spark i {
  scale: 1 0;
}

.vg--research.is-played .vg-id,
.vg--research.is-played .vg-row {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}

.vg--research.is-played .vg-row:nth-child(1) { transition-delay: 0.35s; }
.vg--research.is-played .vg-row:nth-child(2) { transition-delay: 0.55s; }
.vg--research.is-played .vg-row:nth-child(3) { transition-delay: 0.75s; }

.vg--research.is-played .vg-spark i {
  scale: 1 1;
  transition: scale 0.6s cubic-bezier(0.22, 1, 0.36, 1);
  transition-delay: calc(0.85s + var(--i, 0) * 0.04s);
}

.vg--research.is-played::after {
  animation: vg-scan 1.3s cubic-bezier(0.45, 0, 0.3, 1) 0.1s both;
}

@keyframes vg-scan {
  0% { opacity: 0; translate: 0 -2.5rem; }
  15% { opacity: 1; }
  85% { opacity: 1; }
  100% { opacity: 0; translate: 0 10.75rem; }
}

/* ---- Contribute: a name, typed in ---- */
.vg-label {
  color: var(--faint);
  margin-bottom: 0.55rem !important;
}

.vg-field {
  display: flex;
  align-items: center;
  min-height: 2.45rem;
  padding: 0 0.75rem;
  border-radius: 0.55rem;
  border: 1px solid rgba(64, 198, 192, 0.35);
  background: rgba(5, 6, 13, 0.55);
  box-shadow: 0 0 0 3px rgba(64, 198, 192, 0.07);
  font-family: var(--font-sans);
  font-size: 0.9rem;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
}

.vg-caret {
  flex: none;
  width: 1.5px;
  height: 1.1em;
  margin-left: 1px;
  background: var(--brand);
  animation: vg-blink 1.05s steps(1) infinite;
}

@keyframes vg-blink {
  50% { opacity: 0; }
}

.vg-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.85rem;
}

.vg-chip {
  padding: 0.18rem 0.55rem;
  border-radius: 999px;
  border: 1px solid rgba(205, 214, 244, 0.14);
  background: rgba(255, 255, 255, 0.03);
  color: var(--starlight);
  white-space: nowrap;
}

.vg-chip--org {
  border-color: rgba(0, 255, 137, 0.3);
  background: rgba(0, 255, 137, 0.06);
  color: var(--brand);
}

.vg--contribute.is-armed .vg-chip {
  opacity: 0;
  translate: 0 6px;
}

.vg--contribute.is-played .vg-chip {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}

.vg--contribute.is-played .vg-chip + .vg-chip {
  transition-delay: 0.15s;
}

/* ---- Curate: the vote, settling ---- */
.vg-votes {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.55rem;
}

.vg-votes li {
  display: grid;
  gap: 0.25rem;
}

.vg-votes span {
  color: var(--muted);
  font-family: var(--font-sans);
  font-size: 0.8rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.vg-votes b {
  display: block;
  height: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  position: relative;
  overflow: hidden;
}

.vg-votes b::after {
  content: '';
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(var(--v) * 100%);
  border-radius: inherit;
  background: var(--uncharted);
  opacity: 0.6;
}

.vg-votes .is-lead span {
  color: var(--ink);
}

.vg-votes .is-lead b::after {
  background: linear-gradient(90deg, #40c6c0, var(--brand));
  opacity: 1;
  box-shadow: 0 0 10px var(--brand-glow);
}

.vg-verdict {
  position: absolute;
  inset: auto 1rem 0.85rem;
  display: flex;
  align-items: center;
  gap: 0.45rem;
  color: var(--brand);
}

.vg-tick {
  display: grid;
  place-items: center;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  background: var(--brand);
  color: var(--canvas);
  font-size: 0.62rem;
  font-weight: 700;
}

.vg--curate.is-armed .vg-votes b::after {
  width: 0;
}

.vg--curate.is-armed .vg-verdict {
  opacity: 0;
  translate: 0 6px;
}

.vg--curate.is-played .vg-votes b::after {
  width: calc(var(--v) * 100%);
  transition: width 1.4s cubic-bezier(0.22, 1, 0.36, 1);
}

.vg--curate.is-played .vg-votes li:nth-child(2) b::after { transition-delay: 0.12s; }
.vg--curate.is-played .vg-votes li:nth-child(3) b::after { transition-delay: 0.24s; }

.vg--curate.is-played .vg-verdict {
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.4s ease 1.35s, translate 0.5s cubic-bezier(0.22, 1, 0.36, 1) 1.35s;
}

@media (prefers-reduced-motion: reduce) {
  .vg-caret {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Features
   -------------------------------------------------------------------------- */
.features {
  padding-block: var(--section-pad);
  position: relative;
  /* The aurora sits at z-index -1. Without a stacking context of its own here
     that would put it behind the page rather than behind this section. */
  isolation: isolate;
  /* CLIP, not hidden. `overflow: hidden` would make this a scroll container,
     and the sticky panel inside would then be sticky with respect to a box
     that never scrolls — which is to say, not sticky at all. `clip` trims the
     aurora's overhang without any of that. */
  overflow: clip;
}

/* The line that closes the section, the same one "What is Unstealth?" is ruled
   off with. Only at the bottom: the top of this section is where the aurora
   comes up out of nothing, and a rule across that would give the light an edge
   to start at rather than letting it arrive. */
.features::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 1px;
  pointer-events: none;
  background-image: var(--seam);
}

/* Aurora.
   --------------------------------------------------------------------------
   After Aceternity's Aurora Background. The mechanism is the thing worth
   copying, and it is unusual: one striped gradient laid over a mask of stripes
   at a slightly different scale, then the SAME pair again on top, drifting,
   and the two composited with `difference`. Where the two stripe patterns
   agree they cancel to black; where they disagree the colour survives. Because
   the top pair is moving, that agreement slides along the bands, so the light
   appears to move THROUGH itself instead of sliding past. A single animated
   gradient cannot do that at any price.

   Departures, all forced by where it is being used:
     - The palette is the brand green rather than the original's blues.
     - Opacity is 0.2, not the original's 0.5. This sits behind four screens of
       body copy; the original sits behind a headline.
     - The original masks to an ellipse at the top right, which suits the one
       screen it was written for. Over a section this tall that would be a
       single blob at the top and nothing after it, so the envelope here fades
       in and out at the section's ends and keeps to the right of the reading
       column instead. */
.aurora {
  --aurora: repeating-linear-gradient(
    100deg,
    #00ff89 10%,
    #5eead4 15%,
    #6bffbb 20%,
    #18bb38 25%,
    #00d977 30%
  );
  --aurora-stripes: repeating-linear-gradient(
    100deg,
    #000 0%,
    #000 7%,
    transparent 10%,
    transparent 12%,
    #000 16%
  );

  position: absolute;
  /* Overhangs by more than the blur radius so the blur has somewhere to come
     from and never falls off into transparency at the edges; `overflow: clip`
     on the section trims the overhang back. */
  inset: -60px;
  z-index: -1;
  pointer-events: none;

  background-image: var(--aurora-stripes), var(--aurora);
  background-size: 300%, 200%;
  background-position:
    50% 50%,
    50% 50%;

  /* Faint, as asked. */
  opacity: 0.15;
  /* Softens the stripe edges into bands. The original blurs by 10px, but it
     paints into a box one screen tall; this box is three times that, so the
     bands come out proportionally wider and 10px left them reading as stripes
     with edges. Blurred by enough to lose the edges at THIS size.

     This also makes the element a backdrop root, which is harmless here —
     nothing inside this section is glass, and the header and the dock are not
     descendants of it. */
  filter: blur(34px);
  will-change: transform;

  /* The envelope: no ends at the top and bottom of the section, and held well
     back across the reading column so the type stays crisp. Two masks, one per
     axis, intersected — one gradient cannot do both. */
  -webkit-mask-image:
    linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%),
    linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 34%, #000 78%, #000 100%);
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%),
    linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 34%, #000 78%, #000 100%);
  mask-composite: intersect;
}

.aurora::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: var(--aurora-stripes), var(--aurora);
  /* Different scale from the layer underneath — that mismatch is what stops
     the difference resolving to a flat nothing. */
  background-size: 200%, 100%;
  /* Anchored to the viewport rather than to this box, so scrolling past drags
     the bands across the section as well as the clock doing it. */
  background-attachment: fixed;
  mix-blend-mode: difference;
  animation: aurora 60s linear infinite;
}

/* One pass takes a minute. It is meant to be something you notice you have
   been watching, not something that asks to be watched. */
@keyframes aurora {
  from {
    background-position:
      50% 50%,
      50% 50%;
  }
  to {
    background-position:
      350% 50%,
      350% 50%;
  }
}

/* The horizontal half of the envelope assumes two columns — reading on the
   left, evidence on the right. Below 900px there is one column running the
   full width, so keeping the light to one side of it would only put the light
   behind half the type. It goes even, and quieter still to pay for it. */
@media (max-width: 900px) {
  .aurora {
    -webkit-mask-image: linear-gradient(
      180deg,
      transparent 0%,
      #000 8%,
      #000 92%,
      transparent 100%
    );
    -webkit-mask-composite: source-over;
    mask-image: linear-gradient(
      180deg,
      transparent 0%,
      #000 8%,
      #000 92%,
      transparent 100%
    );
    mask-composite: add;
    opacity: 0.1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .aurora::after {
    animation: none;
  }
}

/* Centred intro block. The bento is long gone; this centres the eyebrow and
   the heading above the columns, which are left-aligned. */
.features-intro {
  text-align: center;
}

.features-intro .section-title,
.features-intro .section-lead {
  margin-inline: auto;
}

/* Sticky scroll reveal.
   --------------------------------------------------------------------------
   Four capabilities down the left, one screenshot held at the right, swapping
   to whichever capability is being read. The Aceternity component scrolls an
   inner box of its own and reads that box's scroll progress; this scrolls with
   the page instead — the component's own source offers that as the alternative
   and it is the right one here, because a nested scroller on a landing page
   takes the wheel away from the page and, on a phone, is a trap.

   The default state in this file is the plain stacked one: every item at full
   strength with its screenshot beneath it. Everything below that needs a
   second column is behind `.is-ready`, which script.js only adds when there is
   room for one. With no script, and on a narrow screen, the stack is what you
   get and it is complete. */
.scroll-reveal {
  position: relative;
  margin-top: clamp(2.5rem, 5vw, 4rem);
}

.sr-item {
  padding-block: clamp(2rem, 5vw, 3.25rem);
}

.sr-item h3 {
  font-size: clamp(1.3rem, 2.2vw, 1.75rem);
  line-height: 1.2;
  margin: 0 0 0.9rem;
  max-width: 34ch;
}

.sr-item p {
  color: var(--muted);
  font-size: 1rem;
  margin: 0 0 0.9rem;
  max-width: 56ch;
}

.sr-item p:last-of-type {
  margin-bottom: 0;
}

.sr-figure {
  position: relative;
  margin: clamp(1.5rem, 3vw, 2rem) 0 0;
  /* The ring and the picture both take their curve from here, so there is one
     number to change and no chance of them disagreeing. */
  border-radius: var(--radius-card);
  /* Keeps the contact shadow's z-index: -1 behind the shot but in front of
     the section, rather than sinking under the section's own background. */
  isolation: isolate;
}

/* The contact shadow: a dark pool on the floor under the shot, narrower than it
   and pushed below its bottom edge, so the picture reads as lifted off the page
   rather than printed on it. A box-shadow alone cannot do this here — black on
   a near-black page is invisible except where something lighter sits behind
   it, and this pool is dense enough to darken even the aurora. */
.sr-figure::after {
  content: '';
  position: absolute;
  left: 8%;
  right: 8%;
  bottom: -7%;
  height: 16%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(0, 0, 0, 0.9), transparent);
  filter: blur(14px);
  pointer-events: none;
}

.sr-figure img {
  display: block;
  width: 100%;
  /* The width/height attributes are a presentational hint for `height`, and a
     used height of 900px would win outright. They stay on the tag to reserve
     the box before the file lands. */
  height: auto;
  border-radius: inherit;
  border: 1px solid var(--edge-strong);
  /* Layered, near to far: a tight edge that seats the frame, a mid drop, and a
     long soft throw. The last layer is light, not dark — on a page this close
     to black a shadow has nothing to darken, so the shot is lifted the way the
     rest of the page lifts its cards: brand light spilling out beneath it,
     which the black layers and the contact pool then have something to cut. */
  box-shadow:
    0 2px 4px rgba(0, 0, 0, 0.5),
    0 14px 28px -10px rgba(0, 0, 0, 0.75),
    0 48px 90px -30px rgba(0, 0, 0, 0.95),
    0 70px 140px -46px rgba(0, 255, 137, 0.34);
}

/* Glowing edge.
   --------------------------------------------------------------------------
   After Aceternity's Glowing Effect. Not a glow around the whole frame — an
   ARC of light on the border, pointing at the cursor, which chases it round
   the edge as you move. Everything here is the ring; the angle is the only
   thing script.js writes.

   The ring: a box one hairline larger than the picture, filled with the arc
   and then masked so only its own padding survives — content-box excluded
   from border-box leaves exactly the frame and nothing inside it. */
.sr-glow {
  /* Written by script.js. --start is the angle the arc is centred on, in
     degrees, and is deliberately NOT wrapped to 0-360: leaving it to run on
     past 360 is what makes the tween below take the short way round instead of
     unwinding the whole circle. */
  --start: 0;
  --active: 0;
  /* Half the lit arc. The component's own demos use 40, so the arc is 80deg —
     a bit under a quarter of the frame. */
  --spread: 40;
  --ring: 2px;

  position: absolute;
  inset: calc(var(--ring) * -1);
  border-radius: calc(var(--radius-card) + var(--ring));
  padding: var(--ring);
  pointer-events: none;
  z-index: 1;

  background: conic-gradient(
    from calc((var(--start) - var(--spread)) * 1deg),
    transparent 0deg,
    rgba(107, 255, 187, 0.65) calc(var(--spread) * 0.6deg),
    #00ff89 calc(var(--spread) * 1deg),
    rgba(107, 255, 187, 0.65) calc(var(--spread) * 1.4deg),
    transparent calc(var(--spread) * 2deg)
  );
  /* The arc lights the frame and spills a little past it. drop-shadow rather
     than box-shadow because the ring has been masked down to a thin arc, and
     only drop-shadow follows the shape that is actually painted — box-shadow
     would trace the element's rectangle and put a glow all the way round. */
  filter: drop-shadow(0 0 10px rgba(0, 255, 137, 0.55));

  opacity: var(--active);
  transition:
    opacity 0.3s ease,
    --start 2s cubic-bezier(0.16, 1, 0.3, 1);

  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  mask-composite: exclude;
}

/* A plain custom property cannot be transitioned — the browser has no idea
   what is between "0" and "137" unless it is told the syntax. Registering it
   is what lets the 2s ease above interpolate the angle, which is the whole
   reason there is no animation loop in the script. */
@property --start {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

@media (prefers-reduced-motion: reduce) {
  .sr-glow {
    transition: opacity 0.3s ease;
  }
}

/* ---- Enhanced: two columns, one of them pinned ---- */
.scroll-reveal.is-ready {
  /* One step is how tall an item is AND how tall the panel beside it is. The
     two have to be the same number or they cannot be centred on each other —
     see the stage's `top` below. */
  --sr-step: 64vh;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.08fr);
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: start;
}

.scroll-reveal.is-ready .sr-item {
  /* Each capability gets most of a screen to itself, so that scrolling through
     them is paced rather than flicking past four paragraphs. Centred in that
     space, which is what puts the one you are reading opposite the panel. */
  min-height: var(--sr-step);
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Held back rather than hidden: you can still see what is coming, which is
     what makes the section feel like one piece of writing instead of four
     slides. Aceternity uses 0.3; this palette needs a touch more to stay
     legible on the dark canvas. */
  opacity: 0.34;
  transition: opacity 0.45s ease;
}

.scroll-reveal.is-ready .sr-item.is-active {
  opacity: 1;
}

.sr-stage {
  position: sticky;
  /* The stage is one step tall, the same as an item, and it is inset by half
     of whatever the reading band has spare. That puts its middle on the middle
     of the band once it sticks — which is where script.js looks for the active
     item — and, crucially, it puts its middle on the FIRST item's middle
     before it sticks, because the two boxes are now the same height and start
     at the same line. Make the stage a full band tall instead and the panel
     sits a third of a screen below the first paragraph until the section has
     scrolled far enough to pin it.

     max() keeps it clear of the header on a screen too short to inset it. */
  top: max(
    var(--header-h),
    calc(var(--header-h) + (100svh - var(--header-h) - var(--sr-step)) / 2)
  );
  height: var(--sr-step);
  display: grid;
  align-content: center;
}

.is-ready .sr-figure {
  /* All four in one cell, so they cross-fade in place. The shots are not all
     the same shape, so the cell is as tall as the tallest and each one is
     centred in it — top-aligned, the shorter ones would appear to jump up as
     they arrived. */
  grid-area: 1 / 1;
  align-self: center;
  position: relative;
  isolation: isolate;
  margin: 0;
  opacity: 0;
  scale: 0.985;
  pointer-events: none;
  transition:
    opacity 0.5s ease,
    scale 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}

/* The glow belongs to the figure, not the stage: it has to fade in and out
   with the shot it sits behind, and the stage is now a full screen tall.

   The component cycles a different gradient per card; that was left out on
   purpose — a blurred wash cannot cross-fade between two colours in CSS, so
   it would have snapped, and this section already has a thing that changes. */
.is-ready .sr-figure::before {
  content: '';
  position: absolute;
  inset: -14% -8%;
  z-index: -1;
  border-radius: 50%;
  filter: blur(60px);
  /* Backing light only, to lift the shot off the aurora. The frame carries
     light of its own now, so this stays well under it. */
  opacity: 0.18;
  background: radial-gradient(circle, var(--brand-glow), transparent 70%);
}

.is-ready .sr-figure.is-active {
  opacity: 1;
  scale: 1;
}

@media (prefers-reduced-motion: reduce) {
  .scroll-reveal.is-ready .sr-item,
  .is-ready .sr-figure {
    transition: none;
  }

  .is-ready .sr-figure {
    scale: 1;
  }
}

/* On a narrow screen, with the shots back in their items: the picture comes
   straight after the heading, ahead of the paragraphs. The first capability
   runs to three of them, and with the shot at the end it was a screenful of
   grey text before anything to look at. */
.scroll-reveal:not(.is-ready) .sr-item {
  display: flex;
  flex-direction: column;
}

.scroll-reveal:not(.is-ready) .sr-item h3 {
  order: -2;
}

.scroll-reveal:not(.is-ready) .sr-figure {
  order: -1;
  margin: 0.4rem 0 1.6rem;
}

/* --------------------------------------------------------------------------
   Roadmap
   --------------------------------------------------------------------------
   After Aceternity's Timeline. Each date sticks while its milestone is read;
   a hairline track runs down the left, and a beam grows down it with the
   scroll, its tip held at the middle of the screen. script.js writes the
   beam's length (--tl-beam) and marks each milestone .is-reached as the beam
   gets to its dot.

   --tl-x is the track's centre, measured from the list's left edge. The dots
   are centred on it and the beam is drawn on it, so it is the one number that
   keeps the three in line. */
.roadmap {
  padding-block: var(--section-pad);
}

.timeline {
  --tl-x: 1.25rem;
  --tl-dot: 2.5rem;
  /* Where a date sticks while its milestone is read. */
  --tl-stick: calc(var(--header-h-stuck) + 5rem);
  position: relative;
  margin-top: 1rem;
}

.tl-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.tl-item {
  display: flex;
  gap: 2.5rem;
  padding-top: clamp(2.5rem, 9vw, 8rem);
}

.tl-marker {
  position: sticky;
  top: var(--tl-stick);
  z-index: 2;
  align-self: flex-start;
  display: flex;
  align-items: center;
  flex: 0 0 min(22rem, 38%);
  min-height: var(--tl-dot);
}

/* A disc of page colour, so the track appears to pass BEHIND the dot rather
   than through it, with the marker itself inside. */
.tl-dot {
  position: absolute;
  left: calc(var(--tl-x) - var(--tl-dot) / 2);
  top: 50%;
  translate: 0 -50%;
  width: var(--tl-dot);
  height: var(--tl-dot);
  border-radius: 50%;
  background: var(--canvas);
  display: grid;
  place-items: center;
}

.tl-dot::before {
  content: '';
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--edge-strong);
  transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}

.tl-date {
  margin: 0;
  padding-left: calc(var(--tl-x) + var(--tl-dot) / 2 + 1.75rem);
  font-family: var(--font-display);
  font-size: clamp(2rem, 4.2vw, 3.25rem);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.01em;
  color: var(--faint);
  transition: color 0.4s ease;
}

.tl-body {
  flex: 1;
  min-width: 0;
  max-width: 38rem;
  /* Level the heading with the date's cap height rather than its box top. */
  padding-top: 0.35rem;
}

.tl-body h4 {
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 2vw, 1.5rem);
  margin: 0 0 0.6rem;
}

.tl-body p {
  color: var(--muted);
  font-size: 1rem;
  margin: 0;
}

.tl-link {
  display: inline-block;
  margin-top: 1rem;
  color: var(--brand);
  font-weight: 500;
  text-decoration: none;
}

.tl-link:hover,
.tl-link:focus-visible {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Reached: the beam has got to this milestone. */
.tl-item.is-reached .tl-dot::before {
  background: var(--brand);
  border-color: var(--brand);
  box-shadow: 0 0 14px 2px var(--brand-glow);
}

.tl-item.is-reached .tl-date {
  color: var(--ink);
}

/* The track: faded at both ends, as the component's is. */
.tl-track {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--tl-x) - 1px);
  width: 2px;
  overflow: hidden;
  background: linear-gradient(
    to bottom,
    transparent,
    var(--edge-strong) 8%,
    var(--edge-strong) 92%,
    transparent
  );
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 10%, #000 90%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 10%, #000 90%, transparent 100%);
}

/* The beam: brightest at its leading tip, trailing off to nothing behind it. */
.tl-beam {
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: var(--tl-beam, 0px);
  opacity: var(--tl-beam-opacity, 0);
  border-radius: 999px;
  background: linear-gradient(to top, var(--brand) 0%, var(--uncharted) 12%, transparent 100%);
}

/* The glyph strand. One mono glyph per line, centred on the track, in two
   copies laid exactly over each other: a dim one the full height, and a lit
   one cut off where the beam has reached. They hold the same characters —
   script.js writes both — so the lit copy covers the dim one glyph for glyph.
   Faded at the ends by the same mask as the track it replaces. */
.tl-glyphs {
  --tl-glyph-lh: 15px;
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--tl-x) - 1ch);
  width: 2ch;
  display: none;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: var(--tl-glyph-lh);
  text-align: center;
  white-space: pre;
  overflow: hidden;
  pointer-events: none;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 7%, #000 93%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 7%, #000 93%, transparent 100%);
}

.timeline.has-glyphs .tl-glyphs {
  display: block;
}

.timeline.has-glyphs .tl-track {
  display: none;
}

.tl-glyphs-dim,
.tl-glyphs-lit {
  position: absolute;
  inset: 0 0 auto;
}

.tl-glyphs-dim {
  color: rgba(129, 140, 248, 0.2);
}

/* Brightest at the tip, trailing off behind it, as the beam did. */
.tl-glyphs-lit {
  height: var(--tl-beam, 0px);
  overflow: hidden;
  color: var(--brand);
  opacity: var(--tl-beam-opacity, 0);
  text-shadow: 0 0 8px rgba(0, 255, 137, 0.45);
  -webkit-mask-image: linear-gradient(to top, #000 0, #000 120px, rgba(0, 0, 0, 0.3) 70%);
  mask-image: linear-gradient(to top, #000 0, #000 120px, rgba(0, 0, 0, 0.3) 70%);
}

/* The glyph just struck: a near-white head, like the rain's. */
.tl-glyphs-tip {
  position: absolute;
  left: 50%;
  top: var(--tl-beam, 0px);
  width: 10px;
  height: 10px;
  translate: -50% -100%;
  border-radius: 50%;
  background: rgba(206, 255, 228, 0.9);
  box-shadow: 0 0 12px 4px var(--brand-glow), 0 0 30px 8px rgba(0, 255, 137, 0.18);
  opacity: var(--tl-beam-opacity, 0);
  filter: blur(1px);
}

/* --------------------------------------------------------------------------
   FAQ
   --------------------------------------------------------------------------
   After Aceternity's "FAQs with dashed lines". Closed, a question is a bare
   row. Open, it becomes a framed card, and dashed construction lines run
   along its four edges and out past its corners, fading as they go — as if
   the card had been ruled onto the page.

   --faq-reach is how far those lines run past the card. It is capped by the
   gutter so that on a phone they cannot push the page sideways. */
.faq {
  /* Its background, seams and lattice are shared with .showcase, above. */
  overflow-x: clip;
}

.faq-groups {
  --faq-reach: min(3rem, calc(var(--gutter) - 4px));
  --faq-dash: rgba(205, 214, 244, 0.22);
  max-width: 52rem;
  margin-top: clamp(2.5rem, 5vw, 3.5rem);
  display: grid;
  gap: clamp(2.5rem, 5vw, 3.5rem);
}

/* Larger and heavier than the questions under it, with a hairline running on
   to the right edge, so each group reads as its own chapter of the list. */
.faq-group-title {
  display: flex;
  align-items: center;
  gap: 1rem;
  font-family: var(--font-display);
  font-size: clamp(1.3rem, 2.2vw, 1.6rem);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin: 0 0 1rem;
}

.faq-group-title::after {
  content: '';
  flex: 1;
  height: 1px;
  background: linear-gradient(to right, var(--edge-strong), transparent);
}

.faq-item {
  position: relative;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  transition: background 0.25s ease, border-color 0.25s ease;
}

.faq-item + .faq-item {
  margin-top: 0.25rem;
}

.faq-item[open] {
  background: var(--panel);
  border-color: var(--edge-strong);
  /* Between the frames above and below, which also carry lines. */
  margin-block: 1rem;
}

.faq-item summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  padding: 1rem 1.1rem;
  cursor: pointer;
  list-style: none;
  font-size: 1.02rem;
  font-weight: 500;
  color: var(--ink);
  border-radius: inherit;
}

.faq-item summary::-webkit-details-marker {
  display: none;
}

.faq-item summary:hover {
  color: #fff;
}

.faq-item summary:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The plus, drawn rather than typed so it can turn into a cross: two bars,
   the whole mark rotated an eighth of a turn when open. */
.faq-item summary::after {
  content: '';
  flex: none;
  width: 0.85rem;
  height: 0.85rem;
  background:
    linear-gradient(currentColor, currentColor) center / 100% 1.5px no-repeat,
    linear-gradient(currentColor, currentColor) center / 1.5px 100% no-repeat;
  color: var(--muted);
  transition: rotate 0.3s cubic-bezier(0.22, 1, 0.36, 1), color 0.25s ease;
}

.faq-item[open] summary::after {
  rotate: 45deg;
  color: var(--brand);
}

.faq-answer {
  padding: 0 1.1rem 1.15rem;
}

.faq-answer p {
  margin: 0;
  max-width: 44rem;
  color: var(--muted);
  font-size: 0.97rem;
}

/* The construction lines. ::before carries the two horizontals, stretched
   past the card's sides; ::after the two verticals, stretched past its top
   and bottom. Each line is a 1px strip of 2.5px dashes, faded at both ends
   by a mask. */
.faq-item::before,
.faq-item::after {
  content: '';
  position: absolute;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.faq-item::before {
  inset: -1px calc(var(--faq-reach) * -1);
  background:
    linear-gradient(to right, var(--faq-dash) 50%, transparent 0) top left / 5px 1px repeat-x,
    linear-gradient(to right, var(--faq-dash) 50%, transparent 0) bottom left / 5px 1px repeat-x;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--faq-reach), #000 calc(100% - var(--faq-reach)), transparent);
  mask-image: linear-gradient(to right, transparent, #000 var(--faq-reach), #000 calc(100% - var(--faq-reach)), transparent);
}

.faq-item::after {
  inset: calc(var(--faq-reach) * -1) -1px;
  background:
    linear-gradient(to bottom, var(--faq-dash) 50%, transparent 0) top left / 1px 5px repeat-y,
    linear-gradient(to bottom, var(--faq-dash) 50%, transparent 0) top right / 1px 5px repeat-y;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--faq-reach), #000 calc(100% - var(--faq-reach)), transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 var(--faq-reach), #000 calc(100% - var(--faq-reach)), transparent);
}

.faq-item[open]::before,
.faq-item[open]::after {
  opacity: 1;
}

/* Open and close with a height tween where the browser can animate to and
   from `auto`; everywhere else the answer simply appears, which is fine. */
@supports (interpolate-size: allow-keywords) {
  .faq-item {
    interpolate-size: allow-keywords;
  }

  .faq-item::details-content {
    height: 0;
    overflow: clip;
    transition: height 0.35s cubic-bezier(0.22, 1, 0.36, 1), content-visibility 0.35s allow-discrete;
  }

  .faq-item[open]::details-content {
    height: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .faq-item,
  .faq-item::before,
  .faq-item::after,
  .faq-item summary::after,
  .faq-item::details-content {
    transition: none;
  }
}

/* --------------------------------------------------------------------------
   Beta signup
   -------------------------------------------------------------------------- */
.beta {
  position: relative;
  isolation: isolate;
  padding-block: var(--section-pad);
}

/* The page ends where it began: in the city. The same plate as the hero,
   figure-free, but framed on what the hero's crop has to leave out — the moon
   and the far towers — and graded down hard so the card reads first. Faded in
   from nothing at the top and back out before the footer, so it is a place
   you arrive at rather than a picture with edges. Already in the cache from
   the hero, so it costs no second download. */
.beta::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: url('images/unstealth-promo-bg.jpg') 70% 12% / cover no-repeat;
  filter: saturate(0.7) brightness(0.5) hue-rotate(-14deg) contrast(1.05);
  opacity: 0.75;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
  pointer-events: none;
}

/* Glass over the city rather than a slab in front of it. */
.beta .beta-inner {
  background:
    radial-gradient(60% 80% at 80% 0%, rgba(129, 140, 248, 0.1), transparent 70%),
    radial-gradient(50% 70% at 10% 100%, rgba(0, 255, 137, 0.07), transparent 70%),
    rgba(8, 10, 19, 0.58);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  backdrop-filter: blur(14px) saturate(140%);
  border-color: rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 40px 90px -40px rgba(0, 0, 0, 0.9);
}

/* The card is a .wrap, so below the wrap's own width it ran edge to edge and
   read as a band across the page rather than a panel over the city. Held off
   the sides by the page gutter instead; on a phone by a little less, with
   less padding inside, so the copy keeps its width. */
.beta .beta-inner {
  width: calc(100% - 2 * var(--gutter));
}

@media (max-width: 560px) {
  .beta .beta-inner {
    width: calc(100% - 32px);
    padding: 2.25rem 1.4rem;
  }
}

/* A phone's section is tall and narrow, so the plate's cover crop lands on
   the middle of the city; pull it to where the moon is. */
@media (max-width: 640px) {
  .beta::before {
    background-position: 84% 0%;
    background-size: auto 115%;
  }
}

.beta-inner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: start;
  background:
    radial-gradient(60% 80% at 80% 0%, rgba(129, 140, 248, 0.08), transparent 70%),
    radial-gradient(50% 70% at 10% 100%, rgba(0, 255, 137, 0.06), transparent 70%),
    var(--panel-2);
  border: 1px solid var(--edge);
  border-radius: calc(var(--radius-card) * 1.4);
  padding: clamp(2rem, 5vw, 3.5rem);
}

.beta-perks {
  list-style: none;
  margin: 1.75rem 0 0;
  padding: 0;
  display: grid;
  gap: 0.85rem;
}

.beta-perks li {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  color: var(--ink);
  font-size: 0.98rem;
}

.check {
  color: var(--brand);
  font-weight: 700;
  flex: none;
}

/* Form card */
.beta-form-card {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--edge-strong);
  border-radius: var(--radius-card);
  padding: clamp(1.5rem, 3vw, 2.25rem);
  box-shadow: 0 30px 80px -40px rgba(0, 0, 0, 0.9);
}

.form-title {
  font-size: 1.35rem;
  margin-bottom: 0.4rem;
}

.form-sub {
  color: var(--muted);
  font-size: 0.92rem;
  margin: 0 0 1.5rem;
}

.field {
  margin-bottom: 1.1rem;
}

.field label {
  display: block;
  font-size: 0.85rem;
  color: var(--starlight);
  margin-bottom: 0.4rem;
  font-weight: 500;
}

.field .opt {
  color: var(--faint);
  font-weight: 400;
}

.field input {
  width: 100%;
  padding: 0.8rem 0.9rem;
  background: var(--panel-2);
  border: 1px solid var(--edge-strong);
  border-radius: var(--radius-sm);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 0.98rem;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.field input::placeholder {
  color: var(--faint);
}

.field input:focus {
  outline: none;
  border-color: var(--brand);
  box-shadow: 0 0 0 3px rgba(0, 255, 137, 0.12);
}

.field input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: #ff6b6b;
}

.field-error {
  color: #ff8585;
  font-size: 0.82rem;
  margin: 0.4rem 0 0;
}

.form-fineprint {
  font-size: 0.78rem;
  color: var(--faint);
  margin: 1rem 0 0;
  line-height: 1.5;
}

/* Honeypot + hidden iframe: kept out of view but reachable by bots/browser */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* Once sent, the confirmation below replaces the card's own heading. */
.beta-form-card.is-sent .form-title,
.beta-form-card.is-sent .form-sub {
  display: none;
}

/* Success state */
.form-success {
  text-align: center;
  padding: 1rem 0;
}

.success-mark {
  width: 56px;
  height: 56px;
  margin: 0 auto 1rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--brand-dim);
  color: var(--brand);
  font-size: 1.6rem;
  box-shadow: 0 0 0 1px rgba(0, 255, 137, 0.4), 0 0 30px -6px var(--brand-glow);
}

.form-success h3 {
  font-size: 1.4rem;
  margin-bottom: 0.5rem;
}

.form-success p {
  color: var(--muted);
  margin: 0;
}

.form-submit-error {
  color: #ff8585;
  font-size: 0.9rem;
  text-align: center;
  margin-top: 1rem;
}

/* --------------------------------------------------------------------------
   Signup modal
   --------------------------------------------------------------------------
   script.js moves the form card in here on load. Everything below is scoped
   to that having happened (.beta-modal-ready on <html>), so a page where the
   script never runs keeps the inline form and none of this applies.
   -------------------------------------------------------------------------- */

/* With the form lifted out, the pitch is no longer one half of a two-column
   panel — it becomes a single centred column ending in the button that opens
   the dialog. */
.beta-modal-ready .beta-inner {
  grid-template-columns: 1fr;
  max-width: 48rem;
  margin-inline: auto;
  justify-items: center;
  text-align: center;
}

.beta-modal-ready .beta-perks {
  width: fit-content;
  margin-inline: auto;
  text-align: left;
}

.beta-open-cta {
  margin-top: 2.1rem;
  padding: 0.95rem 1.8rem;
  font-size: 1.02rem;
}

/* The hero deck is driven by scroll position; it should not be playing behind
   an open form. Locking both elements covers either scrolling box. */
.beta-modal-open,
.beta-modal-open body {
  overflow: hidden;
}

.beta-modal {
  width: min(34rem, calc(100vw - 2rem));
  max-width: none;
  max-height: none;
  margin: auto;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  overflow: visible;
}

.beta-modal::backdrop {
  background: rgba(4, 6, 14, 0.72);
  backdrop-filter: blur(10px) saturate(115%);
}

.beta-modal[open] {
  animation: modal-rise 0.3s cubic-bezier(0.32, 0.08, 0.24, 1) both;
}

.beta-modal[open]::backdrop {
  animation: modal-fade 0.3s ease both;
}

@keyframes modal-rise {
  from {
    opacity: 0;
    transform: translateY(16px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes modal-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Only the panel scrolls, so a long form on a short screen stays reachable
   without the page behind it moving. */
.beta-modal-panel {
  max-height: calc(100dvh - 2.5rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: calc(var(--radius-card) * 1.1);
}

.beta-modal .beta-form-card {
  border-radius: calc(var(--radius-card) * 1.1);
  padding: clamp(1.6rem, 4vw, 2.4rem);
  background:
    radial-gradient(70% 60% at 100% 0%, rgba(129, 140, 248, 0.1), transparent 70%),
    radial-gradient(60% 60% at 0% 100%, rgba(0, 255, 137, 0.07), transparent 70%),
    var(--panel);
  box-shadow:
    0 40px 100px -30px rgba(0, 0, 0, 0.95),
    0 0 0 1px rgba(255, 255, 255, 0.05);
}

/* Absolute against the dialog itself (modal dialogs are position: fixed), not
   against the scrolling panel — so it cannot scroll out of reach. */
.beta-modal-close {
  position: absolute;
  top: 0.8rem;
  right: 0.8rem;
  z-index: 2;
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--edge);
  background: rgba(255, 255, 255, 0.04);
  color: var(--muted);
  font-size: 0.82rem;
  line-height: 1;
  cursor: pointer;
  transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}

.beta-modal-close:hover {
  color: var(--ink);
  background: rgba(255, 255, 255, 0.09);
  border-color: var(--edge-strong);
}

.beta-modal-close:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The card carries its own title; the close button sits over where a heading's
   right edge would be, so keep the text out from under it. */
.beta-modal .form-title {
  padding-right: 2.5rem;
}

/* --------------------------------------------------------------------------
   Floating dock
   --------------------------------------------------------------------------
   After Aceternity's Floating Dock, in this page's own materials: the same
   glass as the header, the same brand green on the one item that is an
   action rather than a link.

   The swell is the whole idea, so it is the only thing here allowed to be
   loud. Everything else — the pane, the resting icons, the labels — stays
   quiet so that it reads.

   Sizes are written inline by script.js, which is why the resting values live
   here as the base: with no script the dock is simply five 40px buttons, and
   every one of them still works.
   -------------------------------------------------------------------------- */
.dock {
  position: fixed;
  left: 50%;
  bottom: clamp(1rem, 3vh, 1.75rem);
  translate: -50% 0;
  /* Over the header's glass, under the signup dialog, which is in the top
     layer and out of this ordering entirely. */
  z-index: 60;
}

/* Out of the way until the hero deck has finished playing — the deck keeps
   its own dots at the bottom of the screen, and two floating controls in the
   same place would be one too many. Applied by script.js, so with no script
   the dock is simply always there. */
.dock--auto {
  opacity: 0;
  visibility: hidden;
  translate: -50% 14px;
  transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1),
    visibility 0s linear 0.35s;
}

.dock--auto.is-visible {
  opacity: 1;
  visibility: visible;
  translate: -50% 0;
  transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1), visibility 0s;
}

/* Items sit on the floor of the pane and grow upward out of it, which is what
   makes the swell read as a dock rather than as buttons changing size. */
.dock-bar {
  display: flex;
  align-items: flex-end;
  gap: 0.85rem;
  height: 64px;
  padding: 0 1rem 0.7rem;
  border-radius: 1.15rem;
  border: 1px solid rgba(255, 255, 255, 0.08);
  -webkit-backdrop-filter: blur(18px) saturate(170%);
  backdrop-filter: blur(18px) saturate(170%);
  background:
    linear-gradient(
      180deg,
      rgba(255, 255, 255, 0.07),
      rgba(255, 255, 255, 0.018) 45%,
      rgba(255, 255, 255, 0) 100%
    ),
    linear-gradient(180deg, rgba(6, 8, 16, 0.62), rgba(6, 8, 16, 0.42));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 24px 54px -28px rgba(0, 0, 0, 0.95);
}

.dock-item {
  position: relative;
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  display: grid;
  place-items: center;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.09);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.022));
  color: var(--muted);
  cursor: pointer;
  transition: width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
    height 0.28s cubic-bezier(0.22, 1, 0.36, 1), color 0.2s ease, border-color 0.2s ease;
}

.dock-item:hover,
.dock-item:focus-visible {
  color: var(--ink);
  border-color: rgba(255, 255, 255, 0.18);
}

.dock-item:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 3px;
}

.dock-icon {
  display: block;
  width: 20px;
  height: 20px;
  transition: width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
    height 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}

.dock-icon svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The one item that does something rather than goes somewhere. */
.dock-item--join {
  color: #04210f;
  border-color: rgba(0, 255, 137, 0.45);
  background: linear-gradient(160deg, #6bffbb 0%, var(--brand) 55%, #00d977 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 0 24px -8px var(--brand-glow);
}

.dock-item--join:hover,
.dock-item--join:focus-visible {
  color: #04210f;
  border-color: rgba(0, 255, 137, 0.75);
}

.dock-label {
  position: absolute;
  bottom: calc(100% + 0.7rem);
  left: 50%;
  translate: -50% 5px;
  padding: 0.34rem 0.6rem;
  border-radius: 0.5rem;
  border: 1px solid var(--edge-strong);
  background: rgba(10, 12, 21, 0.94);
  color: var(--ink);
  font-size: 0.78rem;
  line-height: 1;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, translate 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}

.dock-item:hover .dock-label,
.dock-item:focus-visible .dock-label {
  opacity: 1;
  translate: -50% 0;
}

/* No pointer to follow, so no swell: even sizes, touch-sized, and the labels
   go with it — there is nothing to hover. script.js leaves the inline sizes
   off below this width, so nothing here has to fight them. */
@media (max-width: 720px) {
  .dock-bar {
    height: auto;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.65rem;
    border-radius: 999px;
  }

  .dock-item {
    width: 44px;
    height: 44px;
  }

  .dock-label {
    display: none;
  }
}

/* --------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------- */
.site-footer {
  border-top: 1px solid var(--edge);
  padding-block: 2.75rem;
  margin-top: clamp(2rem, 5vw, 4rem);
}

.footer-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 0.6rem;
}

.footer-logo {
  height: 22px;
  opacity: 0.9;
}

.footer-tag {
  color: var(--muted);
  font-size: 0.95rem;
  margin: 0.3rem 0 0;
}

/* The tweet's source, as a footnote: small and quiet, and the only outbound
   link in the page's body copy. --violet-light for the link, which clears AA
   comfortably at this size where solid --violet does not. */
.footer-note {
  max-width: 40rem;
  margin: 0.4rem 0 0;
  color: var(--faint);
  font-size: 0.8rem;
  line-height: 1.6;
}

.footer-note sup {
  color: var(--violet-light);
}

.footer-note a {
  color: var(--violet-light);
  text-decoration: underline;
  text-decoration-color: rgba(196, 181, 253, 0.35);
  text-underline-offset: 2px;
  transition: text-decoration-color 0.15s ease;
}

.footer-note a:hover,
.footer-note a:focus-visible {
  text-decoration-color: currentColor;
}

.footer-legal {
  color: var(--faint);
  font-size: 0.8rem;
  margin: 0;
}

/* --------------------------------------------------------------------------
   Responsive
   -------------------------------------------------------------------------- */
@media (max-width: 860px) {
  .beta-inner {
    grid-template-columns: 1fr;
  }

  /* One column: the date sits above its milestone instead of beside it, and
     nothing sticks — on a phone a stuck date would take a third of the screen
     from the words it labels. The dot stays on the track, level with the date. */
  .timeline {
    --tl-dot: 2rem;
    --tl-x: 1rem;
  }

  .tl-item {
    display: block;
    padding-top: 2.75rem;
    padding-left: calc(var(--tl-x) + var(--tl-dot) / 2 + 1rem);
  }

  .tl-marker {
    position: relative;
    top: auto;
    margin-bottom: 0.75rem;
  }

  /* The marker now starts at the item's padding, so step back across it to
     the track: that padding less --tl-x, plus half the dot. */
  .tl-dot {
    left: calc(-1 * (var(--tl-dot) + 1rem));
  }

  .tl-date {
    padding-left: 0;
    font-size: 1.75rem;
  }

  .tl-body {
    padding-top: 0;
  }
}

/* Tighten the hero fan on small screens so all three cards stay on-stage */
@media (max-width: 640px) {
  .hero-fan {
    height: clamp(220px, 56vw, 340px);
  }

  .fan-card {
    width: 78%;
  }

  .fan-card--left {
    transform: translateX(-34%) translateY(6%) rotate(-8deg) scale(0.86);
  }
  .fan-card--right {
    transform: translateX(34%) translateY(6%) rotate(8deg) scale(0.86);
  }
  .fan-card--front {
    transform: translateY(-2%) rotate(0deg);
  }
}

/* Four section links, the logo at its full top-of-page size and the button
   need about 785px side by side, gutters included; below that the links go
   and the bar keeps the logo and the button. The whole group goes, not the
   links inside it — otherwise the empty pane stays behind. */
@media (max-width: 820px) {
  .nav-links {
    display: none;
  }

  .nav-menu:not([hidden]) {
    display: block;
  }
}

/* ---- The section menu on a phone ----
   The dropdown hanging off the pill was a desktop menu squeezed onto a small
   screen. On a phone it becomes the page's own moment instead: the page
   behind dims and blurs, and the four sections are set large in the display
   face, centred in a pane of the header's glass that spans the screen. The
   rows arrive one after another, so opening it reads as the menu being dealt
   out rather than a box appearing. */
@media (max-width: 560px) {
  .nav-menu-panel {
    position: fixed;
    top: calc(var(--bar-h) + 14px);
    left: 16px;
    right: 16px;
    width: auto;
    max-width: 26rem;
    margin-inline: auto;
    min-width: 0;
    gap: 0.25rem;
    padding: 0.9rem 0.75rem;
    border-radius: 1.5rem;
    border-color: rgba(255, 255, 255, 0.1);
    background:
      radial-gradient(90% 60% at 50% 0%, rgba(0, 255, 137, 0.09), transparent 70%),
      radial-gradient(80% 60% at 50% 100%, rgba(129, 140, 248, 0.1), transparent 70%),
      linear-gradient(180deg, rgba(18, 22, 38, 0.9), rgba(8, 10, 19, 0.94));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 40px 80px -30px rgba(0, 0, 0, 0.95);
    transform-origin: top center;
    animation-duration: 0.3s;
  }

  .nav-menu-panel a {
    justify-content: center;
    gap: 0.7rem;
    min-height: 64px;
    border-radius: 1rem;
    color: var(--ink);
    font: 500 1.6rem/1.1 var(--font-display);
    letter-spacing: -0.01em;
    animation: menu-row 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
  }

  .nav-menu-panel a:nth-child(2) { animation-delay: 0.04s; }
  .nav-menu-panel a:nth-child(3) { animation-delay: 0.08s; }
  .nav-menu-panel a:nth-child(4) { animation-delay: 0.12s; }

  /* The current section: brand green, with its dot leading rather than
     trailing, so the centred word stays centred on its own. */
  .nav-menu-panel a.is-current {
    color: var(--brand);
    text-shadow: 0 0 18px var(--brand-glow);
  }

  /* The pane's top edge catches the light in the page's seam gradient, the
     same edge the header is drawn with. */
  .nav-menu-panel::before {
    content: '';
    position: absolute;
    top: 0;
    left: 12%;
    right: 12%;
    height: 1px;
    background: linear-gradient(
      90deg,
      transparent,
      rgba(0, 255, 137, 0.5) 30%,
      rgba(129, 140, 248, 0.55) 70%,
      transparent
    );
  }

  .nav-menu-panel a.is-current::after {
    order: -1;
  }

  /* Everything behind the menu steps back. The header (z-index 50) stays
     above it; a tap on it lands on the page and closes the menu. */
  .nav-menu-open body::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 40;
    background: rgba(3, 5, 11, 0.55);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    animation: menu-scrim 0.3s ease both;
  }
}

@keyframes menu-row {
  from {
    opacity: 0;
    translate: 0 10px;
  }
}

@keyframes menu-scrim {
  from {
    opacity: 0;
  }
}

@media (max-width: 560px) and (prefers-reduced-motion: reduce) {
  .nav-menu-panel,
  .nav-menu-panel a,
  .nav-menu-open body::after {
    animation: none;
  }
}

/* Logo, pill and button side by side on a phone: about 330px at these sizes,
   which is what a 375px screen has once the header's own gutter is 16px. The
   logo gives up the most, because it is the only one of the three that is
   not something to tap. */
@media (max-width: 560px) {
  /* A thinner bar on a phone: 84px was a tenth of the screen before anything
     had been read. The tokens rather than the bar alone, so the hero's top
     padding, anchor offsets and script.js all follow it. */
  :root {
    --header-h: 64px;
    --header-h-stuck: 56px;
  }

  .site-header .wrap {
    padding-inline: 16px;
  }

  .site-header .header-nav {
    gap: 0.5rem;
  }

  .brand-logo,
  .site-header.is-stuck .brand-logo {
    height: 24px;
  }

  .wide-only {
    display: none;
  }

  .nav-menu-toggle {
    min-height: 38px;
    padding-inline: 0.8rem 0.6rem;
    font-size: 0.84rem;
  }

  .site-header .btn-sm {
    padding-inline: 0.9rem;
  }
}

/* A 320px phone has room for the logo and the button but not a worded pill
   between them, so the pill becomes a list icon. It keeps its accessible
   name: the label is hidden visually, not from screen readers. */
.nav-menu-icon {
  display: none;
}

@media (max-width: 350px) {
  .brand-logo,
  .site-header.is-stuck .brand-logo {
    height: 22px;
  }

  .nav-menu-toggle {
    padding-inline: 0.65rem 0.55rem;
    gap: 0.25rem;
  }

  .nav-menu-icon {
    display: block;
  }

  .nav-menu-label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
}

@media (max-width: 560px) {
  .header-nav {
    gap: 1rem;
  }

  .hero-cta {
    flex-direction: column;
  }

  .hero-cta .btn {
    width: 100%;
  }
}

/* Respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    scroll-behavior: auto !important;
  }

  /* The header still shrinks — that is a change of state, not motion — but it
     arrives rather than travels. */
  .site-header::before,
  .site-header::after,
  .header-progress,
  .header-inner,
  .brand-logo,
  .site-header .btn-sm,
  .nav-links a,
  .nav-links a::before {
    transition: none;
  }

  /* The dock still appears and the icons still swell — both are answers to
     something the reader did — but they arrive rather than travel. */
  .dock--auto,
  .dock--auto.is-visible,
  .dock-item,
  .dock-icon,
  .dock-label {
    transition: none;
  }

  .dock--auto {
    translate: -50% 0;
  }
}
