/* ─── Inter, self-hosted ───────────────────────────────────────────────────
   One variable font covering the 200-600 range the design uses, latin subset
   only (every glyph the page renders is inside it). Replaces five separate
   static weights fetched from Google Fonts behind a render-blocking
   stylesheet. Inter is SIL Open Font License 1.1.
   ─────────────────────────────────────────────────────────────────────────── */

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 200 600; /* variable range */
  font-display: swap;
  /* Plain woff2 format hint on purpose: the older 'woff2-variations' string is
     unrecognised by some browsers, which makes them skip the whole src. */
  src: url('assets/fonts/inter-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
    U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ─── Design tokens ────────────────────────────────────────────────────────
   Mirrors the `primary` scale in the Tailwind config in index.html. Tailwind
   utilities can't be read from plain CSS, so the hand-written rules below
   (.btn-*, .scroll-btn, .card-scroller, .field) reference these instead of
   repeating hex values.
   Change both places together.
   ─────────────────────────────────────────────────────────────────────────── */

:root {
  --primary-400: #f87171;
  --primary-500: #cc0000;
  --primary-600: #990000;
  --primary-700: #7a0000;
  --surface-900: #111827; /* gray-900 */
  --surface-800: #1f2937; /* gray-800 */
  /* Every hairline on the page. White-at-alpha rather than a fixed grey so it
     holds the same weight on a glass card, on the fog and on the dark ground. */
  --hairline: rgba(255, 255, 255, 0.16);
}

/* ─── Base ─────────────────────────────────────────────────────────────────── */

body {
  overflow-x: hidden;
}

/* ─── Ambient smoke backdrop ───────────────────────────────────────────────
   Three oversized radial gradients drifting on long, mutually prime cycles,
   so the layer never visibly repeats. Deliberately not a canvas/WebGL noise
   field: gradients fade to nothing on their own, which means no blur filter,
   no per-frame paint, and nothing but compositor transforms once running.

   The layer sits at z-index -1 and carries the page's own background colour,
   so it is both the ground the content sits on and the thing being lit. That
   opaque base is what makes `mix-blend-mode: screen` on the plumes work — the
   plumes add light to the backdrop and never darken it, and where two overlap
   they accumulate the way backlit smoke does.

   Being at z-index -1 puts the layer behind every section but still in front
   of the page canvas, which works because `bg-gray-900` sits on <body> and
   propagates to the canvas, leaving body itself with nothing to paint. Give
   <html> a background of its own and body's would start painting in place,
   on top of this layer — so keep the base colour where it is.
   ─────────────────────────────────────────────────────────────────────────── */

.smoke {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  background-color: var(--surface-900);
  /* Keep the plumes blending against this layer only, never the page above. */
  isolation: isolate;
}

.smoke-plume {
  position: absolute;
  display: block;
  mix-blend-mode: screen;
}

/* Warm, top-left — sits under the hero heading. */
.smoke-1 {
  width: 85vmax;
  height: 85vmax;
  top: -30vmax;
  left: -22vmax;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(153, 0, 0, 0.38) 0%,
    rgba(153, 0, 0, 0.14) 38%,
    rgba(153, 0, 0, 0) 70%
  );
  animation: smoke-drift-1 46s ease-in-out infinite alternate;
}

/* Cool slate haze, right — stops the whole thing reading as one red glow. */
.smoke-2 {
  width: 72vmax;
  height: 72vmax;
  top: -8vmax;
  right: -26vmax;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(148, 163, 184, 0.16) 0%,
    rgba(100, 116, 139, 0.06) 42%,
    rgba(100, 116, 139, 0) 72%
  );
  animation: smoke-drift-2 61s ease-in-out infinite alternate;
}

/* Deep red, low and wide — lifts the bottom of the viewport off black. */
.smoke-3 {
  width: 95vmax;
  height: 95vmax;
  bottom: -52vmax;
  left: 12vmax;
  background: radial-gradient(
    circle at 50% 50%,
    rgba(204, 0, 0, 0.18) 0%,
    rgba(122, 0, 0, 0.08) 40%,
    rgba(122, 0, 0, 0) 70%
  );
  animation: smoke-drift-3 73s ease-in-out infinite alternate;
}

