/* ═══ CCD SHARED SITE CSS ═══════════════════════════════════════════════
   Loaded after assets/tokens.css and before any page's own <style>.
   Order is load-bearing: tokens → site → page.

   WHAT THIS FILE IS
   The components the landing page and the Opinions pages genuinely share.
   index.html and templates/*.html each grew their own copy of the same
   foundations — the reset, the body setting, the container, the heading
   scale — and the copies drifted, which is the whole reason this file
   exists. One definition, two consumers, no drift.

   WHAT BELONGS IN IT
   Foundations and components that BOTH the landing page and the Opinions
   pages render: the reset, document and body setup, browser-drawn surfaces
   (selection, focus, scrollbars), the container and its full-bleed
   escapes, the heading scale, .sr-only, section rhythm.

   WHAT DOES NOT
   Page-specific sections. The landing page's nav, hero, Gmail mock, process
   collage, bento, FAQ and CTA are index.html's business and stay there.
   If only one page renders it, it does not live here.
   `.reveal` no longer carries a hidden state anywhere — content is visible
   from the start and .in is only a start gate for the home-page mocks — so
   there is nothing reveal-shaped to move here in the first place. If a fade
   is ever reintroduced, it belongs inline in index.html, not here: index.html
   is the only page that sets the `.js` class the rule would key off, so a
   hidden state living here would go permanently invisible the day Opinions
   starts setting `.js` too.

   THE RULE FOR EVERYTHING IN HERE
   Reference the SEMANTIC layer of tokens.css (block 2), never a primitive
   (block 1). --surface-page, not --bg. --text-body, not --fs-base.
   --space-section, not --sp-40. --font-body, not --sans. --leading-body,
   not 1.65. A primitive cannot tell you what it is for, and this file is
   read by people deciding what a thing is for.
   Where no semantic token exists for a value, the literal is written out
   with a comment saying so. Those are gaps in tokens.css to be closed
   deliberately, in a change that can be reviewed for the pixels it moves —
   never by quietly reaching into block 1 to avoid writing a number.
   ═══════════════════════════════════════════════════════════════════════ */


/* ── 0. fonts (self-hosted, P6.4) ──────────────────────────────────────
   Playfair Display and General Sans used to be hotlinked from
   fonts.googleapis.com/fonts.gstatic.com and api.fontshare.com — two
   third-party origins reached on every first paint, before any
   interaction, at the cost of four preconnects and two connection
   handshakes. Both are now local files under assets/fonts/, downloaded
   once and shipped with the site. index.html and both templates no
   longer carry any font <link> at all — site.css is already loaded by
   every page type, so this block is the only place fonts are declared.

   THE TRAP, unchanged from when this lived in a Google Fonts URL: a
   font-weight the CSS asks for but no @font-face below covers is not an
   error — the browser SILENTLY SYNTHESISES it (a fake bold/medium
   smeared from whichever real weight it does have). `bb25d33` exists
   because General Sans 700 was being synthesised this exact way. Keep
   the weights below and the --fw-* tokens in assets/tokens.css in step
   in BOTH directions: every --fw-* value used with --serif or --sans
   needs a matching @font-face here, and every @font-face here should
   back a weight something on the page actually requests.
     Playfair Display needs 500 / 600 / 700 (index.html's serif display
   voice, tokens.css §2 --weight-display and its callers).
     General Sans needs 300 / 400 / 500 / 600 / 700 (the full --fw-*
   ramp; it is the body/UI face and carries every one of those roles).
   Neither face is used in italic anywhere in this codebase (grep for
   `font-style` — the only hit is `.prose em`, which has always relied
   on the browser's synthesised italic; that was true before self-hosting
   and stays true now) — no italic files are shipped, on purpose.

   Playfair Display ships as ONE variable-font file backing all three
   weights: fonts.gstatic.com serves exactly this file (byte for byte —
   captured from a live request while these fonts were still hotlinked)
   for every one of the 500/600/700 requests the old Google Fonts URL
   declared, relying on the same mechanism used below — three @font-face
   blocks, one shared src, each pinning a different discrete point on the
   file's own wght axis (400–900, confirmed via the font's fvar table).
   This is standard, spec'd behaviour (CSS Fonts Module 4): a variable
   font's own `font-weight` descriptor, given a single number rather than
   a range, renders that face at exactly that axis value. It is not an
   approximation of the three static cuts — it IS them.
     General Sans ships as five separate static files, one per weight —
   Fontshare's API never served it as a single variable file, so there is
   nothing to consolidate. */

@font-face {
  font-family: 'Playfair Display';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/playfairdisplay.5d91eb5d.woff2') format('woff2');
}
@font-face {
  font-family: 'Playfair Display';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/playfairdisplay.5d91eb5d.woff2') format('woff2');
}
@font-face {
  font-family: 'Playfair Display';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/playfairdisplay.5d91eb5d.woff2') format('woff2');
}
@font-face {
  font-family: 'General Sans';
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url('/assets/fonts/generalsans-300.ac0b8f29.woff2') format('woff2');
}
@font-face {
  font-family: 'General Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/generalsans-400.3ec2be77.woff2') format('woff2');
}
@font-face {
  font-family: 'General Sans';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/generalsans-500.c30377df.woff2') format('woff2');
}
@font-face {
  font-family: 'General Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/generalsans-600.94a2a0e1.woff2') format('woff2');
}
@font-face {
  font-family: 'General Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/assets/fonts/generalsans-700.a29eab9b.woff2') format('woff2');
}


