/* ============================================================
   Offloop — landing page
   Light, open, airy. Italic display serif over a quiet text serif.
   ============================================================ */

/* ---------- Fonts (self-hosted, latin subsets) ---------- */

@font-face {
  font-family: 'Playfair Display';
  font-style: italic;
  font-weight: 400 500;
  font-display: swap;
  src: url('/fonts/playfair-display-italic-latin.woff2') format('woff2');
}
/* LT Superior Serif — LyonsType, SIL Open Font License 1.1.
   The page's text face (brief v2): body 400, subheads/FAQ 500,
   buttons/labels/nav 600. Upright only — italic stays Playfair's.
   Weights 700/800 exist in the family but nothing uses them, so
   they aren't shipped. */
@font-face {
  font-family: 'LT Superior Serif';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/lt-superior-serif-regular.woff2') format('woff2');
}
@font-face {
  font-family: 'LT Superior Serif';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/fonts/lt-superior-serif-medium.woff2') format('woff2');
}
@font-face {
  font-family: 'LT Superior Serif';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/lt-superior-serif-semibold.woff2') format('woff2');
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/jetbrains-mono-400-latin.woff2') format('woff2');
}
/* Bold cut of JetBrains Mono for the wordmark only, so it is given its
   own family name: this file contains just O, f, l, o and p — the seven
   letters of "Offloop" — which is why 700 weight costs 864 bytes rather
   than the ~22KB a full latin cut would. Instanced at wght 700 from the
   variable JetBrains Mono (SIL OFL 1.1). Nothing else on the page sets
   bold mono; if anything ever does, it wants the full face, not this. */
@font-face {
  font-family: 'JetBrains Mono Wordmark';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/jetbrains-mono-wordmark-700.woff2') format('woff2');
}

/* ---------- Tokens ---------- */

:root {
  --sky: #DCEBF7;
  --haze: #F4F8FB;
  --horizon: #8FB8D4;
  --depth: #1B3A4B;
  /* brief specifies #5A7386, darkened a step so body text clears
     AA (4.5:1) over --sky, not just over --haze */
  --slate: #52697B;
  --meadow: #4A7C4E;
  /* Text-safe green. --meadow is an accent for strokes and rules, which
     only owe 3:1; as 15px bold text on the glass nav it measures
     3.2–4.1:1 depending on the sky compositing through, under AA. This
     is the lightest green that clears 4.5:1 against the darkest of
     those. Same reasoning that darkened --slate. */
  --meadow-ink: #365C3B;

  --font-display: 'Playfair Display', Georgia, serif;
  --font-display-2: 'LT Superior Serif', Georgia, serif;
  --font-body: 'LT Superior Serif', Georgia, 'Times New Roman', serif;
  --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;

  --container: 68rem;
  --narrow: 44rem;
  /* reading measure for the legal pages — ~70 characters at 17px,
     where --narrow would give ~88 */
  --legal-measure: 36rem;

  /* vertical footprint of the fixed nav, so hero copy can clear it
     explicitly instead of relying on a padding value that happens
     to be large enough */
  --nav-space: 5rem;
}

/* ---------- Reset-ish ---------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-padding-top: 5.5rem;
}

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--depth);
  background: linear-gradient(180deg,
    var(--sky) 0%,
    var(--sky) 38%,
    #E8F2F9 58%,
    var(--haze) 80%,
    var(--haze) 100%);
  /* clip, not hidden — hidden would make <body> its own scroll
     container and break the fixed nav / scroll-driven cloud layer */
  overflow-x: clip;
}

h1, h2, h3, p, ul { margin: 0; }
ul { padding: 0; list-style: none; }
img, video { max-width: 100%; display: block; }

a { color: var(--meadow); text-decoration-thickness: 1px; text-underline-offset: 3px; }

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

::selection { background: rgba(74, 124, 78, 0.22); }

.container {
  width: min(100% - 3rem, var(--container));
  margin-inline: auto;
}
.container.narrow { max-width: var(--narrow); }

/* ---------- Skip link ---------- */

.skip-link {
  position: absolute;
  top: 0.75rem; left: 0.75rem;
  z-index: 100;
  padding: 0.6rem 1rem;
  background: #fff;
  color: var(--depth);
  border-radius: 999px;
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-200%);
  box-shadow: 0 8px 32px rgba(27, 58, 75, 0.18);
}
.skip-link:focus-visible { transform: none; }

/* ---------- Display type ---------- */

/* The ornate italic is reserved for the H1 — one editorial
   moment per page. */
.hero-title {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 450;
  letter-spacing: -0.005em;
  line-height: 1.1;
  text-wrap: balance;
}

/* Section headings use the upright secondary serif: still serif,
   so the page stays cohesive, but calmer than the italic. */
.display {
  font-family: var(--font-display-2);
  font-style: normal;
  font-weight: 500;
  letter-spacing: -0.012em;
  line-height: 1.14;
  text-wrap: balance;
  font-size: clamp(2rem, 3.5vw, 3rem);
  margin-bottom: 1.1rem;
}

/* ---------- Glass buttons ---------- */

.btn {
  display: inline-block;
  padding: 0.85rem 1.7rem;
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  text-decoration: none;
  color: var(--depth);
  background: rgba(255, 255, 255, 0.18);
  backdrop-filter: blur(16px) saturate(160%);
  -webkit-backdrop-filter: blur(16px) saturate(160%);
  border: 1px solid rgba(255, 255, 255, 0.35);
  box-shadow:
    0 8px 32px rgba(27, 58, 75, 0.12),
    inset 0 1px 0 rgba(255, 255, 255, 0.45);
  border-radius: 999px;
  transition: background 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}
.btn:hover {
  background: rgba(255, 255, 255, 0.28);
  transform: translateY(-1px);
  box-shadow:
    0 12px 36px rgba(27, 58, 75, 0.16),
    inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.btn:active { transform: translateY(0); }
.btn:focus-visible { outline: 2px solid var(--meadow); outline-offset: 3px; }

.btn-primary {
  background: rgba(255, 255, 255, 0.42);
}
.btn-primary:hover { background: rgba(255, 255, 255, 0.56); }

/* .btn-ghost went with the hero's secondary CTA — the only place on the
   page that ever needed a quieter button. */

@supports not (backdrop-filter: blur(1px)) {
  .btn, .btn-primary { background: rgba(255, 255, 255, 0.85); }
  .btn:hover, .btn-primary:hover { background: rgba(255, 255, 255, 0.95); }
}

/* ---------- Nav ---------- */

.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 50;
  padding: 0.9rem 0;
}

/* Docked to the foot of the viewport while the hero owns the screen.
   translateY rather than swapping top for bottom: the two positions are
   then a single animatable property, and the percentage resolves
   against the bar's own height, so its bottom edge lands on the
   viewport's with the same 0.9rem breathing room it has at the top. */
.nav-docked {
  transform: translateY(calc(100svh - 100%));
}

@media (prefers-reduced-motion: no-preference) {
  /* .nav-animates is added a frame after the observer settles, so a
     load part-way down the page doesn't animate the bar into place */
  .nav-animates {
    transition: transform 480ms cubic-bezier(0.22, 0.61, 0.36, 1);
  }
}

.nav-inner {
  /* hugs its contents so the bar floats as a centred island
     rather than spanning the page */
  width: fit-content;
  max-width: calc(100% - 2rem);
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: 2rem;
  padding: 0.55rem 0.65rem 0.55rem 1.1rem;
  background: rgba(255, 255, 255, 0.22);
  backdrop-filter: blur(16px) saturate(160%);
  -webkit-backdrop-filter: blur(16px) saturate(160%);
  border: 1px solid rgba(255, 255, 255, 0.38);
  box-shadow:
    0 8px 32px rgba(27, 58, 75, 0.10),
    inset 0 1px 0 rgba(255, 255, 255, 0.45);
  /* pill bar; the right padding keeps the inner CTA's curve
     concentric with the bar's own */
  border-radius: 999px;
}

@supports not (backdrop-filter: blur(1px)) {
  .nav-inner { background: rgba(255, 255, 255, 0.88); }
}

/* Wordmark. In LT Superior the name was indistinguishable from the nav
   links beside it — it read as a fourth menu item. JetBrains Mono is
   the page's third voice (app chips, the fn keycap), so setting the
   product's own name in the developer's typeface separates the brand
   from the navigation without importing a fourth face, and gives the
   page a clean split: Playfair italic is the hero's voice, LT Superior
   is for reading, mono is the machine. */
.nav-brand {
  display: inline-flex;
  align-items: center;
  gap: 0.44rem;
  color: var(--depth);
  text-decoration: none;
}
.brand-word {
  /* 700, not the 400 the rest of the mono runs at — at 400 the name read
     as a code sample rather than a logotype. 800 was tried and closes up
     the counters of "oo" at this size. */
  font-family: 'JetBrains Mono Wordmark', var(--font-mono);
  font-weight: 700;
  /* mono's tall x-height sets optically larger than the serif at the
     same value, so this sits a shade under the 0.95rem nav links */
  font-size: 0.94rem;
  /* mono is generously spaced by nature; pull it into one shape */
  letter-spacing: -0.02em;
}
/* The TLD is part of the logotype, not a second word — meadow ties it
   to the mark and keeps "offloop" the thing the eye lands on. */
.brand-tld { color: var(--meadow-ink); }

