/* ==========================================================================
   Base — reset, root typography, page atmosphere, generic element defaults
   ========================================================================== */

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

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--fs-base);
  line-height: var(--lh-normal);
  color: var(--color-text);
  background: var(--color-bg);
  -webkit-font-smoothing: antialiased;
}

/* ---------- Page atmosphere ----------
   Two fixed layers give the flat near-black page some depth without touching
   a single component:

   ::before  a soft two-tone wash in the brand hues. body's own background
             propagates to the canvas, so a z-index:-1 child of body paints on
             top of that canvas but behind everything else. Sections with a
             solid .section-bg cover it; the transparent ones let it through,
             which is what makes the page alternate between lit and settled.
   ::after   fine film grain over the whole page. Kills the plastic flatness
             large dark gradients otherwise have on wide screens. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(62vw 62vw at 80% 8%, var(--wash-1), transparent 62%),
    radial-gradient(54vw 54vw at 8% 68%, var(--wash-2), transparent 62%);
}

body::after {
  content: "";
  position: fixed;
  inset: 0;
  /* Above page content, BELOW the sticky header (60) and the rest of the
     chrome. It used to sit at 200, over everything, and that broke iPhone
     Safari 26: Safari paints the status-bar strip with the colour of the
     topmost fixed/sticky layer that touches the top edge and has a
     background, and this layer has one (the noise image) but no colour, so
     Safari gave up and composited raw page pixels under the clock. With the
     grain beneath the header, the header's own colour is what Safari finds
     and extends. At 2–4% alpha the grain's absence over the header, drawer
     and floating discs is imperceptible. The <dialog> renders in the top
     layer regardless of z-index. Never interactive. */
  z-index: 50;
  pointer-events: none;
  opacity: var(--grain-o);
  /* The SVG feTurbulence tile is what gives the grain its fine fractal
     texture — a pre-rasterised PNG stood in for it briefly and read as
     low-quality noise on Retina desktops. Desktop keeps the SVG; it only
     ever costs where it is hidden below. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Phones and tablets get no grain at all. The comment above says why it
   exists — wide screens — and on a phone it is one more full-screen fixed
   layer for the compositor to hold, painted through WebKit's CPU SVG-filter
   path and repainted every frame the iOS toolbar collapses. test2 → test9
   crashed iPhone Safari into its reload loop under exactly this kind of load. */
@media (max-width: 900px), (hover: none) and (pointer: coarse) {
  body::after { display: none; }
}

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

/* Sized icons default to the current font size — without this a bare <svg>
   falls back to the 300x150 replaced-element default. */
svg.icon {
  width: 1em;
  height: 1em;
  flex-shrink: 0;
}

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-display);
  font-stretch: var(--wdth-display);
  line-height: var(--lh-tight);
  letter-spacing: -0.015em;
  margin: 0 0 var(--space-sm);
  color: var(--color-text);
  text-wrap: balance;
}

h2 { font-size: var(--fs-2xl); font-weight: 800; letter-spacing: -0.025em; }
h3 { font-size: var(--fs-xl); font-weight: 700; }
h4 { font-size: var(--fs-lg); font-weight: 700; }

p { margin: 0 0 var(--space-sm); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }

a {
  color: var(--color-accent);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out);
}
a:hover { color: var(--color-accent-strong); }

ul { margin: 0; padding: 0; list-style: none; }

button, input, textarea {
  font: inherit;
  color: inherit;
}

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
  border-radius: 4px;
}

.container {
  width: 100%;
  max-width: var(--container-w);
  margin-inline: auto;
  /* landscape iPhone puts the Dynamic Island on a side edge: the gutter
     grows to the inset only when the inset is the larger of the two */
  padding-inline: max(var(--container-pad), var(--safe-left))
                  max(var(--container-pad), var(--safe-right));
}

.section-title {
  text-align: center;
  max-width: 660px;
  margin-inline: auto;
  margin-bottom: var(--space-xl);
}

/* The eyebrow is a chip rather than loose caps — it gives every section a
   hard visual start, and the lit dot ties it to the accent system. */
.eyebrow {
  position: relative;   /* anchors the ping ring below */
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-accent);
  padding: 0.4em 1em 0.4em 0.85em;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: var(--color-surface);
  box-shadow: var(--shadow-sm);
  margin-bottom: var(--space-sm);
}

.eyebrow::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--grad-accent);
  box-shadow: 0 0 0 3px var(--color-accent-tint);
  flex-shrink: 0;
}

/* A soft ping expanding off the lit dot — keyed on .is-visible so it never
   runs before the section has revealed (and never at all without JS, which
   is fine: it is pure decoration). Transform/opacity only. These few 7px
   rings keep looping off screen, unlike the IO-parked deco layers — at this
   size the cost is negligible. */
