/* ==========================================================================
   Boreal Data Labs — borealdatalabs.com
   Theme: boreal night. Mobile-first. Breakpoints at 720px and 1080px only;
   everything between is handled by clamp() and auto-fit grids.
   ========================================================================== */

/* ---------------------------------------------------------------- tokens */
:root {
  --bg: #0A1220;
  --bg-raised: #101B2E;
  --ink: #E8EEF6;
  --ink-dim: #93A3B8;
  --teal: #2FD4C4;
  --aurora-green: #4ADE80;
  --blue: #3B82F6;
  --line: #1D2C42;

  --accent: linear-gradient(135deg, var(--teal), var(--blue));

  --font-sans: "DM Sans", "DM Sans Fallback", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, Consolas, monospace;

  --shadow: 0 8px 30px rgb(0 0 0 / 0.35);
  --radius-card: 12px;
  --radius-btn: 8px;

  /* Sized to hold the nav mark with breathing room: 44px mark + 8px padding
     each side + the 1px border. The hero subtracts this token from 100svh, so
     changing it here is the only edit needed — the calc picks it up. */
  --header-h: 64px;
  --shell-pad: clamp(1rem, 4vw, 2.5rem);
}

/* ------------------------------------------------------- font fallbacks */
/* Google Fonts load with display=swap (per brief), so the fallback is painted
   first and then replaced. Left to system-ui that swap reflowed the hero and
   cost 0.139 CLS, because Segoe UI is ~4% narrower than DM Sans and the H1
   re-wrapped. Arial and Courier New are near-exact width matches (100.33% and
   99.98%, measured against the loaded webfonts), so with these overrides the
   fallback occupies the same space and the swap shifts nothing. */
@font-face {
  font-family: "DM Sans Fallback";
  src: local("Arial"), local("Helvetica"), local("Liberation Sans");
  size-adjust: 100.33%;
  ascent-override: 98.88%;
  descent-override: 30.9%;
  line-gap-override: 0%;
}
@font-face {
  font-family: "JetBrains Mono Fallback";
  src: local("Courier New"), local("Liberation Mono");
  size-adjust: 99.98%;
  ascent-override: 102.02%;
  descent-override: 30%;
  line-gap-override: 0%;
}

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: clamp(1rem, 0.95rem + 0.2vw, 1.125rem);
  line-height: 1.65;
  /* clip (not hidden) so no descendant can produce a horizontal scrollbar
     while position:sticky on the header keeps working. */
  overflow-x: clip;
}

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

h1, h2, h3 {
  line-height: 1.15;
  margin: 0;
  font-weight: 700;
  letter-spacing: -0.015em;
  text-wrap: balance;
}

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

a { color: var(--teal); }

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

/* Sections sit below the sticky header when jumped to via anchor. */
section[id], #top { scroll-margin-top: calc(var(--header-h) + 10px); }

/* ---------------------------------------------------------- shell / grid */
.shell {
  width: 100%;
  max-width: 1200px;
  margin-inline: auto;
  padding-inline: var(--shell-pad);
}

/* ------------------------------------------------------------ skip link */
.skip-link {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 200;
  padding: 0.7rem 1.1rem;
  background: var(--bg-raised);
  color: var(--ink);
  border: 1px solid var(--teal);
  border-radius: 0 0 var(--radius-btn) 0;
  font-weight: 500;
  text-decoration: none;
  transform: translateY(-120%);
}
.skip-link:focus-visible { transform: none; }

/* --------------------------------------------------------- boot veil §5b */
/* Hidden by default: without JS the .booting class is never added, so the
   veil is never rendered and can never trap clicks or a screen reader. */
.veil { display: none; }

html.booting .veil {
  display: grid;
  place-items: center;
  position: fixed;
  inset: 0;
  z-index: 150;
  background: var(--bg);
  opacity: 1;
  /* The fade is triggered by script.js adding .is-leaving rather than by a
     fixed CSS delay, so the 0.6s minimum hold can be enforced against the
     actual load time instead of guessed at. */
  transition: opacity 0.3s ease;
}
html.booting .veil.is-leaving { opacity: 0; }

.veil__mark {
  font-size: clamp(1.6rem, 6vw, 2.6rem);
  font-weight: 400;
  letter-spacing: -0.02em;
  white-space: nowrap;
  color: var(--teal); /* fallback when background-clip:text is unsupported */
}
.veil__mark strong { font-weight: 700; }