/* An aperture: six solid blades around a hexagonal opening. It says the
   thing the page is about — the machine looks before it writes, and
   nothing leaves it.

   This mark is FILLED where the one before it was stroked, so this rule
   was inverted rather than left alone. stroke-width used to be the
   load-bearing number here; it is now height, and height is derived
   rather than chosen. A filled mark carries more ink than line art at the
   same size (52% against 47%, measured), so every height below is the one
   it replaced times 0.9502 — the factor that equalises ink AREA, which is
   what keeps the mark's weight against a 700 wordmark unchanged.

   If you retune the mark, re-run brand/build-assets.py rather than
   nudging these by eye: it derives all three from both marks' measured
   coverage, prints them, and writes them to brand/site-snippets.txt. */
.nav-mark {
  /* viewBox is cropped to the artwork, so height sets the mark's actual
     drawn height rather than the height of a mostly-empty square box.
     1.34rem was the line-art mark's height, itself set well above the
     1.02rem the old two-loop mark ran at: that one was half again as wide
     as it was tall, and a near-square mark at the same height carries
     visibly less weight beside a 700 wordmark. */
  height: 1.27rem;
  width: auto;
  flex: none;
  fill: var(--meadow);
}

.nav-brand:hover .nav-mark { fill: var(--depth); }
.nav-mark { transition: fill 200ms ease; }

.nav-links {
  display: flex;
  gap: 1.6rem;
}
.nav-links a {
  color: var(--depth);
  text-decoration: none;
  font-size: 0.95rem;
  font-weight: 600;
}
.nav-links a:hover { color: var(--meadow); }

.btn-nav { padding: 0.55rem 1.15rem; font-size: 0.95rem; white-space: nowrap; }

@media (max-width: 640px) {
  .nav-links { display: none; }
  /* tighter so brand + CTA still fit inside the pill at 360px */
  .nav-inner { gap: 0.75rem; padding-left: 0.9rem; }
  .btn-nav { padding: 0.55rem 0.95rem; }
}

/* Phones: the domain form is 44px wider than the bare name and
   overruns the pill at 360px, so the whole lockup steps down rather
   than dropping ".work" — a wordmark that loses half of itself on the
   screen most people see it on is not a wordmark.

   These live down here, below .nav-mark and .btn-nav, for the same
   reason the hero's short-viewport query does: both of those set the
   properties involved from further down the file, and from above them
   these overrides lose on source order and silently do nothing. */
@media (max-width: 480px) {
  .nav-brand { gap: 0.36rem; }
  .nav-mark { height: 1.15rem; }
  .brand-word { font-size: 0.84rem; }
}
/* At 360px the bar's natural width still landed past its own
   max-width, so the flex row was quietly eating its own padding. The
   CTA is the widest thing in the pill, so most of the room comes from
   its side padding rather than from shrinking the name further. */
@media (max-width: 400px) {
  .nav-mark { height: 1.07rem; }
  .brand-word { font-size: 0.8rem; }
  .btn-nav { padding: 0.55rem 0.8rem; }
}

/* ---------- Hero ---------- */

.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}

.hero-media {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* dissolve the footage into the page background */
  -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 78%, transparent 100%);
  mask-image: linear-gradient(180deg, #000 0%, #000 78%, transparent 100%);
}

.hero-poster img,
.hero-media video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 42%;
}

/* Lifts the footage under the copy so the text clears AA against every
   frame of the loop, not just the average one. The middle two stops are
   set by measurement, not by eye: sampling six frames across the 9s loop
   and compositing this gradient per row, the darkest pixel under the sub
   sat at 4.23:1 against --depth with the old 0.38/0.14 pair — a fail for
   17px text. 0.40/0.20 takes it to 4.78:1.
   The zero stop stays at 56%. That is the constraint that shapes the
   rest: the horizon is at ~53.9%, so any wash still alive past it hazes
   the one edge the composition is built on. Raising the mid stops
   instead of stretching the tail moves the haze at the horizon only
   0.025 -> 0.042, which is invisible, and leaves the grass untouched.
   Re-measure rather than adjust by eye if the footage is ever re-cut. */
.hero-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg,
    rgba(255, 255, 255, 0.52) 0%,
    rgba(255, 255, 255, 0.40) 26%,
    rgba(255, 255, 255, 0.20) 46%,
    rgba(255, 255, 255, 0) 56%);
}

.hero-content {
  position: relative;
  z-index: 1;
  width: min(100% - 3rem, var(--container));
  margin-inline: auto;
  /* The horizon in the footage sits at ~54% of the hero height, and it
     moves with the viewport while the copy block stays about the same
     height. So park the copy a short way above the horizon and let the
     leftover fall to the top gap, rather than splitting the slack
     evenly: the vh term locates the horizon, and the 490px subtracts
     the copy block plus the clearance we want kept below it. Measured
     on the tall viewports where this term is the one that wins — at
     1440×1200 it lands 182px above the block and 117px below it.
     Re-derive it, don't adjust it by feel: it moved 460→490 when the
     block's internal gaps opened up, because every pixel added inside
     the block has to come off this constant to hold the clearance.
     The sub is two lines everywhere in the desktop range now (the 30em
     cap forces the break well before the container does), so this term
     no longer has a one-line case to straddle.
     The floor's own gap tapers with the viewport rather than sitting at
     a flat vh, because short screens have no slack to redistribute: the
     horizon is high, and the block plus the nav already fills most of
     what is above it.
     That gap's ceiling came down 4.5rem → 2.6rem when the CTA link was
     added: on tall viewports the floor was capping at 152px of air above
     the block, which is generous precisely because the nav has docked
     away and left it empty. `--nav-space` itself is untouched — the bar
     still gets its full reserve for when it returns to the top; only the
     extra gap above that reserve is smaller, which is what pays for the
     link's ~64px. */
  padding-top: clamp(
    calc(var(--nav-space) + clamp(1.5rem, 16vh - 72px, 2.6rem)),
    calc(56vh - 490px),
    15rem
  );
  text-align: center;
}

/* Eyebrow chip. Takes the app-name pills' glass numbers (.30 / 12px /
   .45) rather than inventing a rung — it is the same class of object:
   a small tag, not a surface holding content. Set in the mono, which on
   this page is the machine's voice, so it reads as a label on the
   product rather than as another line of the pitch. */
.hero-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: -0.01em;
  color: var(--depth);
  padding: 0.42rem 0.95rem;
  /* the gap under the chip gives way first on short screens */
  margin: 0 0 clamp(1.45rem, 3vh, 1.85rem);
  background: rgba(255, 255, 255, 0.30);
  backdrop-filter: blur(12px) saturate(150%);
  -webkit-backdrop-filter: blur(12px) saturate(150%);
  border: 1px solid rgba(255, 255, 255, 0.45);
  box-shadow:
    0 4px 16px rgba(27, 58, 75, 0.07),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
  border-radius: 999px;
  text-wrap: balance;
}

@supports not (backdrop-filter: blur(1px)) {
  .hero-chip { background: rgba(255, 255, 255, 0.82); }
}

/* Live indicator. Deliberately a dot that fades in place rather than a
   dot with an expanding ring: a ring radiating outward reads as
   broadcasting, which is the one thing this product is claiming not to
   do — the same reason the privacy section's animation catches its dots
   rather than emitting them. Static and fully opaque under reduced
   motion, since the default state carries no motion. */
.chip-dot {
  flex: none;
  width: 0.42rem;
  height: 0.42rem;
  border-radius: 50%;
  background: var(--meadow);
}

@media (prefers-reduced-motion: no-preference) {
  .chip-dot { animation: chip-blink 2.4s ease-in-out infinite; }
}

@keyframes chip-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.25; }
}

.hero-title {
  /* sized off width, but capped by height as well — on a wide, short
     window a 7vw headline would otherwise push the copy onto the
     horizon. The min() keeps that continuous instead of stepping at a
     breakpoint. */
  font-size: min(clamp(3rem, 7vw, 5.5rem), 8vh);
  line-height: 1.08;
}
.hero-line1, .hero-line2 { display: block; }

.hero-sub {
  /* Deliberately narrower than the line needs, so the sub breaks to two
     and sits under the headline as a block rather than as one wide
     rule of text. `balance` picks the break, so both lines land near
     the headline's width rather than one long line and a runt.
     Two lines throughout the desktop range, three at phone widths. */
  max-width: 30em;
  margin: 2.2rem auto 0;
  font-size: 1.125rem;
  color: var(--depth);
  /* keeps the short sub from stranding a word on its own line */
  text-wrap: balance;
}

/* The one machine-measured value in the copy, set in the mono that is
   the machine's voice everywhere else on the page — the eyebrow chip,
   the wordmark, the code samples. Reads as a spec rather than as prose.
   0.92em, not 1em: JetBrains Mono's x-height measures 1.09× LT
   Superior's at the same nominal size, so an unadjusted span sits
   visibly larger than the sentence around it. 0.92 matches the "ms"
   x-height exactly and leaves the figures a hair under the serif's,
   which is the right way round — mono digits carry more weight. */
.metric {
  font-family: var(--font-mono);
  font-size: 0.92em;
  letter-spacing: -0.01em;
}

/* Fallback copy of the latency claim, for viewports too short to carry
   the chip. Hidden by default and switched on by the same media query
   that hides the chip, so exactly one of the two is ever on screen. */
.sub-latency { display: none; }

