/* ==========================================================================
   Base
   ========================================================================== */

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

html {
  -webkit-text-size-adjust: 100%;
  /* The root background is what paints the canvas under the ambient field
     (z-index -1 in the root stacking context), so body stays transparent:
     a body background would paint over the field. */
  background: var(--bg);
  /* No scroll-behavior: smooth. On html it also applied to a deep link
     arriving from another page; site.js scrolls in-page clicks (TOC,
     heading anchors) smoothly itself. */
  /* The one anchor offset: the 12px header inset, the 56px capsule and 16px
     of air. Headings add no scroll-margin of their own on top of it. */
  scroll-padding-top: 84px;
}

body {
  margin: 0;
  font: 400 var(--step-0)/var(--lh-body) var(--font);
  color: var(--text);
  background: transparent;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  position: relative;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0 0 0.5em;
  font-family: var(--font);
  font-weight: 700;
  line-height: var(--lh-title);
  letter-spacing: var(--track-title);
  text-wrap: balance;
}
h1 { letter-spacing: var(--track-h1); }
h2 { letter-spacing: var(--track-h2); }

p { margin: 0 0 1.1em; }
p:last-child { margin-bottom: 0; }

a {
  color: var(--link);
  text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent);
  text-underline-offset: 3px;
  transition: color 0.15s var(--ease), text-decoration-color 0.15s var(--ease);
}
a:hover { text-decoration-color: currentColor; }

img, svg, video { max-width: 100%; height: auto; display: block; }

hr {
  border: 0;
  height: 1px;
  background: var(--border);
  margin: 2.5rem 0;
}

::selection { background: var(--mark); color: var(--text); }

:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
:focus:not(:focus-visible) { outline: none; }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: -100px;
  left: 12px;
  z-index: 200;
  background: var(--surface-raised);
  color: var(--text);
  padding: 12px 18px;
  border-radius: var(--r-chip);
  border: 1px solid var(--border-strong);
}
.skip-link:focus { top: 12px; }

.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: clamp(18px, 4vw, 32px);
}

.etched {
  background: var(--mat-etched);
  box-shadow: var(--rim-etched);
}

/* ==========================================================================
   Ambient field
   --------------------------------------------------------------------------
   Four palette blobs drifting behind every page (partials/ambient.ejs),
   after the app's ambient backdrop: 5.8% peak, radius 0.32 x min(W, H) +
   330px, x on a 48s period and y on 48 / 0.7. Compositor-only CSS: nothing
   reads the pointer and nothing dozes. Each blob is two elements so x and y
   run at their own periods, and an alternate animation on --ease-sine
   traces a cosine, the app's own path.
   The static --x0/--y0 offsets are the app's t = 0.2 frame, which is what
   shows whenever the animations are not declared (reduced motion, the lite
   tier); the animations themselves start from the phases the partial
   writes. z-index -1 in the root stacking context puts the field above the
   canvas and under all page content, and later negative z-index layers
   (the auth wash) still paint over it by tree order.
   ========================================================================== */

.ambient {
  position: fixed;
  inset: 0;
  z-index: var(--z-ambient);
  overflow: hidden;
  pointer-events: none;
  contain: strict;
  background: var(--bg);
}
/* One static grain tile over the blobs: a 5.8% peak is only a few 8-bit
   steps, so the gradients band without it. The filter runs in sRGB so the
   noise centres on mid grey (the linearRGB default centres it near 73% and
   the overlay would lift the ground a level or two); overlay at .45 then
   moves each pixel about one level either way and leaves the mean where it
   was (measured). It lives in the field only, never over content. */