@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .veil__mark {
    color: transparent;
    background-image: linear-gradient(
      100deg,
      var(--ink) 0%, var(--ink) 26%,
      var(--aurora-green) 38%, var(--teal) 47%, var(--blue) 56%,
      var(--ink) 68%, var(--ink) 100%
    );
    background-size: 320% 100%;
    /* Must stay within 0%-100%. Because the gradient is wider than the text
       box, any position in that range still covers it completely; outside it
       the image slides off and uncovered glyphs render fully transparent —
       which made "Bore" vanish mid-sweep. */
    background-position: 100% 0;
    background-repeat: no-repeat;
    -webkit-background-clip: text;
    background-clip: text;
    /* Completes inside the 0.6s minimum hold, leaving a beat on the finished
       wordmark before the fade begins. */
    animation: veil-sweep 0.5s ease-out forwards;
  }
}

@keyframes veil-sweep { to { background-position: 0% 0; } }

/* ---------------------------------------------------------------- header */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: color-mix(in srgb, var(--bg-raised) 88%, transparent);
  border-bottom: 1px solid var(--line);
  backdrop-filter: blur(10px);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  /* Less the 1px bottom border on .site-header, so the header's *total* height
     equals --header-h exactly. The hero subtracts that token from 100svh, and
     any mismatch pushes the hero past the fold by the difference. */
  min-height: calc(var(--header-h) - 1px);
}

.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  font-size: 1.0625rem;
  letter-spacing: -0.01em;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  padding-block: 0.5rem;
}

/* The transparent mark sits on the header surface directly. Sized in CSS with
   a 1:1 intrinsic ratio, so the 28px -> 32px swap at 720px causes no reflow.
   flex:none keeps it from being squeezed when the nav is tight. */
/* 44/48px: 48px is the first size at which this raster mark's mountain and
   network nodes stay genuinely crisp. 48+16 padding = 64 still fits the 67px
   desktop inner box, but 44+16 = 60 did not fit the old 57px mobile one, so
   --header-h rose from 58px to 64px on mobile. Desktop stayed at 68px. */
.wordmark__mark {
  width: 44px;
  height: 44px;
  flex: none;
}

/* Scoped to the name wrapper so the rules don't also catch the mark or the
   wrapper itself. Normal inline flow inside it preserves the word space. */
.wordmark__name strong { font-weight: 700; }
.wordmark__name > span { font-weight: 400; color: var(--ink-dim); }

/* Hamburger. Stays [hidden] until script.js unhides it, so it never appears
   as a dead control when JS is off. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin-right: -10px;
  padding: 0;
  background: none;
  border: 0;
  border-radius: var(--radius-btn);
  color: var(--ink);
  cursor: pointer;
}
.nav-toggle__bars,
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  display: block;
  width: 20px;
  height: 2px;
  background: currentColor;
  border-radius: 2px;
}
.nav-toggle__bars { position: relative; }
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  content: "";
  position: absolute;
  left: 0;
}
.nav-toggle__bars::before { top: -6px; }
.nav-toggle__bars::after  { top: 6px; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars { background: transparent; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::before { top: 0; transform: rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::after  { top: 0; transform: rotate(-45deg); }

.site-nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(0.5rem, 2vw, 1.75rem);
}
.site-nav a {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding-inline: 0.35rem;
  color: var(--ink-dim);
  font-size: 0.9375rem;
  font-weight: 500;
  text-decoration: none;
  transition: color 0.2s ease;
}
.site-nav a:hover { color: var(--ink); }

/* ------------------------------------------------------------------ hero */
.hero {
  position: relative;
  display: grid;
  align-content: center;
  /* svh, not vh: mobile browser chrome must not push content out of view.
     The header is subtracted because the hero starts below it — otherwise the
     hero is one screen *plus* the header and the CTAs fall off the fold. */
  min-height: min(calc(100svh - var(--header-h)), 900px);
  padding-block: clamp(3rem, 9vh, 6rem) clamp(3.5rem, 11vh, 7rem);
  isolation: isolate;
  border-bottom: 1px solid var(--line);
}

.hero__inner { position: relative; z-index: 1; }

/* Large mark, occupying the slot the eyebrow used to. Block-level and first in
   flow, so its left edge is the container's content edge — identical to the
   H1's. Width and height use the same clamp so it stays square, and the 1:1
   intrinsic ratio in the HTML attributes means no reservation error. */
