/* JRD Animation — hand-authored stylesheet.

   Everything that can live as an inline style lives inline, verbatim from the
   Claude Design export, because that is what makes the design survive
   transcription. This file carries only the things an inline style cannot
   express: pseudo-elements, interaction states, media queries and the canvas
   upgrade layer.

   The export writes those as style-before="…", style-hover="…" and
   style-focus="…" attributes, which no browser honours. They are translated
   here, once, against the data-c component names. */

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body {
  background: #F7F8FA;
  color: #0F172A;
  font-family: 'Space Grotesk', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
}
img, svg, canvas { display: block; max-width: 100%; }
a { color: #0EA5E9; text-decoration: none; }
button { font: inherit; color: inherit; }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 99;
  background: #0EA5E9; color: #fff; padding: 10px 16px; border-radius: 0 0 10px 0;
  font: 600 14px/1 'Space Grotesk', system-ui;
}
.skip:focus { left: 0; }

/* ---------- the drifting grid ----------
   One cell per 75s, translated with transform (GPU-composited) rather than
   background-position (a full-layer repaint every frame). That distinction is
   the whole reason this effect is free, and the site's pitch is that it doesn't
   ship weight — so it matters here more than usual.

   inset:-112px is two cells of bleed on every side, so the translate never
   exposes an unpainted edge at the end of a cycle.

   z-index:-1 puts the grid behind its own section's content without needing a
   stacking context on the section. Do NOT add `isolation:isolate` back: it
   traps the fixed canvas below, which is the bug that made the first grid
   prototype render nothing. */
@keyframes gridDrift { from { transform: translate3d(0,0,0); } to { transform: translate3d(56px,56px,0); } }
@keyframes gridDriftM { from { transform: translate3d(0,0,0); } to { transform: translate3d(40px,40px,0); } }