/* ── 1. reset ───────────────────────────────────────────────────────── */

* { margin: 0; padding: 0; box-sizing: border-box; }


/* ── 2. document ────────────────────────────────────────────────────── */

/* overflow-y:scroll keeps the scrollbar track while the menu's scroll lock
   pins the body - otherwise the fixed nav widens and its contents shift right */
/* 12px: no semantic token — it is the gap between the stuck nav's bottom edge
   and the heading a jump link lands on, not a spacing role. --space-inline is
   8px and nothing in the ladder is 12. */
html { scroll-behavior: smooth; scroll-padding-top: calc((var(--nav-h) + 12px) * var(--page-scale)); overflow-y: scroll; }


/* ── 3. body ────────────────────────────────────────────────────────── */

body {
  zoom: var(--page-scale);   /* whole page at 80% — the approved default */
  letter-spacing: var(--tracking-body);
  background: var(--surface-page);
  color: var(--text-on-page);
  font-family: var(--font-body);
  /* 16px, NOT --text-body. --text-body is --fs-base = 1.0625rem = 17px. This
     is the root font-size every rem on the site is measured against, so
     "correcting" it to the token would resize every piece of text on every
     page. The gap is real and deliberate — leave the literal. */
  font-size: 16px;
  /* Body copy runs General Sans Light. At 16px+ on warm paper 300 gives the
     open, editorial colour the serif-display pairing is for. It is set here,
     on the
     root, so it is one token flip to undo — everything that should NOT be
     light (buttons, nav, small-caps labels, table figures, device mocks)
     re-asserts its own weight rather than being carved out of a list. */
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
  /* CLIP, not hidden, and the difference is not cosmetic. Both stop the
     full-bleed bands from producing a horizontal scrollbar, but `hidden` on
     ONE axis forces the other axis to compute to `auto`, and that makes
     <body> a scroll container — one that can never actually scroll, because
     the page's scrolling belongs to the viewport.
     That dead scroller broke every CSS scroll-driven animation on the site
     at once: animation-timeline: view() walks up to the nearest scroll
     container, found <body>, and froze at whatever progress it first
     sampled. The FAQ photo's zoom sat at a fixed scale at every scroll
     position and reported itself as running the whole time.
     `clip` clips identically and is not a scroll container, so `visible`
     survives on the other axis and view()/scroll() resolve to the viewport.
     The site already uses this exact pair on .strip, #process and #faq.
     Do not "simplify" it back to hidden. */
  overflow-x: clip;
}


/* ── 4. browser-drawn surfaces ──────────────────────────────────────── */

/* the surfaces the browser draws still belong to the palette */
/* --accent-ink on selection is the ink ON a light --accent-200 fill, which is
   not a heading — so --text-heading-color would be the wrong role here even
   though it resolves to the same value. No semantic role exists for it. */
::selection { background: var(--accent-200); color: var(--accent-ink); }
:focus-visible { outline: 2px solid var(--border-accent); outline-offset: 3px; }
html { scrollbar-color: var(--border-strong) var(--surface-page); scrollbar-width: thin; caret-color: var(--accent); }
/* 12px / 3px: scrollbar furniture. Browser chrome dimensions, not a spacing
   role — there is nothing in the semantic ladder they should follow. */
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: var(--surface-page); }
::-webkit-scrollbar-thumb { background: var(--border-strong); border: 3px solid var(--surface-page); }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }


/* ── 5. container + full bleed ──────────────────────────────────────── */

/* one wide editorial measure: the page spans the viewport with modest
   gutters, the way a broadsheet does. 1760px of content at the ceiling,
   48px gutters below it. Device mocks keep their own max-widths. */
.container {
  --gutter: var(--space-gutter);
  width: 100%;
  /* 1856px: the measure itself. No semantic token — tokens.css has no
     layout-width scale, and one rung would not be a scale. */
  max-width: 1856px;
  margin: 0 auto;
  padding: 0 var(--gutter);
}
/* full-bleed: an image column escapes the container to touch the viewport
   edge while the text column keeps its gutter. max(0px, ...) keeps the
   value at exactly -gutter below 1856px, so the ever-present scrollbar
   can't push a horizontal overflow. Sections that use these clip X. */
/* The 48px inside var(--gutter, 48px) stays a literal on purpose: it is the
   fallback for when --gutter was never set, and a fallback that points at
   another variable is one more thing that can be undefined at the moment it
   is needed. Same reasoning for the second 1856px. */
.bleed-r { margin-right: calc(-1 * (var(--gutter, 48px) + max(0px, (100vw - 1856px) / 2))); }
.bleed-l { margin-left:  calc(-1 * (var(--gutter, 48px) + max(0px, (100vw - 1856px) / 2))); }


/* ── 6. headings ────────────────────────────────────────────────────── */