.hero__mark {
  width: clamp(80px, 9vw, 128px);
  height: clamp(80px, 9vw, 128px);
  margin-bottom: clamp(0.9rem, 1.6vw, 1.5rem);
}

/* The H1 carries no top margin — the space above it comes from .hero__mark's
   margin, and from the hero's padding plus grid centring above that. */
.hero__title {
  font-size: clamp(2.2rem, 5vw + 1rem, 4.5rem);
  max-width: 20ch;
  margin: 0;
}

/* Accent gradient on part of the headline, with a solid fallback. */
.grad { color: var(--teal); }
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .grad {
    color: transparent;
    background-image: var(--accent);
    -webkit-background-clip: text;
    background-clip: text;
  }
}

.hero__sub {
  max-width: 62ch;
  margin-top: clamp(1rem, 2.5vh, 1.6rem);
  color: var(--ink-dim);
}

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: clamp(1.5rem, 4vh, 2.4rem);
}

/* ------------------------------------------------------- aurora §5 */
/* Fixed to the viewport and living at body level rather than inside the hero,
   so the curtains stay behind the whole document as it scrolls. This is safe
   because everything that passes over them is either opaque (.card, the
   footer) or carries its own scrim (.hero::after) — the section headings are
   the only text that ever sits directly on the aurora. */
.aurora {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none; /* never intercept clicks */
  z-index: 0;
  /* Ambient strength, and deliberately the dim end of the range: with
     JavaScript off nothing ever adjusts this. A background that is too quiet
     costs nothing, one that is too bright costs contrast on every heading.
     0.38 rather than 0.42 because the 12px .eyebrow is the tightest text on
     the page and measured 4.29:1 against a 4.5 requirement at the higher
     value. Do not raise it without re-running the contrast report. */
  opacity: 0.38;
}

/* The downward fade used to be a mask on .aurora. A mask forces its subtree
   into a render surface that has to be re-composited as the page scrolls under
   it, which cost 44 dropped frames out of 150 on a scroll; painting the same
   fade as a plain gradient over the top costs none. It is a sibling rather than
   a child so the aurora's own opacity does not dilute it.

   Top-weighted, like the real thing, and it keeps the lower part of every
   screen clear, which is where body copy sits at any scroll position. */
.aurora-fade {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--bg) 0%, transparent) 0%,
    color-mix(in srgb, var(--bg) 0%, transparent) 40%,
    color-mix(in srgb, var(--bg) 62%, transparent) 66%,
    var(--bg) 88%);
}

/* With JavaScript the strength tracks scroll position: full through the hero,
   settling to ambient once the content starts. The custom property is unset
   until the first handler runs, and the fallback is the hero value because the
   page always opens at the top. */
.has-js .aurora { opacity: var(--aurora-strength, 1); }

/* .aurora is a positioned z-index:0 box that precedes the content, so without
   this it would paint over the non-positioned blocks that follow it. The
   header already sits at 100 and the boot veil at 150. */
main,
.site-footer { position: relative; z-index: 1; }

/* Ribbons are plain divs, not SVG <g> elements: Chrome does not promote SVG
   children to compositor layers, so animating them repainted the whole SVG on
   the main thread every frame. Divs get their own layers, so the same
   transforms run on the compositor.

   Softness comes from the gradients themselves rather than a blur filter. With
   the aurora fixed behind scrolling content, a blurred layer has to be
   re-rasterised as the page moves under it, which halves the frame rate:
   measured over a 150-frame scroll, median frame time was 16.7ms with no blur
   and 33-50ms with any blur at all. That curve is a cliff, not a slope —
   blur(3px) cost as much as blur(22px) — so there was no radius to trade down
   to. A radial gradient fading to transparent on every edge gives the same
   diffuse curtain and composites for free. Do not reintroduce a filter here.

   Widths are ~1.4x what they were, because blur(22px) used to spread each
   ribbon well beyond its box and the gradient must now cover that spread
   itself. Centres are unchanged. */
.ribbon {
  position: absolute;
  top: -7%;
  height: 85%;
}

.ribbon--a { left: -12.9%; width: 36.7%;
  background: radial-gradient(60% 72% at 50% 26%, rgb(74 222 128 / .95) 0%, rgb(74 222 128 / .62) 30%, rgb(47 212 196 / .26) 62%, rgb(47 212 196 / 0) 100%); }