/* `alternate` on every plume: the keyframes run forwards then backwards, so
   there is no seam at the loop point and the start/end frames don't have to
   match. The opacity midpoint is what reads as billowing rather than sliding. */
@keyframes smoke-drift-1 {
  0% {
    transform: translate3d(-5%, -3%, 0) scale(1);
    opacity: 0.65;
  }
  50% {
    transform: translate3d(3%, 4%, 0) scale(1.12);
    opacity: 1;
  }
  100% {
    transform: translate3d(9%, 7%, 0) scale(1.2);
    opacity: 0.75;
  }
}

@keyframes smoke-drift-2 {
  0% {
    transform: translate3d(4%, 5%, 0) scale(1.1);
    opacity: 0.85;
  }
  50% {
    transform: translate3d(-3%, -2%, 0) scale(1);
    opacity: 0.55;
  }
  100% {
    transform: translate3d(-8%, 6%, 0) scale(1.15);
    opacity: 0.9;
  }
}

@keyframes smoke-drift-3 {
  0% {
    transform: translate3d(2%, 3%, 0) scale(1.05);
    opacity: 0.7;
  }
  50% {
    transform: translate3d(-6%, -4%, 0) scale(1.18);
    opacity: 1;
  }
  100% {
    transform: translate3d(-10%, 2%, 0) scale(1.08);
    opacity: 0.6;
  }
}

/* Frozen, the three gradients still read as a calm wash — so the preference
   drops the motion without dropping the effect. */
@media (prefers-reduced-motion: reduce) {
  .smoke-plume {
    animation: none;
  }
}

/* ─── Vanta fog canvas ─────────────────────────────────────────────────────
   The real smoke: a WebGL fractal-noise field with the texture and turbulence
   flat gradients can't fake. It costs ~150KB of three.js, so it is loaded
   lazily and the plumes above are what's on screen until it arrives — and
   what stays there for good on reduced motion, without WebGL, or if the CDN
   never answers. See initFog() in app.js.

   The canvas is opaque and its baseColor is the page's own background, so
   fading it to full opacity crossfades cleanly over the plumes rather than
   stacking with them.
   ─────────────────────────────────────────────────────────────────────────── */

.smoke-fog {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 1.6s ease;
}

.smoke.fog-ready .smoke-fog {
  opacity: 1;
}

/* Paused rather than hidden: `animation-play-state` is an ordinary property,
   so it takes effect without having to out-rank the running animation's own
   hold on `opacity`. Nothing is visible under an opaque canvas anyway — this
   just stops paying for the frames. */
.smoke.fog-ready .smoke-plume {
  animation-play-state: paused;
}

/* Tailwind's `scroll-smooth` on <html> is unconditional, so honour the OS
   setting here. Anchor offsets live on the sections as `scroll-mt-16`. */
@media (prefers-reduced-motion: reduce) {
  /* html.scroll-smooth, not html: Tailwind sets scroll-behavior via the
     .scroll-smooth class (0,1,0), which outranks a bare element selector
     (0,0,1). Matching the class as well takes it to (0,1,1) and wins.
     This also governs programmatic scrolls that pass behavior:'auto'. */
  html.scroll-smooth {
    scroll-behavior: auto;
  }
}

/* ─── Nav bar ──────────────────────────────────────────────────────────────
   Apple's guidance for this layer is to "reduce the use of toolbar
   backgrounds" and let the content underneath inform the bar's appearance.
   The old `bg-gray-900/95` did the opposite: 95% opaque, with a blur behind
   it that only 5% of the page could ever show through.

   These numbers are measured, not picked. Over the lightest colour the fog
   can produce, the gray-400 nav links land at 5.83:1 — clear of the 4.5:1 AA
   floor — which is why the links keep their colour even though the bar is now
   mostly transparent. The veil is what buys that: the fog is dimmed, then a
   gray-900 wash is laid over it, so the bar can be glassy without ever
   drifting light enough to threaten the labels.
   ─────────────────────────────────────────────────────────────────────────── */

/* Both veils are spelled out rather than routed through a custom property in
   the alpha slot: there are only two states, so the indirection bought nothing
   and this is the version that reads plainly at the point of use. */