h1, h2, h3, h4 {
  font-weight: var(--weight-heading);
  /* -0.02em: no semantic token. --tracking-display is -0.015em, a different
     value for a different face — h3/h4 are sans (see the note below). */
  letter-spacing: -0.02em;
  /* 1.12 is NOT --leading-tight (1.14). tokens.css names 1.12 explicitly as
     a one-off outside the leading ladder and says do not round it onto a
     rung; the h1/h2 rules below re-declare the real 1.14. */
  line-height: 1.12;
  font-style: normal;
}
/* Display headings run on the serif; h3/h4 stay on the sans for contrast.
   The serif is Playfair Display again as of 28 Aug — Boska was trialled that
   day and rejected as too thin (the full account is on --serif in
   assets/tokens.css). These clamps were LIFTED for Playfair's small
   x-height, which is the face they are back in front of, so they are correct
   as they stand and were never touched during the trial.
   The measurements are kept because the next trial will want them. At 100px,
   Boska against Playfair: x-height 48.5 vs 51.6, cap 71.5 vs 70.8, zero
   51.2 vs 61.5. What that says is that CAP-height — which is what sets the
   apparent size of Title Case headings, and this site is Title Case
   throughout — barely moved between the two, so the clamps did not need to
   move either. Width was the real difference: Boska set ~17% narrower, so
   headings occupied less measure and wrapped later. Do not "correct" these
   clamps down on the assumption that a new serif means a bigger x-height —
   measure the new face's cap-height first.
   The ch-based max-widths further down survive any such swap on their own:
   ch resolves off the font's own zero, so a character count stays one.
   h3/h4 keep their own negative tracking: they are sans and larger than
   body, and large text needs less of it, so they deliberately do not
   follow --tracking-body up to +3%. */
h1, h2 { font-family: var(--font-display); font-weight: var(--weight-display); letter-spacing: var(--tracking-display); font-variant-numeric: lining-nums; color: var(--text-heading-color); }
h1 { font-size: var(--text-display); line-height: var(--leading-tight); }
h2 { font-size: var(--text-title); line-height: var(--leading-tight); }
h1, h2, h3 { text-wrap: balance; }
p, li, summary, figcaption { text-wrap: pretty; }  /* no single-word last lines */
/* -0.01em: no semantic token, same gap as -0.02em above. */
h3 { font-size: var(--text-heading); letter-spacing: -0.01em; }


/* ── 7. screen-reader-only ──────────────────────────────────────────── */

/* Every number here is mechanical — the standard visually-hidden recipe, not
   a design value. There is nothing for a token to mean. */
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }


/* ── 8. section rhythm ──────────────────────────────────────────────── */

section { padding: var(--space-section) 0; }


/* ── 9. nav ─────────────────────────────────────────────────────────── */

/* Scoped to `body > nav` below (was a bare `nav {}`, correct while the site
   nav was the only <nav> landmark on any page): a breadcrumb trail is also
   a <nav>, correctly, and sits nested several levels deep inside a section
   — it must never inherit position: fixed meant for the direct-child site
   nav, or it renders pinned over the hero instead of in the document flow. */

/* Nav — floating card over the hero, expanding full-bleed once scrolled.
   <nav> is only the fixed positioning shell; .nav-shell is the visible card.

   HOW THE COLLAPSE IS ANIMATED, AND WHY IT IS BUILT LIKE THIS
   Every box below is STATIC — identical in both states. The resting card and
   the stuck bar are two BACKDROP LAYERS (.nav-shell::before and ::after)
   stacked on one full-width shell and crossfaded on opacity; the logo and the
   link row are moved into place with translate. Nothing in the nav transitions
   a layout property any more, so the whole collapse runs on the compositor.

   The obvious shortcut — transform: scaleX() on .nav-shell — is wrong.
   Scaling the shell scales everything inside it, so the wordmark and the link
   labels would squash horizontally by the same 1040/viewport ratio as the card
   and the type would visibly distort mid-flight. Two separate backdrop layers
   distort nothing, because neither one contains any content: the only thing
   being scaled is a rectangle.

   Each layer does carry a small convergent transform as it fades, so the card
   reads as EXPANDING INTO the bar rather than dissolving under it — two static
   rectangles crossfading in place leaves both outlines visible at once, which
   is the one thing a pure opacity swap gets wrong. Those transforms exist only
   mid-flight: at both endpoints the transformed layer is the one at opacity 0,
   so its stretched border is never on screen. */
body > nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: var(--z-nav);
  /* no padding: the shell is full-bleed in both states and the resting card's
     20px gutter is an inset on the backdrop layer, not a box the nav gives up.
     The ≤900 block puts real padding back — there the card look is permanent
     and identical in both states, so it can afford to be laid out. */
  pointer-events: none;              /* the bar takes the hits, not the gutters */
  /* the query container the two insets below are measured against. Container
     units and not vw on purpose: html sets overflow-y:scroll, so 100vw is the
     viewport INCLUDING the scrollbar track and every offset derived from it
     would land ~15px out. 100cqw is this element's own content box. */
  container-type: inline-size;
}
.nav-shell {
  /* The two horizontal geometries the nav lives between, written down once so
     that the backdrop and the content shift are derived from the same numbers
     instead of being kept in sync by hand.
       card: a 1040px box centred inside a 20px gutter — the resting card
       bar:  a 1200px box centred inside the bar's 2px border — .nav-inner
     1040 / 1200 / 20px: no semantic tokens — tokens.css has no layout-width
     scale, the same gap .container's 1856px is written out for. 1040px also
     matches .gmail: the bar and the email share one width. */
  --nav-card-inset: max(20px, calc((100cqw - 1040px) / 2));
  --nav-bar-inset:  max(2px,  calc((100cqw - 1200px) / 2));
  /* how much further in the content sits at rest than when stuck */
  --nav-shift: calc(var(--nav-card-inset) - var(--nav-bar-inset));

  position: relative;                /* containing block for the two backdrops */
  max-width: 100%;
  /* The RESTING card's own frame, held as shell padding: --nav-gap plus the
     card's 2px top border down, the 20px gutter plus its 2px side border in.
     .nav-inner therefore lands exactly inside the resting card without ever
     being laid out against it — and the untransformed state is the resting
     one on purpose, because a transform puts an element on its own compositing
     layer and re-rasterises the wordmark and the link labels. Identical to the
     eye, but not byte-identical, and the resting bar is the state the
     screenshot baselines pin. The stuck state carries the transforms.
     No border-WIDTH token exists; --border-strong is the colour role only. */
  padding: calc(var(--nav-gap) + 2px) 22px 0;
  /* 78px: the resting card's outer height — 74px of content plus its 2px border
     top and bottom. The shell is sized for the TALLER of the two states and
     never changes; the stuck bar's shorter 77px is drawn by ::after inside it. */
  height: calc(var(--nav-gap) + 78px);
  pointer-events: none;              /* the visible layers take the hits */
}
/* the two backdrop layers — same stack, different geometry, crossfaded.
   pointer-events is set per layer so the clickable area is always exactly the
   VISIBLE box: the shell is a 92px full-bleed rectangle in both states and
   must not block the hero behind the resting card's gutters. */