.ribbon--b { left: 19.9%;  width: 46.6%;
  background: radial-gradient(60% 72% at 50% 26%, rgb(47 212 196 / .95) 0%, rgb(47 212 196 / .60) 32%, rgb(59 130 246 / .24) 64%, rgb(59 130 246 / 0) 100%); }
.ribbon--c { left: 59.6%;  width: 38.6%;
  background: radial-gradient(60% 72% at 50% 26%, rgb(59 130 246 / .90) 0%, rgb(59 130 246 / .56) 32%, rgb(47 212 196 / .22) 64%, rgb(47 212 196 / 0) 100%); }
.ribbon--d { left: 91.1%;  width: 26%;
  background: radial-gradient(60% 72% at 50% 26%, rgb(74 222 128 / .80) 0%, rgb(47 212 196 / .48) 34%, rgb(59 130 246 / .20) 66%, rgb(59 130 246 / 0) 100%); }
.ribbon--e { left: 10.6%;  width: 15.1%;
  background: radial-gradient(64% 70% at 50% 28%, rgb(74 222 128 / .95) 0%, rgb(74 222 128 / .60) 30%, rgb(47 212 196 / .24) 62%, rgb(47 212 196 / 0) 100%); }
.ribbon--f { left: 47.5%;  width: 17.6%;
  background: radial-gradient(64% 70% at 50% 28%, rgb(59 130 246 / .90) 0%, rgb(59 130 246 / .56) 32%, rgb(47 212 196 / .22) 64%, rgb(47 212 196 / 0) 100%); }
.ribbon--g { left: 78%;    width: 13.2%;
  background: radial-gradient(64% 70% at 50% 28%, rgb(47 212 196 / .95) 0%, rgb(47 212 196 / .60) 32%, rgb(59 130 246 / .24) 64%, rgb(59 130 246 / 0) 100%); }

/* Readability scrim over the hero. The aurora can therefore run at full
   strength in the empty regions where it reads as sky, while the area behind
   the text keeps its contrast. An ellipse anchored near the headline rather
   than to the viewport's left edge, so it still tracks the text once the shell
   centres itself on wide screens.

   The aurora now sits behind the whole page, so this scrim no longer separates
   two siblings — it darkens the fixed layer showing through the hero. It stays
   below .hero__inner (z-index 1) as a positioned z-index:auto box. */
.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    /* Tight enough to sit under the text block only. The previous 125%x95%
       ellipse covered practically the whole hero, which cancelled out the
       brighter ribbons instead of protecting a specific area. */
    radial-gradient(88% 64% at 24% 46%,
      color-mix(in srgb, var(--bg) 86%, transparent) 0%,
      color-mix(in srgb, var(--bg) 70%, transparent) 46%,
      color-mix(in srgb, var(--bg) 24%, transparent) 68%,
      transparent 82%),
    linear-gradient(to bottom,
      color-mix(in srgb, var(--bg) 12%, transparent) 0%,
      transparent 55%);
}

/* Only transform and opacity animate, both compositor properties, so a ribbon
   is rasterised once and then re-composited rather than repainted per frame.
   Do not add a filter here: see the note above the gradients. */
.ribbon { will-change: transform, opacity; }

/* Hold the curtains still until the page has loaded. Animating seven layers
   during load competed with the main thread and cost ~600ms of mobile LCP
   (Performance 98 -> 91). The boot veil covers the hero
   for the first ~900ms regardless, so nothing is lost visually. Gated on
   .has-js so that without JavaScript — where nothing would ever unpause them —
   the animations simply run from the start as before. */
.has-js .ribbon { animation-play-state: paused; }
.has-js.aurora-live .ribbon { animation-play-state: running; }
/* Opacity now breathes inside each keyframe set, which is what reads as a
   living curtain rather than a drifting shape. The value declared here is the
   frozen state used under prefers-reduced-motion, so it sits mid-range.
   Durations are deliberately non-harmonic so the layers never resynchronise. */
/* Negative delays start each layer part-way through its cycle. Without them
   every layer sits at its 0% keyframe on load — the dim end — so the hero
   opened on the flattest frame it ever shows and only lit up ~10s later. */