.nav-glass {
  background-color: rgba(17, 24, 39, 0.4); /* gray-900 over the blurred fog */
  -webkit-backdrop-filter: blur(20px) saturate(1.4) brightness(0.85);
  backdrop-filter: blur(20px) saturate(1.4) brightness(0.85);
  transition: background-color 0.3s ease, border-color 0.3s ease;
}

/* Once content is actually underneath it, the bar firms up slightly and its
   bottom hairline appears — the job the old box-shadow was meant to do, which
   it never could at 6% black on a near-black page. */
.nav-glass.scrolled {
  background-color: rgba(17, 24, 39, 0.55);
  border-bottom-color: rgba(255, 255, 255, 0.09);
}

/* ─── Scroll edge effect ───────────────────────────────────────────────────
   Apple: "Scroll edge effects further enhance legibility by blurring and
   reducing the opacity of background content." A progressive blur — four
   stacked backdrop-filters at increasing radii, each masked to fade out
   sooner than the last, so all four overlap just below the bar and only the
   weakest survives at the bottom. Content dissolves into the nav instead of
   sliding under a hard line.

   It is a SIBLING of the nav, not a child, and must stay that way: an element
   with backdrop-filter is a backdrop root, so nesting this inside the bar
   would leave these layers with nothing to sample but the bar itself.
   ─────────────────────────────────────────────────────────────────────────── */

.nav-edge {
  position: fixed;
  top: 4rem; /* height of the h-16 bar above it */
  left: 0;
  right: 0;
  height: 3.5rem;
  z-index: 40; /* under the nav's z-50, over the page */
  pointer-events: none;
  /* No background here — the wash is a ::after so it lands on TOP of the blur
     layers. See the note on it below. */
  /* At the top of the page there is nothing under the bar to dissolve, so the
     effect stays out of the way until there is. Rides the same `scrolled`
     class the nav already toggles — sibling selector, no extra JS. */
  opacity: 0;
  transition: opacity 0.3s ease;
}

#navbar.scrolled ~ .nav-edge {
  opacity: 1;
}

/* The open mobile menu makes the bar taller, and this strip is a fixed sibling
   pinned below the collapsed bar — so it would sit behind the menu. That was
   invisible while the menu was an opaque panel; now that the menu is
   transparent and shares the bar's glass, the strip's wash would show straight
   through it as a band. There is nothing scrolling under an open menu anyway,
   so it stands down until the menu closes. Beats the rule above on
   specificity: :has() contributes its most specific argument. */
#navbar:has(#nav-toggle[aria-expanded='true']) ~ .nav-edge {
  opacity: 0;
}

.nav-edge span {
  position: absolute;
  inset: 0;
  display: block;
}

/* The "reducing the opacity" half of the effect, and it deliberately paints
   over the blur rather than under it.

   blur() is isotropic: it smears sideways exactly as much as it smears along
   the scroll axis, and only the vertical smear is wanted. A pale subject on a
   dark page — the hero portrait — makes that obvious, because the spread
   brightness reads as the image glowing wider than it is.

   Underneath the blur, this wash dims content *before* it is smeared, and the
   blur then spreads whatever brightness survives. On top, it dims the smear
   itself, which is the part you actually see. Same gradient either way; only
   the order differs, and the order is the whole point. Combined with a halved
   blur ramp (was 2/4/8/16, now 1/2/4/7) the bloom drops from roughly 55px of
   horizontal spread to about 25px, and what remains is dimmed as it appears. */
.nav-edge::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgba(17, 24, 39, 0.78),
    rgba(17, 24, 39, 0.32) 45%,
    rgba(17, 24, 39, 0)
  );
}

.nav-edge span:nth-child(1) {
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 70%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 70%, transparent 100%);
}
.nav-edge span:nth-child(2) {
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 72%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 72%);
}
.nav-edge span:nth-child(3) {
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 24%, transparent 50%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 24%, transparent 50%);
}
.nav-edge span:nth-child(4) {
  -webkit-backdrop-filter: blur(7px);
  backdrop-filter: blur(7px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 28%);
  mask-image: linear-gradient(to bottom, #000 0%, transparent 28%);
}

/* Without backdrop-filter the strip would be a bare dark band hanging under
   the bar, which is worse than nothing. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .nav-edge {
    display: none;
  }
}

/* ─── Hamburger → X ────────────────────────────────────────────────────────
   Driven off the aria-expanded state the JS already maintains, so there is
   no second source of truth. Bars are 1px with a 6px gap, hence the 7px
   translate to bring the outer two together.
   ─────────────────────────────────────────────────────────────────────────── */

#nav-toggle[aria-expanded='true'] span:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}
#nav-toggle[aria-expanded='true'] span:nth-child(2) {
  opacity: 0;
}
#nav-toggle[aria-expanded='true'] span:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