@media (prefers-reduced-motion: no-preference) {
  .section-title.is-visible .eyebrow::after,
  .about-copy.is-visible .eyebrow::after {
    content: "";
    position: absolute;
    left: 0.85em;   /* the dot sits at the chip's 0.85em left padding */
    top: 50%;
    width: 7px;
    height: 7px;
    margin-top: -3.5px;
    border-radius: 50%;
    border: 1px solid var(--color-accent);
    animation: eyebrow-ping 3.2s var(--ease-out) infinite 0.9s;
  }
}
@keyframes eyebrow-ping {
  0%        { transform: scale(1);   opacity: 0.8; }
  55%, 100% { transform: scale(2.7); opacity: 0; }
}

/* A short gradient rule under every section heading — the one repeated mark
   that reads as "this is the same site" from section to section. */
.section-title h2,
.about-copy h2 {
  position: relative;
  padding-bottom: 0.42em;
  margin-bottom: var(--space-sm);
}
.section-title h2::after,
.about-copy h2::after {
  content: "";
  position: absolute;
  bottom: 0;
  width: 68px;
  height: 3px;
  border-radius: 2px;
  background: var(--grad-accent);
}
.section-title h2::after { left: 50%; transform: translateX(-50%); }
.about-copy h2::after { left: 0; }

.section-title p {
  color: var(--color-text-muted);
}

/* Numbers that initCountUps() rolls on scroll — the badge's "30+", every
   "since 1996". Equal-width digits keep the roll from shifting a pill's
   shape or the ticker's seam. In the inline-flex chips (hero badge,
   eyebrow) the rolling span sits inside a .chip-copy wrapper: a bare child
   of a flex container is blockified into its own flex item and picks up
   the chip's gap, opening a hole after "since". The one roll whose digit
   count changes ("30+") gets inline-block + a min-width from
   initCountUps() instead — it is already a flex item of the about badge. */
[data-count] {
  font-variant-numeric: tabular-nums;
}

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

/* Reveal-on-scroll base state — see components.css for the transition itself.
   Scoped to .js so content stays visible if scripts fail to run. Elements
   return to this state (instantly) once they have fully scrolled off, so
   the reveal plays again on every re-entry. */
.js [data-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--reveal-y), 0);
}
.js [data-reveal].is-visible {
  opacity: 1;
  transform: none;
}

/* --- Reveal variants ---
   The observer keys on the bare attribute, so a valued data-reveal keeps the
   same JS and only swaps the hidden-state transform. Wrapped in no-preference
   so under reduced motion the variants collapse back to the plain reveal,
   which the reduce block below already forces to its final state. */
@media (prefers-reduced-motion: no-preference) {
  .js [data-reveal="left"]  { transform: translate3d(calc(-1 * var(--reveal-x)), 0, 0); }
  .js [data-reveal="right"] { transform: translate3d(var(--reveal-x), 0, 0); }
  .js [data-reveal="scale"] { transform: scale(0.92); }

  /* Squeegee wipe: the card is uncovered left to right. The visible edge
     hugs the card's own radius; the end state parks the clip 120px out so
     the persisting clip-path can never crop --shadow-lg or a hover glow.

     92%, not 100%: Chrome applies the target's own clip-path to
     IntersectionObserver geometry, and a zero-width start rect reports
     intersectionRatio 0 forever — the reveal observer would never fire.
     The 8% strip is never actually seen: the card holds opacity 0 until
     .is-visible arrives, when both fade and wipe run together. */
  .js [data-reveal="clip"] {
    transform: none;
    clip-path: inset(-1px 92% -1px -1px round var(--radius-lg));
  }
  .js [data-reveal="clip"].is-visible {
    clip-path: inset(-120px round var(--radius-lg));
  }
}

/* --- Masked line-reveal for section headings ---
   The h2 is the mask; the .h2-line span rises out of it once the title block
   reveals, and the gradient underline (the h2's ::after, styled above) draws
   itself in after the line lands. Transitions live in components.css so they
   share --reveal-delay with the rest of the reveal system. The h2's own
   padding-bottom keeps descenders clear of the mask edge at rest. */
.js .section-title h2,
.js .about-copy h2 { overflow: hidden; }

@media (prefers-reduced-motion: no-preference) {
  .js .section-title .h2-line,
  .js .about-copy .h2-line {
    display: block;
    transform: translate3d(0, 110%, 0);
  }
  .js .section-title.is-visible .h2-line,
  .js .about-copy.is-visible .h2-line { transform: none; }

  .js .section-title h2::after { transform: translateX(-50%) scaleX(0); }
  .js .about-copy h2::after { transform: scaleX(0); transform-origin: 0 50%; }
  .js .section-title.is-visible h2::after { transform: translateX(-50%) scaleX(1); }
  .js .about-copy.is-visible h2::after { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .js [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
    --reveal-step: 0ms;
  }
  .deco-layer {
    transform: none !important;
  }
}