.nav-shell::before,
.nav-shell::after {
  content: '';
  position: absolute;
  /* negative, not 0. .nav-shell is position:relative with z-index auto, so it
     does not open a stacking context of its own and these two land in <nav>'s
     — where a negative z-index paints them below every in-flow descendant,
     .nav-inner included, and above nothing but <nav>'s own (absent)
     background. Left at auto they would paint in DOM order, which puts ::after
     ON TOP of the link row. */
  z-index: -1;
  border: 2px solid transparent;
  border-radius: var(--radius-card);
  pointer-events: auto;
  /* The opacity runs SHORTER than the transform on purpose. Both layers are
     near-opaque, so while they are both half-visible the region where they
     overlap is covered twice and reads as a lighter panel inside the bar, with
     two crisp vertical seams down it. Retiring the outgoing layer over
     --motion-base while the geometry keeps travelling for --motion-slow cuts
     that window to about a tenth of a second and leaves the last third of the
     move as a single opaque bar settling into place. */
  transition: opacity var(--motion-base) var(--motion-ease),
              transform var(--motion-slow) var(--motion-ease);
}
/* the resting card */
.nav-shell::before {
  top: var(--nav-gap);
  bottom: 0;
  left: var(--nav-card-inset);
  right: var(--nav-card-inset);
  background: var(--surface-raised);
  border-color: var(--border-strong);
  box-shadow: var(--elevation-raised);
}
/* the stuck bar */
.nav-shell::after {
  top: 0; left: 0; right: 0;
  /* the bar's content height (--nav-h - 1) plus its 2px borders */
  height: calc(var(--nav-h) + 3px);
  /* 82%: not on the alpha ladder (7/9/18/32/55) — written out rather than
     rounded onto a rung, which would move the blur's opacity. */
  background: color-mix(in srgb, var(--surface-page) 82%, transparent);
  border-bottom-color: var(--border-strong);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  opacity: 0;
  pointer-events: none;
  /* mid-flight only — sits low and slightly narrow so it rises and widens into
     place as it fades in. Invisible at this end of the transition. */
  transform: translateY(15px) scaleX(0.94);
}
nav.is-stuck .nav-shell::before {
  opacity: 0;
  pointer-events: none;
  /* mid-flight only — the card lifts to the bar's line and spreads past its own
     edges as it goes, so the two outlines converge instead of double-printing.
     Invisible at this end of the transition. */
  transform: translateY(calc(-1 * var(--nav-gap) - 1px)) scaleX(1.12);
}
nav.is-stuck .nav-shell::after {
  opacity: 1;
  pointer-events: auto;
  transform: none;
}
.nav-inner {
  position: relative;                /* anchors the hover indicator */
  pointer-events: auto;              /* the shell above is none */
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 1036px = the resting card's 1040px minus its two 2px borders, i.e. the
     card's content box, which is what this row used to be laid out into.
     No semantic token — tokens.css has no layout-width scale, the same gap
     .container's 1856px is written out for. */
  max-width: 1036px;
  margin: 0 auto;
  /* the RESTING card's box, held in BOTH states. The stuck bar's wider,
     shorter box is reached by the translates below, never by re-laying this
     out: --nav-h is the stuck bar's height and 74px is the resting one, they
     genuinely differ (--nav-h also drops to 64px at ≤900 while this resting
     height does not follow it), so one of the two has to be the transform. */
  padding: 0 16px 0 28px;
  height: 74px;
  transition: transform var(--motion-slow) var(--motion-ease);
}
/* 37px = half the resting card's 74px content height. The card's content row
   is centred --nav-gap + 2 + 37 down the shell, the bar's is centred
   2 + (--nav-h - 1)/2 down, and this is the distance between those two centre
   lines. Both rows centre themselves in their box, so moving the box by that
   distance lands every glyph exactly where the old rule put it. */
nav.is-stuck .nav-inner {
  transform: translateY(calc((var(--nav-h) - 1px) / 2 - var(--nav-gap) - 37px));
}
/* The horizontal half of the same move, split because the two ends travel
   different distances: the card insets its content by 28px on the left and
   16px on the right, the bar by 32px on both.
     logo:  (bar-inset + 32) - (card-inset + 2 + 28) = 2px - --nav-shift
     links: (card-inset + 2 + 16) - (bar-inset + 32) = --nav-shift - 14px
   Neither is a constant — below 1204px the bar stops centring and below
   1080px the card does too, so a fixed −78px/+66px pair would be right at
   1440px and wrong at every width in between. */