/* ─── Hero fade-up animation ───────────────────────────────────────────────── */

@keyframes fadeUp {
  from {
    opacity: 0;
    transform: translateY(18px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.fade-up {
  animation: fadeUp 0.6s ease both;
}

@media (prefers-reduced-motion: reduce) {
  .fade-up {
    animation: none;
    opacity: 1;
  }
}

/* ─── Buttons ──────────────────────────────────────────────────────────────
   Sizing stays on the elements (the three primary buttons differ); the shared
   look lives here.

   These used to lift 2px and cast a wide red glow. Two problems: the ghost
   button lifted without the glow, so the pair in the hero behaved differently
   side by side, and nothing else on the page lifts at all. The glow also
   predates the glass work, which gave the site a neutral, soft shadow
   vocabulary that a saturated red halo now reads as a leftover from.

   So they respond the way a control should — colour changes, the button stays
   put: the primary shifts its fill, the ghost its edge and label. Nothing here
   transforms, which is also why there is no reduced-motion carve-out any more:
   a colour fade needs no exemption.
   ─────────────────────────────────────────────────────────────────────────── */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 9999px;
  font-weight: 500;
  transition: background-color 0.2s ease, border-color 0.2s ease,
    color 0.2s ease, -webkit-backdrop-filter 0.2s ease,
    backdrop-filter 0.2s ease;
}

.btn-primary {
  background-color: var(--primary-600);
  color: #fff;
}

/* Hover goes lighter and press goes darker, so the two states read as
   opposite ends of one motion rather than both being "darker than rest".
   White clears AA on all three fills (5.89 / 8.92 / 11.49). */
.btn-primary:hover:not(:disabled) {
  background-color: var(--primary-500);
}

.btn-primary:active:not(:disabled) {
  background-color: var(--primary-700);
}

.btn-primary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-ghost {
  border: 1px solid #374151; /* gray-700 */
  color: #d1d5db; /* gray-300 */
}

/* Red edge and label, over a frost. The frost is the site's own recipe — blur
   plus a brightness cut, same as the nav and cards — rather than a plain white
   fill, and that choice is doing real work: a white wash lightens the surface
   and drags the red label down to 3.28:1, while dimming what's behind pushes
   it to 4.13:1, a shade *better* than having no hover state at all. The 8%
   sheen on top is what keeps it reading as frosted glass rather than a hole.
   The frost fades in with the colours: `none` interpolates fine, because a
   missing filter is substituted with its identity values, so the blur ramps
   0→8px and the brightness 1→0.7 rather than snapping. No neutral filter is
   needed at rest, so the button is only a backdrop root while it's hovered. */
.btn-ghost:hover {
  border-color: var(--primary-400);
  color: var(--primary-400);
  background-color: rgba(255, 255, 255, 0.08);
  -webkit-backdrop-filter: blur(8px) saturate(1.3) brightness(0.7);
  backdrop-filter: blur(8px) saturate(1.3) brightness(0.7);
}

/* Press knocks the sheen back — the pressed state the lift used to provide. */
.btn-ghost:active {
  background-color: rgba(255, 255, 255, 0.03);
}

/* Keyboard focus needs to be visible on both, since the buttons are <a> tags
   with their own backgrounds. */
.btn:focus-visible {
  outline: 2px solid var(--primary-400);
  outline-offset: 3px;
}

/* ─── Horizontal card scroller ─────────────────────────────────────────────
   The scrollbar is deliberately left visible and styled rather than hidden:
   it's the main signal that there is more content to the right. The cards
   also stop short of the container edge so the next one peeks into view.
   ─────────────────────────────────────────────────────────────────────────── */

.card-scroller {
  scrollbar-width: thin;
  scrollbar-color: #4b5563 transparent; /* gray-600 thumb, invisible track */
  /* Keep a horizontal fling from turning into a page scroll or a browser
     back-swipe once the row hits its end. */
  overscroll-behavior-x: contain;
}

.card-scroller::-webkit-scrollbar {
  height: 8px;
}

.card-scroller::-webkit-scrollbar-track {
  background: transparent;
}

.card-scroller::-webkit-scrollbar-thumb {
  background-color: #4b5563; /* gray-600 */
  border-radius: 9999px;
}

.card-scroller::-webkit-scrollbar-thumb:hover {
  background-color: #6b7280; /* gray-500 */
}

/* ─── Glass cards ──────────────────────────────────────────────────────────
   Three layers, which is what separates this from "a box with some alpha":

     backdrop  blurred, desaturated-up and *darkened* sample of the fog behind
     ::before  a breath of white to frost it
     ::after   a 1px specular rim, bright along the top and fading down

   The `brightness()` in the backdrop filter is the load-bearing part. Plain
   translucency would let the bright patches of smoke drift under the body copy
   and take the contrast with them; darkening what it samples means the card
   keeps a consistent, readable ground no matter what passes behind it.

   Replaces `border border-gray-700/60 bg-gray-800` on these cards — the
   surface is owned here now, the Tailwind utilities left on them are layout
   only. Form inputs deliberately keep the solid fill: a text field should read
   as a well you can type into, not as more glass.
   ─────────────────────────────────────────────────────────────────────────── */

.glass {
  /* ── The four dials. Tune here; everything else derives from them. ──
     --glass-blur  how far the smoke behind is smeared. Lower = clearer glass,
                   higher = frosted. This is the clear/frosted axis.
     --glass-dim   brightness applied to the backdrop, and the legibility
                   control: lower is safer under text, higher lets more of the
                   smoke through. At 1 the filter is a no-op and the card shows
                   the smoke at full strength; it is kept as a dial rather than
                   dropped because it is the one knob that trades legibility for
                   atmosphere. Above 1 it amplifies, showing more smoke than no
                   filter at all.

                   What sets the ceiling is the text on top, not the glass. Card
                   copy is gray-300, which clears 4.5:1 AA out to about 1.3
                   (5.03:1) against the lightest colour the fog can produce.
                   The old gray-400 only reached 0.78 — that swap is what bought
                   the headroom this value now spends. Anything darker than
                   gray-300 on these cards needs re-measuring before it ships.
     --glass-tint  the frost itself, as white alpha.
     --glass-rim   brightness of the lit top-left edge. */
  --glass-blur: 10px;
  --glass-dim: 1;
  --glass-tint: 0.05;
  --glass-rim: 0.5;

  position: relative;
  /* No fill of its own — everything visible comes from the layers below. */
  background-color: transparent;
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.35) brightness(var(--glass-dim));
  backdrop-filter: blur(var(--glass-blur)) saturate(1.35) brightness(var(--glass-dim));
  box-shadow:
    /* lift off the page */
    0 12px 40px -20px rgba(0, 0, 0, 0.8),
    /* thickness: a lit top inner wall, a shaded lower one. Together these are
       what stop it reading as a flat sheet of tinted plastic. */
    inset 0 1px 0 0 rgba(255, 255, 255, 0.14),
    inset 0 20px 32px -28px rgba(255, 255, 255, 0.22),
    inset 0 -20px 32px -28px rgba(0, 0, 0, 0.5);
}

.glass::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-color: rgba(248, 250, 252, var(--glass-tint));
  pointer-events: none; /* both layers sit over the content; neither may eat clicks */
}