[data-c="band-light"]::before,
[data-c="band-dark"]::before,
[data-c="site-footer"]::before {
  content: ''; position: absolute; inset: -112px; z-index: -1;
  pointer-events: none; will-change: transform;
  animation: gridDrift 75s linear infinite;
}
[data-c="band-light"]::before {
  background:
    repeating-linear-gradient(0deg,  rgba(15,23,42,0.038) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(90deg, rgba(15,23,42,0.038) 0 1px, transparent 1px 56px);
}
[data-c="band-dark"]::before,
[data-c="site-footer"]::before {
  background:
    repeating-linear-gradient(0deg,  rgba(255,255,255,0.055) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(90deg, rgba(255,255,255,0.055) 0 1px, transparent 1px 56px);
}
/* Fade the grid out behind the big numbers. Without this the stat blocks sit on
   a patterned ground and lose the punch that makes them the page's best asset. */
[data-c="band-dark"]::before {
  -webkit-mask-image: radial-gradient(120% 92% at 24% 56%, transparent 0 24%, #000 66%);
          mask-image: radial-gradient(120% 92% at 24% 56%, transparent 0 24%, #000 66%);
}

/* Section content sits above the fixed canvas; the canvas sits above the band
   backgrounds. Three layers, in this order, or the dark bands paint over it. */
[data-c="band-light"] > *,
[data-c="band-dark"] > *,
[data-c="site-footer"] > * { position: relative; z-index: 2; }

/* ---------- the background watermark ----------
   The logo, very faint, parallaxing behind a handful of sections. Which
   sections is data (pages.json global.watermark); how loud it is, is here.

   THE COMPOUND SELECTOR IS THE POINT. The rule directly above puts every band
   child at `position:relative; z-index:2` and scores (0,1,0). A bare
   [data-c="band-mark"] would score exactly the same, so it would win or lose on
   source order alone — a rule whose correctness depends on nobody ever moving
   it. Qualifying it with the parent takes it to (0,2,0) and the question stops
   being a question. This file has been bitten twice by specificity ties.

   NO `transform` HERE, AND THAT IS DELIBERATE. main.js owns transform on this
   element for the parallax. The band grid above animates transform in CSS and
   the mark must not, or the two would fight and the one that is not !important
   would silently lose. Whoever owns a property owns all of its states.

   `overflow:hidden` on the band does the cropping, so the art can hang off the
   edge and be trimmed to the section rather than sized to fit inside it. */
[data-c="band-light"] > [data-c="band-mark"],
[data-c="band-dark"]  > [data-c="band-mark"] {
  --wm: 940px;
  position: absolute; z-index: 0;
  top: 50%; margin-top: calc(var(--wm) / -2);
  width: var(--wm); height: var(--wm);
  background-repeat: no-repeat; background-position: center; background-size: contain;
  pointer-events: none; will-change: transform;
}
[data-c="band-mark"][data-mark-side="right"] { right: -170px; }
[data-c="band-mark"][data-mark-side="left"]  { left:  -190px; }

/* Two opacities, and the gap between them is not a compromise. On #0D1117 the
   mark at 6% is a texture you notice on the second look. The same 6% on
   #F7F8FA is a gray smear sitting directly behind body copy, because a light
   ground gives a dark shape far more contrast than a dark ground gives a light
   one — the two are not symmetric and cannot share a number.

   The light figure was 2.2% and was, fairly, called too faint. At 5% the mark
   composites to #EEF5FA — 1.04:1 against the page white, so it reads on the
   open part of the band — and body copy set over it goes from 5.60:1 to
   5.40:1, still clear of AA's 4.5:1. Measured, not estimated. There is room to
   go to 7% (body copy 5.33:1) if it is ever wanted louder. */
[data-c="band-dark"]  > [data-c="band-mark"] { opacity: 0.06; }
[data-c="band-light"] > [data-c="band-mark"] { opacity: 0.05; }

/* Narrow screens: smaller, pushed further off-frame. At full size on a 390px
   phone the mark spans the whole column and stops being background. */
@media (max-width: 900px) {
  [data-c="band-light"] > [data-c="band-mark"],
  [data-c="band-dark"]  > [data-c="band-mark"] { --wm: 520px; }
  [data-c="band-mark"][data-mark-side="right"] { right: -150px; }
  [data-c="band-mark"][data-mark-side="left"]  { left:  -160px; }
}

/* ---------- the cursor-pulled upgrade layer ----------
   Ships hidden. assets/js/main.js adds .canvas-on only after a successful init,
   so no-JS, no-canvas and prefers-reduced-motion all keep the CSS grid above.
   pointer-events:none is load-bearing — without it this eats every click and
   scroll on the site. */
#warp { position: fixed; inset: 0; z-index: 1; pointer-events: none; display: none; }
body.canvas-on #warp { display: block; }
body.canvas-on [data-c="band-light"]::before,
body.canvas-on [data-c="band-dark"]::before,
body.canvas-on [data-c="site-footer"]::before { display: none; }

@media (prefers-reduced-motion: reduce) {
  [data-c="band-light"]::before,
  [data-c="band-dark"]::before,
  [data-c="site-footer"]::before { animation: none; }
}

/* ---------- interaction states ----------
   Translated from the export's style-hover / style-focus attributes.
   Every interactive element gets focus-visible, not just hover — half the point
   of the design system is that the built site is keyboard-navigable. */
/* Nav-link color lives HERE, in full, including the rest state. chrome.js
   used to inline `color:#0F172A` on every nav anchor, and an inline
   declaration beats any rule below that is not !important — so hover did
   nothing and the current page never went blue, it only got its underline.
   Order matters in this block: the [data-nav-ext] rest color has to come
   before the hover rules, and the hover rule that re-states [data-nav-ext]
   exists so it outranks that rest color rather than tying with it. */
[data-c="nav-link"] { color: #0F172A; }
/* External destinations — the CMS. Quieter than a page link, because it is a
   door to another application rather than more of this site. */
[data-c="nav-link"][data-nav-ext] { color: #5A6478; }
[data-c="nav-link"]:hover,
[data-c="nav-link"][data-nav-ext]:hover { color: #0EA5E9; }
[data-c="nav-link"][aria-current="page"] { color: #0EA5E9; text-decoration: underline; text-underline-offset: 6px; }
[data-c="btn-primary"]:hover { background: #0284C7; color: #fff; }
/* The language switch in the header (scripts/chrome.js#langSwitch). The same
   hover as a nav link, on a quiet bordered pill so it reads as a control and
   not as another page in the nav. Kept out of the inline style so the hover
   can win. */
[data-c="lang-switch"] { color: #0F172A; border: 1px solid #E3E6EC; background: #FFFFFF; }
[data-c="lang-switch"]:hover { color: #0EA5E9; border-color: #0EA5E9; }

/* ACCENTED CAPITALS NEED HEADROOM. The small mono labels are set 10px/1, a
   line box exactly one em tall, which leaves nothing above the capitals. In
   English that never mattered. In Spanish the accent on an É or Í sits above
   that box, and where a label is the first thing in a band, the band's
   overflow:hidden (which is there to hold its drifting grid) cuts it off:
   "QUÉ PASA DESPUÉS" rendered as "QUE PASA DESPUES". Three pixels of top
   padding on the Spanish pages gives the accent room. The section eyebrows
   are left alone; they are 20px/1.3 and already have it. */
html[lang="es"] [data-c="mono-label"]:not([data-section-eyebrow]) { padding-top: 3px; }
[data-c="btn-primary"]:active { background: #0369A1; }
[data-c="btn-primary-dark"]:hover { background: #7DD3FC; }
[data-c="btn-secondary"]:hover { border-color: #0EA5E9; color: #0EA5E9; }
[data-c="link-arrow"]:hover { color: #0284C7; }
[data-c="footer-links"] a:hover { color: #38BDF8; }
[data-c="service-card"]:hover,
[data-c="work-card"]:hover { border-color: #CBD5E1; transform: translateY(-2px); }
[data-c="service-card"], [data-c="work-card"] { transition: border-color .18s ease, transform .18s ease; }

a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible { outline: 2px solid #0EA5E9; outline-offset: 3px; border-radius: 4px; }

/* Horizontal scrollers keep the scroll, lose the gray browser bar. The cut-off
   pill at the right edge is the affordance instead. */
[data-scroll-x] { scrollbar-width: none; -ms-overflow-style: none; overflow-x: auto; }
[data-scroll-x]::-webkit-scrollbar { display: none; }

/* ---------- accordion ----------
   Rows are <details>/<summary>, so they work with no JS at all and are open to
   in-page search. main.js only adds the smooth height transition. */
[data-c="faq-row"] > summary { list-style: none; cursor: pointer; }
[data-c="faq-row"] > summary::-webkit-details-marker { display: none; }
[data-c="faq-row"] [data-chevron] { transition: transform .18s ease; }
[data-c="faq-row"][open] [data-chevron] { transform: rotate(180deg); }

/* ---------- the sticky header ----------
   The header follows the page down (position:sticky in chrome.js) and goes
   translucent once it has left the top, so the content scrolling under it
   stays visible rather than sliding beneath an opaque white bar.

   WHY THE STATE IS AN ATTRIBUTE AND NOT JUST CSS. There is no CSS selector for
   "the page has been scrolled", and the header should NOT be translucent at
   rest: at scroll 0 it sits on the page's own background, so blurring is a
   blur of nothing and the only visible effect is a white that no longer quite
   matches the white beside it. main.js sets [data-scrolled] past 8px.

   THE FALLBACK MATTERS. Without backdrop-filter (Firefox with it disabled, and
   anything older) a 72%-opaque bar over moving text is unreadable, so the
   @supports block is what buys the transparency — browsers that cannot blur
   keep the solid bar they have now, which is not a downgrade, just the
   previous design. */
[data-c="site-header"][data-scrolled] {
  box-shadow: 0 1px 0 rgba(15, 23, 42, .04), 0 8px 24px rgba(15, 23, 42, .06);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  /* !important here is load-bearing, not lazy. The header carries
     `background:#FFFFFF` as an INLINE style — chrome.js writes it there so the
     bar is still a bar if this stylesheet fails to load — and an inline
     declaration outranks any stylesheet rule no matter its selector weight.
     Overriding one is the case !important was actually added to CSS for. The
     backdrop-filter beside it needs none: nothing sets that inline. */
  [data-c="site-header"][data-scrolled] {
    background: rgba(255, 255, 255, 0.85) !important;
    border-bottom-color: rgba(227, 230, 236, .8) !important;
    -webkit-backdrop-filter: saturate(180%) blur(18px);
    backdrop-filter: saturate(180%) blur(18px);
  }
  /* The drawer is the header on a phone — a translucent panel with the page
     showing through behind a list of links is unreadable, so it goes solid
     the moment it opens. */
  body.nav-open [data-c="site-header"][data-scrolled] {
    background: #FFFFFF !important;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* A sticky header covers the top 60px of whatever a #hash link lands on. This
   is the one line that stops the FAQ's table of contents, the pricing ladder
   anchors and the skip link from all landing under it. 76px = the 60px bar
   plus a little air; 72px on a phone, where the bar is 56px. */
html { scroll-padding-top: 76px; }
@media (max-width: 767px) { html { scroll-padding-top: 72px; } }

@media (prefers-reduced-motion: reduce) {
  [data-c="site-header"] { transition: none !important; }
}

/* ---------- side-scrolling strips ----------
   The shell and the two arrows are built by main.js around anything carrying
   [data-scroll-x]; these are their looks. Everything hangs off two attributes
   the driver toggles: [data-scroll-over] on the shell when the row is actually
   wider than its box, and [data-scroll-off] on an arrow when that end has been
   reached. Both default to hidden, so a row that fits — or a page with no
   JavaScript — shows nothing at all.

   pointer-events follows opacity deliberately. An arrow faded to 0 that still
   swallows clicks is a dead zone over the first pill in the row, which is the
   one most likely to be tapped. */
[data-scroll-shell] { position: relative; }
[data-scroll-shell] [data-scroll-arrow] {
  position: absolute; top: 50%; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; margin-top: -14px; padding: 0;
  border: 1px solid #DDE2E9; border-radius: 999px;
  background: rgba(255, 255, 255, .92); color: #0F172A;
  box-shadow: 0 2px 8px rgba(15, 23, 42, .10);
  cursor: pointer; opacity: 0; pointer-events: none;
  transition: opacity 180ms ease;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
[data-scroll-shell] [data-scroll-arrow="-1"] { left: -2px; }
[data-scroll-shell] [data-scroll-arrow="1"] { right: -2px; }
[data-scroll-shell][data-scroll-over] [data-scroll-arrow]:not([data-scroll-off]) {
  opacity: 1; pointer-events: auto;
}
[data-scroll-shell] [data-scroll-arrow]:hover { border-color: #0F172A; }

/* On the dark Spine strip a white pill would be the brightest thing in the
   band, over a row of tabs that are deliberately quiet. */
[data-scroll-shell-dark] [data-scroll-arrow] {
  border-color: #24303F; background: rgba(13, 17, 23, .86); color: #E6ECF5;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .5);
}
[data-scroll-shell-dark] [data-scroll-arrow]:hover { border-color: #4A5A6E; }

@media (prefers-reduced-motion: reduce) {
  [data-scroll-shell] [data-scroll-arrow] { transition: none; }
}

/* ==================================================================
   MOTION
   The whole of the hiding, and the whole of the safety, is the
   [data-motion] on <html>. Only assets/js/main.js sets it, and it sets
   it AFTER checking prefers-reduced-motion — so no JavaScript, an old
   browser, or a reader who has asked for stillness all get the finished
   page with nothing hidden and nothing transitioning.

   Written as custom properties rather than fixed numbers because
   /motion.html drives them live: one write to the root restyles every
   reveal on the page at once, which is what makes the lab worth having.
   ================================================================== */
:root {
  --m-dur: 620ms;
  --m-dist: 18px;
  --m-delay: 0ms;
  --m-ease: cubic-bezier(.16, 1, .3, 1);
}

[data-motion=""] [data-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--m-dist), 0);
  transition: opacity var(--m-dur) var(--m-ease) var(--m-delay),
              transform var(--m-dur) var(--m-ease) var(--m-delay);
}
[data-motion=""] [data-reveal][data-revealed] {
  opacity: 1;
  transform: none;
}

/* THE UNTYPED REMAINDER KEEPS ITS BOX. visibility:hidden, not display
   or opacity — the text still takes up exactly the room it will take up,
   so the line breaks and the height are the finished ones from the first
   character and nothing below the headline moves.

   display:contents is doing real work here and it is not obvious. The
   hero headline is text-wrap:balance, and balancing looks at the boxes it
   is given — so splitting the sentence into two inline spans made it
   balance DIFFERENTLY from the unsplit sentence, and the line below the
   hero stepped up nine pixels the moment typing began. With no box of its
   own the remainder's text is laid out as though it had never been split,
   which is exactly the promise being made: same breaks, same height,
   before, during and after. */
[data-type-rest] { display: contents; visibility: hidden; }

/* The caret, and only while something is being typed. It hangs off the
   TYPED span, not the element, or it would sit after the invisible
   remainder at the end of the sentence. A block rather than a bar: at a
   headline's weight a 1px bar disappears. */
[data-typing] [data-type-shown] { position: relative; }
[data-typing] [data-type-shown]::after {
  content: "";
  /* ABSOLUTE, AND THAT IS THE WHOLE OF IT. As an inline-block the caret is
     0.86em tall sitting on the baseline, which at a 68px headline is taller
     than the line box — so the line grew by nine pixels the moment typing
     started and everything below the hero stepped down. Out of flow it
     renders at exactly the same place and contributes nothing to the line. */
  position: absolute;
  width: 0.5em;
  height: 0.86em;
  margin-left: 0.06em;
  transform: translateY(0.06em);
  background: currentColor;
  opacity: .85;
  animation: m-caret 1s steps(1, end) infinite;
}
@keyframes m-caret { 0%, 50% { opacity: .85; } 50.01%, 100% { opacity: 0; } }

/* Belt and braces. If the attribute is ever set by something that has
   not checked, this still wins. */
@media (prefers-reduced-motion: reduce) {
  [data-motion=""] [data-reveal],
  [data-motion=""] [data-reveal][data-revealed] {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }
  [data-typing] [data-type-shown]::after { display: none; }
}
