/* generic — page styles for the landing page (issue #17)
 * Consumes fonts.css + the token file index.html loads (tokens.mono.css).
 * Almost literal-free: every colour arrives through a var(), except three
 * paint values in the min-width:992px scrolled-nav block below. */

/* --- Base ---------------------------------------------------------------- */

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

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

body {
  margin: 0;
  background: var(--color-paper);
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: var(--leading-body);
  letter-spacing: var(--tracking-body);
}

h1, h2, h3, p { margin: 0; }

a { color: inherit; }

/* --- Shared voice --------------------------------------------------------- */

.label {
  font-family: var(--font-outlier);
  font-size: var(--text-2xs);
  line-height: var(--leading-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  font-weight: 400;
}

.marker { opacity: var(--opacity-tertiary); }

/* .muted declares its opacity through --reveal-opacity because .reveal.is-in
   (higher specificity) settles at var(--reveal-opacity, 1). A bare opacity
   here is silently overridden to 1 the moment the element reveals — that was
   a live bug on .hero__lede. The two rules compose through the variable:
   un-muted reveals settle at the fallback 1, muted ones at 75%. */
.muted {
  --reveal-opacity: var(--opacity-secondary);
  opacity: var(--reveal-opacity);
}

.btn {
  display: inline-block;
  font-family: var(--font-outlier);
  font-size: var(--text-xs);
  line-height: var(--leading-label);
  letter-spacing: 0.05em;
  text-decoration: none;
  padding: var(--space-sm) var(--space-xl);
  white-space: nowrap;
  clip-path: var(--clip-chamfer);
  border: 0;
  border-radius: var(--radius-pill);
}

.btn--primary {
  background: var(--color-accent);
  color: var(--color-accent-ink);
}

/* A PLAIN BORDER. This used to be two clipped layers -- outer box painting
   the rule colour, ::before inset by the rule width and repainting the
   ground at z-index -1 -- because a clip-path cuts a real border open at the
   clipped corners.
   That hack had one load-bearing dependency, stated in its own comment: the
   clip-path created the stacking context that kept `z-index: -1` LOCAL.
   Removing the chamfer removed the clip, so nothing scoped the negative
   z-index any more and ::before fell behind the element entirely. What was
   left was the solid outer background with same-coloured text on top of it:
   a filled, unreadable button. Reported as "you can't even see the text",
   and correctly.
   border-radius does not cut borders the way clip-path did, so with rounded
   corners the whole trick is unnecessary. One border, transparent ground. */
.btn--ghost {
  /* Interactive-accent seam. Fallbacks are the pre-seam paint, so a token
     file that never defines --color-interactive is unchanged. */
  /* OPAQUE, not transparent — owner, 2026-08-28. A see-through button sits
     over whatever runs behind it: the dot field, the hero's own glow, the
     nav chip. --color-paper is the page's ground, so the button reads as a
     solid object at every scroll position while keeping the outline and the
     accent text it always had. */
  background: var(--color-paper);
  color: var(--color-interactive, var(--color-ink));
  border: var(--rule-hair) solid var(--color-interactive, var(--color-rule));
}

/* HOVER, WHICH THE BUTTONS NEVER HAD. Checked the history: no .btn rule has
   ever carried a :hover state. Every link, the footer ticker and the theme
   toggle do, so a button was the one control on the page that did nothing
   when you pointed at it -- it read as decoration rather than something you
   could press.

   NO NEW COLOUR. The chromatic identity is locked to nine values, so the
   states are built entirely from the pair each button already owns: the
   ghost fills with the accent it was outlined in and takes the primary's
   ink, and the primary is the same pair the other way round. That makes the
   two buttons swap appearances on hover, which is the cheapest possible way
   to say "these are the same kind of thing" without introducing a tint that
   is not in the palette.

   The lift is 1px. Enough to register as a press target, small enough that
   a row of buttons does not jump. */
.btn {
  cursor: pointer;
  transition:
    background-color var(--dur-short) var(--ease-out),
    color var(--dur-short) var(--ease-out),
    border-color var(--dur-short) var(--ease-out),
    transform var(--dur-short) var(--ease-out);
}

/* THE TEXT ON THE FILL IS --color-paper, IN BOTH THEMES, and that is not a
   coincidence worth losing. Paper flips with the theme and the accent does
   not flip the same way, so paper always lands on the far side of whichever
   green is in play: light theme puts near-white on the deep green, dark
   theme puts near-black on the bright one. One declaration, both themes,
   no new value.
   My first attempt used --color-accent-ink here, which is dark in both. On
   the light theme that is dark ink on the deep green -- measured 3.03:1,
   below the 4.5 floor for text this size. Caught by measuring rather than
   by looking, because in dark theme it was a perfectly healthy 15:1. */
.btn--ghost:hover,
.btn--ghost:focus-visible {
  background: var(--color-interactive, var(--color-ink));
  color: var(--color-paper);
  border-color: var(--color-interactive, var(--color-ink));
}

/* The primary swaps its own pair rather than emptying out. Going transparent
   with accent text measured 1.20:1 on the light theme -- #92FF73 on paper,
   which is the same reason the page-level green is still the deep one. The
   fill goes to the ink the button already carried and the text becomes the
   accent: dark box, bright green, 15:1 in both themes. The border keeps the
   button's shape readable where that dark box sits on a dark ground. */
.btn--primary:hover,
.btn--primary:focus-visible {
  background: var(--color-accent-ink);
  color: var(--color-accent);
  box-shadow: inset 0 0 0 var(--rule-hair) var(--color-accent);
}

.btn:hover { transform: translateY(-1px); }

/* The press has to undo the lift, or a click reads as nothing happening. */
.btn:active { transform: translateY(0); }

/* Keyboard users get the same state plus a real ring -- the colour change
   alone is not a focus indicator. */
.btn:focus-visible {
  outline: var(--rule-fine) solid var(--color-focus);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn:hover, .btn:active { transform: none; }
}

/* --- Nav (superpower behaviour, our surface). The outer shell never
   changes; the inner bar contracts to a centred 840px chamfered chip past
   50px of scroll. The flanking link group holds a fixed width at rest and
   releases to auto when scrolled — the release is what makes the bar
   contract rather than merely shrink. The logo is absolutely centred on
   the viewport in both states, scales to 0.75 scrolled, and its colour
   SNAPS deliberately (kept out of every transition list). No scrolled
   state below 992px, matching the source. ------------------------------- */

/* Geometry measured on superpower.com at 1440, both states, which are
   identical there -- shell 1440x83 with padding 22.49px 67.46px, inner bar
   1268x38 with border-radius 9368.81px and backdrop-filter blur(0px), laid
   out space-between / align-center. Expressed here in rem so the bar scales
   with the type scale rather than freezing one viewport's pixels. */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--nav-z);
  padding-block: 1.4rem;        /* 22.49px at 1440 -> 83px shell */
}

.nav__bar {
  /* THREE COLUMNS, NOT space-between. The brand is absolutely centred on the
     viewport, so with space-between the link group and the CTA grew toward a
     centre that was already occupied and slid under the lockup between about
     768px and 1100px — the narrower the window, the deeper the overlap. The
     middle column is dead space the exact width of the lockup plus air, which
     the brand is centred over; the flanking 1fr columns can never reach it,
     at any width, without the reserved track having to be re-measured. */
  display: grid;
  grid-template-columns: 1fr var(--nav-brand-track) 1fr;
  align-items: center;
  gap: var(--space-lg);
  width: calc(100% - 2 * var(--nav-pad-x));
  margin-inline: auto;
  min-height: 2.375rem;         /* 38px, the reference's inner bar height */
  color: var(--color-ink);
  /* The bar IS a pill. Invisible while the ground is transparent, which is
     the reference's rest state too -- but it is the shape the bar has, and
     anything that ever paints a ground here (a blur, a scrolled fill) takes
     it without a second rule. */
  border-radius: var(--radius-pill);
  /* NO BACKDROP FILTER AT REST. This used to read blur(0px), copied from the
     reference's measured value, on the assumption that a zero radius costs
     nothing. It does not: ANY backdrop-filter other than none makes the
     element a backdrop root, so the compositor has to sample and re-composite
     everything underneath this fixed, full-width bar on every scrolled frame
     -- the whole viewport, continuously, to draw a blur of zero pixels. The
     blur that is actually visible lives in .is-scrolled and only exists while
     the chip does. */
  backdrop-filter: none;
  /* The bar's dimensional morph (width/padding) is the sanctioned exception to
     transform/opacity-only -- the contraction IS the briefed behaviour. */
  transition:
    width var(--nav-transition) var(--ease-out),
    padding var(--nav-transition) var(--ease-out),
    background-color var(--nav-transition) var(--ease-out),
    color var(--nav-transition) var(--ease-out);
}

.nav__cta { grid-column: 3; justify-self: end; }

.nav__brand {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  font-family: var(--font-display);
  font-size: var(--text-md);
  letter-spacing: var(--tracking-heading);
  text-decoration: none;
  /* --color-interactive is the brand/interactive-accent SEAM: a token file
     MAY set it (tokens.mono.css does); left unset, every consumer falls
     back to the exact value it painted before the seam existed, so
     tokens.css needs no edit and the chromatic page does not move.
     Consumers: .nav__brand, .ticker (text + border), .tabs__title. Named
     for its job, not its colour. The scrolled-nav snap below still wins on
     scroll — inside the inverted chip the brand yields to the chip's own
     pair, by design. */
  color: var(--color-interactive, var(--color-ink));
  transition: transform var(--nav-transition) var(--ease-out);
}

.nav__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xl);
  margin: 0;
  padding: 0;
  list-style: none;
  /* No fixed width. The 20rem block was a spacer for the scroll contraction,
     which no longer exists; the reference distributes with space-between on
     natural widths, and the fixed block made our spacing read wrong against
     it. */
}

/* Interactive-accent seam. These inherited the ink; the fallback keeps that. */
.nav__links a { text-decoration: none; color: var(--color-interactive, inherit); }

/* The panel is a MOBILE-ONLY box. On desktop it must not exist as a box at
   all — display:contents drops it from the layout so .nav__bar still lays
   out the list directly, and no wrapper can introduce a width, a gap or a
   stacking context that the bar never had. */
.nav__panel { display: contents; }
.nav__close { display: none; }

/* The menu's own CTA row exists only where the bar's button does not. */
.nav__links-cta { display: none; }
@media (max-width: 767px) { .nav__links-cta { display: block; } }

/* THE SCROLLED STATE, RESTORED. Past 50px the bar contracts to a centred chip
   on ink -- the behaviour that was there before, and that the owner asked to
   KEEP. I deleted it in #65 after reading "the shape should be the same on
   both page load and unscroll" as "rest and scrolled must be identical". It
   meant the bar should return to the same shape when you scroll back UP. Only
   the corners were ever wrong.

   So this is the original block with one change: the chamfer is gone and the
   chip takes --radius-pill. The .btn--ghost rules inside it also drop the old
   two-layer trick, which depended on a clip-path that no longer exists -- the
   button now carries a real border like it does everywhere else. */