.nav-logo, .nav-links { transition: transform var(--motion-slow) var(--motion-ease); }
nav.is-stuck .nav-logo  { transform: translateX(calc(2px - var(--nav-shift))); }
nav.is-stuck .nav-links { transform: translateX(calc(var(--nav-shift) - 14px)); }
.nav-logo img { height: 34px; width: auto; display: block; }
.nav-links {
  display: flex;
  align-items: center;
  gap: 34px;
  list-style: none;
}
nav.is-stuck .nav-links { gap: 40px; }
.nav-links a {
  color: var(--text-muted);
  text-decoration: none;
  font-size: var(--text-caption);
  font-weight: var(--weight-regular);
  text-transform: uppercase;
  /* 0.04em is NOT --tracking-label (0.08em) — half of it, and deliberately so
     at this size. */
  letter-spacing: 0.04em;
  transition: color 0.25s;
}
/* each nav item owns a colour; the hovered label tints to it */
.nav-links a:hover, .nav-links a:focus-visible { color: var(--accent-text); }

/* Reduced motion kills the crossfade and the two translates alike — every
   moving part of the collapse is named here, so the bar simply cuts between
   its two states. The mid-flight transforms still apply, but only ever at an
   endpoint, where the transformed layer is the invisible one. */
@media (prefers-reduced-motion: reduce) {
  nav, .nav-shell, .nav-shell::before, .nav-shell::after,
  .nav-inner, .nav-logo, .nav-links { transition: none; }
}
.nav-links a.btn { color: var(--accent); }
.nav-links a.btn:hover { color: var(--text-on-accent); }
.nav-toggle {
  display: none;
  background: none;
  border: none;
  cursor: pointer;
  padding: 10px;
  min-width: 44px;
  min-height: 44px;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 5px;
}
.nav-toggle span {
  display: block;
  width: 22px;
  height: 2px;
  /* --ink, not --text-on-page: these three bars are an icon drawn in the body
     ink, not text, and there is no glyph/icon colour role in block 2. Same
     judgement as ::selection in §4 above. */
  background: var(--ink);
  /* 0, not --radius-card: a 2px rule is neither a card, a chip nor a pill, and
     those are the only three radius intents. */
  border-radius: 0;
  transition: transform 0.3s ease, opacity 0.3s ease;
}
nav.open .nav-toggle span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
nav.open .nav-toggle span:nth-child(2) { opacity: 0; }
nav.open .nav-toggle span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }


/* ── 10. buttons ────────────────────────────────────────────────────── */

/* Buttons */
/* CTA: rests as a 4px accent bar on a transparent field; the fill slides
   across on hover and the label flips to white. isolation keeps the -1
   layer inside the button. */
.btn {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 17px 24px;
  background: none;
  /* var(--accent) is kept verbatim. tokens.css calls --accent "FILL only" and
     --accent-text the TEXT/link role, so this is arguably the wrong role for a
     colour property — but the two resolve to the same hex and reclassifying it
     is a decision, not a substitution. Flagged, left alone. */
  color: var(--accent);
  text-decoration: none;
  /* 14px: off the rem ramp entirely. --text-caption is 0.84rem = 13.44px, so
     the token is NOT this value. Left as the literal. */
  font-size: 14px;
  font-weight: var(--weight-label);
  text-transform: uppercase;
  letter-spacing: var(--tracking-label);
  white-space: nowrap;
  /* --radius-card: block 2 has no control radius intent, so a button borrows
     the card's 2px. Same softening as every other edge. */
  border-radius: var(--radius-card);
  border: 0;
  cursor: pointer;
  transition: color 0.45s var(--motion-ease);
}
.btn::before {
  content: '';
  position: absolute;
  inset: 0;
  /* -1 stays a literal: tokens.css names this exact declaration as local
     stacking arithmetic inside the button's own isolate, not a rung of the
     --z-* scale. */
  z-index: -1;
  background: var(--accent);
  transform: translateX(calc(-100% + 4px));
  transition: transform 0.45s var(--motion-ease);
}
.btn:hover, .btn:focus-visible { color: var(--text-on-accent); }
.btn:hover::before, .btn:focus-visible::before { transform: translateX(0); }
@media (prefers-reduced-motion: reduce) {
  .btn, .btn::before { transition: none; }
}
.nav-cta-bar { display: none; } /* only shown in the mobile bar */
/* nav CTA — plain button in the mock's Gmail blue */
.btn-nav { padding: 15px 24px; }
.btn-light {
  background: var(--surface-raised);
  /* --accent-ink, not --text-heading-color: that role is h1/h2 and this is a
     button label. They resolve to the same hex; the role would be a lie. */
  color: var(--accent-ink);
  border-color: var(--surface-raised);
}
.btn-light:hover { background: var(--accent-soft); border-color: var(--accent-soft); transform: translateY(-1px); }


/* ── 11. button on dark ─────────────────────────────────────────────── */