/* The hero's call to action, as a link rather than a button. The button
   pair was removed because two filled glass pills under the headline
   read as the loudest thing in the hero; this restores the verb at the
   weight of the surrounding copy — same size, same ink, an underline to
   say it is clickable and nothing more.
   The underline is drawn at 40% ink so the rule sits under the words
   without boxing them, and goes solid on hover/focus rather than
   changing colour, so the only thing that moves is emphasis. */
.hero-link {
  display: inline-flex;
  align-items: center;
  gap: 0.42rem;
  margin-top: 1.25rem;
  font-size: 1rem;
  color: var(--depth);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.24em;
  text-decoration-color: rgba(27, 58, 75, 0.4);
  transition: text-decoration-color 180ms ease;
}
.hero-link:hover,
.hero-link:focus-visible { text-decoration-color: currentColor; }
.hero-link:focus-visible { outline: 2px solid var(--meadow); outline-offset: 4px; border-radius: 2px; }

/* Sized in em so it tracks the link, and nudged down by a hair: the
   arrow's optical centre sits above the text's because it has no
   descender to balance it. */
.hero-link-arrow {
  flex: none;
  width: 0.72em;
  height: 0.72em;
  transform: translateY(0.06em);
}

.microcopy {
  margin-top: 1rem;
  font-size: 0.875rem;
  color: var(--slate);
}
/* the hardware requirement, carried at full contrast so it
   reads as a constraint rather than fine print */
.microcopy strong {
  font-weight: 600;
  color: var(--depth);
}
/* Short viewports: the headline is already capped by vh above, so
   mainly the gaps between the hero's parts need tightening here.

   Two things about where this block sits. It is gated at 640px, not
   900px — the desktop hero layout runs all the way down to 640px, and
   a 640–900px window that is also short is the tightest case there is;
   the old 900px gate left a cliff just below it where the headline
   snapped back to its tall cap and the copy crossed the horizon. And
   it has to come *after* .hero-sub: that sets margin with the
   shorthand, so from higher up the file this query's margin-top
   longhand lost to source order and quietly did nothing. */
@media (max-height: 960px) and (min-width: 641px) {
  .hero-chip { font-size: 0.72rem; padding: 0.3rem 0.8rem; margin-bottom: 1.45rem; }
  .hero-title { font-size: min(clamp(3rem, 7vw, 5.5rem), 6.4vh); }
  .hero-sub { margin-top: 1.75rem; font-size: 1.0625rem; line-height: 1.45; }
}
/* Short viewports keep the chip but tighten around it. The gaps are set
   in rem while the headline is sized in vh, so a gap that reads as 0.4×
   the cap height at 1440×900 reads as 0.55× against the 46px headline of
   a 720px-tall window. Pulling the chip's gap to 0.9rem holds the ratio
   at ~0.35× and hands ~9px back to the clearance above the grass. */
@media (max-height: 760px) and (min-width: 641px) {
  .hero-chip { margin-bottom: 0.9rem; }
  .hero-sub { margin-top: 1.4rem; }
  .hero-link { margin-top: 1rem; font-size: 0.9375rem; }
}
/* Under ~760px tall the block cannot carry both the chip and the CTA
   link. The chip costs ~54px even tightened, the link ~40px, and at
   1280×720 there is only ~90px between the sub and the grass to spend —
   keeping both leaves 10px, which puts the link on the skyline.
   The chip is what goes, because the link is the only verb in the hero
   and the chip's content survives its own removal: the claim moves into
   the sub via `.sub-latency`, in this same query, so it is on screen
   exactly once at every size. The chip's category wording is the only
   thing genuinely lost, and the headline plus sub already carry it.
   (This gate was 680px before the link existed; the link is what pushed
   it back to 760px. If the link is ever removed, put it back.)
   Width-gated to ≥641px so the phone layout, which has no horizon to
   clear and always shows the chip, is unaffected. */
@media (max-height: 760px) and (min-width: 641px) {
  .hero-chip { display: none; }
  .sub-latency { display: inline; }
}

/* entrance: gentle fade-and-rise */
@media (prefers-reduced-motion: no-preference) {
  .hero-chip, .hero-title, .hero-sub, .hero-link {
    animation: rise 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }
  .hero-title { animation-delay: 0.08s; }
  .hero-sub { animation-delay: 0.2s; }
  .hero-link { animation-delay: 0.32s; }
}

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

/* ---------- Scroll reveals ---------- */

@media (prefers-reduced-motion: no-preference) {
  .js .reveal {
    opacity: 0;
    transform: translateY(16px);
    transition: opacity 0.7s ease var(--reveal-delay, 0s),
                transform 0.7s ease var(--reveal-delay, 0s);
  }
  .js .reveal.in { opacity: 1; transform: none; }
}

/* ---------- Sections ---------- */

section {
  padding: clamp(4.5rem, 9vh, 7.5rem) 0;
  text-align: center;
}

.section-body {
  color: var(--slate);
  max-width: 36em;
  margin-inline: auto;
  font-size: 1.0625rem;
}

.wash-section { position: relative; }

/* soft colour washes so backdrop-filter glass has something to blur */
.wash {
  position: absolute;
  inset: -8rem 0;
  pointer-events: none;
  z-index: -1;
}
.wash-sky {
  background:
    radial-gradient(42rem 26rem at 18% 40%, rgba(143, 184, 212, 0.20), transparent 70%),
    radial-gradient(36rem 22rem at 82% 65%, rgba(255, 255, 255, 0.5), transparent 70%);
}
.wash-meadow {
  background:
    radial-gradient(40rem 24rem at 78% 35%, rgba(74, 124, 78, 0.08), transparent 70%),
    radial-gradient(38rem 24rem at 22% 70%, rgba(143, 184, 212, 0.16), transparent 70%);
}

/* ---------- How it works ---------- */

/* full section padding here — the hero's masked fade needs clear
   air before the first content block */
.qualities { padding-top: clamp(4.5rem, 10vh, 8rem); }

.flow-intro {
  text-align: center;
  margin-bottom: clamp(2.5rem, 6vh, 4rem);
}

.qualities-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(2rem, 5vw, 4.5rem);
}

.quality-label {
  font-family: var(--font-body);
  font-style: normal;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--depth);
  padding-top: 0.9rem;
  margin-bottom: 0.55rem;
  border-top: 2px solid var(--meadow);
  max-width: 100%;
}

.quality p { color: var(--slate); }

/* identifiers in the copy are quoted material, so they take the mono at
   the same 0.92em correction .metric uses — JetBrains Mono's x-height is
   1.09× LT Superior's, so an unadjusted span sits visibly larger */
.quality code {
  font-family: var(--font-mono);
  font-size: 0.92em;
  letter-spacing: -0.01em;
  color: var(--depth);
}

@media (max-width: 720px) {
  .qualities-grid { grid-template-columns: 1fr; gap: 2.25rem; }
}

/* ---------- The flow diagram ---------- */

/* Wordless on purpose — see the note in index.html. Sized in rem and
   capped at the container so it scales down to a phone intact; the
   width/height attributes on the element are the belt-and-braces against
   an unsized inline SVG ballooning to 300×150 if the stylesheet fails. */
.flow {
  display: block;
  width: min(100%, 26rem);
  height: auto;
  margin: clamp(1.5rem, 4vh, 2.5rem) auto 0;
  color: var(--slate);
  overflow: visible;
}

/* The mark keeps its own colour — it is the brand, not a diagram part.
   `fill`, not `stroke`: the mark is filled everywhere, and a leftover
   `stroke` here would win over the inline attributes and repaint it. */
.flow-mark { color: var(--meadow); fill: var(--meadow); }

/* Both of these are graphical objects needed to understand the diagram,
   so they owe 3:1 — measured against the backdrop this section actually
   has, which is the page background plus the bleed from the next
   section's wash (`.wash` is inset -8rem, so it reaches up into here).
   The first draft failed both: connectors at 0.55 alpha measured 2.04:1
   and the output lines in --horizon measured 1.57:1, which made the
   payoff of the whole diagram the faintest thing in it. */
.flow-line { opacity: 0.85; }        /* 3.28:1 */
.flow-out { color: var(--depth); }   /* 8.91:1 — the result should be the
                                        darkest mark here, not the palest */
.flow-cursor { color: var(--meadow); }  /* 3.83:1 */

/* The wipe rects live inside <clipPath>, so nothing about them is painted
   — scaling one horizontally is purely how much of its line is allowed
   through. transform-box is the SVG default already; it is spelled out
   because the origin below is in viewBox units and that only holds while
   the box is the view box. x=198 is the left edge of every line, so all
   three grow from the same margin. Unanimated they sit at scaleX(1),
   which is why the static diagram is whole text with no rules needed. */
.flow-wipe {
  transform-box: view-box;
  transform-origin: 198px 64px;
}

/* Dots are motion-only: invisible at rest, so the static diagram is just
   three sources, a mark and a clean line — which says the whole thing.
   Every moving part on this page owes that: the unanimated state has to be
   the finished one, never a half-drawn frame. */
.flow-dot {
  color: var(--meadow);
  opacity: 0;
}