@media (min-width: 768px) {
  .nav.is-scrolled .nav__bar {
    width: min(840px, calc(100% - 2 * var(--nav-pad-x)));
    padding: var(--space-3xs) var(--space-3xs) var(--space-3xs) var(--space-md);
    /* DARK AND TRANSLUCENT, with the page visible through it -- the reference
       chip's defining quality, and the reason the whole dark-green detour
       existed. The chip used to paint --color-ink, which in the dark theme is
       near-WHITE, so the approved #92FF73 measured 1.14:1 on it and I minted a
       dark green to compensate. That green was never asked for. A dark chip
       needs no compensation: the accent works on it directly, in both themes,
       and the chip reads the same way in both instead of inverting. */
    /* NEUTRAL, not navy. This was oklch(18% 0.012 250 / 0.86) — a chroma of
       0.012 at hue 250 is a blue cast, small in a swatch and plain against a
       page whose ground is a true grey (--color-paper is oklch(16% 0 0), set
       when the navy ground went). A chip that is the only tinted surface on
       a neutral page reads as a mistake. Zero chroma, a step lighter than
       the ground so the pill is legible against it, and slightly more
       transparent so the field still shows through. Owner ruling
       2026-08-28. */
    background: oklch(21% 0 0 / 0.82);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
    color: var(--color-paper);
    border-radius: var(--radius-pill);
  }
  .nav.is-scrolled .nav__brand {
    transform: translate(-50%, -50%) scale(0.75);
    /* Inside the chip the brand takes the chip's accent like every other
       control here. Snaps -- deliberately not transitioned. */
    color: var(--color-interactive, var(--color-paper));
  }
  /* The chip is a DARK surface in BOTH themes -- it does not invert with the
     page, exactly like the reference, whose chip stays dark over a bright
     photo. So the accent on it is the approved #92FF73 in both, stated once
     here rather than routed through a per-theme token.

     This replaces the old --color-interactive-on-ink rebind, which existed
     only because the chip used to paint near-white in dark mode and forced a
     compensating dark green that was never asked for. Measured before this
     change: light theme put oklch(0.48 0.14 139.2) on the chip at 1.46:1.

     Bound on .nav, NOT .nav__bar -- .nav__brand is a SIBLING of the bar,
     absolutely positioned so it only LOOKS like it sits inside the chip, and
     a rebind on the bar never reaches it. */
  .nav.is-scrolled {
    --color-interactive: oklch(90.5% 0.205 139.2);
  }
  .nav.is-scrolled .nav__bar .btn--ghost {
    color: var(--color-interactive, var(--color-paper));
    border-color: var(--color-interactive, var(--color-paper));
    /* Opaque here too (owner, 2026-08-28) — the nav's CTA sits on a
       translucent chip over the dot field, which is the most visible place a
       see-through button showed what was behind it. */
    background: var(--color-paper);
  }
  /* The chip's own ghost rule is more specific than the base hover, so
     without this the nav CTA is the one button that stays dead once the bar
     has contracted -- the state exists everywhere except the place the
     button is most visible. Same fill-with-its-own-accent as elsewhere; the
     chip is dark in both themes, so the ink on the fill is the chip's own
     dark rather than the page's. */
  .nav.is-scrolled .nav__bar .btn--ghost:hover,
  .nav.is-scrolled .nav__bar .btn--ghost:focus-visible {
    /* --color-accent DIRECTLY, not the --color-interactive seam. The chip is
       a dark surface in both themes and carries the bright green in both, but
       the seam still resolved to the page's deep green here -- measured 3.03:1
       on the light theme, the same failure the page buttons had. The accent
       pair is unambiguous and is what the chip already paints at rest. */
    background: var(--color-accent);
    color: var(--color-accent-ink);
    border-color: var(--color-accent);
  }
}

/* THE CHIP IS THE NARROW LAYOUT. Between 768 and 991 the full-width bar has
   no room for three links, a centred lockup and the CTA on one row — that is
   the band where the links used to slide under the logo. The fix is not a
   hamburger: it is the shape the bar already takes past 50px of scroll. Here
   it wears that shape at REST as well, so narrowing the window contracts the
   bar exactly the way scrolling does, and the two states are the same object.
   Declarations repeat the .is-scrolled block above rather than sharing a
   selector list: one case is a scroll class and the other is a width, and CSS
   has no way to say "this rule, plus this condition" across the two. */
@media (min-width: 768px) and (max-width: 991px) {
  .nav .nav__bar {
    width: min(840px, calc(100% - 2 * var(--nav-pad-x)));
    padding: var(--space-3xs) var(--space-3xs) var(--space-3xs) var(--space-md);
    /* NEUTRAL, not navy. This was oklch(18% 0.012 250 / 0.86) — a chroma of
       0.012 at hue 250 is a blue cast, small in a swatch and plain against a
       page whose ground is a true grey (--color-paper is oklch(16% 0 0), set
       when the navy ground went). A chip that is the only tinted surface on
       a neutral page reads as a mistake. Zero chroma, a step lighter than
       the ground so the pill is legible against it, and slightly more
       transparent so the field still shows through. Owner ruling
       2026-08-28. */
    background: oklch(21% 0 0 / 0.82);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
    color: var(--color-paper);
    border-radius: var(--radius-pill);
    /* Tighter than --space-xl, and the brand track shrinks with the brand:
       the lockup is at 0.75 here, so its ink is ~112px and a 152px reserve is
       32px the links cannot have. Measured at 768 — the worst case — the link
       group needs 234px and a 0.5rem track gap leaves each 1fr column 239px.
       The columns are wide enough that the halved gap costs nothing visually
       — both groups sit at the chip's edges, not against the brand. */
    gap: 0.5rem;
    --nav-brand-track: 7.5em;
  }
  .nav .nav__links { gap: 0.75rem; }
  .nav .nav__brand {
    transform: translate(-50%, -50%) scale(0.75);
    color: var(--color-interactive, var(--color-paper));
  }
  .nav { --color-interactive: oklch(90.5% 0.205 139.2); }
  .nav .nav__bar .btn--ghost {
    color: var(--color-interactive, var(--color-paper));
    border-color: var(--color-interactive, var(--color-paper));
    /* Opaque here too (owner, 2026-08-28) — the nav's CTA sits on a
       translucent chip over the dot field, which is the most visible place a
       see-through button showed what was behind it. */
    background: var(--color-paper);
  }
  .nav .nav__bar .btn--ghost:hover,
  .nav .nav__bar .btn--ghost:focus-visible {
    background: var(--color-accent);
    color: var(--color-accent-ink);
    border-color: var(--color-accent);
  }
}

/* A CIRCLE WITH THREE GREEN BARS. Owner, 2026-09-04: the word "Menu" out,
   an icon in, on the same grey the chip and the cards already use — 21%
   lightness at 82% alpha, not a new colour. Fixed 2.75rem square so the
   circle is a circle at any type size, and a hit area at the 44px minimum
   without padding maths. */
.nav__toggle {
  display: none;
  width: 2.75rem;
  height: 2.75rem;
  flex: 0 0 auto;
  place-items: center;
  background: oklch(21% 0 0 / 0.82);
  color: var(--color-interactive, inherit);
  border: var(--rule-hair) solid color-mix(in oklch,
      var(--color-rule) calc(var(--opacity-faint) * 100%), transparent);
  border-radius: 50%;
  padding: 0;
  cursor: pointer;
  transition: background-color var(--dur-short) var(--ease-out);
}

.nav__toggle:hover,
.nav__toggle:focus-visible { background: oklch(26% 0 0 / 0.9); }

.nav__toggle-bars {
  display: block;
  position: relative;
  width: 1.125rem;
  height: 0.75rem;
}

.nav__toggle-bars > span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  border-radius: 2px;
  background: var(--color-accent);
  transition: transform var(--dur-short) var(--ease-out),
              opacity var(--dur-short) var(--ease-out);
}

.nav__toggle-bars > span:nth-child(1) { top: 0; }
.nav__toggle-bars > span:nth-child(2) { top: 50%; transform: translateY(-50%); }
.nav__toggle-bars > span:nth-child(3) { bottom: 0; }

/* Open, the outer bars cross and the middle one goes — the state the word
   "Menu" could only carry by changing to "Close". */
.nav.menu-open .nav__toggle-bars > span:nth-child(1) {
  top: 50%;
  transform: translateY(-50%) rotate(45deg);
}
.nav.menu-open .nav__toggle-bars > span:nth-child(2) { opacity: 0; }
.nav.menu-open .nav__toggle-bars > span:nth-child(3) {
  bottom: auto;
  top: 50%;
  transform: translateY(-50%) rotate(-45deg);
}

:root[data-motion="reduced"] .nav__toggle-bars > span { transition: none; }

@media (max-width: 767px) {
  /* The centred-brand geometry collides with the CTA below ~420px, so on
     mobile the brand rejoins the flow at the left and the bar flexes
     beside it. */
  .nav {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    /* The page gutter here too, not a flat 16px. On mobile the bar is
       width:auto beside the brand, so what sets the nav's left edge is this
       padding -- and at 390 a flat 16 put the nav 23px left of the text
       under it. */
    padding-inline: var(--gutter-page);
  }

  /* CENTRED HERE TOO, owner 2026-09-04. It was pulled into the flow at the
     left when the bar still had to fit a text toggle AND a full CTA button
     beside the brand; with the CTA gone and the toggle a 44px circle there
     is room for the desktop's own geometry, so the brand goes back to the
     centre of the bar and the hamburger sits at the right edge. Absolute, so
     the brand is centred on the NAV rather than on whatever space the
     toggle leaves — those are different centres the moment the toggle
     changes width. */
  .nav__brand {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    order: 0;
  }

  .nav__bar {
    /* One item in the bar now — the toggle — and it goes hard right. The
       brand is absolutely centred over it, so nothing in this row has to
       reserve space for it. */
    display: flex;
    justify-content: flex-end;
    flex: 1;
    width: auto;
    margin: 0;
  }

  .nav__toggle { display: grid; }   /* place-items centres the bars */

  /* ⛔ AND THE CTA COMES OUT OF THE BAR. Measured at 375: its right edge
     landed at 410, 35px past the viewport, which is the overflow in the
     owner's screenshot. The hamburger is the mobile control; the same
     destination lives in the menu it opens. */
  .nav__cta { display: none; }

  /* A FULL-SCREEN POPOVER, NOT A DROPDOWN. Owner, 2026-09-04: "the
     selections are too small to be acceptable for accessibility. Nobody has
     fingers that small." The old panel hung under the bar and inherited the
     nav's own label type, so each row was ~17px tall in a 44px bar. Fixed to
     the viewport, every row is its own line of large type with a 3.5rem hit
     box — the finger target is the row, not the glyphs. */
  .nav__panel {
    display: flex;
    flex-direction: column;
    position: fixed;
    inset: 0;
    z-index: 100;
    background: var(--color-paper);
    padding: var(--gutter-page);
    padding-block-start: calc(2.75rem + var(--space-2xl));
    overflow-y: auto;
    overscroll-behavior: contain;
    visibility: hidden;
    opacity: 0;
    transition:
      opacity var(--dur-short) var(--ease-out),
      visibility 0s linear var(--dur-short);
  }

  .nav.menu-open .nav__panel {
    visibility: visible;
    opacity: 1;
    transition-delay: 0s;  /* reset of the close-delay above, not a duration */
  }

  /* THE LOCKUP STAYS ON TOP OF THE PANEL. Owner, 2026-09-04: the logo
     belongs at the top of the popover. Lifting the nav's own brand above
     the panel rather than printing a second copy of the SVG inside it —
     two copies means two elements carrying mask id="cut", and the second
     one silently steals the reference from the first. It is already
     centred on the X's own centre line, so the open panel gets a header
     row for free. */
  .nav.menu-open .nav__brand { z-index: 101; }

  /* Its own X, at the hamburger's place and size, so the control does not
     appear to move when the panel opens over it. */
  .nav__close {
    display: grid;
    place-items: center;
    position: absolute;
    /* 1.375rem, not the gutter: this is the hamburger's own top offset in
       the bar, so the X opens exactly where the button the finger just left
       was standing. */
    top: 1.375rem;
    right: var(--gutter-page);
    width: 2.75rem;
    height: 2.75rem;
    background: oklch(21% 0 0 / 0.82);
    color: var(--color-interactive, inherit);
    border: var(--rule-hair) solid color-mix(in oklch,
        var(--color-rule) calc(var(--opacity-faint) * 100%), transparent);
    border-radius: 50%;
    padding: 0;
    cursor: pointer;
  }

  .nav__close-bars {
    display: block;
    position: relative;
    width: 1.125rem;
    height: 1.125rem;
  }

  .nav__close-bars > span {
    position: absolute;
    top: 50%;
    left: 0;
    width: 100%;
    height: 2px;
    border-radius: 2px;
    background: var(--color-accent);
  }

  .nav__close-bars > span:nth-child(1) { transform: translateY(-50%) rotate(45deg); }
  .nav__close-bars > span:nth-child(2) { transform: translateY(-50%) rotate(-45deg); }

  /* .nav-prefixed: the scrolled-chip block sets `.nav .nav__links { gap }`
     at every width, and it would win over a bare class here. */
  .nav .nav__links {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    width: 100%;
    margin-block: auto;   /* centred in whatever the X leaves */
    padding: 0;
  }

  .nav__links li { border-block-end: var(--rule-hair) solid var(--color-rule); }
  .nav__links li:last-child { border-block-end: 0; }

  .nav .nav__links a {
    display: flex;
    align-items: center;
    min-height: 3.5rem;
    font-size: 1.5rem;
    letter-spacing: 0.02em;
  }

  /* The underline sweep is sized for a 12px label in a bar; under 24px type
     across a full-width row it reads as a stray rule. The row IS the target
     here, so the press state is the row's own. */
  .nav .nav__links a::after { display: none; }
  .nav .nav__links a:active { color: var(--color-accent); }
}