/* the slide-fill CTA, luminance-matched for a dark ground */
.btn--ondark { color: var(--accent-ondark); }
.btn--ondark::before { background: var(--accent-ondark); }
.btn--ondark:hover, .btn--ondark:focus-visible { color: var(--accent-ink); }
/* the global :focus-visible ring is 2px of --accent, which measures 1.50:1
   on --accent-ink — a keyboard ring nobody can see on any of the three dark
   panels (.callbox, .pc-center, .niches-head). Restating it on the modifier
   rather than per panel means the ring follows the button wherever the
   modifier goes. --accent-ondark is 9.60:1 on the ink, and outline-offset
   leaves 3px of ground between the ring and the slid-in fill so the two do
   not merge into one blue block at focus.
   The .testimonials band is the same ink; its carousel arrows are not .btn
   and carry the same restatement of their own (see .t-arrow:focus-visible).
   Swept 2026-08-28: those five are the whole set. Every other focusable
   control on the page — nav, the hero mock's links (the Gmail send button's
   ring lands on the mock's white toolbar, not on its blue fill), .svc-head,
   .niche-row, the FAQ summaries, the footer — has its ring on a paper ground
   at 7.45:1 or better and wants the default --accent. */
.btn--ondark:focus-visible { outline-color: var(--accent-ondark); }


/* ── 12. editorial label ────────────────────────────────────────────── */

/* The ALL-CAPS tracked eyebrow that sits above a section heading. The project's
   design notes ban uppercase tracked labels in general; the owner has sanctioned
   exactly four slots and this is one of them — the others are .btn (§10),
   .footer-col h4 (§13) and .nav-links a (§9). A standing exception, not an
   oversight. Do not "fix" it.
   Colour, and only colour, is overridden per band: .niches-head .eyebrow and
   .callbox .eyebrow swap to --label-ondark on the dark panels, and two heads
   restate the margin. Those stay in index.html — they are page sections, not
   shared components — and every one of them outranks this rule on specificity,
   so the split survives the move to an earlier stylesheet. */
.eyebrow, .about-eyebrow {
  display: block;
  /* 26px: no spacing role fits. --space-stack is 20px and the ramp's nearest
     rungs are 24 and 32; tokens.css names 26 as one of the page's frequent
     off-ramp values and warns against rounding it onto --sp-6. */
  margin-bottom: 26px;
  /* 14px: off the rem ramp — --text-label is 0.72rem (11.5px) and
     --text-caption 0.84rem (13.4px). The same gap .btn's 14px is written
     out for in §10. */
  font-size: 14px;
  font-weight: var(--weight-label);
  text-transform: uppercase;
  letter-spacing: var(--tracking-label);
  color: var(--accent-text);
}


/* ── 13. footer ─────────────────────────────────────────────────────── */

/* This is the SHARED footer: index.html and templates/post.html,
   templates/opinions-index.html and templates/404.html all render the same
   markup — .footer-shell, .footer-body, .footer-cols, .footer-side,
   .footer-legal, .footer-copy, .footer-brand-summary — and all four link
   this file. There is no separate template-only footer component; the
   reconciliation an earlier version of this comment called for is done.
   Every breakpoint override lives here too, in §14 below — none are inline
   in index.html any more. (index.html's inline `.footer-brand` rule at its
   own ≤900 block is dead: a pre-reconciliation class name nothing in the
   markup uses today.) */

/* Footer */
/* Footer — tall, quiet, structured by one full-height vertical rule.
   Ground is the same paper field as the tools band above it. */
footer {
  /* 210px / 80px: no spacing role fits either end. --space-section is 160px and
     this band is deliberately taller than a section; 80px sits between the
     ramp's 72 and 96. Both written out. */
  padding: 210px 0 80px;
  background: var(--surface-sunken);
  /* --accent-ink, not --text-heading-color: that role is h1/h2 and this is the
     colour the whole footer inherits. Same hex, but the role would be a lie —
     the same call §11's .btn-light makes. */
  color: var(--accent-ink);
}
/* col 1 parks the rotated wordmark; the body starts clear of it */
.footer-shell {
  display: grid;
  /* 128px: tokens.css has no layout-width scale — the same gap .nav-shell's
     1040px and .container's 1856px are written out for. */
  grid-template-columns: 128px minmax(0, 1fr);
}
.footer-mark {
  grid-column: 1;
  align-self: start;
  justify-self: start;
  /* 16px: no spacing role. --space-inline is 8px and nothing in the intent
     ladder is 16. */
  padding-left: 16px;
  writing-mode: vertical-rl;
  transform: rotate(180deg);          /* reads bottom-to-top */
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  /* NOT --text-title. That clamp is (2.125rem, 3.6vw, 3.25rem): same vw term,
     same maximum, higher floor here. Pointing this at the token would shrink
     the wordmark on narrow viewports — a rendering change, not a substitution. */
  font-size: clamp(2.4rem, 3.6vw, 3.25rem);
  line-height: var(--leading-none);
  letter-spacing: var(--tracking-display);
  color: var(--accent-ink);
  white-space: nowrap;
}
/* 73/27 split; the rule sits exactly on the seam and runs the full height */
.footer-body {
  grid-column: 2;
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 73fr) minmax(0, 27fr);
  grid-template-rows: auto 250px auto;
}
.footer-body::before {
  content: '';
  position: absolute;
  left: 73%;
  top: 0;
  bottom: -80px;                      /* through the legal row to the floor */
  width: 2px;                         /* matches .svc and the FAQ list — the
                                       footer was the page's one hairline */
  /* --accent, not --border-accent: this is a painted pseudo-element, not a
     border, and tokens.css names "rules" among --accent's own fill uses. */
  background: var(--accent);
}
.footer-cols {
  grid-column: 1; grid-row: 1;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0 var(--gap-grid);             /* --gap-grid is the 40px column gutter */
}
/* 48px here and on .footer-copy is the inset past the vertical rule, NOT
   --space-gutter — that role is .container's page gutter and moving it should
   not move the footer's seam. */