@media (prefers-reduced-motion: no-preference) {
  /* The output is on the same 4.4s loop as the dots, not a one-shot draw.
     That is the whole point of the diagram: the text appears *as the dots
     land*, every cycle, so the reader sees three inputs causing one output
     rather than two unrelated things that happen to move.
     The phases are cut against the dot timeline — dots arrive at 46% and
     the mark takes them at 48%, so all three lines are written between
     52.8% and 60.3%: **330ms for the whole block**, against 2s of input
     travelling in. The asymmetry is the claim. An earlier cut spread the
     writing over 1.58s, which was legible but made the thing look like it
     was labouring; the page says <500ms two screens up and the diagram
     should not contradict it.
     Every phase is written into the keyframes rather than offset with
     animation-delay: a delay moves a whole loop, and with three lines that
     put the last one still holding while the first had already cleared.
     All four animations below therefore run on the same clock with no
     delay, and linear, so the wipe front and the caret keep pace. */
  /* text absent before scroll-in, so it types itself once the section
     arrives rather than fading in already finished */
  .js .reveal .flow-wipe { transform: scaleX(0); }
  /* and the caret waits where it will start, not where it will end */
  .js .reveal .flow-cursor { transform: translate(-50px, -24px); }

  .js .reveal.in .flow-wipe--1 { animation: flow-wipe-1 4.4s linear infinite; }
  .js .reveal.in .flow-wipe--2 { animation: flow-wipe-2 4.4s linear infinite; }
  .js .reveal.in .flow-wipe--3 { animation: flow-wipe-3 4.4s linear infinite; }
  .js .reveal.in .flow-out    { animation: flow-out-clear 4.4s linear infinite; }
  /* Position and blink are separate animations on the same element: the
     blink wants hard edges on a short period, the travel wants smooth
     interpolation on the long one. 4.4 / 1.1 is exactly 4, so the two stay
     phase-locked for the life of the page instead of drifting apart.
     That lock is load-bearing — see the note on @keyframes flow-blink. */
  .js .reveal.in .flow-cursor {
    animation: flow-caret 4.4s linear infinite,
               flow-blink 1.1s linear infinite;
  }

  /* then the sources keep feeding it, one run every 4.4s */
  .js .reveal.in .flow-dot {
    animation-duration: 4.4s;
    animation-timing-function: cubic-bezier(0.4, 0, 0.5, 1);
    animation-iteration-count: infinite;
  }
  .js .reveal.in .flow-dot--v { animation-name: flow-run-v; }
  .js .reveal.in .flow-dot--s { animation-name: flow-run-s; animation-delay: 0.16s; }
  .js .reveal.in .flow-dot--a { animation-name: flow-run-a; animation-delay: 0.32s; }

  .js .reveal.in .flow-mark {
    animation: flow-mark-pulse 4.4s ease-in-out infinite;
  }
}

/* travel ends at the mark's left edge, where the dot is absorbed rather
   than passing through — the mark is where the three become one */
@keyframes flow-run-v {
  0%       { transform: translate(0, 0); opacity: 0; }
  8%       { opacity: 0.95; }
  46%      { transform: translate(86px, 34px); opacity: 0.95; }
  56%      { transform: translate(90px, 36px); opacity: 0; }
  100%     { transform: translate(90px, 36px); opacity: 0; }
}
@keyframes flow-run-s {
  0%       { transform: translate(0, 0); opacity: 0; }
  8%       { opacity: 0.95; }
  46%      { transform: translate(86px, 0); opacity: 0.95; }
  56%      { transform: translate(90px, 0); opacity: 0; }
  100%     { transform: translate(90px, 0); opacity: 0; }
}
@keyframes flow-run-a {
  0%       { transform: translate(0, 0); opacity: 0; }
  8%       { opacity: 0.95; }
  46%      { transform: translate(86px, -34px); opacity: 0.95; }
  56%      { transform: translate(90px, -36px); opacity: 0; }
  100%     { transform: translate(90px, -36px); opacity: 0; }
}

/* The mark takes the arrival, rather than spinning or scaling — this
   page's motion vocabulary is small and none of it is showy. It peaks at
   48%, between the dots landing at 46% and the first line at 52.8%, so
   the order reads as cause: arrive, register, write. (It used to run on a
   0.5s delay, which put the peak at 63% — after the text. Harmless while
   the writing took a second and a half, wrong now that it takes 330ms.) */
@keyframes flow-mark-pulse {
  0%, 36%, 100% { opacity: 0.75; }
  48%, 52%      { opacity: 1; }
}

/* One line at a time, left to right, each wipe's span proportional to its
   line so the writing keeps a constant speed across all three (84 units
   in 2.8%, 76 in 2.5%, 50 in 1.6% — about thirty units per percent
   throughout). The 0.3% between lines is the carriage return.
   All three snap back to zero together at 90.1%, under cover of the fade
   below: reversing the wipes would read as the text being un-typed, which
   is not what happens. */
@keyframes flow-wipe-1 {
  0%, 52.8%   { transform: scaleX(0); }
  55.6%, 90%  { transform: scaleX(1); }
  90.1%, 100% { transform: scaleX(0); }
}
@keyframes flow-wipe-2 {
  0%, 55.9%   { transform: scaleX(0); }
  58.4%, 90%  { transform: scaleX(1); }
  90.1%, 100% { transform: scaleX(0); }
}
@keyframes flow-wipe-3 {
  0%, 58.7%   { transform: scaleX(0); }
  60.3%, 90%  { transform: scaleX(1); }
  90.1%, 100% { transform: scaleX(0); }
}

/* The finished text then holds for 1.16s — more than a third of the loop,
   and far longer than it took to write — which is the read. Opacity
   carries the clear-out so the reset of the wipes underneath is never
   seen. */
@keyframes flow-out-clear {
  0%, 86%     { opacity: 1; }
  89.5%, 91%  { opacity: 0; }
  91.5%, 100% { opacity: 1; }
}

/* The caret is the only thing on this side of the diagram present for the
   whole cycle. It waits at the start of line one while the dots are in
   flight, rides each wipe front, rests four units past the last word,
   then returns home as the text clears. Its own geometry is that resting
   place, so every offset here is negative and the unanimated diagram
   needs no correction. */
@keyframes flow-caret {
  0%, 52.8%   { transform: translate(-50px, -24px); }
  55.6%       { transform: translate(34px, -24px); }
  55.9%       { transform: translate(-50px, -12px); }
  58.4%       { transform: translate(26px, -12px); }
  58.7%       { transform: translate(-50px, 0); }
  60.3%, 87%  { transform: translate(0, 0); }
  89%, 100%   { transform: translate(-50px, -24px); }
}

/* Waiting is what a caret does, and a still one reads as a dead mark
   rather than a live insertion point — so it blinks the whole time it has
   nothing to write. Hard edges (the 0.01% step is 0.11ms) because a caret
   that fades is a glow, not a caret.
   55% on rather than an even 50: the rhythm matches a real editor's and it
   buys margin for the one thing this timing has to guarantee — that the
   caret is **solid through the writing**, since a blink mid-word would
   read as a dropped frame. On-phases fall at 0–13.75%, 25–38.75%,
   50–63.75% and 75–88.75% of the 4.4s loop, and the writing window
   (52.8–60.3%) sits inside the third with 123ms of lead and 152ms of
   tail. Retime the writing and check it still does. */
@keyframes flow-blink {
  0%, 55%      { opacity: 1; }
  55.01%, 100% { opacity: 0; }
}

/* ---------- Privacy: what stays inside the box ----------
   This replaced a strip chart that carried numbers ("3.4 s / 4 frames /
   0 B") in a telemetry idiom. The numbers were invented, which for this
   audience reads as a dashboard mockup used as proof and costs more trust
   than it earns — so there is nothing numeric here, and the device is an
   obvious diagram rather than a mimicked instrument.
   The box is the headline drawn: one boundary, everything inside it. That
   is why it is worth the space it takes. */

.vault {
  max-width: 34rem;
  margin: clamp(1.8rem, 4vh, 2.5rem) auto 0;
  padding: clamp(1.1rem, 3vw, 1.5rem) clamp(1.1rem, 3vw, 1.6rem) clamp(1.2rem, 3vw, 1.6rem);
  border: 1px solid rgba(27, 58, 75, 0.22);
  border-radius: 14px;
  text-align: left;
}

/* A region name in an architecture diagram, not a <legend>: this section
   sits on a gradient wash, so a label that interrupts the rule would need
   an opaque background it cannot match to what is behind it. Inside the
   box, top-left, is the treatment that needs no eraser. */
.vault-tag {
  margin: 0 0 clamp(0.9rem, 2.4vw, 1.15rem);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  color: var(--slate);
}

/* One grid for the whole list, not one per row — three per-row grids in the
   previous version sized each label column to its own label and threw the
   rows 33px out of line. <dl> because that is what this is: four terms and
   what becomes of each. */
.vault-list {
  margin: 0;
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: baseline;
  gap: clamp(0.85rem, 2.2vw, 1.1rem) clamp(1.1rem, 3vw, 1.75rem);
}

.vault-term {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 600;
  color: var(--depth);
  white-space: nowrap;
}

/* Same system as .q-icon in section one, which is where three of these four
   shapes come from: 1.5px stroke, rounded caps, currentColor, slate. */
.vault-icon {
  flex: none;
  width: 1.15rem; height: 1.15rem;
  color: var(--slate);
}

.vault-fate {
  margin: 0;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--slate);
}

/* Below this the fate column cannot carry a sentence beside its term, so
   each pair stacks: the term on its own line, its fate indented under the
   icon. 30rem rather than the 26rem the chart used because these rows are
   prose, and prose runs out of room sooner than a trace does. */
@media (max-width: 30rem) {
  .vault-list {
    grid-template-columns: 1fr;
    gap: 0.3rem;
  }
  .vault-term { font-size: 0.95rem; }
  .vault-term + .vault-fate { margin-bottom: 0.9rem; }
  .vault-fate {
    font-size: 0.95rem;
    padding-left: calc(1.15rem + 0.6rem);
  }
  .vault-list .vault-fate:last-child { margin-bottom: 0; }
}