/* Scroll lock for the open popover — set on <html> by the nav script. */
:root.menu-locked, :root.menu-locked body { overflow: hidden; }

/* --- Sections: hard ground change, never whitespace ----------------------- */

/* The horizontal inset is the LARGER of the percentage and whatever it takes
   to hold the content at --content-max. Below the cap the percentage governs
   and the page breathes proportionally; above it the cap governs and the
   measure stops widening. Measured on the source: 1280px of content at 1440
   and unchanged at 1920.

   Vertical rhythm comes from the content and the separator bands, not from
   heavy section padding — the source runs zero vertical padding on seven of
   its nine sections. */
.section {
  background: var(--color-paper);
  color: var(--color-ink);
  padding-block: var(--space-3xl);
  padding-inline: var(--gutter-page);
}

/* The section under the hero takes the hero's own set-off from the ticker.
   The strip is a hard-edged full-bleed band on both sides, so 64px above the
   first heading read as half the air the hero leaves below its buttons.
   Scoped to the hero's next sibling — every other section keeps --space-3xl,
   which is the rhythm between two ordinary bands. */
.hero + .section { padding-top: 8rem; }


.section { position: relative; }

/* --- Dot field ------------------------------------------------------------

   A square-dot lattice that thins toward the edges — the texture behind the
   reference illustration, in periwinkle instead of grey.

   Squares, not circles, because every corner in this system is cut or square
   and a field of round dots reads as a different language. Getting isolated
   squares out of gradients takes two layers that INTERSECT: a vertical band
   painted by the background, a horizontal band cut by the mask, and only the
   overlap survives. One gradient alone gives stripes.

   The density falloff is a second mask layer — a soft radial composited with
   the lattice, so the field is strongest behind the hero's centre and gone
   by the edges. That is what stops it reading as graph paper. */
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  /* .section::before is the container rail and .hero IS a .section, so the
     rail's width:1px and background-color arrive here too. inset:0 alone
     does not undo a fixed width — the element stayed 1px wide and the field
     never appeared. Both have to be reset explicitly. */
  width: auto;
  background-color: transparent;
  pointer-events: none;
  background-image: linear-gradient(90deg,
    var(--alt-periwinkle) 0 var(--dot-size), transparent var(--dot-size));
  background-size: var(--dot-gap) var(--dot-gap);
  /* Second layer is the COVERAGE mask, and it used to be a vignette:
     120% x 90% at 50%/45%, down to 0.4 by 55% out and gone by 85%, which
     drew the lattice as a blob in the middle of the hero with bare ground
     around it. 80% x 80% at the centre puts the box edge at 0.625 of the
     gradient — solid — so the field reaches all four edges; only the extreme
     corners feather, which stops it ending on a visible straight seam.
     The canvas field's density() is the same ellipse; the two must move
     together or the no-JS fallback stops matching what JS draws. */
  -webkit-mask-image:
    linear-gradient(180deg, #000 0 var(--dot-size), transparent var(--dot-size)),
    radial-gradient(80% 80% at 50% 50%, #000 0 85%, transparent 100%);
  mask-image:
    linear-gradient(180deg, #000 0 var(--dot-size), transparent var(--dot-size)),
    radial-gradient(80% 80% at 50% 50%, #000 0 85%, transparent 100%);
  -webkit-mask-size: var(--dot-gap) var(--dot-gap), cover;
  mask-size: var(--dot-gap) var(--dot-gap), cover;
  -webkit-mask-repeat: repeat, no-repeat;
  mask-repeat: repeat, no-repeat;
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  /* Well below the wordmark, which runs at 1. The field is texture; if it
     competes with the mark it has stopped being a background. */
  opacity: var(--dot-alpha);
}

/* The canvas version of the same field, which can displace individual dots
   around the pointer — a gradient lattice cannot. The CSS field above stays
   as the baseline and is switched off only once the canvas is actually
   running, so a page with no JS, or one asking for reduced motion, still
   gets the texture rather than nothing. */
.hero-field {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  display: none;
}

/* THE DIM IS THE DEFAULT, and the script lights it. Owner, 2026-09-04: the
   burst overwhelmed the hero copy, which reveals from 0 over the same
   second. 45% was still too bright on load (owner, same day); 22%, and the
   climb back is 1.6s rather than 1.2 so the gain is not itself an event. It cannot be a class the script ADDS at start — the element would
   already be at 1, so adding it would transition DOWN and the entrance
   would begin at full brightness, which is the complaint. Starting dim and
   adding .is-lit when the burst lands moves the only transition in the
   right direction.
   Reduced motion never runs the burst, so nothing would ever light it —
   it takes the full value outright. */
.hero-field {
  opacity: 0.22;
  transition: opacity 1600ms var(--ease-out);
}
.hero-field.is-lit { opacity: 1; }
:root[data-motion="reduced"] .hero-field { opacity: 1; transition: none; }

.hero.has-field .hero-field { display: block; }
.hero.has-field::before { content: none; }

.section__head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-lg);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  padding-bottom: var(--space-xs);
  margin-bottom: var(--space-2xl);
}

/* 3.5x body, the source's own heading ratio — not a rung on the rig.ai type
   scale, which jumps 2rem to 3.2rem with nothing between. A 3.2rem section
   title reads 28% larger than the reference and is the second half of why a
   faithful grid still looks wrong. Role token, like --text-wordmark. */
/* CLEARS THE FIXED NAV. .nav is position: fixed, so it is out of flow and
   nothing below it reserves its height — every page built on Page.astro put
   its h1 at y=64 with the nav's bar ending at y=82, so the heading ran under
   the chip on /terms/, /privacy/, /security/ and both new pages. The homepage
   never showed it because its hero carries --hero-pad-top instead.

   --nav-shell is that height as a token rather than a number repeated here:
   1.4rem of padding above and below a bar whose own content is 2.4rem, which
   measures the 83px the nav actually occupies at 1440. */
.page-head {
  padding-top: calc(var(--nav-shell) + var(--space-2xl));
}

.section__title {
  font-family: var(--font-display);
  font-size: var(--text-section);
  line-height: var(--leading-heading);
  letter-spacing: var(--tracking-heading);
  font-weight: var(--weight-display);
  max-width: 22ch;
  margin-bottom: var(--space-2xl);
  text-align: center;
  align-self: center;
  justify-self: center;
}

/* --- Hero (rig.ai DNA) ----------------------------------------------------- */

.hero {
  /* rig.ai proportion: hero + ticker ~65% of the viewport, sized by content
     under the fixed nav — never a viewport-height panel. overflow-x clip
     (not hidden — no scroll container) guards the 50vw full-bleed ticker on
     classic-scrollbar platforms, where 50vw exceeds half the visible width. */
  /* Same capped inset as .section — above ~1600px the raw 10% ran the
     hero 128px wider than every heading below it, and a hero whose left
     edge misses the section edge is the first misalignment a reader
     sees. The ticker still bleeds; it escapes with its own margin. */
  /* The top inset is a NAMED local, not --space-4xl direct, because
     .hero-mark has to start at exactly the same line — the watermark begins
     where the content begins, and two literals drift. 10rem, up from 8: at 8
     the display sat 23px under the fixed bar, close enough to read as a
     collision on a page whose nav has no ground of its own at rest. */
  --hero-pad-top: 13rem;
  padding: var(--hero-pad-top) var(--gutter-page) 0;
  overflow-x: clip;
}

/* Hero watermark. Measured on rig.ai: the brand mark FILLED at 8 %, anchored
   left:50% and running to right:-5% so it bleeds past the viewport edge, and
   taller than the hero so the section crops it top and bottom. Filled, not
   outlined — the CTA mark is the stroked one; two registers of the same
   device, exactly as the source uses them.

   Its own clipping box rather than overflow on .hero, because the hero also
   holds the full-bleed ticker, which escapes by negative margin and must not
   be clipped with it. */
.hero { position: relative; }
/* Lifts the hero's CONTENT above the decorative layers. Every decoration
   has to be named here: the rule sets position:relative, which beats the
   decoration's own position:absolute and drops it back into flow — a
   canvas at height:100% then feeds its own height back into the hero and
   the section grows without bound. */
.hero > *:not(.hero-mark):not(.hero-field) { position: relative; }

.hero-mark {
  position: absolute;
  /* Clears the nav. The bar is transparent in its load state, so a
     full-strength mark running under it swallows the brand and the links.
     The offset is the hero's own top padding, so the mark begins where the
     content begins. */
  inset: var(--hero-pad-top) 0 0 0;
  overflow: hidden;
  pointer-events: none;
}

/* The mark itself. A first pass drew a chamfered square here and it read as
   exactly that — a block filling half the hero, not a mark. A watermark has
   to be a FORM the reader recognises; the source's is their logomark, and a
   featureless rectangle is not the same device at any opacity.

   The default is the wordmark, set past the hero's width so it runs off the
   right edge the way the source's mark does. It is the same string the
   footer sets, so a rebrand is one word in two places. */
.hero-mark > span {
  position: absolute;
  /* Vertically centred in the hero and starting at its horizontal centre.
     The display heading crosses it, deliberately: the heading is the bright
     and sits on top, the mark is the ground behind it, and the overlap is
     what makes the mark read as scale rather than as a graphic parked in a
     corner. A version confined to the band below the heading avoided every
     collision and lost the effect with it. */
  top: 50%;
  left: 50%;
  transform: translateY(-50%);
  font-family: var(--font-display);
  /* Sized to fill the hero's height, which makes the word far wider than the
     half it starts in, so it runs off the right edge — that crop is the
     device, not an accident. A size that fits the whole word inside the
     viewport reads as a band, not a watermark. */
  font-size: 34vw;
  font-weight: var(--weight-display);
  line-height: 0.8;
  letter-spacing: var(--tracking-display);
  white-space: nowrap;
  color: var(--hero-mark-color, var(--alt-periwinkle));
  /* Full strength. At 0.08 — the value the hero source uses — the periwinkle
     barely separated from the navy and the mark read as a smudge; the colour
     source runs its own mark at full strength on the same two values.
     The display heading crosses it, deliberately — measured at 45% of the
     mark's area — and the colour is cut for that overlap rather than to
     avoid it. */
  opacity: 1;
  animation: watermark-glitch 4s linear infinite;
}

/* A project with a real logomark drops it in as a direct child; the slot
   sizes it and the default text steps aside. Same contract as .media__slot:
   the template ships the slot, never the content. */
.hero-mark:has(> svg, > img) > span { display: none; }

.hero-mark > svg,
.hero-mark > img {
  position: absolute;
  /* Same anchor as the text default, so a supplied logomark lands where the
     wordmark did — including the translateY(-50%) the keyframes restore on
     every burst. */
  top: 50%;
  left: 50%;
  right: -5%;
  height: auto;
  transform: translateY(-50%);
  color: var(--alt-periwinkle);
  fill: currentColor;
  opacity: 1;
  animation: watermark-glitch 4s linear infinite;
}

/* The source's glitch: four bursts in four seconds, each 2-4 % of the cycle,
   asymmetric displacement with an opacity flicker, and long dead stretches
   between them — the rhythm is what reads, not the individual jump. Theirs
   also runs hue-rotate() and saturate() on each burst; those are dropped,
   because a filter that rotates hue invents colours the palette does not
   have (R1), and R5 holds motion to transform and opacity. */
@keyframes watermark-glitch {
  0%,   5%, 15%, 18%, 40%, 41.5%, 65%, 66.5%, 100% {
    transform: translateY(-50%) translate(0);
    opacity: 1;
  }
  2%    { transform: translateY(-50%) translate(8px, -3px);            opacity: 0.88; }
  4%    { transform: translateY(-50%) translate(-6px, 2px);            opacity: 0.94; }
  15.5% { transform: translateY(-50%) translate(-10px) skew(-3deg);    opacity: 0.80; }
  16%   { transform: translateY(-50%) translate(5px, -2px) skew(2deg); opacity: 0.90; }
  17%   { transform: translateY(-50%) translate(-3px, 1px);            opacity: 0.88; }
  40.3% { transform: translateY(-50%) translate(12px) scaleX(1.02);    opacity: 0.76; }
  40.6% { transform: translateY(-50%) translate(-8px, 3px) scaleX(0.98); opacity: 0.95; }
  41%   { transform: translateY(-50%) translate(4px, -1px);            opacity: 0.90; }
  65.2% { transform: translateY(-50%) translate(-5px, -4px) skew(-5deg); opacity: 0.72; }
  65.5% { transform: translateY(-50%) translate(7px, 2px) skew(2deg);  opacity: 0.96; }
  66%   { transform: translateY(-50%) translate(-2px);                 opacity: 0.90; }
}

.hero__display {
  font-family: var(--font-display);
  font-size: var(--text-display);
  line-height: var(--leading-display);
  letter-spacing: var(--tracking-display);
  margin-bottom: var(--space-2xl);
  /* The token, not a literal. This was font-weight: 100, which selected
     Chalet's 400 cut anyway — there is nothing thinner to select — and the
     token now carries that same 400 for every heading on the page. */
  font-weight: var(--weight-display);
  text-align: center;
  align-self: center;
  justify-self: center;
}

.hero__lede {
  font-size: var(--text-md);
  max-width: 44ch;
  margin-bottom: var(--space-xl);
  align-self: center;
  justify-self: center;
  text-align: center;
}

.hero__ctas {
  display: flex;
  gap: var(--space-sm);
  margin-bottom: var(--space-2xl);
  text-align: left;
  align-self: center;
  justify-self: center;
}

@media (max-width: 767px) {
  .hero__ctas { flex-direction: column; }
  .hero__ctas .btn { width: 100%; text-align: center; }
}

/* --- Ticker (mono marquee) ------------------------------------------------ */

.ticker {
  /* Its own ground, so it stays a hard-edged strip over the hero mark
     rather than losing its rules and its text in it. */
  position: relative;
  background: var(--color-paper);
  overflow: hidden;
  white-space: nowrap;
  /* Interactive-accent seam (see .nav__brand). The colour fallback is the
     ink the ticker inherited before it had a colour of its own, and the
     border fallback is the rule it already used — unset token, identical
     paint. aria-hidden decoration: no text-contrast obligation, but the
     mono token is text-grade anyway because .nav__brand shares it. */
  color: var(--color-interactive, var(--color-ink));
  border-top: var(--rule-hair) solid var(--color-interactive, var(--color-rule));
  border-bottom: var(--rule-hair) solid var(--color-interactive, var(--color-rule));
  padding-block: var(--space-2xs);
  /* Full bleed from inside the hero's inset: 50% of the containing block
     minus 50vw reaches the viewport edge whatever the inset is. The old
     -10% margin resolved against the CONTENT width, leaving a 2vw gutter
     each side. */
  margin-inline: calc(50% - 50vw);
}

/* Air between the hero's last control and the strip. The two margins COLLAPSE
   — the button row already carries 48px below it — so this is the total gap,
   not an addition to it: at --space-xl (32px) the measured distance did not
   move off 48. 8rem is the set-off a hard-edged full-bleed band
   needs against a row of buttons — ordinary block spacing reads as a collision
   at this weight of rule. */
.hero .ticker { margin-top: 8rem; }

/* FOUR COPIES, SLIDING BY ONE. The loop closes when the content still under
   the strip at the end of a cycle is at least a viewport wide. Six phrases
   measure 1315px; at 1440 the last 125px had nothing left to draw, which is
   the gap that showed before the jump back.

   The first fix pinned each copy to 100vw and let space-around spend the
   slack. That closed the loop but PAID FOR IT IN SPACING -- the gaps between
   words stretched with the viewport, which was never asked for. Spacing here
   is padding-inline and nothing else, fixed at every width.

   So the copies pay instead. Four of them, sliding by 25% -- one copy -- so
   three copies' worth stays under the strip at the wrap: 3945px of headroom,
   past any real display. Wider than that and the gap returns; add copies,
   never stretch them. The four must stay identical, and the slide must stay
   at 100/N %. */
.ticker__track {
  display: inline-flex;
  /* 28s unchanged: one copy is still the distance travelled, so the speed is
     the speed it always was -- 1315px over 28s. */
  animation: ticker-quarter 28s linear infinite;
}

.ticker__track > span { padding-inline: var(--space-lg); }

@keyframes ticker-quarter {
  to { transform: translateX(-25%); }
}

/* OFF-SCREEN ANIMATION STOPS. Three animations ran for the life of the page,
   so the compositor kept their layers live and moving while the reader was
   nowhere near any of them -- paid for on every frame of every scroll, for
   something nobody could see. Measured in Chrome, at rest:

     .hero-mark > span    1.09 Mpx   the watermark glitch, the biggest of them
     .footer__cta-track   0.76 Mpx
     .ticker__track       0.08 Mpx

   The hero mark is the one that matters most and the one the first pass
   missed: at 34vw it is the largest animated surface on the page, and it sits
   in the hero, so it keeps running for the entire length of a scroll that has
   long left it behind.

   An observer adds .is-offscreen when the band leaves the viewport. Stated as
   "off-screen pauses" rather than "on-screen plays": if the script never runs
   no element carries the class, and everything animates exactly as it did
   before. A failure leaves the page working, not frozen. */
.ticker.is-offscreen .ticker__track,
.footer__cta.is-offscreen .footer__cta-track,
.hero-mark.is-offscreen > span,
.hero-mark.is-offscreen > svg,
.hero-mark.is-offscreen > img { animation-play-state: paused; }

/* --- Hairline-ruled grid --------------------------------------------------- */

/* --- Tab strip + media stage (medusa's signature section) ------------------

   The section IS the media panel. The strip above it is structural-cell
   idiom: zero gap, square, divided by hairlines, cells touching. The panel
   below is content-panel idiom: chamfered, floating inside a frame. The two
   are never blended — that tension is the whole look.                      */

/* The card radius reaches every bordered surface, not just the buttons. The
   corner language moved to rounded in the token files, but these components
   never referenced --radius-card -- they were hard zero -- so the page still
   read as square everywhere except the two buttons that did reference it.
   .ticker and .footer__cta are deliberately excluded: both are full-bleed
   bands, and a radius on a strip that runs edge to edge only clips its own
   corners off the viewport. */
.tabs {
  border-radius: var(--radius-card);
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  border-top: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  border-left: var(--rule-hair) solid var(--color-rule);
}

.tabs__tab {
  border: 0;
  border-right: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-fine) solid transparent;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  padding: var(--space-lg) var(--space-xl);
  display: flex;
  flex-direction: column;
  gap: var(--space-3xs);
  transition: border-color var(--dur-short) var(--ease-out);
}