/* The rim. Two mask layers — a full-box one and a content-box one composited
   with `exclude` — punch the middle out of a filled rounded box, leaving just
   the 1px padding ring. It's the only way to get a *gradient* border that
   still follows border-radius; a real border can only be one flat colour.
   Angled rather than straight down, so the highlight sweeps across the top-left
   and catches again at the opposite corner the way a real edge picks up light. */
.glass::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    160deg,
    rgba(255, 255, 255, var(--glass-rim)) 0%,
    rgba(255, 255, 255, 0.12) 40%,
    rgba(255, 255, 255, 0.06) 70%,
    rgba(255, 255, 255, 0.16) 100%
  );
  /* Safari below 15.4 only understands the prefixed form and its own
     composite keyword; the standard pair below wins wherever it's supported. */
  -webkit-mask: linear-gradient(#fff, #fff) content-box, linear-gradient(#fff, #fff);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#fff, #fff) content-box exclude, linear-gradient(#fff, #fff);
  pointer-events: none;
}

/* Without backdrop-filter there is nothing to see through, and a transparent
   card over drifting smoke would be unreadable. Fall back to the solid fill
   these cards had before. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass {
    background-color: var(--surface-800);
  }
}

/* ─── In practice — accordion below md, cards above ────────────────────────
   One set of markup serves both. Above md the head is an ordinary block and
   every panel is open; below md, JS gives the head button semantics and
   collapses all but the first. The panel is visible by default and only ever
   hidden by JS, so if the script never runs nothing is trapped shut.
   ─────────────────────────────────────────────────────────────────────────── */