/* ---------- Works wherever ---------- */

/* The headline says "wherever the cursor blinks", so there is one at the end
   of it doing exactly that. A pseudo-element rather than markup: nothing for
   a screen reader to announce, and it scales with the clamped display size
   for free.
   An empty inline-block sits with its bottom margin edge on the baseline —
   the CSS 2.1 rule for a box with no in-flow line box — so the height alone
   places it, no vertical-align nudge needed. There is no whitespace between
   the text and generated content, so the caret cannot wrap onto a line of
   its own; the margin opens the gap without opening a break opportunity.
   0.72em is measured against this face rather than guessed: it lands at
   1.03x the cap height and 0.94x the ascender, so it clears the capitals
   without out-topping the tallest letters beside it. */
.apps .display::after {
  content: '';
  display: inline-block;
  width: 0.06em;
  height: 0.72em;
  margin-left: 0.12em;
  border-radius: 1px;
  background: currentColor;
}

/* Shares the flow diagram's caret rhythm rather than inventing a second one
   — a caret is a caret, and one blink in the codebase means they cannot
   drift apart. Hard edges for the reason documented there: a caret that
   fades is a glow. Gated like every other moving part, so it holds solid
   under reduced motion and before the section scrolls in. */
@media (prefers-reduced-motion: no-preference) {
  .js .apps .reveal.in .display::after {
    animation: flow-blink 1.1s linear infinite;
  }
}

.app-row {
  margin-top: 2.25rem;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.7rem;
}

.app-row li {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.875rem;
  color: var(--depth);
  padding: 0.5rem 1rem;
  background: rgba(255, 255, 255, 0.30);
  backdrop-filter: blur(12px) saturate(150%);
  -webkit-backdrop-filter: blur(12px) saturate(150%);
  border: 1px solid rgba(255, 255, 255, 0.45);
  box-shadow:
    0 4px 16px rgba(27, 58, 75, 0.07),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
  border-radius: 999px;
}

@supports not (backdrop-filter: blur(1px)) {
  .app-row li { background: rgba(255, 255, 255, 0.8); }
}

/* official marks, rendered monochrome so the row stays one system */
.chip-logo {
  flex: none;
  width: 1rem;
  height: 1rem;
  fill: currentColor;
}

/* Raster marks for the three projects that ship no vector. They are app
   icons — filled tiles with their own backgrounds — so unlike the glyphs
   beside them they cannot take currentColor. The radius matches what the
   icons already have baked in, and object-fit keeps a non-square source
   from stretching. */
.chip-logo--img {
  border-radius: 3px;
  object-fit: contain;
}

/* ---------- FAQ ---------- */

.faq .display { margin-bottom: 2rem; }

.faq-list {
  border-top: 1px solid var(--horizon);
}

.faq-item {
  border-bottom: 1px solid var(--horizon);
}

.faq-item h3 { font-size: 1.0625rem; font-weight: 400; }

.faq-question {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.15rem 2.75rem;
  font-family: var(--font-body);
  font-size: 1.0625rem;
  font-weight: 500;
  text-align: center;
  color: var(--depth);
  background: none;
  border: 0;
  cursor: pointer;
}
.faq-question:hover { color: var(--meadow); }

/* pinned right so the label stays optically centred regardless of
   its length; the plus rotates 45° into a cross on open */
.faq-plus {
  position: absolute;
  right: 0.5rem;
  top: 50%;
  width: 1rem; height: 1rem;
  color: var(--slate);
  transform: translateY(-50%);
  transition: transform 200ms ease;
}
.faq-question[aria-expanded="true"] .faq-plus {
  transform: translateY(-50%) rotate(45deg);
}

.faq-answer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms ease;
}
.faq-answer > div {
  overflow: hidden;
  visibility: hidden;
  transition: visibility 0s 300ms;
}
.faq-answer.open {
  grid-template-rows: 1fr;
}
.faq-answer.open > div {
  visibility: visible;
  transition: visibility 0s;
}
.faq-answer p {
  padding: 0 1.5rem 1.25rem;
  margin-inline: auto;
  color: var(--slate);
  max-width: 38em;
}

@media (prefers-reduced-motion: reduce) {
  .faq-answer, .faq-answer > div, .faq-plus { transition: none; }
}

/* ---------- Section icons (hand-drawn SVG set) ----------
   Shared system: 1.5px stroke, rounded caps/joins, currentColor.
   Meadow is reserved for the privacy loop and pricing checks so it
   stays an accent. */

.q-icon {
  width: 1.75rem; height: 1.75rem;
  margin: 0 auto 0.9rem;
  display: block;
  color: var(--slate);
}

/* waveform bars idle at full height; the pulse only runs with
   motion allowed, once the section has scrolled in.
   Phased in groups of five rather than one rule per bar: the waveform
   is 11 bars now, and a 5-phase cycle reads as a travelling ripple
   instead of a row blinking in unison. */
.wave-bar {
  transform-box: fill-box;
  transform-origin: center;
}
@media (prefers-reduced-motion: no-preference) {
  .js .reveal.in .wave-bar { animation: wave-pulse 1.8s ease-in-out infinite; }
  .js .reveal.in .wave-bar:nth-child(5n+1) { animation-delay: 0s; }
  .js .reveal.in .wave-bar:nth-child(5n+2) { animation-delay: 0.15s; }
  .js .reveal.in .wave-bar:nth-child(5n+3) { animation-delay: 0.3s; }
  .js .reveal.in .wave-bar:nth-child(5n+4) { animation-delay: 0.45s; }
  .js .reveal.in .wave-bar:nth-child(5n)   { animation-delay: 0.6s; }
}
@keyframes wave-pulse {
  0%, 100% { transform: scaleY(1); }
  50% { transform: scaleY(0.55); }
}

/* ---------- App chip stagger ---------- */

@media (prefers-reduced-motion: no-preference) {
  .js .reveal .app-row li {
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 0.45s ease, transform 0.45s ease;
  }
  .js .reveal.in .app-row li { opacity: 1; transform: none; }
  .js .reveal.in .app-row li:nth-child(2) { transition-delay: 0.06s; }
  .js .reveal.in .app-row li:nth-child(3) { transition-delay: 0.12s; }
  .js .reveal.in .app-row li:nth-child(4) { transition-delay: 0.18s; }
  .js .reveal.in .app-row li:nth-child(5) { transition-delay: 0.24s; }
  .js .reveal.in .app-row li:nth-child(6) { transition-delay: 0.3s; }
  .js .reveal.in .app-row li:nth-child(7) { transition-delay: 0.36s; }
  .js .reveal.in .app-row li:nth-child(8) { transition-delay: 0.42s; }
  .js .reveal.in .app-row li:nth-child(9) { transition-delay: 0.48s; }
}

/* ---------- Pricing ---------- */

.pricing-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.5rem;
  margin-top: 1rem;
  text-align: left;
}

/* ---------- The correction demo ---------- */

/* The spoken line above, held still, and the same words below correcting
   themselves. The output panel takes the .price-card rung below
   (.32 / 14px / .45 / 22px) rather than inventing one — it is the same
   class of object, a surface holding content, and the two blocks sit
   adjacent here so that stays legible. */

.polish {
  margin-top: clamp(2rem, 4vh, 3rem);
  text-align: left;
}

.polish-said {
  max-width: 34rem;
  margin: 0 auto clamp(1.5rem, 3vh, 2.25rem);
  text-align: center;
}

/* Small caps-ish label, in the mono — it names a part of the diagram
   rather than speaking, so it takes the machine's voice. */
.polish-label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--slate);
  margin-bottom: 0.5rem;
}

/* The person talking, so: the reading face. Quoted and italic to mark it
   as speech rather than as the page addressing you, and in --slate rather
   than --depth because it is the input — the corrected line below is what
   the eye should land on. */
.polish-quote {
  font-size: clamp(1.05rem, 2vw, 1.3rem);
  font-style: italic;
  line-height: 1.4;
  color: var(--slate);
  margin: 0;
  text-wrap: balance;
}

.polish-wave {
  display: block;
  width: 7.5rem;
  height: 1.5rem;
  margin: 0.9rem auto 0;
  color: var(--horizon);
}

/* One panel, not two. The meadow edge is the same signal the Pro price
   card uses for "this is the one" — no new vocabulary for the same job. */