/* Active tab is marked by a rule, not a fill — a fill would put a second
   bright inside the section and break the one-bright rule. */
.tabs__tab[aria-selected="true"] { border-bottom-color: var(--color-rule); }
.tabs__tab[aria-selected="true"] .tabs__note { opacity: 1; }

.tabs__note { font-size: var(--text-sm); }

/* --- The media slot -------------------------------------------------------

   `generic` ships the SLOT, never the content — a project drops its own
   image or video in. The placeholder below is drawn from tokens so the
   archetype is legible while empty, and it says "media", it does not
   pretend to be a screenshot.

   Real media goes in as a direct child and inherits the crop:

       <div class="media__slot"><img src="…" alt="…" width="…" height="…"></div>

   Note the deliberate overflow. On the source, product shots are wider than
   their panel (1680px inside a 1280px frame) and crop — the image is too big
   for its box on purpose. `--media-overscan` carries that.                  */

/* The strip and the panel are one object — the source butts them together,
   and a wide gap turns a tabbed panel into two unrelated blocks. */
.stage { margin-top: var(--space-lg); }

/* A media-led section is mostly panel, so it does NOT also take the full
   section padding — that is what pushed it past the one-viewport ceiling.
   Nothing here gets a whole screen; that rule is the section rhythm. */
.section--media { padding-bottom: var(--space-3xl); }

.media {
  border-radius: var(--radius-card);
  border: var(--rule-hair) solid var(--color-rule);
  padding: var(--space-2xs);
}

.media__slot {
  position: relative;
  overflow: hidden;
  clip-path: var(--clip-chamfer);
  display: grid;
  place-items: end start;
  padding: var(--space-md);
}

/* 2:1, not 16:9 — measured off the source, whose wide panel is 1264x622.
   A 16:9 panel at this width is 70px taller and takes the section over the
   one-viewport ceiling on its own. */
.media--wide .media__slot { aspect-ratio: 2 / 1; }
.media--card .media__slot { aspect-ratio: 4 / 3; }

.media__slot > img,
.media__slot > video {
  position: absolute;
  inset: 0;
  width: var(--media-overscan);
  height: 100%;
  max-width: none;
  object-fit: cover;
  object-position: left top;
}

/* Empty-slot treatment: a chamfered field at low opacity, ruled into thirds
   so it reads as a frame awaiting content rather than as a broken image.
   Everything here is the section's own bright under --opacity-*; no colour
   is introduced. It disappears the moment a real child is added. */
.media__slot::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--color-rule);
  opacity: var(--opacity-faint);
}

.media__slot:has(> img, > video)::before { content: none; }
.media__slot:has(> img, > video) > .label { display: none; }

.media__slot > .label { position: relative; }

@media (max-width: 767px) {
  .tabs { grid-template-columns: 1fr; }
  .tabs__tab { border-right: 0; }
}

/* --- Section rhythm (medusa structure, our surface) ------------------------ */

/* Thin connective section between the heavy blocks: nothing gets a full
   screen; heights alternate dense block, breath, dense block. */
.section--thin { padding-block: var(--space-2xl); }

/* medusa's separator, drawn WITHIN a ground per R-D: rule, a 64px void
   crossed by a single 1px tick at the exact horizontal centre, rule. The
   grounds still rotate — this marks internal structure, not the break. */
.separator {
  position: relative;
  height: var(--space-3xl);
  border-top: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  margin-bottom: var(--space-2xl);
}
.separator::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: var(--rule-hair);
  background: var(--color-rule);
}

/* Drag rail: the one lateral mechanism. Native horizontal overflow plus
   pointer drag — nothing sticky, nothing scrubbed. */
.rail {
  display: flex;
  gap: var(--space-sm);
  overflow-x: auto;
  cursor: grab;
  padding-bottom: var(--space-2xs);
}
.rail:active { cursor: grabbing; }

/* Card: mostly media, with a two-line caption beneath. On the source the
   media block is roughly four fifths of the card's height — the text is a
   caption on the image, never a paragraph with a picture attached. */
.card {
  flex: 0 0 min(26rem, 82%);
  display: flex;
  flex-direction: column;
}

.card__text {
  padding: var(--space-md) var(--space-2xs) 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3xs);
}