.acc-head {
  display: flex;
  align-items: flex-start;
  gap: 1rem;
}

.acc-head-text {
  flex: 1 1 auto;
  min-width: 0;
}

.acc-chevron {
  display: none; /* card mode has nothing to toggle */
  margin-top: 0.2rem;
  transition: transform 0.25s ease;
}

.acc-panel {
  margin-top: 1.25rem;
}

@media (max-width: 767.98px) {
  .acc-chevron {
    display: block;
  }
  .js .acc-head {
    cursor: pointer;
  }
  .js .acc-head[aria-expanded='true'] .acc-chevron {
    transform: rotate(180deg);
  }
  .acc-head:focus-visible {
    outline: 2px solid var(--primary-400);
    outline-offset: 4px;
    border-radius: 0.5rem;
  }
}

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

/* ─── Scroller controls ──────────────────────────────────────────────────
   Hidden unless JS is running, since the buttons are inert without it. The
   row itself still scrolls natively (and by keyboard, via tabindex) either
   way. Display is owned here rather than by a Tailwind `flex` class so the
   gate can't lose a specificity tie to injected utilities.
   ─────────────────────────────────────────────────────────────────────────── */

.scroller-controls {
  display: none;
}

/* Only from md up: below that the row is an accordion with nothing to page. */
@media (min-width: 768px) {
  .js .scroller-controls {
    display: flex;
  }
}

.scroll-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 9999px;
  border: 1px solid #374151; /* gray-700 */
  color: #d1d5db; /* gray-300 */
  transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}

.scroll-btn:hover:not(:disabled) {
  border-color: var(--primary-400);
  color: var(--primary-400);
}

.scroll-btn:focus-visible {
  outline: 2px solid var(--primary-400);
  outline-offset: 3px;
}

.scroll-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

/* The region is focusable so it can be scrolled by keyboard, so it needs a
   focus ring of its own. */
.card-scroller:focus-visible {
  outline: 2px solid var(--primary-400);
  outline-offset: 4px;
  border-radius: 0.75rem;
}

@media (prefers-reduced-motion: reduce) {
  /* Mandatory snapping can yank the viewport in ways that defeat the
     preference; plain scrolling still works. */
  .card-scroller {
    scroll-snap-type: none;
  }
}

/* ─── Scroll reveal ────────────────────────────────────────────────────────
   Gated on `html.js` so the content is visible when JS is unavailable rather
   than stuck at opacity 0. The IntersectionObserver lives in app.js.
   ─────────────────────────────────────────────────────────────────────────── */

/* Transform only, and deliberately no opacity fade: an element at opacity < 1
   is a "backdrop root", which starves any backdrop-filter inside it. With a
   fade here, every .glass card in the section rendered unblurred and
   undarkened for the whole 0.6s and then snapped — very visible, since the
   cards go pale red while the smoke shows straight through them. Translating
   alone creates a stacking context but not a backdrop root, so the glass stays
   correct throughout. Distance is up slightly to keep the movement legible now
   that it travels alone. */
.js .reveal {
  transform: translateY(24px);
  transition: transform 0.6s ease;
}

.js .reveal.is-visible {
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .js .reveal {
    transform: none;
    transition: none;
  }
}