/* The declared opacity is the frozen prefers-reduced-motion state, so it sits
   mid-range in each keyframe set — a reduced-motion visitor gets an aurora of
   comparable weight, not a washed-out one. All are below the animated peaks
   the contrast report measured against, so they cannot be the worst case. */
.ribbon--a { opacity: 0.62; animation: drift-a 15s ease-in-out infinite; animation-delay: -5s; }
.ribbon--b { opacity: 0.56; animation: drift-b 18s ease-in-out infinite; animation-delay: -7s; }
.ribbon--c { opacity: 0.48; animation: drift-c 17s ease-in-out infinite; animation-delay: -3s; }
.ribbon--d { opacity: 0.38; animation: drift-d 24s ease-in-out infinite; animation-delay: -11s; }
.ribbon--e { opacity: 0.44; animation: drift-e 16s ease-in-out infinite; animation-delay: -6s; }
.ribbon--f { opacity: 0.40; animation: drift-f 21s ease-in-out infinite; animation-delay: -9s; }
.ribbon--g { opacity: 0.34; animation: drift-g 25s ease-in-out infinite; animation-delay: -14s; }

/* The blur feature check that used to guard this is gone: the curtains are
   plain radial gradients now, so there is no filter to fall back from. */

/* Each layer drifts horizontally, stretches vertically, and breathes in
   intensity on its own period. Three offset phases per layer rather than a
   simple there-and-back, so no single layer reads as a loop. */
/* Peaks calibrated against the measured contrast headroom: the worst-case
   background behind the sub sat at luminance 0.008 against a 0.041 ceiling, so
   there was roughly 5x of room. Re-measure if these move. */
/* Horizontal drift carries the motion, and it is cheap: a translate on a
   composited layer needs no re-rasterisation. Amplitude is roughly 4x an
   earlier version, which drifted about 5 screen px per second and read as
   static.

   scaleY stays in a narrow band and translateY is dropped entirely — scaling a
   layer forces Chrome to re-rasterise it at the new size, and a 0.88-1.24 range
   across seven layers cost ~700ms of mobile LCP. Opacity is free by
   comparison. */
/* Travel is expressed in vw so it scales with the viewport exactly as it did
   inside the SVG viewBox, where 1 user unit was 128%/1200 = 0.1067vw. Using
   raw px here would have cut the drift by about a third at 1440. */
@keyframes drift-a {
  0%, 100% { transform: translate3d(-17.6vw, 0, 0) scaleY(0.98); opacity: 0.28; }
  30%      { transform: translate3d(-4.3vw, 0, 0)  scaleY(1.05); opacity: 0.90; }
  62%      { transform: translate3d(10.1vw, 0, 0)  scaleY(1.00); opacity: 0.44; }
}
@keyframes drift-b {
  0%, 100% { transform: translate3d(16vw, 0, 0)    scaleY(1.04); opacity: 0.26; }
  36%      { transform: translate3d(2.1vw, 0, 0)   scaleY(0.98); opacity: 0.82; }
  68%      { transform: translate3d(-12.8vw, 0, 0) scaleY(1.05); opacity: 0.40; }
}
@keyframes drift-c {
  0%, 100% { transform: translate3d(-14.9vw, 0, 0) scaleY(0.99); opacity: 0.22; }
  42%      { transform: translate3d(3.2vw, 0, 0)   scaleY(1.06); opacity: 0.74; }
  74%      { transform: translate3d(17.1vw, 0, 0)  scaleY(1.01); opacity: 0.36; }
}
@keyframes drift-d {
  0%, 100% { transform: translate3d(13.3vw, 0, 0)  scaleY(1.03); opacity: 0.18; }
  48%      { transform: translate3d(-11.7vw, 0, 0) scaleY(0.97); opacity: 0.58; }
}
@keyframes drift-e {
  0%, 100% { transform: translate3d(9.1vw, 0, 0)   scaleY(0.98); opacity: 0.20; }
  34%      { transform: translate3d(-10.1vw, 0, 0) scaleY(1.06); opacity: 0.68; }
  70%      { transform: translate3d(-19.2vw, 0, 0) scaleY(1.01); opacity: 0.32; }
}
@keyframes drift-f {
  0%, 100% { transform: translate3d(-13.9vw, 0, 0) scaleY(1.05); opacity: 0.18; }
  40%      { transform: translate3d(6.4vw, 0, 0)   scaleY(0.98); opacity: 0.62; }
  76%      { transform: translate3d(15.5vw, 0, 0)  scaleY(1.04); opacity: 0.30; }
}
@keyframes drift-g {
  0%, 100% { transform: translate3d(12.3vw, 0, 0)  scaleY(1.02); opacity: 0.16; }
  46%      { transform: translate3d(-11.2vw, 0, 0) scaleY(1.05); opacity: 0.52; }
}