/* Sub-heading register. rig sets these in the DISPLAY face at 2rem / 700 —
   there IS a middle rung, and the earlier "two type steps, mono at body
   size, weight only" rule was medusa's, imported with its grid. medusa owns
   structure; rig owns type. */
.card__text h3, .tabs__title, .trio__cell h3 {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: var(--weight-display);
  line-height: 0.95;   /* rig's leading at this rung */
  letter-spacing: var(--tracking-heading);
  margin: 0;
}

/* Interactive-accent seam (see .nav__brand): only the tab titles take it —
   the owner painted .tabs__title, not the sibling h3s above. Fallback is
   the ink the title inherited through .tabs__tab { color: inherit }. */
/* .card__text is REUSED by .trio__cell, which is a plain div and not
   clickable — so scoping to .card is load-bearing, not tidiness. Selecting
   `.card__text h3` painted the three capability headings green, which is the
   exact "everything is green so nothing reads as clickable" failure. */
.tabs__title,
.card .card__text h3 { color: var(--color-interactive, var(--color-ink)); }

.card__text p { font-size: var(--text-sm); margin: 0; }

/* --- Connective band -------------------------------------------------------

   The breath between the two media sections. Split, with the supporting
   line bottom-aligned to the heading block — the source's one structural
   move for a section carrying no media. */

.band, .band__split {
  display: flex;
  align-items: flex-end;
  gap: var(--space-3xl);
}

.band__lead { flex: 0 0 50%; }
.band__lead .label { margin-bottom: var(--space-sm); }
.band__note { max-width: 34ch; font-size: var(--text-md); margin: 0; }

.band__split { margin-bottom: var(--space-2xl); }

@media (max-width: 767px) {
  .band, .band__split {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-lg);
  }
  .band__lead { flex: 1 1 auto; }
}

/* --- Lattice (structural cell wall) ---------------------------------------

   The source's densest block: a grid of touching cells, zero gap, one
   hairline between neighbours, each holding a mono label over a short line.
   Negative margins on the container hide the outer border's double weight
   where cells meet the section edge — the wall reads as one object, not ten
   boxes. Structural-cell idiom throughout: square, never chamfered.       */

.lattice {
  border-radius: var(--radius-card);
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  border-top: var(--rule-hair) solid var(--color-rule);
  border-left: var(--rule-hair) solid var(--color-rule);
}

.lattice__cell {
  border-right: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  padding: var(--space-lg) var(--space-md);
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  min-height: 9rem;
}

.lattice__cell p { margin: 0; }
.lattice__cell .muted { font-size: var(--text-sm); }

@media (max-width: 1023px) { .lattice { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 599px)  { .lattice { grid-template-columns: repeat(2, 1fr); } }

/* --- Trio (3-up media row) ------------------------------------------------

   Same structural-cell idiom as the lattice, but each cell carries a media
   block. The dividers are the composition; the cards do not float and do
   not gain a second border of their own, so `.media` loses its frame here. */

.trio {
  border-radius: var(--radius-card);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  border-top: var(--rule-hair) solid var(--color-rule);
  border-left: var(--rule-hair) solid var(--color-rule);
}

.trio__cell {
  border-right: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  padding: var(--space-md);
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.trio__cell .media { border: 0; padding: 0; }
.trio__cell .media--card .media__slot { aspect-ratio: 16 / 9; }
.trio__cell .card__text { padding: 0; }

/* Three 16:9 cells need ~340px each to read; below 1024 they stack. */
@media (max-width: 1023px) { .trio { grid-template-columns: 1fr; } }

/* --- Bento (modular span grid) --------------------------------------------

   Ported from 21st.dev "Bento Monochrome" (larsen66 / David Hakobyan). The
   source is React + Tailwind; only its GEOMETRY survived the port -- a six
   column track with spans 4/2, 2/1, 2/1, 3/1, 3/1, so the first cell reads
   as the anchor and the rest fall in two descending ranks.

   Everything else is this system's own. The original ships an aurora
   gradient, a dashed background grid, rounded-2xl corners, drop shadows and
   blue radial washes inside each card; the first two duplicate the hero dot
   field, and the last three contradict --shadow-card: none and the mono
   identity's single-tone rule. So: content-panel idiom instead -- hairline
   rule, --radius-card, flat ground, one accent, lift on hover. The staggered
   entrance is the page's own .reveal with --reveal-delay, not the source's
   bento2-card keyframes.                                                   */

.bento {
  display: grid;
  /* minmax(0,1fr), not 1fr: a cell spanning two tracks otherwise lets its
     content's min-width push those tracks wider than their neighbours, and
     the two-column tablet rank came out 307/296 instead of even. */
  grid-template-columns: repeat(6, minmax(0, 1fr));
  grid-auto-rows: minmax(7.5rem, auto);
  gap: var(--space-xs);

  /* The resting border, shared by cell and nest cell. The alpha is the
     named --opacity-faint rung, not a minted number -- the token file's
     rule is one ink, opacity for emphasis. It has to be a colour rather
     than element opacity because opacity would fade the cell's content
     with its frame. Declared here, not on :root, so a section-level
     --color-rule override still reaches it. */
  --bento-rule: color-mix(in oklch,
      var(--color-rule) calc(var(--opacity-faint) * 100%), transparent);
}

/* The lift on hover is the source's move, kept. Its shadow is not: the
   token set carries --shadow-card: none, so depth is spent on the border
   going from faint structure to full ink instead. */
.bento__cell {
  border: var(--rule-hair) solid var(--bento-rule);
  border-radius: var(--radius-card);
  /* THE SAME PADDING AS THE PANELS ABOVE — owner, 2026-08-28. The grid cells
     sat at a flat 24px against the stack panels' 48/32, so two runs of the
     same component, one under the other, framed their content differently.
     One declaration, both runs; the stack's own rule below no longer needs
     to restate it. */
  padding: var(--space-2xl) var(--space-xl);
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  transition: transform var(--dur-short) var(--ease-out),
              border-color var(--dur-short) var(--ease-out);
}


/* Entrance stagger, one .reveal beat per cell on the --stagger-line scale.
   nth-child rather than inline styles: the cascade follows grid position
   when cells are reordered, and the calm path can reach a stylesheet rule
   where it cannot see into style attributes. Each cell is observed
   individually, so the delay only shows where a rank enters together. */
.bento__cell:nth-child(2) { --reveal-delay: calc(1 * var(--stagger-line)); }
.bento__cell:nth-child(3) { --reveal-delay: calc(2 * var(--stagger-line)); }
.bento__cell:nth-child(4) { --reveal-delay: calc(3 * var(--stagger-line)); }
.bento__cell:nth-child(5) { --reveal-delay: calc(4 * var(--stagger-line)); }

:root[data-motion="reduced"] .bento__cell { --reveal-delay: 0ms; }

/* OWNER, 2026-08-28: the large unit sits on top, on its own, and everything
   after it runs in ranks of two. The source's md: spans put the wide cell in
   the left two thirds with a 2-1-2 ragged column beside it — five cells in
   three different widths, which is a mosaic, not a hierarchy. One full-width
   panel followed by even pairs says which one is the lead.

   `1 / -1` rather than `span 6` so the rule survives a change to the track
   count; `span 3` on both of the other two classes is what makes any of them
   pair with any other, whatever order the cells are written in. */
.bento__cell--wide  { grid-column: 1 / -1; grid-row: auto; }
.bento__cell--third,
.bento__cell--half  { grid-column: span 3; }

.bento__head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-sm);
}

/* One rule for both display voices in the cell -- title and nest figure
   are the same rung. 1.05 is the rung's own leading: --leading-heading is
   the 56px value, and the token file records 1.05 at 21.6px. The anchor
   title takes the sub-heading register (2rem / 0.95) by joining its
   selector list above. */
.bento__title, .bento__nest-figure {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  line-height: 1.05;
  letter-spacing: var(--tracking-heading);
  font-weight: var(--weight-display);
}

.bento__title { margin: 0; }
/* The titles are links and were taking the UA underline — the only underlined
   run of display type on the page. The anchor still carries the whole title as
   its hit area; only the rule under it goes. */
.bento__title a { text-decoration: none; color: inherit; }

.bento__body { margin: 0; font-size: var(--text-sm); }

/* ⛔ DESKTOP ONLY, >=1024. Mobile and tablet keep the sizes they had.
   Headings 32px/36.8, body 18px/28.8. Desktop only — mobile and tablet are
   untouched at 21.6 and 14.4. */
@media (min-width: 1024px) {
  .bento__title, .bento__nest-figure { font-size: 2rem; line-height: 1.15; }
  .bento__body { font-size: 1.125rem; line-height: 1.6; }
}

/* ⛔ THE ANCHOR NO LONGER SETS LARGER. Owner ruling 2026-08-28: the first
   cell's text is to match the rest.

   It used to run one rung up on both title and body — 32px/19px against the
   other cells' 22px/14px — on the argument that a 4x2 cell holding a 2x1's
   worth of copy is otherwise 200px of dead space, and that the source hides
   that with a radial wash this port drops. The argument was about filling
   the box; what it produced was five cards in two type sizes, and the odd
   one out is the first thing read. Size is not the only way to make a cell
   the anchor — it still holds the widest span and the nested rank.

   ⛔ AND THE 42ch MEASURE IS GONE TOO — owner, 2026-08-28. On the lead cell
   it capped the paragraph at 363px inside a 583px box and left 220px of
   nothing beside it, which is the dead space the cap was supposed to be
   preventing elsewhere. The lead cell now runs the full inner width. */
.bento__cell--wide .bento__body { max-width: none; }

/* Nested rank inside the anchor. A 4x2 cell holding one paragraph is a big
   empty box however large its type is set; the bento idiom answers that by
   subdividing the anchor rather than padding it out with copy. Three cells,
   pushed to the floor of the anchor with margin-top: auto so they sit on
   its bottom edge whatever the copy above does.

   Corners step DOWN when boxes nest -- --radius-input (12px) inside
   --radius-card (16px) -- so the inner rank reads as contained rather than
   as three cards that happen to overlap the anchor. */
.bento__nest {
  margin-top: auto;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-2xs);
}

.bento__nest-cell {
  border: var(--rule-hair) solid var(--bento-rule);
  border-radius: var(--radius-input);
  padding: var(--space-sm);
  display: flex;
  flex-direction: column;
  gap: var(--space-3xs);
}

/* The source floats and pulses a lucide glyph per card. One accent rule
   instead: the mark is a token-coloured square, and the wide cell's is the
   only one that takes the green. */
.bento__mark {
  width: var(--space-lg);
  height: var(--space-lg);
  flex: none;
  border: var(--rule-fine) solid var(--color-rule);
}

.bento__cell--wide .bento__mark {
  background: var(--color-accent);
  border-color: var(--color-accent);
}

/* The "Where We Start" run sits narrower than the rest of the page — owner
   ruling, 2026-08-28: three quarters of the measure it had. BOTH blocks, the
   grid and the stack below it, so the two read as one run at one measure.
   Applied to the sections' CHILDREN rather than the sections themselves, so
   the band's ground still runs edge to edge and only the content inside it
   pulls in; a max-width on .section would have shrunk the painted surface
   with it.

   Desktop only. Below 992 the page is already at its narrow measure and
   taking another quarter off would leave the panels too cramped to read. */
/* The bento sections used to carry their own 1102px inset here, because
   --content-max was 1280 and a section could not reach the reference width
   any other way. The token is 1102 now, so every section — nav and hero
   included — lands on the same edge and the special case is gone. */

/* --- The stack: the same cells, sequenced vertically ----------------------

   A MODIFIER, not a second component. The homepage's "Where We Start" run is
   five panels one under the other, each one title-left / body-right, rather
   than a bento of mixed spans -- the supermodular.ai arrangement the owner
   pointed at. Everything that paints a cell (border, radius, reveal stagger,
   type rungs) is untouched and shared; only the container's axis and the
   cell's internal direction change.

   Scoped to .bento--stack because .bento is NOT homepage-only: the nonprofits
   page runs a real bento grid off the same classes, and .bento__body is
   reused as a body-text class on the contact page. A global change to .bento
   would have quietly restyled both.

   The two measures are the reference's: the title column holds 360px before
   it will shrink, and the body is capped near 300px so it stays a short
   right-hand column instead of a full-width paragraph. align-items: flex-end
   sits both on the panel's floor, which is what makes a tall panel read as
   one unit rather than two stacked blocks. */