/* ─── Hairline dividers ────────────────────────────────────────────────────
   A fixed grey cannot work on this page any more. `border-gray-700` measured
   1.07:1 against a glass card, and the section rules at `border-gray-800`
   measured 1.21–1.30:1 over the fog — not "subtle", invisible, which is
   exactly what a divider must not be.

   White at low alpha tracks whatever sits behind it instead of fighting it,
   and that is the whole point: the same 0.16 reads at 1.59:1 on a card,
   1.62:1 on the bright part of the fog and 1.63:1 on the dark ground. One
   value, consistent everywhere, including wherever the smoke happens to
   drift. Raise --hairline to make them firmer — 0.20 is ~1.8:1, 0.26 ~2.1:1.

   Both kinds share the token deliberately, so the short rules inside cards and
   the full-bleed ones between sections can't drift apart later. `.divider` is
   for <hr>, which needs its other three borders cleared; `.rule-t` is for
   elements that only want a top edge.
   ─────────────────────────────────────────────────────────────────────────── */

.divider {
  border: 0;
  border-top: 1px solid var(--hairline);
}

.rule-t {
  border-top: 1px solid var(--hairline);
}

/* ─── Honeypot ─────────────────────────────────────────────────────────────
   Positioned off-screen rather than display:none — plenty of bots skip fields
   that are display:none, but will happily fill one that is merely out of view.
   aria-hidden and tabindex="-1" keep it away from screen readers and the tab
   order, so nobody real ever meets it.
   ─────────────────────────────────────────────────────────────────────────── */

.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ─── Form fields ──────────────────────────────────────────────────────────
   Translucent at rest, glass on focus. That split is Apple's own rule rather
   than a compromise: Liquid Glass is for the functional layer and explicitly
   not for the content layer, with one carve-out — a control sitting in the
   content layer "takes on a Liquid Glass appearance to emphasize its
   interactivity when a person activates it". A text field is exactly that
   case, so the material arrives on focus and the field is a cheap translucent
   panel the rest of the time.

   The border is doing real work and is why it's as bright as it is. An
   input's boundary is a UI component and needs 3:1, and the old
   `border-gray-700` on `bg-gray-800` measured 1.42:1 — the only thing marking
   where the box was, and it was invisible. Normally the fill could identify
   the field instead, but not here: the fog is already so dark that no
   interior colour can sit 3:1 below it, so the boundary has to carry it
   alone. White at 0.4 gives 3.46:1 against the interior and 3.21:1 against
   the page. Anything softer fails.
   ─────────────────────────────────────────────────────────────────────────── */

.field {
  background-color: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.4);
  /* The filter is present at rest, and that is the point: going from `none` to
     a filter on focus is a structural change — Chromium promotes the element
     to a backdrop root — and with a semi-transparent border, what shows
     through the border changes in the same frame. The result was a white
     flash before the red. Declaring the filter here keeps the element a
     backdrop root the whole time, so focus only changes numbers.

     Deliberately luminance-neutral: blur and saturate leave average
     brightness alone, so the border's measured 3.46:1 against the interior
     and 3.21:1 against the page still hold. brightness(1) is written out
     rather than omitted so both states have the same three functions in the
     same order and interpolate pairwise. */
  -webkit-backdrop-filter: blur(6px) saturate(1.1) brightness(1);
  backdrop-filter: blur(6px) saturate(1.1) brightness(1);
  /* Explicit list rather than the utility's `transition-all`: only these four
     should ever animate. */
  transition: border-color 0.2s ease, box-shadow 0.2s ease,
    background-color 0.2s ease, -webkit-backdrop-filter 0.2s ease,
    backdrop-filter 0.2s ease;
}

/* Was #888f9c, which scraped 4.51:1 on the old solid fill but falls to 2.88:1
   once the fog shows through. gray-300 restores it to 6.36:1 and still reads
   as secondary against the gray-100 the typed text uses. */
.field::placeholder {
  color: #d1d5db;
  opacity: 1; /* Firefox dims placeholders by default, which would undo this */
}

/* Activation: the material thickens and dims, which also lifts the contrast of
   whatever is typed. Every value here has a counterpart at rest, so this is a
   numeric change rather than a structural one. */
.field:focus {
  border-color: var(--primary-400);
  box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);
  -webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(0.7);
  backdrop-filter: blur(12px) saturate(1.3) brightness(0.7);
}