.polish-out {
  max-width: 44rem;
  margin-inline: auto;
  padding: 1.4rem 1.4rem 1.25rem;
  background: rgba(255, 255, 255, 0.32);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  border: 1.5px solid var(--meadow);
  border-radius: 22px;
  box-shadow:
    0 14px 44px rgba(27, 58, 75, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@supports not (backdrop-filter: blur(1px)) {
  .polish-out { background: rgba(255, 255, 255, 0.85); }
}

/* --polish-size is the line's own size held in a custom property because
   the keyframes below need to animate *back to* it: font-size is what
   collapses a dropped word, and a keyframe cannot say "inherit". */
.polish-line {
  --polish-size: clamp(0.95rem, 2.3vw, 1.2rem);
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--polish-size);
  line-height: 1.75;
  color: var(--depth);
}

/* Base state is the corrected sentence: the dropped words and the closed
   spaces have no size, and every swap shows the character it ends on.
   white-space: pre because these spans hold single spaces, and a space is
   the whole point of some of them — the browser would otherwise collapse
   it away and the raw line would be missing its gaps. */
.polish-cut,
.polish-close {
  font-size: 0;
  opacity: 0;
  white-space: pre;
}

/* A fixed 1ch slot, so a character changing does not move anything around
   it. .polish-to stays in flow and gives the slot its baseline —
   absolutely positioning *both* children would leave the inline-block with
   no line box, and its baseline would fall back to the bottom margin edge,
   dropping the whole slot below the line. */
.polish-swap {
  position: relative;
  display: inline-block;
  width: 1ch;
  white-space: pre;
}
.polish-swap .polish-from {
  position: absolute;
  inset: 0;
  opacity: 0;
}

/* The two names that come off the screen, marked once they are spelled.
   A background rather than coloured text: at this size green text would
   owe 4.5:1 and --meadow does not reach it on glass. Negative margins
   cancel the padding so the pill costs no advance width and the ch
   arithmetic above still holds. */
.polish-id {
  padding: 0.12em 0.28em;
  margin: 0 -0.28em;
  border-radius: 5px;
  background: rgba(74, 124, 78, 0.13);
}

/* The finished sentence for assistive tech, since .polish-line is a bag of
   half-characters. Not display:none — that would take it out of the
   accessibility tree along with everything else. */
.polish-said-plain {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The evidence line. --meadow-ink rather than --meadow: this is small
   text and needs 4.5:1, which the lighter green does not reach on glass. */
.polish-source {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  margin: 0.9rem 0 0;
  padding-top: 0.8rem;
  border-top: 1px solid rgba(27, 58, 75, 0.12);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  line-height: 1.5;
  color: var(--meadow-ink);
}
.polish-source-icon { flex: none; transform: translateY(0.1em); }

/* The line must never re-wrap while it is animating — a word teleporting
   across a wrap boundary mid-collapse would undo the effect — so the
   breaks are fixed rather than natural. One line while 53ch fits (the raw
   form is the wide one, at 52 characters plus the slot the full stop will
   land in); three fixed lines of at most 20ch below that. 40rem is where
   the clamp above bottoms out and the single line stops fitting, and it is
   the breakpoint the nav and the hero video already use. */
.polish-brk { display: none; }

@media (max-width: 40rem) {
  .polish-brk { display: inline; }
  .polish-line { --polish-size: 1.0625rem; }
}

@media (prefers-reduced-motion: no-preference) {
  /* Two beats, in the order the two claims are made: the line tightens
     (20–28%), then it sharpens (28–36%). 700ms for the whole correction,
     against 2.4s holding the result — the same asymmetry the flow diagram
     upstairs runs on, and the same 4.4s clock, so the page has one
     heartbeat.
     No animation-delay anywhere: a delay shifts a whole loop, so a
     staggered token would still be finishing while the first had already
     reset. Everything below is phased inside its own keyframes.
     The revert at 97.5% happens after .polish-line has faded to 0 — the
     correction must never be seen running backwards, which would read as
     the tool undoing its own work. */
  .js .reveal .polish-line { opacity: 0; }

  .js .reveal.in .polish-line { animation: polish-appear 4.4s linear infinite; }
  .js .reveal.in .polish-cut  { animation: polish-cut 4.4s ease-out infinite; }
  .js .reveal.in .polish-close { animation: polish-close 4.4s ease-out infinite; }
  .js .reveal.in .polish-from { animation: polish-from 4.4s ease-out infinite; }
  .js .reveal.in .polish-to   { animation: polish-to 4.4s ease-out infinite; }
  .js .reveal.in .polish-id   { animation: polish-id 4.4s ease-out infinite; }
}

@keyframes polish-appear {
  0%        { opacity: 0; }
  8%, 90%   { opacity: 1; }
  97%, 100% { opacity: 0; }
}

/* beat one: the filler and the stutter are squeezed out */
@keyframes polish-cut {
  0%, 20%     { font-size: var(--polish-size); opacity: 1; }
  28%, 97%    { font-size: 0; opacity: 0; }
  97.5%, 100% { font-size: var(--polish-size); opacity: 1; }
}

/* beat two: the spaces inside the two identifiers close up */
@keyframes polish-close {
  0%, 28%     { font-size: var(--polish-size); opacity: 1; }
  36%, 97%    { font-size: 0; opacity: 0; }
  97.5%, 100% { font-size: var(--polish-size); opacity: 1; }
}

/* beat two: and the characters that change, change */
@keyframes polish-from {
  0%, 28%     { opacity: 1; }
  36%, 97%    { opacity: 0; }
  97.5%, 100% { opacity: 1; }
}
@keyframes polish-to {
  0%, 28%     { opacity: 0; }
  36%, 97%    { opacity: 1; }
  97.5%, 100% { opacity: 0; }
}

/* the marks land with the spelling, not before it */
@keyframes polish-id {
  0%, 28%     { background-color: rgba(74, 124, 78, 0); }
  36%, 97%    { background-color: rgba(74, 124, 78, 0.13); }
  97.5%, 100% { background-color: rgba(74, 124, 78, 0); }
}

/* ---------- Pricing ---------- */

/* The signup promotion. It is not a plan, so it deliberately does not
   use .price-card's frosted white on its own: a meadow tint separates
   "offer" from "plan" at a glance. It takes the rest of the glass
   treatment though, blur and inset highlight and lift, because it sits
   between the nav's pill and the cards and belonged to neither while it
   was a flat tinted box.

   A pill, not a panel. This was a two-line block at 80px tall, then a
   flat strip at 41px, and is now 36px: the promotion is worth a glance
   on the way to the $9, and tinted ground above the cards was asking
   for a read it does not repay. The radius echoes the nav, which is the
   page's other full pill.

   The tint runs out towards the right so the band is anchored at the
   chip and fades into the section rather than sitting on the page as a
   slab. It is the same move the wash sections make at their edges.

   Green is --meadow-ink, never --meadow. --meadow is a stroke and rule
   accent that only owes 3:1. Measured over the tint at its strongest
   (#EDF2ED): --meadow-ink 6.73:1, --slate 5.05:1, and white on the
   chip's --meadow-ink ground 7.63:1. Everything to the right of the
   chip sits over less tint than that, so those are the floors. */
.price-offer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem 0.7rem;
  margin-top: 1.1rem;
  /* Concentric would put the chip 8px off the left edge: the band's
     18px radius less the chip's own 10px. That is the geometric answer
     and it read as cramped, because the chip is a dark solid and reads
     heavier than its box. 11.2px gives it air and still sits inside the
     band's curve. */
  padding: 0.4rem 1.1rem 0.4rem 0.7rem;
  /* 18px is exactly half the one-row height, so on a MacBook this
     renders as a stadium and is pixel-identical to 999px. It is written
     as 18 rather than 999 because the two stop agreeing the moment the
     band wraps: 999px on a three-line box puts half circles on the ends.
     That used to be a media query, and a media query was the wrong tool.
     Wrapping depends on the text's size against the band's width, so a
     reader running a larger default font wraps it at a viewport width no
     breakpoint had been told about. */
  border-radius: 18px;
  text-align: left;
  font-size: 0.95rem;
  line-height: 1.4;
  background:
    linear-gradient(90deg,
      rgba(74, 124, 78, 0.15) 0%,
      rgba(74, 124, 78, 0.07) 55%,
      rgba(255, 255, 255, 0.3) 100%);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  border: 1px solid rgba(74, 124, 78, 0.24);
  box-shadow:
    0 6px 22px rgba(27, 58, 75, 0.07),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@supports not (backdrop-filter: blur(1px)) {
  .price-offer { background: rgba(74, 124, 78, 0.1); }
}

/* The one solid, saturated shape in the section. That is the whole job:
   a filled chip beside two panes of glass reads as the thing that is
   not always there. */
.price-offer-tag {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 0.34rem;
  padding: 0.3rem 0.62rem;
  font-size: 0.62rem;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  background: var(--meadow-ink);
  border-radius: 999px;
  box-shadow: 0 1px 3px rgba(27, 58, 75, 0.2);
}

.price-offer-icon {
  width: 0.62rem;
  height: 0.62rem;
  color: #A8D6AD;
}

.price-offer-lead {
  font-family: var(--font-display-2);
  font-weight: 500;
  color: var(--meadow-ink);
}

.price-offer-note { color: var(--slate); }

/* The band is one statement, so it should not be a second reveal beat —
   it rides in with the heading above it rather than with the cards. */

.price-card {
  display: flex;
  flex-direction: column;
  padding: 1.75rem 1.6rem 1.6rem;
  background: rgba(255, 255, 255, 0.32);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  border: 1.5px solid rgba(255, 255, 255, 0.45);
  border-radius: 22px;
  box-shadow:
    0 8px 32px rgba(27, 58, 75, 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@supports not (backdrop-filter: blur(1px)) {
  .price-card { background: rgba(255, 255, 255, 0.85); }
}

.price-card--pro {
  position: relative;
  border-color: var(--meadow);
  box-shadow:
    0 14px 44px rgba(27, 58, 75, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}

.price-flag {
  position: absolute;
  top: -0.7rem;
  left: 50%;
  transform: translateX(-50%);
  padding: 0.2rem 0.8rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #fff;
  background: var(--meadow);
  border-radius: 999px;
  white-space: nowrap;
}

.price-name {
  font-size: 1.0625rem;
  font-weight: 600;
  color: var(--slate);
}

.price-amount {
  font-family: var(--font-display-2);
  font-size: 2.4rem;
  font-weight: 500;
  line-height: 1.15;
  margin: 0.2rem 0 1rem;
}
.price-per {
  font-size: 1rem;
  font-weight: 400;
  color: var(--slate);
}

.price-features {
  flex: 1;
  margin-bottom: 1.5rem;
  display: grid;
  gap: 0.55rem;
  font-size: 0.9375rem;
}
.price-features li {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}
.price-lead { color: var(--slate); }
.check {
  flex: none;
  width: 1rem; height: 1rem;
  color: var(--meadow);
}

.price-card .btn { text-align: center; }

/* the cards land together; the Pro emphasis arrives a beat later so
   the eye registers "two options" before "here's the pick" */
@media (prefers-reduced-motion: no-preference) {
  .js .pricing-reveal .price-card--pro {
    border-color: rgba(255, 255, 255, 0.45);
    box-shadow:
      0 8px 32px rgba(27, 58, 75, 0.08),
      inset 0 1px 0 rgba(255, 255, 255, 0.5);
    transition: border-color 0.45s ease 0.4s, box-shadow 0.45s ease 0.4s;
  }
  .js .pricing-reveal .price-flag { opacity: 0; transition: opacity 0.45s ease 0.4s; }
  .js .pricing-reveal.in .price-card--pro {
    border-color: var(--meadow);
    box-shadow:
      0 14px 44px rgba(27, 58, 75, 0.14),
      inset 0 1px 0 rgba(255, 255, 255, 0.5);
  }
  .js .pricing-reveal.in .price-flag { opacity: 1; }
}

@media (max-width: 720px) {
  .pricing-grid { grid-template-columns: 1fr; gap: 1.75rem; }
}

/* ---------- Final CTA ---------- */

.cta-actions { margin-top: 2rem; }
.cta .microcopy { margin-top: 1.1rem; }

/* ---------- Footer ---------- */

.footer {
  padding: 3.5rem 0 2.5rem;
  border-top: 1px solid var(--horizon);
  background: var(--haze);
  text-align: center;
}

.footer-tagline {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 1.25rem;
  color: var(--depth);
}

.footer-links {
  margin-top: 1.5rem;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem 1.75rem;
}
.footer-links a {
  color: var(--slate);
  text-decoration: none;
  font-size: 0.9rem;
}
.footer-links a:hover { color: var(--meadow); text-decoration: underline; }

.footer-note {
  margin-top: 1.75rem;
  font-size: 0.8rem;
  color: var(--slate);
}

/* ---------- Legal pages ---------- */

/* privacy-policy.html and terms.html. They borrow the nav and footer
   verbatim and add nothing else from the landing page: no hero, no
   washes, no reveals, no cloud layer. A document that asks to be
   believed should sit still. */

/* body's own gradient is sized to the document, so on a 3,000px policy
   the sky stretches into a pale wash and the page loses its top. This
   ramp is measured in rem instead, so the sky band is the same height
   whatever the document's length, and everything past it is haze. */
body.legal {
  background:
    linear-gradient(180deg, var(--sky) 0, #E8F2F9 22rem, var(--haze) 44rem)
    no-repeat;
  background-color: var(--haze);
}

.legal-main {
  /* --nav-space is the bar's own footprint; the clamp is the air. */
  padding-top: calc(var(--nav-space) + clamp(2rem, 6vh, 4rem));
  padding-bottom: clamp(4rem, 8vh, 6rem);
}

/* One measure for the whole document, set once.
   36rem, not the 44rem .container.narrow gives, because at 17px that is
   ~88 characters a line; this is ~70. Every block on the page — title,
   summary, jump list, prose, the rules between them — takes this width
   and is centred in the container, so the column has one left edge and
   one right edge from the eyebrow to the last line, and its centre
   lines up with the footer's. */
.legal-main > .container > * {
  max-width: var(--legal-measure);
  margin-inline: auto;
}

.legal-head { margin-bottom: clamp(2rem, 5vh, 2.75rem); }

.legal-eyebrow {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--slate);
  margin-bottom: 0.9rem;
}

/* .display is the section-heading style from the landing page. Playfair
   italic stays reserved for the hero — a legal title set in the ornate
   face would be dressing up the one document that shouldn't be. */
.legal-head .display { margin-bottom: 0.75rem; }

.legal-updated {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--slate);
}

/* ---- The plain-English box at the top of the policy ----
   Same hairline-on-nothing treatment as .vault in the .privacy section,
   so a reader arriving from that section meets an object they have
   already seen rather than a new one. */
.legal-summary {
  margin-bottom: clamp(2.25rem, 5vh, 3rem);
  padding: clamp(1.1rem, 3vw, 1.5rem) clamp(1.1rem, 3vw, 1.6rem);
  border: 1px solid rgba(27, 58, 75, 0.22);
  border-radius: 14px;
}
.legal-summary-tag {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  color: var(--slate);
  margin-bottom: 0.9rem;
}
.legal-summary ul {
  display: grid;
  gap: 0.7rem;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--depth);
}
/* the list markers are drawn rather than inherited: a disc would pull
   the text off the box's own left edge for no gain */
.legal-summary li {
  position: relative;
  padding-left: 1.15rem;
}
.legal-summary li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 0.34rem;
  height: 0.34rem;
  border-radius: 50%;
  background: var(--meadow);
}

/* ---- Jump list ---- */

.legal-toc {
  margin-bottom: clamp(2.25rem, 5vh, 3rem);
  padding-bottom: clamp(1.75rem, 4vh, 2.25rem);
  border-bottom: 1px solid rgba(27, 58, 75, 0.14);
}
.legal-toc-tag {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  color: var(--slate);
  margin-bottom: 0.85rem;
}
.legal-toc ol {
  margin: 0;
  padding: 0;
  list-style: none;
  columns: 2;
  column-gap: 2rem;
  font-size: 0.9375rem;
}
.legal-toc li { break-inside: avoid; margin-bottom: 0.4rem; }
.legal-toc a {
  color: var(--slate);
  text-decoration: none;
}
.legal-toc a:hover { color: var(--meadow); text-decoration: underline; }

.legal-body { color: var(--depth); }

.legal-body h2 {
  font-family: var(--font-display-2);
  font-weight: 500;
  font-size: 1.375rem;
  line-height: 1.25;
  letter-spacing: -0.008em;
  margin-top: clamp(2.5rem, 6vh, 3.25rem);
  margin-bottom: 0.85rem;
  /* Adds to the global html { scroll-padding-top: 5.5rem }, which is
     88px against a bar whose bottom edge is at 92 — on the landing page
     that 4px shortfall is invisible because the anchors are sections
     with 7.5rem of padding above their headings, but here the heading
     *is* the anchor and a jump parks it under the glass. */
  scroll-margin-top: 2rem;
}
.legal-body > h2:first-child { margin-top: 0; }

.legal-body h3 {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 1.0625rem;
  margin-top: 1.75rem;
  margin-bottom: 0.5rem;
}

.legal-body p { margin-bottom: 1.05rem; }
.legal-body > *:last-child { margin-bottom: 0; }

/* The global reset kills list markers for the nav and the feature
   lists; prose needs them back. */
.legal-body ul,
.legal-body ol {
  margin: 0 0 1.05rem;
  padding-left: 1.35rem;
  list-style: disc;
}
.legal-body ol { list-style: decimal; }
.legal-body li { margin-bottom: 0.45rem; }
.legal-body li:last-child { margin-bottom: 0; }
.legal-body li > ul { margin-top: 0.45rem; margin-bottom: 0; }

.legal-body strong { font-weight: 600; }

/* The nav and footer suppress underlines; running prose wants them.
   --meadow-ink rather than the global --meadow: these are 17px body
   links, which is the case that token was darkened for. Plain --meadow
   measures 4.59:1 against the top of this page's ramp — over the 4.5
   line, but 0.09 is not headroom. */
.legal-body a {
  color: var(--meadow-ink);
  text-decoration: underline;
}

/* Section rows that mirror the .vault list in the .privacy section:
   the thing, then what becomes of it.

   Deliberately not a grid with one gap: a single gap value puts the same
   space between a term and its own definition as between one pair and
   the next, so the terms float free of what they define. The pair is
   bound tight and the space goes between pairs instead. */
.legal-body dl { margin: 0 0 1.05rem; }
.legal-body dt {
  font-weight: 600;
  margin-bottom: 0.2rem;
}
.legal-body dd {
  margin: 0 0 1.15rem;
  color: var(--slate);
}
.legal-body dd:last-child { margin-bottom: 0; }

.legal-crosslink {
  margin-top: clamp(2.75rem, 6vh, 3.5rem);
  padding-top: clamp(1.75rem, 4vh, 2.25rem);
  border-top: 1px solid rgba(27, 58, 75, 0.14);
  font-size: 0.9375rem;
  color: var(--slate);
}

/* Bracketed placeholders, so a value nobody has filled in cannot be
   read as a real one — and cannot be missed on the way to publishing. */
.legal-todo {
  font-family: var(--font-mono);
  font-size: 0.86em;
  padding: 0.05em 0.3em;
  border-radius: 3px;
  background: rgba(74, 124, 78, 0.12);
  color: var(--meadow-ink);
  white-space: nowrap;
}

@media (max-width: 40rem) {
  /* two columns of jump links stop being scannable once each one wraps */
  .legal-toc ol { columns: 1; }
  .legal-body h2 { font-size: 1.25rem; }
}

/* ---------- The download page ---------- */

/* It takes body.legal wholesale: the same background ramp, the same nav
   clearance, and the same single 36rem measure that every block on a
   legal page shares. A download page is not a legal document, but it is
   the same shape of thing — one column, short, read once, nothing
   competing for the eye — and the measure is the reason those pages
   read calmly. .download exists to scope the rules below, not to change
   the shell.

   The mono is reserved here for anything a reader would compare
   character by character: version, filename, the shell command, the
   checksum. Sizes and dates are quantities you read rather than match,
   so they stay in the text face. */

/* The page's blocks are <section> elements, because each one has a
   heading and a name. The bare `section` rule up in Sections is written
   for the landing page's full-bleed bands: centred text and 4.5rem of
   air top and bottom. Both are wrong inside a single 36rem column, and
   the legal pages never hit it only because they are built from divs.
   Reset it here rather than qualifying the global, which the landing
   page depends on. */
.download-main section {
  padding: 0;
  text-align: left;
}

.dl-head { margin-bottom: clamp(1.75rem, 4vh, 2.25rem); }
.dl-head .display { margin-bottom: 0.75rem; }

.dl-lede {
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--slate);
}

/* The release panel takes .price-card's glass rung exactly (.32 / 14px /
   .45 / 22px) rather than inventing one. It is the same class of object:
   a surface holding content, on a page whose other surfaces are the nav
   and the cards this reader saw on the way here. */
/* Qualified, because the section reset above carries a type selector and
   would otherwise strip this padding: two classes outrank one class and
   one type, whatever the source order says. */
.download-main .dl-panel {
  margin-top: clamp(1.75rem, 4vh, 2.25rem);
  padding: 1.5rem 1.5rem 1.4rem;
  background: rgba(255, 255, 255, 0.32);
  backdrop-filter: blur(14px) saturate(150%);
  -webkit-backdrop-filter: blur(14px) saturate(150%);
  border: 1.5px solid rgba(255, 255, 255, 0.45);
  border-radius: 22px;
  box-shadow:
    0 8px 32px rgba(27, 58, 75, 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@supports not (backdrop-filter: blur(1px)) {
  .dl-panel { background: rgba(255, 255, 255, 0.85); }
}

.dl-panel-heading {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--slate);
}

/* Two columns, not four. Four facts in a row need about 580px and the
   measure gives 576, so a single row would wrap on its own at the widest
   this page ever gets — and a row that wraps at full width is a row that
   was never one row. 2x2 also pairs them the way they are read: what it
   is above, what it weighs beside it. */
.dl-facts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem 1.5rem;
  margin: 1.15rem 0 0;
}

.dl-fact dt {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--slate);
}
.dl-fact dd {
  margin: 0.28rem 0 0;
  font-size: 1rem;
  line-height: 1.35;
  color: var(--depth);
}