.bento--stack {
  display: flex;
  flex-direction: column;
  /* 8px, the reference's own gap. The panels are meant to read as one run
     of units, not as five separated cards. */
  gap: var(--space-2xs);
}

.bento--stack .bento__cell {
  flex-direction: row;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--space-2xl);
  min-height: 16.25rem;
}

/* ⛔ HALF EACH, AND THE TITLE NEVER WRAPS. Owner, 2026-08-28.

   The original rule gave the title a 22.5rem floor at `flex: none`: 360px
   of fixed title plus the gap took 424px of a 565px panel and left the body
   141px, six lines in a column narrower than the words in it. Then the fix
   for that let the title size itself, which fixed the squeeze and started
   the titles wrapping — the wrong trade, and the thing the owner did not
   ask for.

   So: an even split, and `white-space: nowrap` on the title, which is the
   only part that guarantees one line. The basis is what makes it even; the
   title's nowrap is what makes it honest, because a title that would rather
   be wider than its half takes the width it needs and the body keeps the
   rest. Below 768 the panel is a column and neither applies. */
.bento--stack .bento__head {
  flex: 0 1 auto;
  min-width: 0;
}

/* ⛔ NOWRAP ONLY WHERE THERE IS A SECOND COLUMN TO PROTECT. Unscoped it
   followed the titles into the phone layout, where the panel is 252px wide
   and "Systems that carry volume" at 32px is 365px — measured overflowing
   its own card by 113px. One line is a rule about the two-column panel, not
   about the words. */
@media (min-width: 768px) {
  .bento--stack .bento__head { white-space: nowrap; }
}

.bento--stack .bento__body {
  /* 0 1 50%: half the panel, and it does NOT grow into the leftover. With
     grow on, a short title handed the body 76% of a wide panel — no longer
     squashed, but no longer half either. The leftover belongs in the middle,
     which is what `margin-left: auto` does with it. Shrink stays on so a
     long title on a narrow panel takes what it needs. */
  flex: 0 1 50%;
  min-width: 0;
  margin-left: auto;
  text-align: right;
}

/* One panel is taller than a phone is wide, so the two columns become two
   rows and the body loses its right alignment with them -- a right-ragged
   paragraph on a narrow screen reads as a mistake. */
@media (max-width: 767px) {
  /* Both runs come down to 24px on a phone, not just the stack: 48/32 inside
     a 300px card is more frame than content. Matching padding has to hold at
     every width or it is not matching. */
  .bento__cell { padding: var(--space-lg); }

  .bento--stack .bento__cell {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-sm);
    min-height: 0;
  }
  .bento--stack .bento__head { min-width: 0; }
  .bento--stack .bento__body { max-width: none; margin-left: 0; text-align: left; }
}

/* Six columns need the full measure. At tablet the spans collapse to a two
   rank grid, and below that the source stacks -- grid-cols-1 -- so we do. */
@media (max-width: 1023px) {
  .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .bento__cell--wide  { grid-column: span 2; grid-row: auto; }
  .bento__cell--third,
  .bento__cell--half  { grid-column: span 1; }
}

/* Orphan guard, scoped to the two-column window only -- at (0,3,0) it
   outweighs the span classes, so left open-ended it would leak into the
   single-column layout below 600px and force an implicit second track.
   The counted stream is the single-span cells, not all children: a lone
   last cell is one at an ODD position among the non-wide cells, whatever
   position the anchor holds and however many wide cells exist. */
@media (min-width: 600px) and (max-width: 1023px) {
  .bento__cell:nth-child(odd of .bento__cell:not(.bento__cell--wide)):last-child {
    grid-column: span 2;
  }
}