/* --------------------------------------------------------------- sections */
.section {
  padding-block: clamp(3.5rem, 9vw, 6.5rem);
  border-bottom: 1px solid var(--line);
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--teal);
  margin: 0;
}

.section__title {
  font-size: clamp(1.75rem, 3vw + 0.5rem, 2.75rem);
  margin-block: 0.5rem clamp(1.75rem, 4vw, 2.75rem);
}

/* ------------------------------------------------------------------ cards */
.grid { display: grid; gap: clamp(0.875rem, 2vw, 1.25rem); }
.grid--services { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
/* min() keeps the track floor from exceeding the container on a 320px phone,
   where a bare minmax(300px,...) would overflow its own gutter. */
.grid--products { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); }

.card {
  position: relative;
  padding: clamp(1.25rem, 3vw, 1.75rem);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
}

/* Gradient hover ring, drawn as a masked outline so the 1px border geometry
   never changes (no reflow, no layout shift). */
.card::after {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: 13px;
  padding: 1px;
  background: var(--accent);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;
}
.card:hover::after,
.card:focus-within::after { opacity: 1; }

.card h3 {
  font-size: clamp(1.05rem, 0.9rem + 0.4vw, 1.25rem);
  margin-bottom: 0.5rem;
}
.card p { color: var(--ink-dim); font-size: 0.9375rem; }

.icon {
  width: 24px;
  height: 24px;
  margin-bottom: 0.9rem;
  fill: none;
  stroke: var(--teal);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Featured product card */
.card--featured {
  margin-bottom: clamp(0.875rem, 2vw, 1.25rem);
  padding: clamp(1.5rem, 4vw, 2.5rem);
  background:
    linear-gradient(135deg, rgb(47 212 196 / 0.07), rgb(59 130 246 / 0.05)),
    var(--bg-raised);
  box-shadow: var(--shadow);
}
.card--featured__title {
  font-size: clamp(1.5rem, 2.5vw + 0.5rem, 2.25rem);
  margin-bottom: 0.6rem;
}
.card--featured p { font-size: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem); max-width: 68ch; }

/* The featured card runs full width, which left a large empty column beside the
   copy. The preview fills it from 720px up; below that everything stacks. */
.card--featured .shots { margin: clamp(1.25rem, 3vw, 1.75rem) 0 0; }
@media (min-width: 720px) {
  .card--featured {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(1.75rem, 4vw, 3rem);
  }
  .card--featured .shots { margin: 0; }
}

.tag {
  display: inline-block;
  margin-bottom: 0.85rem;
  padding: 0.25rem 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--teal);
  background: rgb(47 212 196 / 0.1);
  border: 1px solid rgb(47 212 196 / 0.32);
  border-radius: 999px;
}

.meta {
  margin-top: 0.9rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
  color: var(--ink-dim);
}

/* ------------------------------------------------- product screenshots */
.shots {
  margin: 0 0 clamp(1rem, 2.5vw, 1.35rem);
}

/* aspect-ratio reserves the box before any image decodes, so rotating slides
   can never shift layout. Matches the 1100x529 intrinsic size. */
.shots__frame {
  position: relative;
  aspect-ratio: 1100 / 529;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--bg);
}

.shots__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  visibility: hidden;
  /* visibility flips only after the fade, so an outgoing slide stays painted
     while it fades but is removed from the a11y tree once gone. */
  transition: opacity 0.6s ease, visibility 0s linear 0.6s;
}
/* Without JS the first slide is simply the one that shows. */
.shots__img:first-child { opacity: 1; visibility: visible; }
.has-js .shots__img:first-child { opacity: 0; visibility: hidden; }
.has-js .shots__img.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.6s ease, visibility 0s;
}

.shots__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-height: 28px;
  margin-top: 0.5rem;
}

.shots__dots { display: flex; gap: 0.15rem; }

/* 44px hit area around a small visual dot, so the target is reachable without
   making the indicator itself heavy. */