/* Literals: what you would match against what Finder shows you. */
.dl-fact dd[data-release="version"],
.dl-fact dd[data-release="file"],
.dl-mono {
  font-family: var(--font-mono);
  font-size: 0.92em;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}

/* The one thing on the page to do, so it sits on the panel's centre line
   while the facts above it stay ranged left. The facts are a table you
   scan down a left edge; the button is not part of that reading, and
   left-aligning it made it look like a fifth row of the same list. Its
   caption goes with it: a centred button over a left-aligned requirements
   line reads as two blocks that were never introduced. */
.dl-button {
  display: block;
  width: fit-content;
  margin: 1.6rem auto 0;
}

.dl-requirements {
  margin-top: 0.9rem;
  text-align: center;
}

.dl-trust,
.dl-steps-wrap,
.dl-verify { margin-top: clamp(2.25rem, 5vh, 3rem); }

.dl-section-heading {
  font-family: var(--font-display-2);
  font-size: 1.35rem;
  font-weight: 500;
  line-height: 1.25;
  margin-bottom: 0.85rem;
  color: var(--depth);
}

.dl-trust p,
.dl-verify p { margin-bottom: 1.05rem; }
.dl-trust p:last-child,
.dl-verify p:last-child { margin-bottom: 0; }
.dl-trust strong { font-weight: 600; }