.footer-side { grid-column: 2; grid-row: 1; padding-left: 48px; }
.footer-side-block + .footer-side-block { margin-top: var(--space-block); }
.footer-col h4, .footer-side h4 {
  /* 15px: off the rem ramp, the same gap as §12's 14px. */
  font-size: 15px;
  font-weight: var(--weight-label);
  text-transform: uppercase;
  letter-spacing: var(--tracking-label);
  margin-bottom: 34px;
  color: var(--accent-ink);
}
.footer-col ul, .footer-side ul { list-style: none; }
.footer-col li + li, .footer-side li + li { margin-top: 28px; }
.footer-col a {
  color: var(--accent-ink);
  text-decoration: none;
  /* 21px is the same number as --fs-lede / --text-lede, and that is a
     coincidence: the lede role is a standfirst under a heading, this is a
     footer link. Left as the literal rather than borrowing a role. */
  font-size: 21px;
  font-weight: var(--weight-regular);
  /* 1.15, NOT --leading-tight (1.14). A hundredth apart, and snapping it onto
     the rung would move every footer link's baseline. */
  line-height: 1.15;
  letter-spacing: -0.01em;
  /* 0.25s is not on the --motion-* ramp (fast .2 / base .35 / slow .55). */
  transition: color 0.25s;
  overflow-wrap: anywhere;
}
.footer-col a:hover, .footer-col a:focus-visible { color: var(--accent); }
.footer-side a {
  display: inline-flex;
  align-items: center;
  /* 10px, not --space-inline (8px). Two pixels wider than the icon-to-label
     role, and the role is defined as the 8. */
  gap: 10px;
  color: var(--accent);
  text-decoration: none;
  font-size: 18px;
  transition: color 0.25s;
}
.footer-side a:hover { color: var(--accent-ink); }
.footer-side svg { width: 18px; height: 18px; flex: none; }
/* legal row — the vertical rule is the only structure; no rule above it */
.footer-legal { grid-column: 1; grid-row: 3; display: flex; flex-wrap: wrap; gap: 8px 28px; align-items: baseline; }
.footer-legal span, .footer-legal a { font-size: 16px; color: var(--text-muted); }
/* The legal row mixes plain spans (location, coverage) with the one real
   LINK in it, the privacy policy. All three were undecorated at the same
   size and colour, so the link was visually indistinguishable from the
   sentences beside it - the owner looked for it in the footer and could
   not find it. An underline is the affordance rather than a colour
   change: this row is deliberately muted, and recolouring the one link
   to --accent would make a legal line shout louder than the nav columns
   above it. Spans stay undecorated; only the anchor is underlined. */