.shots__dot {
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.shots__dot::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--line);
  transition: background-color 0.2s ease, transform 0.2s ease;
}
.shots__dot:hover::before { background: var(--ink-dim); }
.shots__dot[aria-current="true"]::before {
  background: var(--teal);
  transform: scale(1.25);
}

.shots__toggle {
  display: none;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--ink-dim);
  cursor: pointer;
}
.has-js .shots__toggle:not([hidden]) { display: grid; place-items: center; }
.shots__toggle:hover { color: var(--teal); }
.shots__toggle svg { width: 13px; height: 13px; fill: currentColor; }
.shots__toggle .shots__play { display: none; }
.shots__toggle[aria-pressed="true"] .shots__pause { display: none; }
.shots__toggle[aria-pressed="true"] .shots__play { display: block; }

.card__link { margin-top: 1.15rem; }

.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  font-weight: 500;
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.link-arrow span { transition: transform 0.2s ease; }
.link-arrow:hover { border-bottom-color: currentColor; }
.link-arrow:hover span { transform: translateX(3px); }

/* ---------------------------------------------------------------- buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0.7rem 1.35rem;
  border-radius: var(--radius-btn);
  border: 1px solid transparent;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 500;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  transition: transform 0.2s ease, background-color 0.2s ease,
              border-color 0.2s ease, opacity 0.2s ease;
}
.btn:hover { transform: translateY(-1px); }

.btn--primary {
  background-image: var(--accent);
  color: var(--bg); /* dark on teal 10.2:1, on blue 5.1:1 — both pass AA */
  font-weight: 700;
}
.btn--primary:hover { opacity: 0.92; }

.btn--ghost {
  border-color: var(--line);
  color: var(--ink);
  background: transparent;
}
.btn--ghost:hover { border-color: var(--teal); background: rgb(47 212 196 / 0.06); }

.btn--secondary {
  color: var(--teal);
  background: rgb(47 212 196 / 0.12);
  border-color: rgb(47 212 196 / 0.4);
  font-weight: 700;
}
.btn--secondary:hover { background: rgb(47 212 196 / 0.18); border-color: var(--teal); }

/* ---------------------------------------------------------------- contact */
/* Single column since the logo card was removed: a full-width panel in the
   same surface language as the cards above, with the address and the actions
   pushed to opposite ends. That fills the measure instead of leaving a
   half-width column with dead space beside it. */
.contact {
  display: grid;
  gap: clamp(1.5rem, 4vw, 2rem);
  padding: clamp(1.375rem, 3.5vw, 2.25rem);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
}

address {
  font-style: normal;
  color: var(--ink-dim);
  line-height: 1.8;
}
.contact__name {
  display: block;
  color: var(--ink);
  font-weight: 700;
  font-size: 1.0625rem;
}

/* A short label would leave the right-hand column looking thin against the
   five-line address opposite, so the button gets a floor on its width instead.
   Independent of the label text, and 11rem still clears the 244px content box
   at 320px. */
.contact__cta { justify-self: start; }
.contact__cta .btn {
  min-width: 11rem;
  max-width: 100%;
}

/* ----------------------------------------------------------------- footer */
.site-footer { padding-block: clamp(2rem, 5vw, 2.75rem); }
.site-footer__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;
}
.site-footer__copy { color: var(--ink-dim); font-size: 0.875rem; }
.site-footer__nav ul { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; }
.site-footer__nav a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--ink-dim);
  font-size: 0.875rem;
  text-decoration: none;
}
.site-footer__nav a:hover { color: var(--teal); }

/* ---------------------------------------------------------- scroll reveal */
/* Only hide content when JS is present to reveal it again. */
.has-js .reveal {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.4s ease, transform 0.4s ease;
}
.has-js .reveal.is-visible { opacity: 1; transform: none; }

/* ============================================================ breakpoints */

/* Mobile nav: collapsible dropdown, JS-gated. */
@media (max-width: 719.98px) {
  /* 28px clears the 24px WCAG 2.2 AA floor and is fine for a mouse, but these
     are thumb targets once the cards go full width. */
  .shots__dot, .shots__toggle { width: 44px; height: 44px; }
  .shots__bar { min-height: 44px; }

  .has-js .nav-toggle:not([hidden]) { display: inline-flex; }

  .has-js .site-nav {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: var(--bg-raised);
    border-bottom: 1px solid var(--line);
    box-shadow: var(--shadow);
  }
  .has-js .site-nav.is-open { display: block; }

  .has-js .site-nav ul { display: block; padding-inline: var(--shell-pad); }
  .has-js .site-nav li + li { border-top: 1px solid var(--line); }
  .has-js .site-nav a { min-height: 52px; padding-inline: 0; font-size: 1rem; }
}