.dl-trust a,
.dl-verify a {
  color: var(--meadow-ink);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}
/* The underline thickens; the colour does not move. The obvious hover
   here is the brighter --meadow, and it measures 4.03:1 against the page
   ground, under the 4.5:1 small text owes. .legal-body links solve the
   same problem by not changing colour at all. */
.dl-trust a:hover,
.dl-verify a:hover { text-decoration-thickness: 2px; }

/* Numbers in the mono, hung outside the text, so the three steps read as
   a sequence at a glance rather than as three paragraphs. Same device as
   the correction demo's labels: the machine counts, the text speaks. */
.dl-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: dl-step;
}
.dl-steps li {
  position: relative;
  counter-increment: dl-step;
  padding-left: 2.4rem;
  margin-bottom: 1.15rem;
  color: var(--slate);
}
.dl-steps li:last-child { margin-bottom: 0; }
.dl-steps li::before {
  content: counter(dl-step, decimal-leading-zero);
  position: absolute;
  left: 0;
  top: 0.15em;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  color: var(--meadow-ink);
}
.dl-step-title {
  font-weight: 600;
  color: var(--depth);
  margin-bottom: 0.15rem;
}

/* A command, so it takes the tint rather than the glass: this is
   something to copy, not a surface holding content. It scrolls rather
   than wraps, because a wrapped shell command invites a half-copied one. */
.dl-command {
  margin: 0 0 1.05rem;
  padding: 0.7rem 0.9rem;
  overflow-x: auto;
  background: rgba(27, 58, 75, 0.05);
  border: 1px solid rgba(27, 58, 75, 0.1);
  border-radius: 10px;
}
.dl-command .dl-mono {
  font-size: 0.86rem;
  color: var(--depth);
  white-space: nowrap;
}

/* Ships ahead of the checksum it styles. The verify block in the markup
   is commented out until there is a real SHA-256 to put in it, and these
   rules exist so that filling it in is an HTML edit and nothing else. */
.dl-sum-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.6rem;
}
/* Full width, so the button drops below it rather than beside it. A
   SHA-256 is 64 characters, which at this size needs about 480px; the
   measure gives 576 and a button on the same row leaves 478. It fitted
   or wrapped depending on whether the mono had loaded yet, and a
   checksum that reflows under the reader is a checksum that is hard to
   compare. */
.dl-sum {
  flex: 1 1 100%;
  min-width: 0;
  padding: 0.6rem 0.75rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 1.5;
  color: var(--depth);
  background: rgba(27, 58, 75, 0.05);
  border: 1px solid rgba(27, 58, 75, 0.1);
  border-radius: 10px;
  overflow-wrap: anywhere;
}
.dl-copy {
  flex: none;
  padding: 0.5rem 0.95rem;
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--depth);
  background: rgba(255, 255, 255, 0.55);
  border: 1px solid rgba(27, 58, 75, 0.14);
  border-radius: 999px;
  cursor: pointer;
}
.dl-copy:hover { background: rgba(255, 255, 255, 0.8); }
/* The label swaps to "Copied" on success; the width must not jump with
   it, and a button that resizes under the cursor reads as a glitch. */
.dl-copy { min-width: 5.5rem; text-align: center; }
.dl-sum-note {
  font-size: 0.875rem;
  color: var(--slate);
}

@media (max-width: 30rem) {
  /* Below this the two fact columns are narrower than the filename they
     have to hold. */
  .dl-facts { grid-template-columns: minmax(0, 1fr); gap: 0.9rem; }
  .download-main .dl-panel { padding: 1.25rem 1.15rem 1.2rem; }
}


/* ---------- WebGL cloud layer ---------- */

.cloud-canvas {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
}

/* ---------- Small screens ---------- */

/* Small screens: full-bleed footage would put copy on top of the
   desk and grass. Keep the copy on the sky gradient and show the
   poster as a blended band beneath it instead. */
@media (max-width: 640px) {
  .hero { min-height: 100svh; }
  .hero-content {
    order: 1;
    /* Same clearance rule as desktop: `--nav-space` reserves the bar's
       own height, and the extra gap on top of it is the adjustable part.
       It came down 2.5rem → 1rem when the CTA link was added — the link
       pushed the hero 67px past the viewport and started cutting the
       desk out of the footage band. Half came back from here (this space
       is empty anyway while the bar is docked at the bottom) and half
       from the band's own height below. */
    padding-top: calc(var(--nav-space) + 1rem);
  }
  .hero-media {
    position: relative;
    inset: auto;
    order: 2;
    /* 46svh → 43svh with the CTA link; see the padding note above */
    height: 43svh;
    margin-top: 2.25rem;
    -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 22%, #000 82%, transparent 100%);
    mask-image: linear-gradient(180deg, transparent 0%, #000 22%, #000 82%, transparent 100%);
  }
  .hero-poster img,
  .hero-media video { object-position: 50% 72%; }
  .hero-scrim { display: none; }
  /* The desktop gap is set against a display headline; at 42px it reads
     as a full line of space and the sub drifts away from the line it
     belongs to. Tightened here so the sub stays nearer its headline
     than the picture below it. */
  .hero-sub { margin-top: 1.5rem; }
  /* a size down is what keeps it to one row at phone widths — it stays
     a pill because it stays one row */
  .hero-chip {
    font-size: 0.72rem;
    padding: 0.45rem 0.85rem;
    max-width: 20rem;
  }
}

@media (max-width: 400px) {
  .hero-title { font-size: 2.6rem; }
}