.footer-legal span { text-decoration: none; }
.footer-legal a { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.footer-legal a:hover { color: var(--accent); }
.footer-copy { grid-column: 2; grid-row: 3; padding-left: 48px; font-size: 16px; color: var(--text-muted); }

/* Brand summary — a short paragraph, generic copy the owner will replace
   later. Sits in .footer-body's row 2, which the 250px row-height above was
   already reserving as quiet space between the nav columns and the legal
   line; the row's height is fixed, not auto, so dropping prose into it does
   not push .footer-legal or .footer-copy down a pixel. */
.footer-brand-summary {
  grid-column: 1;
  grid-row: 2;
  align-self: center;
  max-width: 640px;
  /* 18px: off the rem ramp, the same convention as this section's other
     literal sizes — a statement line, not the --text-lede role. */
  font-size: 18px;
  font-weight: var(--weight-regular);
  line-height: var(--leading-normal);
  color: var(--text-muted);
}

/* ── 14. responsive ─────────────────────────────────────────────────── */

/* The shared layer's own breakpoints. Landing-page-only overrides at these
   same widths stay inline in index.html — the two halves used to live in one
   block each, so read them together when changing either.
   Order is load-bearing twice over: 900 before 640 here, and every rule below
   keeps the relative order it had inline. */
@media (max-width: 900px) {
  section { padding: 56px 0; }
  .container { --gutter: 24px; }
  h1 { font-size: clamp(2rem, 5.6vw, 2.375rem); }
  footer { padding: 72px 0 40px; }
  .footer-shell { grid-template-columns: 1fr; gap: 40px; }
  .footer-mark {
    grid-column: 1; padding-left: 0;
    writing-mode: horizontal-tb; transform: none; font-size: 2rem;
  }
  .footer-body { grid-column: 1; grid-template-columns: 1fr; grid-template-rows: auto auto auto auto auto; }
  .footer-body::before { display: none; }   /* becomes a hairline on the social block */
  .footer-cols { grid-column: 1; grid-row: 1; grid-template-columns: 1fr 1fr; gap: 40px; }
  .footer-side { grid-column: 1; grid-row: 2; padding-left: 0; padding-top: 36px; border-top: var(--rule-hairline) solid var(--accent); }
  .footer-side-block + .footer-side-block { margin-top: 36px; }
  /* stacked layout has no fixed spacer row to borrow, so the summary becomes
     a real row of its own, between the nav columns and the legal line */
  .footer-brand-summary { grid-column: 1; grid-row: 3; align-self: start; max-width: none; margin-top: 44px; font-size: 17px; }
  .footer-legal { grid-column: 1; grid-row: 4; margin-top: 44px; }
  .footer-copy { grid-column: 1; grid-row: 5; padding-left: 0; margin-top: 12px; }
  .footer-col a { font-size: 18px; }
  .footer-col h4 { font-size: var(--fs-md); }
}

/* Hamburger menu: the full link row + CTA overflows the container below
   ~900px, so tablets get the stacked menu too */
@media (max-width: 900px) {
  /* phones/tablets: wellcopy-style floating card bar — logo left, CTA in
     the bar next to the hamburger, menu drops as a detached card. Square,
     like the bar above it: the whole visual language is flat and
     right-angled, and a rounded menu was the one survivor of the old
     softer look. */
  nav, nav.is-stuck { padding: 10px 12px 0; }
  /* The card look is permanent and IDENTICAL in both states here, so there is
     nothing to animate and the desktop machinery is switched off wholesale:
     .nav-shell paints itself again (site.css §9 hands that job to two
     crossfaded backdrop layers above 900px), and every translate that carries
     the desktop collapse is reset to none. That last part is load-bearing
     beyond looks — a transformed .nav-inner would become the containing block
     for the absolutely positioned .nav-links panel below, and the offsets
     that align the panel with the bar's outer edges are all measured from
     .nav-shell. */
  .nav-shell, nav.is-stuck .nav-shell {
    --nav-bw: 2px;
    max-width: 100%;
    position: relative;
    padding: 0;
    height: auto;
    pointer-events: auto;
    border-radius: var(--radius-card);
    border: var(--nav-bw) solid var(--line);
    box-shadow: var(--elev-3);
    background: var(--bg-card);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .nav-shell::before, .nav-shell::after { display: none; }
  .nav-inner, nav.is-stuck .nav-inner { padding: 0 10px 0 18px; height: calc(var(--nav-h) - 1px); transform: none; }
  /* the is-stuck half has to be spelled out: site.css hangs the translates
     off `nav.is-stuck .nav-logo`, which outweighs a bare `.nav-logo` here */
  .nav-logo, .nav-links,
  nav.is-stuck .nav-logo, nav.is-stuck .nav-links { transform: none; }
  .nav-logo img { height: 30px; }
  .nav-toggle { display: flex; }
  .nav-cta-bar {
    display: inline-flex;
    padding: 11px 16px;
    font-size: var(--fs-xs);
    margin-left: auto;
    margin-right: 8px;
  }
  .nav-links .nav-cta-li { display: none; }
  .nav-links {
    display: none;
    position: absolute;
    /* These offsets cancel the shell's border on purpose — 0 is wrong here.
       .nav-shell is the containing block, it carries a border on all four
       sides, and it has no padding. An absolutely positioned box resolves
       its offsets against the containing block's PADDING box, not its
       border box, so `left: 0; right: 0` land 2px inside each visible edge
       and the panel comes out 4px narrower than the bar it hangs off,
       inset 2px per side. `top: 100%` has the same problem twice over: the
       percentage is of the padding-box height AND it is measured from an
       origin already 2px down, so the panel's top lands 2px above the
       shell's outer bottom edge and an intended 8px gap renders as 6px.
       Pulling each offset back by one border width puts the panel's edges
       on the bar's outer edges, where the eye expects them.
       Derived from --nav-bw (declared on .nav-shell, inherited down through
       .nav-inner) rather than hardcoded, so that changing the border width
       in one place keeps the panel aligned instead of silently re-opening
       this bug at a new magnitude. */
    top: calc(100% + var(--nav-bw) + 8px);
    left: calc(-1 * var(--nav-bw));
    right: calc(-1 * var(--nav-bw));
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: var(--bg-card);
    border: var(--nav-bw) solid var(--line);
    border-radius: var(--radius-card);
    padding: 8px 22px 12px;
    box-shadow: var(--elev-4);
  }
  nav.open .nav-links { display: flex; max-height: calc(100dvh - var(--nav-h) - 26px); overflow-y: auto; }
  .nav-links li { border-top: var(--rule-hairline) solid var(--line); }
  .nav-links li:first-child { border-top: none; }
  .nav-links a {
    display: block;
    padding: 16px 0;
    font-size: 1.05rem;
    font-weight: var(--fw-medium);
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }
  /* open menu: opaque header (the dim layer must not tint it), frozen page,
     dimmed content behind the panel. iOS scroll lock is done in JS via
     position:fixed on body — overflow:hidden alone doesn't hold there.
     The opacity is the requirement, not the colour, so this is --bg-card:
     any opaque value keeps the dim layer off the bar, and --bg-card is
     what the bar already shows at rest and what the panel below it uses.
     --bg is a different white (#FDFCFA vs #FFFFFF) and made the open bar
     and its own dropdown read as two mismatched sheets of paper. */
  nav.open .nav-shell { background: var(--bg-card); backdrop-filter: none; -webkit-backdrop-filter: none; }
  body.nav-open { overflow: hidden; }
  body.nav-open::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: var(--z-backdrop);
    background: color-mix(in srgb, var(--ink) 38%, transparent);
  }
}

@media (max-width: 640px) {
  section { padding: 56px 0; }
  h1 { font-size: clamp(1.9rem, 8vw, 2.375rem); }
  /* footer stacks; the 3-col grid's min content width (long email link)
     was wider than the viewport and stretched the whole page canvas */
  .footer-cols { grid-template-columns: 1fr; gap: 32px; }
}