@media (min-width: 720px) {
  :root { --header-h: 68px; }
  .wordmark__mark { width: 48px; height: 48px; }
  .grid--products { grid-template-columns: repeat(2, 1fr); }
  .contact {
    grid-template-columns: 1fr auto;
    align-items: center;
    gap: clamp(2rem, 5vw, 3.5rem);
    padding-inline: clamp(1.75rem, 4vw, 2.75rem);
  }
  .contact__cta { justify-self: end; }
}

@media (min-width: 1080px) {
  .hero__title { max-width: 22ch; }
  .grid--services { grid-template-columns: repeat(4, 1fr); }
}

/* Small phones in portrait (320x568 and similar): reclaim vertical padding so
   the hero CTAs land above the fold. The H1 clamp floor is fixed by the design
   system, so the space has to come from padding rather than type. */
@media (orientation: portrait) and (max-height: 620px) {
  .hero { padding-block: clamp(1.75rem, 5vh, 3rem) clamp(2rem, 6vh, 3.5rem); }
  /* The 80px clamp floor pushed the CTAs ~95px below the fold on a 320x568
     phone. Only short portrait viewports are affected; 390x844 is taller than
     620px and keeps the full 80px. */
  .hero__mark { width: 56px; height: 56px; }
}

/* Phone landscape (and other short viewports): the hero must fit without the
   headline colliding with the sticky nav. */
@media (orientation: landscape) and (max-height: 560px) {
  .hero {
    min-height: 0;
    padding-block: clamp(1.25rem, 5vh, 2rem) clamp(1.5rem, 6vh, 2.25rem);
  }
  /* A short landscape viewport has ~322px for the whole hero, which the 80px
     clamp floor overruns — it pushed the CTAs below the fold. 48px is still
     the smallest size this raster mark stays crisp at. */
  .hero__mark {
    width: 48px;
    height: 48px;
    margin-bottom: 0.5rem;
  }
  .hero__title { font-size: clamp(1.6rem, 3.4vw + 0.5rem, 2.4rem); max-width: 26ch; }
  .hero__sub { margin-top: 0.75rem; font-size: 0.9375rem; max-width: 70ch; }
  .hero__actions { margin-top: 1.1rem; }
  /* Pull the fade in tighter so the curtains do not crowd a hero that is only
     ~322px tall. This overrides the overlay, not a mask on .aurora — a mask
     here would put the render surface back and cost the scroll frames that
     removing it recovered. */
  .aurora-fade {
    background: linear-gradient(to bottom,
      color-mix(in srgb, var(--bg) 0%, transparent) 0%,
      color-mix(in srgb, var(--bg) 0%, transparent) 40%,
      color-mix(in srgb, var(--bg) 62%, transparent) 60%,
      var(--bg) 78%);
  }
  .section { padding-block: clamp(2.5rem, 12vh, 4rem); }
}

/* ------------------------------------------------------- reduced motion §8 */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /* Aurora freezes into a static gradient rather than animating. */
  .ribbon {
    animation: none !important;
    transform: none !important;
    will-change: auto;
  }

  /* No scroll-linked brightening either — pinned to the ambient level, which
     is also the contrast-safest end of the range. script.js skips installing
     the scroll handler under this preference, so nothing fights this value. */
  .has-js .aurora { opacity: 0.38; }

  /* Veil: no sweep, just a plain overlay showing a static gradient wordmark.
     The transition kill above collapses the fade, so it disappears the moment
     the 0.6s minimum hold elapses — which script.js still enforces. */
  .veil__mark {
    animation: none !important;
    background-position: 50% 0 !important;
  }

  /* Never leave revealed content stuck invisible. */
  .has-js .reveal { opacity: 1 !important; transform: none !important; }

  /* Screenshots swap instantly instead of crossfading, and script.js does not
     auto-advance them at all — the dots still work for stepping through. */
  .shots__img { transition: none !important; }

  .btn:hover { transform: none; }
  .link-arrow:hover span { transform: none; }
}