@media (max-width: 599px) {
  .bento { grid-template-columns: minmax(0, 1fr); }
  .bento__cell--wide { grid-column: span 1; }
  /* Three nested cells need ~90px each; at this width the rank halves and
     the third cell wraps under. */
  .bento__nest { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* --- Split header (heading left, deck and CTA right) ----------------------

   The source's one asymmetric move: the supporting line and its call to
   action both hang under the RIGHT column, so the heading keeps the whole
   left measure. Two columns, no rule between them — the media panel below
   is what closes the block. */

.split-head {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--space-3xl);
  row-gap: var(--space-sm);
  align-items: start;
  margin-bottom: var(--space-2xl);
}

.split-head__lead { grid-row: 1 / span 2; }
.split-head__lead .label { margin-bottom: var(--space-sm); }
.split-head__lead .section__title { margin-bottom: 0; }
.split-head__deck { max-width: 38ch; font-size: var(--text-md); margin: 0; }
.split-head__cta { margin: 0; }

@media (max-width: 767px) {
  .split-head { grid-template-columns: 1fr; }
  .split-head__lead { grid-row: auto; margin-bottom: var(--space-lg); }
}

/* --- Accordion (lateral testimonial) --------------------------------------

   One panel open, the rest collapsed to a spine with the name set upright
   along it. Lateral, like every other mechanism on the page — nothing pins
   and nothing scrubs. flex-basis carries the open/closed state, so the row
   always fills its measure whatever the panel count.                      */

.accordion {
  border-radius: var(--radius-card);
  display: flex;
  border-top: var(--rule-hair) solid var(--color-rule);
  border-left: var(--rule-hair) solid var(--color-rule);
  min-height: 22rem;
}

.accordion__panel {
  flex: 0 1 5rem;
  border: 0;
  border-right: var(--rule-hair) solid var(--color-rule);
  border-bottom: var(--rule-hair) solid var(--color-rule);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  display: flex;
  align-items: flex-end;
  padding: var(--space-lg) var(--space-md);
  transition: flex-basis var(--dur-long) var(--ease-out);
}

.accordion__panel[aria-expanded="true"] { flex: 1 1 auto; cursor: default; }

/* Interactive-accent seam: the spine is the panel's own label. */
.accordion__spine {
  color: var(--color-interactive, inherit);
  writing-mode: vertical-rl;
  white-space: nowrap;
  transition: opacity var(--dur-short) var(--ease-out);
}

.accordion__panel[aria-expanded="true"] .accordion__spine { display: none; }

.accordion__body {
  display: none;
  flex-direction: column;
  gap: var(--space-md);
  max-width: 44ch;
}

.accordion__panel[aria-expanded="true"] .accordion__body { display: flex; }

.quote {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: var(--weight-display);
  line-height: 0.95;
  letter-spacing: var(--tracking-heading);
}

/* An open panel needs room for a 44ch quote; below 1024 the spines eat it. */
@media (max-width: 1023px) {
  .accordion { flex-direction: column; min-height: 0; }
  .accordion__panel { flex: 0 0 auto; align-items: flex-start; }
  .accordion__spine { writing-mode: horizontal-tb; }
}

/* --- Closing CTA ----------------------------------------------------------

   The one centred composition below the fold, and the only place a heading
   above section size is spent. Still on the type scale — --text-2xl, not a
   new rung. */

/* The closing CTA is the page's biggest single gesture — on rig.ai it is the
   tallest section on the page (1.08 viewports) and the oversized mark behind
   it fills nearly the whole of it. A 0.47-viewport close cropped the mark to
   two vertical lines, which reads as two more rails, not as a form. Height
   first, then the mark is sized off it. */
.section--close {
  text-align: center;
  min-height: 78vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* Kept after the oversized chamfer watermark came out. The children still
   take a stacking position of their own; the exclusion the selector used to
   carry has no target any more, because nothing in the section is absolutely
   positioned. */
.section--close > * { position: relative; }

/* The funnel sits behind everything in the section and takes no space: the
   band's own height still comes from the copy. `inset: 0` rather than a
   width/height pair so it tracks the section through every breakpoint, and
   a mask fades it out at the top and bottom edges so it ends in the ground
   rather than at a hard line. */
.close__grid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  -webkit-mask-image: radial-gradient(120% 85% at 50% 42%, #000 35%, transparent 78%);
  mask-image: radial-gradient(120% 85% at 50% 42%, #000 35%, transparent 78%);
}

/* MOBILE TYPE HIERARCHY. Owner, 2026-09-04: on mobile the H1 read smaller
   than the section H2s — and it did. --text-display is clamp(2.2rem, 9vw,
   7rem), so below 391px the 2.2rem FLOOR governs and the hero sat at
   35.2px, while --text-section is a flat 3.5rem that never shrinks: 56px.
   The page's biggest heading was its section labels. Both are re-set here
   for mobile only; desktop keeps the tokens untouched. */
@media (max-width: 767px) {
  .hero__display { font-size: clamp(2.75rem, 11.5vw, 4.5rem); }
  .section__title { font-size: clamp(2.4rem, 7.5vw, 3.5rem); }
  /* The lede drops a rung, from --text-md to --text-base: 19.2px under a
     44px H1 was competing with it. Leading follows the size down so the
     three lines do not open up as the type closes. */
  .hero__lede { font-size: var(--text-base); line-height: 1.55; }
}

.section--close { overflow: hidden; }

.close__title {
  font-family: var(--font-display);
  font-size: var(--text-display);
  line-height: 0.9;   /* rig's CTA leading; the hero runs 0.88 at the same size */
  letter-spacing: var(--tracking-heading);
  font-weight: var(--weight-display);
  max-width: 14ch;
  margin: var(--space-sm) auto var(--space-md);
}

.close__deck { max-width: 44ch; margin: 0 auto var(--space-xl); font-size: var(--text-md); }

.close__ctas {
  display: flex;
  gap: var(--space-sm);
  justify-content: center;
}

@media (max-width: 767px) {
  .close__ctas { flex-direction: column; }
  .close__ctas .btn { width: 100%; text-align: center; }

  /* THE CLOSE READS AT THE HERO'S SIZE. Owner, 2026-09-04: on mobile the
     title and its deck take the hero's pair. Same expressions as the hero
     block above, not copied numbers, so a change to one moves both. */
  .close__title { font-size: clamp(2.75rem, 11.5vw, 4.5rem); }
  .close__deck { font-size: var(--text-base); line-height: 1.55; }
}

/* --- Legal prose (/privacy, /terms, /security) -----------------------------

   .prose carried NO rules at all: the three legal pages were rendering as
   raw markdown defaults inside a full 984px band — 16px lines running the
   whole measure, no space between paragraphs, unstyled tables, and the
   markdown's own H1 repeating the H1 the layout had already printed.

   Everything here is tokens. The type is a rung smaller than body copy
   because these pages are reference, not reading: --text-sm for the running
   text, --text-md and --text-base for the two heading levels, --text-xs in
   the tables. The measure is the real fix — 68ch is about 78 characters at
   this size, and it is what makes the column look set rather than poured. */
/* ONE COLUMN, IN REM. 68ch was measured per element, so the h2s (19.2px)
   came out 785px wide against the paragraphs' 588 and the two left edges
   never lined up — a heading rule that starts 98px left of its own text.
   A single length is the column: 34rem is ~75 characters at --text-sm. */
.prose { --prose-measure: 40rem; }

/* CENTRED IN THE BAND. Owner, 2026-09-04: left-aligned, the column left a
   lump of empty band down the right-hand side. Centred, the air falls
   evenly on both sides and reads as a set column rather than a short line
   length. 40rem is ~89 characters at --text-sm — wide enough that the
   measure is doing the work, not the alignment. */
.prose > * {
  max-width: var(--prose-measure);
  margin-inline: auto;
}

/* The layout already prints the page H1 from its `heading` prop, so the
   markdown's own first heading is the same words a second time. Kept in the
   source — it belongs to the document, and the file is meant to survive
   being read on its own. */
.prose > h1:first-child { display: none; }

.prose p,
.prose li {
  font-size: var(--text-sm);
  line-height: 1.7;
  color: color-mix(in oklch, var(--color-ink) 88%, transparent);
}

.prose p { margin-block: 0 1.1em; }

.prose h2 {
  font-family: var(--font-display);
  font-size: var(--text-md);
  line-height: 1.25;
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-heading);
  margin-block: 2.75rem 0.75rem;
  padding-top: 1.5rem;
  border-top: var(--rule-hair) solid var(--color-rule);
}

/* The first section heading sits under the page head's own air; a rule and
   44px on top of that reads as a gap, not a division. */
.prose h2:first-of-type { margin-top: 1rem; padding-top: 0; border-top: 0; }

.prose h3 {
  font-family: var(--font-display);
  font-size: var(--text-base);
  line-height: 1.3;
  font-weight: var(--weight-display);
  margin-block: 1.75rem 0.5rem;
}

.prose ul,
.prose ol { margin-block: 0 1.1em; padding-inline-start: 1.25rem; }
.prose li { margin-block: 0.35em; }
.prose li::marker { color: var(--color-accent); }

.prose a { color: var(--color-ink); text-decoration-color: var(--color-accent); }

.prose hr {
  border: 0;
  border-top: var(--rule-hair) solid var(--color-rule);
  margin-block: 2.5rem;
}

/* display:block so a wide table SCROLLS inside the measure instead of
   pushing the page sideways — these carry four and five columns. */
/* THE TABLES STAY INSIDE THE MEASURE. Owner, 2026-09-04: they were breaking
   out to the full band, which put them wider than every line of text above
   them. Same column as the prose; a table with more columns than fit scrolls
   inside it. */
.prose > table { width: 100%; }

.prose table {
  display: block;
  overflow-x: auto;
  border-collapse: collapse;
  margin-block: 0 1.5em;
  font-size: var(--text-xs);
}

.prose th,
.prose td {
  text-align: left;
  vertical-align: top;
  padding: 0.6rem 0.9rem 0.6rem 0;
  border-bottom: var(--rule-hair) solid var(--color-rule);
  line-height: 1.55;
}

.prose th {
  font-weight: var(--weight-display);
  letter-spacing: var(--tracking-heading);
  white-space: nowrap;
}

.prose td { color: color-mix(in oklch, var(--color-ink) 82%, transparent); }

.prose code {
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 0.9em;
}

/* --- Footer (alethia.earth DNA + scrolling CTA) ---------------------------- */

.footer {
  background: var(--color-paper);
  color: var(--color-ink);
  padding-top: var(--space-3xl);
  /* ONE VIEWPORT, TICKER DOWN. Nothing in the footer used to know how tall
     the window was: the four parts were block boxes stacked at their own
     content heights and the wordmark was sized off WIDTH alone
     (--text-wordmark is 24.8vw), so at 1280x720 they summed to 980px inside
     a 720px window. The ticker was pushed 260px above the bottom of the
     page and could never be on screen at the same time as the legal bar.
     min-height ties the region to the window instead. box-sizing is
     border-box, so the 64px padding-top above is INSIDE min-height -- add it
     back or the ticker-to-legal run comes out a scale step short.
     svh, not vh: on mobile 100vh is the LARGE viewport (chrome retracted),
     so a region sized to fit exactly gets its bottom cut off by the address
     bar. svh is the small viewport -- the one always visible. */
  /* ponytail: a FLOOR, not a guarantee. min-height cannot shrink the three
     fixed parts, so below the height they sum to -- measured 558.8px on
     desktop (169 ticker + 333.1 grid + 56.7 legal), 770.4px under the 767px
     breakpoint -- the slot is already at 0, the wordmark is gone, and the
     region runs over again. Every real viewport clears it; a 1440x520 window
     does not. Fixing that means shortening the link grid itself, which is a
     design call, not a layout one. */
  min-height: calc(100svh + var(--space-3xl));
  display: flex;
  flex-direction: column;
  /* The wordmark is a full-width reveal; its entry transform (+12px, 2deg)
     juts past the viewport mid-tumble. Clip the transient overflow here —
     clip, not hidden, so no scroll container is created. */
  overflow-x: clip;
}

.footer__cta {
  /* The three fixed parts of the column. Only .footer__mark-slot flexes;
     these three keep the height their content asks for. */
  flex: 0 0 auto;
  /* FLEX, NOT BLOCK. As a block the track was an inline-flex box on a line,
     so the band's own line box added leading UNDER it -- the text could not
     sit centred however the track aligned itself. Flex takes the track out
     of inline layout, and then the band's symmetric padding is the whole
     story. */
  display: flex;
  align-items: center;
  overflow: hidden;
  white-space: nowrap;
  text-decoration: none;
  color: var(--color-interactive, inherit);
  border-top: var(--rule-hair) solid var(--color-interactive, var(--color-rule));
  border-bottom: var(--rule-hair) solid var(--color-interactive, var(--color-rule));
  /* 32px, not 16. The band used to LOOK padded without being padded: the
     untrimmed line box carried ~28px of dead space under the baseline, so
     16px read as roughly 44 at the bottom and 9 at the top. Trimming the box
     to the letters exposed the real figure, and 16px against a 103px
     letterbox is tight. This is the honest version of the space the band
     always appeared to have -- 169px tall now against 164.6 before, but the
     space is symmetric and actually there. */
  padding-block: var(--space-xl);
}

.footer__cta-track {
  display: inline-flex;
  /* Keeps the words and slashes on one centre line as they scroll. The band
     does the centring in the band; this only levels the items against each
     other. */
  align-items: center;
  /* FOUR COPIES, SLIDING BY ONE — the hero strip's fix, applied here. Two
     copies sliding -50% ran dry above 2806px, one copy's width: past that
     the band had nothing left to draw before the wrap and a gap opened.
     Four copies at -25% leave three copies' worth (8418px) under the band at
     the wrap. 18s is unchanged because the distance travelled is unchanged:
     -25% of four copies is the same one copy that -50% of two copies was.

     The copies pay for the loop, never the spacing: no 100vw copy width, no
     space-around, no stretched gaps. Word spacing is padding-inline on the
     spans and nothing else, fixed at every width. Wider display than 8418px
     and the gap returns; add copies, never stretch them. */
  animation: ticker-quarter 26s linear infinite;
}

/* Words in the surface's own text colour, slashes on the --ticker-slash
   seam. Three earlier attempts failed for the same reason -- naming a token
   whose value differs per identity:
     --color-paper  the ground itself on mono; invisible words.
     --color-ink    near-white on mono, but GREEN on chromatic, whose palette
                    has no white at all.
     inherit        the chromatic footer's own colour is that same green, so
                    word and slash came out identical.
   --color-ink is right for the word on both (near-white on mono, the only
   legal text colour on chromatic). The slash takes a seam: mono sets it to
   the green, and chromatic falls back to --color-focus, the periwinkle
   already in the locked nine at 6.22:1 on navy. Exact on mono; the closest
   legal pair on chromatic, where white is not one of the nine values. The track alternates
   <span>Get in touch</span><span>/</span>, so the slash is every even child
   -- keyed on position rather than a class so the markup stays plain text
   that reads correctly and edits cleanly on a canvas. */
.footer__cta-track > span { color: var(--color-ink); }
.footer__cta-track > span:nth-child(even) {
  color: var(--ticker-slash, var(--color-focus));
}

.footer__cta-track > span {
  /* The page's biggest type, per coffee-tech.com. This used --text-display,
     which caps at 112px against the source's measured 148px — visibly
     smaller, and the cap could not be raised without moving every H1 on the
     page. --text-ticker is that measurement as its own role. */
  font-family: var(--font-display);
  font-size: var(--text-ticker);
  line-height: var(--leading-display);
  letter-spacing: var(--tracking-display);
  padding-inline: var(--space-xl);
  /* TRIM THE BOX TO THE LETTERS. --leading-display is tighter than the type
     size, and "Get in touch" has no descenders, so the line box carried dead
     space under the baseline and the words rode high in the band -- reported
     twice as not vertically centred. text-box cuts the box to cap-height on
     top and the baseline underneath, so what gets centred is the letters
     rather than the font's metrics.

     It needs a BLOCK box: the span used to be display:flex (an earlier
     attempt at this same fix), and a flex container's text sits in an
     anonymous child that trimming never reaches. Measured with the property
     on and the span still flex: no change at all.

     Browsers without text-box ignore it and land where the page was before,
     which is the old behaviour, not a broken one. */
  display: block;
  text-box: trim-both cap alphabetic;
}

.footer__cta:hover .footer__cta-track { animation-play-state: paused; }

/* Centred. The five columns spanned the full width and read as drifting
   left under the ticker; centring the tracks and their text settles the
   block beneath it. */
.footer__grid {
  /* PUSHED DOWN, so the footer's leftover height sits above the links
     rather than between them and the wordmark. The footer is a viewport
     tall and only one part of it could stretch; whichever part that was
     became a gap somewhere. Above the links it reads as the footer opening
     up under the CTA band. Between the links and the wordmark it read as
     two unrelated blocks — measured 260px there before this. */
  /* CENTRED IN THE LEFTOVER, NOT PUSHED TO ONE END. `margin-top: auto` sent
     every spare pixel above the links, so the block sat at a different height
     at every window size and the columns drifted away from the middle as the
     window was dragged. `margin-block: auto` splits the leftover evenly, so
     the run stays optically centred at any height. Owner, 2026-08-28. */
  margin-block: auto;
  flex: 0 0 auto;
  justify-items: center;
  text-align: center;
  /* One top edge for every column, whatever its link count, and a real gap
     between wrapped rows — without this a 3-link column made its neighbours
     stretch to match and their headings stopped lining up. */
  align-items: start;
  row-gap: var(--space-2xl);
  /* deck.co's footer shape: an equal-width column track rather than two
     flex columns sized by their own content. Measured there: a 6-column
     grid, 24px gutter, every column the same width whether it holds four
     links or nine. That evenness is what makes it read as a site map
     instead of two stacked lists.
     An EXPLICIT five-track template, not repeat(auto-fit, minmax(...)).
     auto-fit looked equivalent and was measured wrong: at a 1865px viewport
     it generated EIGHT tracks, collapsed the three empty ones to 0 as
     documented — and still charged their column gaps. 3 x 20px of phantom
     gutter overflowed the container by exactly 60px and pushed the five real
     columns left, so the grid sat short of the full-bleed ticker directly
     above it. Collapsed tracks lose their width, not their gaps.
     The breakpoints are cheaper than that surprise.

     ONE TRACK PER COLUMN, COUNTED BY THE BROWSER. A fixed five-track
     template held an empty fifth track that still took a fifth of the row
     and pushed the four real columns left of centre — the leftward drift,
     and no justify-items could fix it, because the TRACKS were what sat
     off centre. A fixed four would have the same fault the moment a column
     is added or removed, and one was removed the same day.

     `grid-auto-flow: column` makes exactly as many tracks as there are
     children, so the row is symmetrical whatever the count. Below, the
     wrap counts are 2 then 1: with three or four columns a three-track row
     can strand one underneath, and an orphaned column reads as a mistake
     rather than as the last item. Two divides both; one is allowed only
     where it is the only thing that fits. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 11rem);
  gap: var(--space-lg) var(--space-md);
  padding: var(--space-3xl) var(--gutter-page);
  align-self: center;
  justify-self: center;
}

@media (max-width: 991px) {
  .footer__grid {
    grid-auto-flow: row;
    grid-template-columns: repeat(2, minmax(0, 11rem));
  }
}

/* ⛔ NEVER A DANGLING COLUMN. Owner's rule, and the count went from four to
   five the moment CONTACT was added — five in two tracks leaves one alone on
   the last row. The last column spans both tracks when it is odd-numbered,
   which centres it under the pairs instead. Scoped to the two-track window:
   below 480px there is one track and nothing to orphan. */
@media (min-width: 480px) and (max-width: 991px) {
  .footer__col:last-child:nth-child(odd) { grid-column: span 2; }
}

@media (max-width: 479px) {
  .footer__grid {
    grid-auto-flow: row;
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Column heading. NOT .label: deck's headings are sentence case at body-small
   size, full-strength ink, sitting directly above their list — the contrast
   with the muted links separates them, not uppercase tracking. Our .label
   idiom (uppercase, wide tracking, 11px) is the marker device used elsewhere
   on the page, and reusing it here made every column read as one more
   eyebrow rather than as a heading over a list. */
.footer__col-head {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--leading-label);
  letter-spacing: normal;
  text-transform: none;
  color: var(--color-ink);
}

.footer__col ul {
  /* 4px under the heading, 12px between links — both measured on deck.co.
     The heading sits far closer to its own list than the lists sit to one
     another, which is what groups them without a rule or a box. */
  margin: var(--space-3xs) 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

/* Links are the QUIET half of the pair: body-small, sentence case, set back
   from the heading. deck.co runs them at 14px in a mid grey against a
   full-strength heading; this system has no second neutral by design, so the
   same separation comes from opacity, which is how it does muted everywhere
   else. Hover raises them to full rather than dimming them — the underline
   wipe further down already carries the hover, and dimming on hover moved
   the link the wrong way. */
.footer__col li {
  font-family: var(--font-body);
  font-size: var(--text-sm);
  line-height: var(--leading-label);
  letter-spacing: -0.011em;   /* deck: -0.16px at 14px */
}

.footer__col a {
  text-decoration: none;
  color: var(--color-interactive, inherit);
  opacity: var(--opacity-secondary);
  transition: opacity var(--dur-short) var(--ease-out);
}
.footer__col a:hover { opacity: 1; }

/* Full-bleed wordmark: one element, one text node — a consuming project
   replaces the text and nothing else. --text-wordmark is a dedicated
   viewport-driven role (see tokens.css); the page's fit script measures
   the ink and corrects font-size and side bearing so any replacement text
   spans the content inset edge to edge. nowrap keeps a long word on one
   line until the fit runs. */
/* The slack in the column. Everything else in the footer is the height its
   content asks for; this one child takes whatever a viewport leaves over,
   which is what makes the ticker-to-legal run come out exact at any window
   size. flex-basis 0 + min-height 0 means the wordmark inside can never push
   the slot open -- without min-height:0 a flex item's automatic minimum size
   is its content, and a 433px wordmark would size the slot and the whole
   trick would collapse.
   COLUMN, not row: as a row the wordmark would be a flex item shrunk to its
   own ink width, and the fit script measures `mark.clientWidth` to work out
   how much inset it has to span. In a column the cross axis stretches it
   full width, which is what it was as a block.
   container-type: size makes the slot a query container in both axes, so the
   wordmark can size itself against the height it was handed (cqh below). */
/* THE FOOTER LOCKUP IS THE NAV'S LOCKUP. Owner, 2026-08-28: "make it the
   same as the logo in the nav bar". Same file, same viewBox, same 1074x208
   geometry — mark and wordmark as one drawing, so their sizes and the gap
   between them cannot drift from the nav's.

   ⛔ THIS REPLACED A FITTED TEXT WORDMARK AND ITS SCRIPT, and that is what
   fixed the clipping. The old <p> carried `padding-inline: --gutter-page`,
   which was the footer's whole left inset; a disc added beside it sat
   OUTSIDE that padding and ran off the left edge of the page. An SVG needs
   no fit script at all — `width: 100%` inside the inset is the fit — so the
   inset belongs to the box and applies to the mark and the word alike. */
.footer__lockup {
  flex: 0 0 auto;
  /* WIDER THAN THE CONTENT COLUMN, and deliberately. Everything else on the
     page stops at --content-max (984px); the lockup is the one element meant
     to run with the window, so it takes its own inset and gets bigger the
     bigger the screen is. 15% a side = 70% of the window (owner, 2026-09-04;
     4vw was 92%, then 80%), with a 24px floor so it does not close up on a
     phone. */
  padding-inline: max(1.5rem, 15%);
}

.footer__lockup svg {
  display: block;
  width: 100%;
  height: auto;
  color: var(--color-ink);
}

.footer__mark-slot {
  /* No longer a size container. The wordmark used to resolve its font-size
     against 100cqh of this box, which is why it needed a definite height and
     a container-type; an SVG takes its height from its own aspect ratio. */
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding-bottom: var(--space-2xs);
}

.footer__legal {
  flex: 0 0 auto;
  display: flex;
  justify-content: space-between;
  gap: var(--space-lg);
  /* Tight to the wordmark above it: 16px of top padding put a second gap
     under the one the slot already left. */
  padding: var(--space-2xs) var(--gutter-page) var(--space-lg);
  border-top: var(--rule-hair) solid var(--color-rule);
}

.footer__legal .label { opacity: var(--opacity-tertiary); }

/* NARROW: THE GRID HAS TO LIVE INSIDE THE HEIGHT BUDGET TOO. .footer is now
   one viewport tall from the ticker down, and .footer__mark-slot takes
   whatever the other three parts leave -- so on a phone the grid is what
   decides whether the wordmark has any room at all. Measured at 390x844 with
   the old rule: the grid alone was 710.7px of an 844px window, the slot was
   squeezed to 0, the wordmark vanished, and the region still ran 38px over.
   Two things were spending that height, and both were accidents:
     flex-direction  INERT. .footer__grid is display: grid. This line is left
                     over from when it was two flex columns and has done
                     nothing since; it only made the rule look intentional.
     gap             a single value, so it set the ROW gap too -- 48px between
                     rows where the base rule asks for 24, doubled on exactly
                     the viewport that can least afford it.
   Restoring the base gap and taking the block padding down one step gives
   144px back, which is the difference between a wordmark and no wordmark. */
@media (max-width: 767px) {
  .footer__grid {
    gap: var(--space-lg) var(--space-md);
    padding-block: var(--space-xl);
  }
}

/* --- Reveals (noth.in): fire once at 95%, never reverse ------------------- */

/* Minority pattern: plain opacity over --dur-reveal, no stagger. No
   transform — the old 24px/2deg tumble is retired; the signature lives
   in .reveal-lines. */
.reveal {
  opacity: 0;
  transition: opacity var(--dur-reveal) var(--ease-fade);
  transition-delay: var(--reveal-delay, 0ms);
}

.reveal.is-in {
  opacity: var(--reveal-opacity, 1);  /* composes with .muted — see .muted */
}

/* The signature: text split into lines at runtime, each line masked by its
   own overflow-hidden wrapper and sliding up from behind it. Hidden state
   lives in CSS (no flash before the script runs); is-split hands hiding
   over to the masks. The 0.15em padding/negative-margin pair keeps
   descenders from clipping without changing layout. */
.reveal-lines { opacity: 0; }
.reveal-lines.is-split { opacity: 1; }
@media (scripting: none) {
  .reveal-lines { opacity: 1; }   /* no JS: no masks, just show the text */
}

.mask {
  display: inline-block;
  overflow: hidden;
  vertical-align: top;
  margin-block: -0.15em;
}

.mask__line {
  display: inline-block;
  transform: translateY(100%);
  /* the 0.15em pair keeps descenders from clipping; the top pair keeps
     ascender tips from peeking through the mask at display leading 0.88 */
  padding-block: 0.15em;
  transition: transform var(--dur-reveal) var(--ease-reveal);
  /* the split script sets --line-i per line; the stagger step is a token */
  transition-delay: calc(var(--line-i, 0) * var(--stagger-line));
}

.reveal-lines.is-in .mask__line { transform: translateY(0); }

/* Link underline (noth.in's highest-ratio effect): wipes in from the left
   on enter, out to the right on leave — the origin flip continues the
   timeline instead of reversing it. transform-only translation of the
   source's width/left animation, per R5. */
.nav__links a, .footer__col a { position: relative; }
.nav__links a::after, .footer__col a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: var(--rule-hair);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform var(--dur-wipe) var(--ease-reveal);
}
.nav__links a:hover::after, .nav__links a:focus-visible::after,
.footer__col a:hover::after, .footer__col a:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

/* Wordmark letters (split at runtime, after the fit script sizes the word) */
/* text-indent: 0 is load-bearing, not tidiness. text-indent INHERITS, and an
   inline-block establishes its own first line -- so any indent on
   .footer__mark is re-applied inside every single letter, shrinking each one
   by the full indent. The fit script no longer sets one (see index.html), so
   today this only guards the invariant; it stays because the failure is
   silent and absurd when it happens. Measured at 2079x1020 before the script
   stopped setting it: each letter lost 107.88px on top of the -13.21px
   tracking, "r" and "i" collapsed to zero width because their advances are
   smaller than that, and the ink of the wordmark read 360.3px inside a
   1280px column instead of 1064.2px. */
.footer__mark-letter { display: inline-block; text-indent: 0; }
.footer__mark-letter.is-squish {
  animation: mark-squish calc(var(--dur-squash) + var(--dur-spring)) linear both;
}

/* Squash to 0.05 over --dur-squash on --ease-squash, then spring back over
   --dur-spring on scale(t) = 1 + 2^(-10t)*sin((t-0.2)*7.854), sampled into
   keyframes — a cubic-bezier cannot produce the overshoot AND undershoot
   that make it read as a spring. The stops and the bezier below stay
   literal: keyframe bodies do not reliably substitute var(), and the
   percentages encode squash/(squash+spring) = 25% — if the duration
   tokens change ratio, resample. */
@keyframes mark-squish {
  0%   { transform: scale(1); animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); }
  25%  { transform: scale(0.05); }
  28.75% { transform: scale(0.3467); }
  32.50% { transform: scale(0.6464); }
  36.25% { transform: scale(0.8647); }
  40.00% { transform: scale(1.0000); }
  43.75% { transform: scale(1.0676); }
  47.50% { transform: scale(1.0884); }
  51.25% { transform: scale(1.0817); }
  55.00% { transform: scale(1.0625); }
  58.75% { transform: scale(1.0408); }
  62.50% { transform: scale(1.0221); }
  66.25% { transform: scale(1.0085); }
  70.00% { transform: scale(1.0000); }
  73.75% { transform: scale(0.9958); }
  77.50% { transform: scale(0.9945); }
  81.25% { transform: scale(0.9949); }
  85.00% { transform: scale(0.9961); }
  88.75% { transform: scale(0.9974); }
  92.50% { transform: scale(0.9986); }
  96.25% { transform: scale(0.9995); }
  100.00% { transform: scale(1.0000); }
  100% { transform: scale(1); }
}

/* Calm path, opt-in via <html data-motion="reduced"> (see tokens.css):
   marquees stop, reveals collapse to a short crossfade, line masks and the
   wordmark squash go still, underlines snap. */
:root[data-motion="reduced"] .ticker__track,
:root[data-motion="reduced"] .hero-mark > *,
:root[data-motion="reduced"] .footer__cta-track { animation: none; }
:root[data-motion="reduced"] .reveal { transition-duration: var(--dur-long); }
:root[data-motion="reduced"] .reveal-lines { opacity: 0; transition: opacity var(--dur-long) var(--ease-out); }
:root[data-motion="reduced"] .reveal-lines.is-in { opacity: 1; }
:root[data-motion="reduced"] .mask__line { transition: none; transform: none; }
:root[data-motion="reduced"] .footer__mark-letter.is-squish { animation: none; }
:root[data-motion="reduced"] .nav__links a::after,
:root[data-motion="reduced"] .footer__col a::after { transition: none; }

/* --- Nav brand lockup ----------------------------------------------------
   Mark plus wordmark on one baseline. Layout only, no colour: the mark is
   `fill: currentColor`, so it takes whatever --color-ink the nav resolves,
   exactly as the bare text did before it.                                  */
.nav__brand  { display: inline-flex; align-items: center; }
.nav__lockup { height: 1.5em; width: auto; display: block; }
.u-sr-only   { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
               overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