.ambient::after {
  content: '';
  position: absolute;
  inset: 0;
  opacity: .45;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.amb {
  position: absolute;
  left: 50%;
  top: 55%;
  transform: translateX(var(--x0));
}
/* The falloff is the app's exp(-4.56 d^2) curve, sampled every tenth of the
   radius (peak times the sample). */
.amb > i {
  --R: calc(min(100vw, 100vh) * .32 + 330px);
  /* Paint at a fixed, DPR-independent raster size and grow it to --R's
     visual radius with the compositor-only `scale` property instead of
     sizing the box (and so its backing store) directly off --R and the
     device's real pixel ratio: unbounded, that reached about 5.8MB per
     layer at desktop DPR1 and about 28.4MB per layer at phone DPR3 (4
     blobs each), a jump this gradient's own softness (over 95% transparent
     past a third of its radius) never needed. The snow canvas in site.js
     already caps its own DPR at 2 for the same reason. `scale` composes
     with the `transform` translate below rather than replacing it, and
     scales from the box's own centre, so the blob still grows from the
     same anchor point. */
  --paint: 480px;
  position: absolute;
  left: calc(-1 * var(--paint) / 2);
  top: calc(-1 * var(--paint) / 2);
  width: var(--paint);
  height: var(--paint);
  scale: calc(2 * var(--R) / var(--paint));
  transform: translateY(var(--y0));
  background: radial-gradient(closest-side,
    color-mix(in srgb, var(--c) 5.8%, transparent) 0%, color-mix(in srgb, var(--c) 5.54%, transparent) 10%,
    color-mix(in srgb, var(--c) 4.83%, transparent) 20%, color-mix(in srgb, var(--c) 3.85%, transparent) 30%,
    color-mix(in srgb, var(--c) 2.8%, transparent) 40%, color-mix(in srgb, var(--c) 1.85%, transparent) 50%,
    color-mix(in srgb, var(--c) 1.12%, transparent) 60%, color-mix(in srgb, var(--c) .62%, transparent) 70%,
    color-mix(in srgb, var(--c) .31%, transparent) 80%, color-mix(in srgb, var(--c) .11%, transparent) 90%,
    transparent 100%);
}
/* A light ground shows tint more, and --text-muted has little headroom on
   it: a 3.5% peak instead of 5.8%. Near white that peak is only a few 8-bit
   steps, and the moving blobs are composited layers that are never
   dithered, so they showed as rings and the overlay grain (weak near white)
   could not hide them. Here each blob is painted at ten times the strength
   (a fine alpha ramp in its layer) under a static noise mask, which dithers
   it before the layer is blended down. .096 x 10 x the mask's mean alpha
   (about .625, measured) keeps the same 3.5% peak. */
:root[data-theme='light'] .amb > i {
  opacity: .096;
  /* Pre-shrunk by the same ratio the fixed-size raster above already
     shrank the box by, so the compositor's `scale` grows it back to the
     same 180px on screen the dither was tuned against, whatever --paint's
     constant happens to be. */
  -webkit-mask-size: calc(180px * (var(--paint) / (2 * var(--R))));
          mask-size: calc(180px * (var(--paint) / (2 * var(--R))));
  background: radial-gradient(closest-side,
    color-mix(in srgb, var(--c) 58%, transparent) 0%, color-mix(in srgb, var(--c) 55.4%, transparent) 10%,
    color-mix(in srgb, var(--c) 48.3%, transparent) 20%, color-mix(in srgb, var(--c) 38.5%, transparent) 30%,
    color-mix(in srgb, var(--c) 28%, transparent) 40%, color-mix(in srgb, var(--c) 18.5%, transparent) 50%,
    color-mix(in srgb, var(--c) 11.2%, transparent) 60%, color-mix(in srgb, var(--c) 6.2%, transparent) 70%,
    color-mix(in srgb, var(--c) 3.1%, transparent) 80%, color-mix(in srgb, var(--c) 1.1%, transparent) 90%,
    transparent 100%);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 -0.35'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
          mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 -0.35'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
@media (prefers-reduced-motion: no-preference) {
  html:not([data-glass='lite']) .amb { animation: amb-x var(--ambient-x-half) var(--ease-sine) var(--dx) infinite alternate; }
  html:not([data-glass='lite']) .amb > i { animation: amb-y var(--ambient-y-half) var(--ease-sine) var(--dy) infinite alternate; }
}
@keyframes amb-x {
  from { transform: translateX(-30vw); }
  to { transform: translateX(30vw); }
}
@keyframes amb-y {
  from { transform: translateY(-32vh); }
  to { transform: translateY(32vh); }
}
/* The wuji sequence drains the page's colour; the field holds where it is
   and resumes from there. */
html.wuji-playing .amb,
html.wuji-playing .amb > i { animation-play-state: paused; }
/* A backgrounded tab: CSS animations, unlike rAF, are not throttled by the
   browser on their own, so these would otherwise keep running at full rate
   while hidden, the same waste the precipitation canvas's own
   document.hidden check in site.js already avoids. site.js toggles this
   class on <html> from its visibilitychange listener. */
html.tab-hidden .amb,
html.tab-hidden .amb > i { animation-play-state: paused; }
@media print, (forced-colors: active) {
  .ambient { display: none; }
}

/* ==========================================================================
   Brand marks
   ========================================================================== */

/* A default width matters: an SVG with no intrinsic size stretches to fill
   whatever container it lands in, which is how a 30px logo becomes a
   full-width one. Every context that needs a different size overrides it. */
.mark { display: block; width: 32px; aspect-ratio: 1; }

.mark-bg { fill: url(#boundless-stroke); }
.mark-path {
  fill: none;
  stroke: #fff;
  stroke-width: 4.6;
  stroke-linecap: round;
}
/* The #boundless-stroke gradient's own stops (see head.ejs), driving the
   icon's background fill everywhere the mark appears. stop-color is a real
   CSS property on SVG <stop> elements, and custom properties cascade into
   inline SVG the same as anywhere else in the document, so these four
   rules are what makes the icon retint the instant --indigo/--violet/
   --blue/--teal change, whether from the palette switcher or the
   data-palette attribute it sets, with no JS of its own needed here. */
.stop-indigo { stop-color: var(--indigo); }
.stop-violet { stop-color: var(--violet); }
.stop-blue { stop-color: var(--blue); }
.stop-teal { stop-color: var(--teal); }

/* .wordmark itself is header.css's: it redeclares every one of these
   properties (display, gap, padding, radius, font, colour, text-shadow,
   the mark's own size/radius/box-shadow and its own two breakpoints) under
   the identical selectors, loading after this file, so it always wins.
   This file keeps only the shared marks above (.mark, .mark-bg, .mark-path,
   .stop-*) that every context using the icon reads from. */
