/* ============================================================================
   Ridgecap — site styles. The home page cover as one continuous sequence,
   then the product, legal and 404 pages that share its chrome.
   ============================================================================
   THE ONLY STYLESHEET ON THE SITE. It began as the v4 design variant's own
   file, deliberately sealed off from the old site's /styles.css so the two
   could be compared side by side. v4 won on 2026-10-02, every page now loads
   this file, and /styles.css was deleted (it is in git history). The tokens
   below were copied by value from it.

   The other self-hosted assets are /vendor/gsap.min.js,
   /vendor/ScrollTrigger.min.js, /dotfield.js, /topo.js and /ember.js.

   ---------------------------------------------------------------------------
   WHAT THIS PAGE IS

   v2's cover was a composition you scrolled past. This one is a sequence you
   scroll THROUGH, and the difference is structural rather than decorative:

     at rest      absolute black. The mark builds. Two seconds later the match
                  line arrives and says the page continues. Nothing else exists
                  — no grid, no header, no title block.
     on scroll    the cover is PINNED and the sequence is scrubbed against the
                  scrollbar: grid, then header, then the sheet frame, then the
                  handover where the emblem dissolves and RIDGECAP resolves.
     released     the pin ends and the page scrolls normally into the horizon,
                  the products, the reviews.

   The runway is ~110vh of scrolling for the whole thing. That is deliberate:
   the beats are connected, not long. A sequence that takes four screens to
   finish is a chore, and the point here is that it reads as one intentional
   move.

   ---------------------------------------------------------------------------
   THE FAILURE MODE THIS FILE IS BUILT AROUND

   Two shipped bugs on this site had the same shape: something was parked at
   opacity 0 for a timeline to bring in, and a code path existed where the
   timeline never ran. Phones got a cover page with no words on it and nothing
   reported an error.

   So the rule here is inverted. THE DEFAULT STATE OF THIS STYLESHEET IS THE
   FINISHED PAGE. Everything is visible, at its final value, with no JS at all.
   The pre-animation state is applied only under `html.seq` — a class an inline
   script adds synchronously before first paint and that the motion module
   REMOVES on any failure, plus a hard timeout that removes it regardless. If
   GSAP 404s, fails to parse, or the visitor asked for reduced motion, what is
   left is a complete, legible, static page.

   Anything you park at opacity 0 goes in the `html.seq` block at the bottom,
   and nowhere else.
   ========================================================================== */

:root {
  /* ---- Ink & paper — copied by value from the old /styles.css ---- */
  --ink: #16181d;
  --ink-deep: #101114;
  --paper: #f6f6f8;
  --surface: #ffffff;
  --sunken: #efeff2;
  --hairline: #e2e3e8;
  --hairline-strong: #d4d5db;
  --body: #464956;
  --muted: #6f7280;

  /* ---- Brand copper. The three text steps exist for contrast reasons that
          the README's Accessibility floor sets out; they are reproduced here
          so nobody has to go and look them up.
          --copper      #a85c2e  FILLS ONLY. 4.32:1 on --sunken, fails AA.
          --copper-text #8c4a23  small copper text on paper/sunken. ~6.4:1
          --copper-deep #703a1b  copper text on --copper-wash
          --copper-dark #c96a3c  the AA-safe accent ON GRAPHITE — this page's
                                 working copper, since this page is all dark. */
  --copper: #a85c2e;
  --copper-hover: #8c4a23;
  --copper-text: #8c4a23;
  --copper-deep: #703a1b;
  --copper-dark: #c96a3c;
  --copper-dark-hover: #d67e4d;
  --copper-wash: #f9f0e9;

  /* ---- On-dark neutrals ---- */
  --d-surface: #16181d;
  --d-raised: #1d2026;
  --d-hairline: #292d36;
  --d-hairline-strong: #3d424e;
  --d-text: #f0f0f2;
  --d-body: #b9bcc4;
  --d-muted: #878b96;

  /* ---- v3 only ----
     THE COVER IS ABSOLUTE BLACK, not --ink-deep. That is Sam's call and it is
     load-bearing for the sequence: the mark has to build on nothing, and
     #101114 is a colour you can see, which makes it a surface. #000 is not a
     surface, so the first frame reads as a mark in space rather than a logo on
     a dark box. The products surface below IS --ink-deep, and the step between
     the two is exactly what the horizon divides. */
  --void: #000000;
  --panel: #191c22;
  --panel-hi: #20242c;

  --font-display: 'Montserrat', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-sans: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-label: 'Work Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;

  --s1: 4px;  --s2: 8px;   --s3: 12px;  --s4: 16px;  --s5: 24px;
  --s6: 32px; --s7: 48px;  --s8: 64px;  --s9: 96px;  --s10: 128px;

  --r-sm: 4px;
  --r-md: 8px;
  --r-lg: 14px;

  --ease: cubic-bezier(0.2, 0, 0, 1);
  --dur: 180ms;

  --shell: 1160px;

  /* THE GRID CELL. One number, used by the background grid and by the sheet
     frame, so the frame lands on grid lines instead of near them. */
  --cell: 68px;
}

/* ---------- Reset ----------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html {
  -webkit-text-size-adjust: 100%;
  /* NO `scroll-behavior: smooth`. ScrollTrigger reads scroll position every
     frame and pins against it; a smooth-scrolling root fights the pin and also
     makes every measurement taken during a programmatic scroll wrong, which is
     documented in the handoff as a thing that already cost a session. */
  background: var(--void);
  /* ITEM 10 — the sideways swipe on iPhone.
     There is no horizontal overflow to remove: measured at 375x812 the
     document's scrollWidth equals its clientWidth exactly. What Sam is
     dragging is iOS Safari's horizontal RUBBER BAND, which happily pulls the
     whole page sideways past its own edge and shows the black behind it even
     when nothing overflows. `overscroll-behavior-x: none` is the property that
     turns that off.
     `overflow-x: hidden` is the usual folk remedy and is the wrong tool twice
     over here: on the root it propagates to the viewport, and GSAP explicitly
     warns against it on html/body because it breaks pinning. */
  overscroll-behavior-x: none;
}
body {
  margin: 0;
  background: var(--void);
  color: var(--d-body);
  font-family: var(--font-sans);
  font-size: 1.0625rem;
  line-height: 1.62;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overscroll-behavior-x: none;
  /* Belt and braces for the same thing, and it does a second job: it tells the
     browser this page only ever pans vertically, so the compositor can start
     scrolling without waiting to find out whether a touch handler will call
     preventDefault. `pinch-zoom` is kept deliberately — removing it would
     block zooming, which is an accessibility regression, not a fix. */
  touch-action: pan-y pinch-zoom;
}

/* THE CLIP GOES ON <main>, NOT ON <body>, and this cost a real bug.
   `overflow-x: clip` on <body> does nothing, because the used overflow of the
   root and body PROPAGATES TO THE VIEWPORT — the browser moves the value up to
   the viewport and computes body's own overflow back to `visible`. So the
   full-bleed elements were never clipped by it. Measured: scrollWidth 2586
   against clientWidth 2545, and the page genuinely scrolled sideways 41px.
   <main> is an ordinary element, so its clip is its own and actually applies.
   `clip` rather than `hidden` on purpose: `hidden` would make this a scroll
   container, which breaks position:sticky and ScrollTrigger's pin inside it. */
main { overflow-x: clip; }
img, svg { display: block; max-width: 100%; }
h1, h2, h3 {
  font-family: var(--font-display);
  color: var(--d-text);
  margin: 0;
  letter-spacing: -0.025em;
  line-height: 1.08;
  font-weight: 700;
}
p { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
a { color: inherit; }

.skip {
  position: absolute; left: -9999px; top: 0;
  background: var(--copper); color: #fff;
  padding: var(--s3) var(--s5); z-index: 100; border-radius: 0 0 var(--r-md) 0;
}
.skip:focus { left: 0; }

:where(a, button):focus-visible {
  outline: 2px solid var(--copper-dark);
  outline-offset: 3px;
  border-radius: var(--r-sm);
}

.shell {
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--s5);
}

/* ============================================================================
   THE GRID — item 4 and item 12
   ============================================================================
   ONE grid, and it is FIXED. v2 had two: a fixed `.table` at 0.022 alpha and a
   second one painted onto `.hero__wash`, which lives inside the scrolling hero.
   Two grids at different alphas sliding against each other is what "the boxes
   move with the scroll" actually was — not the fixed one moving, but a second
   one travelling over it. There is only one here and it cannot move, because
   `position: fixed` has nothing to move relative to.

   CENTRING (item 12). `background-position: center` is not a rounding-friendly
   guess, it is exactly the requested result, and the arithmetic is worth
   writing down because the obvious alternative is wrong.

   For background-position, a percentage aligns the P-point of the tile with the
   P-point of the container, so the offset is (W - tile) x P. At `center`:

       offset = (W - 68) / 2  =  W/2 - 34

   A tile paints its line on its own left edge, so lines land at W/2 - 34, and
   then every 68px after: W/2 + 34, W/2 + 102 ... The viewport centre sits
   exactly half a cell from the lines either side of it. That is the ask: the
   grid is centred, the dividing lines are equidistant from centre, and there is
   NO line down the middle.

   (`calc(50% + 34px)` — the intuitive version — resolves to W/2 - 34 + 34 =
   W/2 and puts a line precisely down the centre, which is the opposite.)
   ========================================================================== */
.grid {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* 0.042, not the 0.022 v2 used and not the 0.030 this file started at. The
     grid ARRIVING is a beat in the sequence — it is the thing that turns the
     void into a drafting table — and a beat you have to hunt for is not a beat.
     Measured against #000 a 0.042 white line composites to rgb(11,11,11),
     which is visible on a decent panel and still nowhere near competing with
     the mark. */
  background-image:
    linear-gradient(rgba(255, 255, 255, 0.042) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.042) 1px, transparent 1px);
  background-size: var(--cell) var(--cell);
  background-position: center center;
  /* A vignette, so the grid is a table under the work rather than wallpaper
     over it. It also stops the lines fighting the mark at the exact centre of
     the screen, which is where the mark is. */
  -webkit-mask-image: radial-gradient(ellipse 120% 95% at 50% 50%, #000 20%, rgba(0,0,0,0.40) 74%, transparent 100%);
          mask-image: radial-gradient(ellipse 120% 95% at 50% 50%, #000 20%, rgba(0,0,0,0.40) 74%, transparent 100%);
}

main, .foot { position: relative; z-index: 1; }

/* ============================================================================
   THE HEADER — items 2, 3, 5
   ============================================================================
   Item 2: the emblem is gone from the nav. Only the wordmark. On a page whose
   whole cover is the emblem building itself, a 26px copy of it in the corner is
   the same information twice, and the small one loses.

   Item 3: `.nav__inner` is NOT `.shell`. A 1160px centred rail on a 2545px
   monitor puts RIDGECAP and "Sign in" 1160px apart in the middle of the screen
   with 690px of nothing either side, which is what "everything is so close to
   the middle" is. It now spans the viewport with a real margin, so the two ends
   of the header sit at the two ends of the header.

   Item 5: it is not visible on arrival. See the `html.seq` block.
   ========================================================================== */
.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 40;
  padding-block: var(--s5);
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), padding var(--dur) var(--ease);
  border-bottom: 1px solid transparent;
}
.nav.is-stuck {
  background: rgba(0, 0, 0, 0.72);
  -webkit-backdrop-filter: saturate(140%) blur(10px);
          backdrop-filter: saturate(140%) blur(10px);
  border-bottom-color: var(--d-hairline);
  padding-block: var(--s4);
}
.nav__inner {
  max-width: none;
  margin-inline: 0;
  /* The whole point of item 3. 4vw keeps the ends off the bezel on a laptop and
     still reads as a margin on a 2545px monitor; the 72px ceiling stops it
     drifting into the middle again on an ultrawide. */
  padding-inline: clamp(var(--s5), 4vw, 72px);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s5);
}
/* THE HIT AREA IS PADDING, THE LAYOUT IS THE NEGATIVE MARGIN. A wordmark is a
   wide, very short shape — the header's is 12px tall on a phone and the
   footer's is 13 — so as a link it was a 102 x 12 target, which is a quarter of
   the 44px a thumb needs and thinner than the two rows of the grid behind it.
   The footer's text links already solve this with `padding-block: 8px`; these
   need more because they start shorter.
   The negative margin cancels the padding in layout only, so the header keeps
   its height and the footer keeps its spacing while the tappable box grows to
   44px. Verified: no element moved. */
.logo {
  display: inline-flex;
  align-items: center;
  color: var(--d-text);
  text-decoration: none;
  padding-block: 16px;
  margin-block: -16px;
}
.logo__word { height: 17px; width: auto; }
.logo--sm .logo__word { height: 13px; }
/* The header's emblem. Present in the markup at every width and shown only on a
   phone — see the note in the 640px block. Hidden here rather than absent from
   the DOM so the swap is a CSS decision with nothing to keep in step. */
.logo__mark { display: none; }

.nav__links {
  display: flex;
  align-items: center;
  /* Wider than v2's --s6. The right-hand cluster is the other end of a
     full-width header now, so it can afford to breathe. */
  gap: clamp(var(--s5), 2.4vw, var(--s7));
}

/* ---- Phone nav: one "Products" control instead of two links (item 12) ----
   Above 640px the two product links show and the menu is hidden; below, the
   reverse. Both are in the DOM at all times and CSS picks — so the correct nav
   is present on first paint with no JS, and JS is only needed to OPEN the menu.
   The reason this exists: at 375px the wordmark plus two links plus a Sign in
   button do not fit on one line, so Sign in wrapped underneath. */
.nav__menu { position: relative; display: none; }
.nav__toggle {
  display: inline-flex; align-items: center; gap: 6px;
  background: none; border: 0; padding: 10px 0;
  color: var(--d-body);
  font-family: var(--font-label); font-weight: 500; font-size: 0.95rem;
  cursor: pointer;
}
.nav__toggle svg { width: 11px; height: auto; transition: transform var(--dur) var(--ease); }
.nav__toggle[aria-expanded="true"] { color: var(--d-text); }
.nav__toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.nav__drop {
  position: absolute;
  top: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  min-width: 168px;
  display: grid;
  background: var(--panel);
  border: 1px solid var(--d-hairline-strong);
  border-radius: var(--r-md);
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
  overflow: hidden;
  z-index: 50;
}
.nav__drop[hidden] { display: none; }
.nav__drop a {
  padding: 13px 18px;
  text-decoration: none;
  color: var(--d-body);
  font-family: var(--font-label); font-weight: 500; font-size: 0.95rem;
}
.nav__drop a + a { border-top: 1px solid var(--d-hairline); }
.nav__drop a:hover, .nav__drop a:focus-visible { background: var(--panel-hi); color: var(--d-text); }

@media (max-width: 640px) {
  .nav__menu { display: block; }
  .nav__wide { display: none; }
  /* The budget at 375px, measured: 375 - 32 of padding leaves 343.
     wordmark 124 + gap 16 + Products 78 + gap 16 + Sign in 84 = 318, with 25
     to spare. Every one of those numbers had to come down for the row to fit,
     which is why the wordmark shrinks here too. */
  .nav__links { gap: var(--s4); }
  .nav__inner { padding-inline: var(--s4); gap: var(--s4); }
  .logo__word { height: 12px; }
  .nav__toggle { white-space: nowrap; flex: none; }

  /* THE HEADER SHOWS THE MARK, NOT THE WORDMARK. The cover already ends on a
     288px wordmark — 77% of a 375px screen — and a 124px copy of the same
     lockup sitting above it read as the brand stuttering rather than as chrome.
     The emblem says the same thing in a shape the wordmark is not, and the two
     stop competing.

     It also buys the row real space, which is the constraint the note above is
     about: at 375px the wordmark took 124px of a 343px budget and the emblem
     takes 41. That is 83px back for the Products control and Sign in, on the
     one width where they were 25px from wrapping.

     24px tall rather than the wordmark's 12: an emblem is a compact shape where
     a wordmark is a long thin one, so matching their HEIGHTS would make the
     mark look like a smudge. Matching optical weight means going taller and
     much narrower. 24 is still inside the 45px header row.

     SCOPED TO `.nav`, AND THAT IS NOT TIDINESS. Written as a bare
     `.logo__word { display: none }` this also hid the FOOTER's wordmark — which
     has no emblem beside it to fall back to, so the footer logo became an empty
     link. Measured that before catching it. The footer is nowhere near the
     cover and has no duplication to solve, so it keeps its wordmark. */
  .nav .logo__word { display: none; }
  .nav .logo__mark { display: block; height: 24px; width: auto; }
}
.nav__links > a:not(.btn) {
  position: relative;
  color: var(--d-body);
  text-decoration: none;
  font-family: var(--font-label);
  font-weight: 500;
  font-size: 0.95rem;
  /* 10px, not --s1's 4px. Measured at 375px the nav links were 33px tall
     against a 44px minimum tap target; this takes them to ~45px without
     changing where a single line of text sits, because the padding is
     symmetric. The underline rule is positioned from `bottom`, so it follows. */
  padding-block: 10px;
}
.nav__links > a:not(.btn)::after {
  content: '';
  position: absolute; left: 0; right: 0; bottom: -2px; height: 1px;
  background: var(--copper-dark);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur) var(--ease);
}
.nav__links > a:not(.btn):hover { color: var(--d-text); }
.nav__links > a:not(.btn):hover::after { transform: scaleX(1); }

/* ---------- Buttons --------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s2);
  font-family: var(--font-label);
  font-weight: 600;
  text-decoration: none;
  border-radius: var(--r-md);
  border: 1px solid transparent;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease), color var(--dur) var(--ease);
  cursor: pointer;
  /* A BUTTON LABEL NEVER WRAPS. This was the actual cause of "Sign in on two
     lines" — not the nav layout. As a flex child the button was allowed to
     shrink below its content width: measured 78px wide against a label needing
     ~84px, so "Sign in" broke across two lines INSIDE the button and made the
     whole header 67px tall. `flex: none` stops it being squeezed and `nowrap`
     stops it breaking if something else ever squeezes it anyway. */
  white-space: nowrap;
  flex: none;
}
.btn--sm { padding: 9px 16px; font-size: 0.9rem; }
.btn--lg { padding: 15px 30px; font-size: 1.05rem; }
.btn--ghost { border-color: var(--d-hairline-strong); color: var(--d-text); }
.btn--ghost:hover { border-color: var(--copper-dark); color: var(--copper-dark); }
.btn--accent { background: var(--copper); color: #fff; }
.btn--accent:hover { background: var(--copper-dark-hover); }

/* ============================================================================
   THE TITLE BLOCK
   ============================================================================
   Kept from v2 — it is the progress indicator and it is real drawing-set
   language. But it is part of the chrome, and item 1a says the first screen
   holds the mark and nothing else, so it arrives with the header rather than
   being there from the start.
   ========================================================================== */
.tblock {
  display: none;
  position: fixed;
  right: 0; top: 50%;
  transform: translateY(-50%);
  z-index: 30;
  flex-direction: column;
  align-items: center;
  gap: var(--s5);
  padding: var(--s5) 10px;
  border-left: 1px solid var(--d-hairline);
  pointer-events: none;
}

/* ── LEGIBILITY OVER A PAGE THAT CHANGES UNDERNEATH ─────────────────────────
   `.tblock` is `position: fixed`, so it crosses every band on the page while
   its own colours never change. On the reviews band — solid `--copper` #a85c2e
   — `--copper-dark` text measured 1.32:1. That is not low contrast, it is
   invisible; the copper cell simply is not there over that band.

   THE FIRST ATTEMPT WAS A SCRIM, AND IT WAS THE WRONG TRADE. A dark gradient
   behind the block is arithmetically perfect on the cover — black over black is
   zero change — and a visible dark panel on everything else, which is exactly
   backwards: it did nothing where nothing was needed, and drew a rectangle
   across the one band that was the problem. Jack: "on lighter areas it looks
   really bad." Painting opacity over a contrast problem only moves it.

   The block RESTATES ITS OWN COLOURS instead of hiding what is behind it.
   Nothing is painted, so there is no panel to look bad on any backdrop — the
   type inverts from copper-on-dark to warm-white-on-copper, which is what the
   reviews band already does with its own eyebrow, so it is the page's existing
   answer rather than a new one.

   Measured on #a85c2e:  #fff5ee 4.62:1 · #ffffff 4.96:1 — both clear AA at this
   size, against 1.32:1 unhandled.

   The transition exists because the switch fires while the block is straddling
   the boundary; the observer driving it is in site.js. */
.tblock,
.tblock__cell,
.tblock__cell--rule,
.tblock__cell--rule i {
  transition: color var(--dur) var(--ease),
              background-color var(--dur) var(--ease),
              border-color var(--dur) var(--ease);
}
.tblock.is-on-light { border-left-color: rgba(255, 255, 255, 0.34); }
.tblock.is-on-light .tblock__cell { color: #fff5ee; }
.tblock.is-on-light .tblock__cell--stamp { color: #ffffff; }
.tblock.is-on-light .tblock__cell--rule { background: rgba(255, 255, 255, 0.30); }
.tblock.is-on-light .tblock__cell--rule i { background: #ffffff; }

@media (min-width: 1280px) { .tblock { display: flex; } }
.tblock__cell {
  writing-mode: vertical-rl;
  font-family: var(--font-label);
  font-size: 10px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--d-muted);
}
.tblock__cell--stamp { color: var(--copper-dark); }
.tblock__cell--rule { display: block; width: 1px; height: 90px; background: var(--d-hairline); position: relative; }
.tblock__cell--rule i {
  position: absolute; left: 0; top: 0; width: 1px;
  height: calc(var(--p, 0) * 100%);
  background: var(--copper-dark);
}

/* ============================================================================
   THE COVER — items 1, 6, 10, 11
   ============================================================================
   ITEM 11, WHICH IS THE ONE THAT MATTERED MOST.

   The complaint was that the mark sits higher or lower depending on the
   machine. It did, and the cause was not a missing aspect-ratio check — it was
   that v2 never centred the mark in the VIEWPORT at all. It centred it inside a
   170vh hero whose inner stage was sticky, so where the mark landed was a
   function of how far through a 170vh box you happened to be and how tall your
   window was. Two laptops at two heights genuinely resolved it to two places.

   There is nothing to detect. `.cover__inner` is exactly one viewport tall and
   `place-items: center` puts the mark's box centre on the viewport's centre, on
   every screen, at every aspect ratio, with no measurement. The mark is then
   made to agree with its own box by giving the build SVG a viewBox that is
   symmetric about the mark's true axis — see the viewBox note in index.html.

   ── svh, NOT dvh, AND THIS WAS THE iPHONE BUG ────────────────────────────
   This used to be `100dvh`, with a note explaining that dvh tracks the URL bar
   so the mark stays centred in what is actually on screen, and that
   ScrollTrigger is configured with `ignoreMobileResize` so the bar collapsing
   does not tear the pin down mid-sequence.

   Those two sentences are in direct conflict and the note did not notice.
   dvh means THIS ELEMENT'S HEIGHT CHANGES when the URL bar collapses.
   ignoreMobileResize means SCROLLTRIGGER DOES NOT RE-MEASURE when it does. So
   the cover grew by the height of the bar — 60-90px on an iPhone — under
   measurements that were deliberately frozen, and the pin's end plus every
   position after the pin spacer was wrong by that much. Everything below the
   cover then sat waiting on triggers that could not be reached, which is what
   Jack saw: "all of the other sections are totally empty", and then, once the
   safety net caught it, "it took almost 5 seconds".

   svh is the SMALL viewport — the height with the browser chrome showing — and
   it is defined not to change as you scroll. The layout is stable, so frozen
   measurements stay correct and the triggers below the cover land where they
   were computed. `ignoreMobileResize` stays: it is still right that a pin
   should not rebuild mid-scroll, and now nothing depends on it being wrong.

   The cost is that with the bar hidden the cover is ~80px shorter than the
   glass, so a strip of the page below shows under it. That strip is the pin
   spacer over `body`, which is #000, under a cover that is also #000 — the
   seam is invisible, and it is a straight trade for the sections below
   actually appearing. vh stays as the fallback for anything predating svh; on
   desktop all three units are identical.

   ITEM 11, SECOND HALF: the match line is absolutely positioned against the
   bottom of that same one-viewport box, so it is at the bottom of the screen
   with almost nothing under it — independent of where the mark is, because they
   are no longer in the same flow.
   ========================================================================== */
.cover {
  position: relative;
  height: 100vh;
  height: 100svh;
}
.cover__inner {
  position: relative;
  height: 100vh;
  height: 100svh;
  display: grid;
  place-items: center;
}

/* The drawn sheet frame that used to sit here is gone as of v3.2 (item 4).
   It was a grey rule boxing in the cover. The reasoning for it was sound —
   a drawing has a border — but the cover's job is to hold one mark, and a
   rectangle around the mark is a second thing to look at. */

/* ---------- The mark -------------------------------------------------------- */
.plate {
  position: relative;
  display: grid;
  place-items: center;
  /* Sized against BOTH axes, which is the part that is easy to get wrong. The
     width term gives the mark real presence on a wide monitor — on a 2545px
     screen v2's fixed 660px cap rendered the emblem at 549px, which is a small
     logo floating in a lot of nothing on the one screen that holds nothing
     else. The 80vh term is what stops that from becoming a collision on a
     short laptop: at a 735/407 aspect the plate is 1.806x as wide as it is
     tall, so an unbounded width would push the dimension string down into the
     cover copy the moment the window got short. Whichever limit binds first
     wins, and the mark stays centred either way because the centring is done
     by the viewBox, not by the size.
     THIS IS THE KNOB. One number, 740px, sets how big the mark reads. */
  width: min(clamp(280px, 42vw, 740px), 80vh);
  aspect-ratio: 735 / 407;
}
/* Both layers occupy the same grid cell, so the wordmark sits exactly behind
   the emblem and the handover is a cross-fade in place rather than a move. */
.plate > * { grid-area: 1 / 1; }

.cover__word {
  width: 100%;
  height: auto;
  color: var(--d-text);
  /* The ghost value. The copper diamond inside the A is invisible at 0.07 and
     only resolves during the handover, so the reward for scrolling is the
     brand assembling itself rather than a logo swap. */
  opacity: 0.07;
}

/* ---------- The pass: the wordmark's continuous animation -------------------
   A narrow band of light travels across the letterforms, pauses, and comes
   round again. A plotter head going over a finished sheet.

   WHY A BAND AND NOT A BREATH. A pulse on a static screen is noticeable for
   about three cycles and irritating for every one after, and there is no way
   to make one slow enough to be calm without making it invisible. A pass has a
   REST — the band is off the sheet for most of the cycle — so the animation is
   an event that recurs rather than a state you are stuck inside. Roughly three
   seconds crossing, four and a half of nothing; the exact figures are in the
   timing note below.

   WHY NOT A TRANSFORM. The obvious version scales or slides the mark itself.
   It cannot be done here: GSAP writes an inline `transform` on .cover__word
   during the scrubbed handover, and a CSS animation on the same property wins
   over inline styles — so the loop would fight the scrub for the same matrix
   and the handover would land somewhere different depending on when the loop
   happened to be. Animating the MASK leaves transform untouched and out of it.

   The mask-position arithmetic, because percentages here are not intuitive:
   the offset is (area - image) x P, so with a band 46% wide the travel is
   0.54W per 100%. -140% parks it fully off the left, 240% fully off the right.
   Larger P moves it RIGHT only because the band is narrower than the box. */
.cover__word--pass {
  color: #fff;
  -webkit-mask-image: linear-gradient(101deg,
      rgba(0,0,0,0) 0%, rgba(0,0,0,0.08) 32%, rgba(0,0,0,0.85) 50%,
      rgba(0,0,0,0.08) 68%, rgba(0,0,0,0) 100%);
          mask-image: linear-gradient(101deg,
      rgba(0,0,0,0) 0%, rgba(0,0,0,0.08) 32%, rgba(0,0,0,0.85) 50%,
      rgba(0,0,0,0.08) 68%, rgba(0,0,0,0) 100%);
  -webkit-mask-size: 46% 100%;
          mask-size: 46% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  /* Parked off the sheet. This is also the no-animation resting state, so a
     browser that ignores the keyframes shows nothing rather than a stripe. */
  -webkit-mask-position: -140% 0;
          mask-position: -140% 0;
}
/* Opt IN to the motion, so prefers-reduced-motion never has to remember to
   switch it off — same posture as `html.seq`.

   LINEAR, AND THAT IS NOT LAZINESS. An ease-in-out spends its speed in the
   middle of the travel, which is exactly where the band is on the mark: the
   crossing measured 1.7s of an 8s cycle and read as a flash rather than a head
   moving over a sheet. Linear is also free of the usual cost — you never see
   the start or the stop, because the band is fully off the sheet at both ends
   of the travel. So the only part anyone watches is the constant-speed middle,
   which is what a plotter actually does.

   The timing, measured rather than guessed: the band is on the mark for 71% of
   the travel, so a 4.5s travel is a 3.2s crossing followed by 4.5s of nothing. */
@media (prefers-reduced-motion: no-preference) {
  .cover__word--pass { animation: rc-pass 9s linear infinite; }
}
@keyframes rc-pass {
  0%        { -webkit-mask-position: -140% 0;  mask-position: -140% 0; }
  50%, 100% { -webkit-mask-position: 240% 0;   mask-position: 240% 0; }
}

.rcbuild { width: 100%; height: auto; overflow: visible; }

/* NO `vector-effect: non-scaling-stroke` here, and it is not an oversight: it
   would freeze the guides' 5/7 dash at a fixed screen pattern while the drawing
   around it scaled. Line weight scaling with the plate is what a drawing does
   anyway — reduce a sheet and the linework reduces with it.

   `.rcbuild__draw` and its four stroked paths are gone in v4. The rationale is
   on the markup they used to sit in; the short version is that a centred stroke
   leaves half its width outside the fill it traces, and those strokes stayed up
   after the build, so the finished mark wore a permanent outline. */
.rcbuild__guides line {
  stroke: var(--copper-dark);
  stroke-width: 1.2;
  stroke-dasharray: 5 7;
  opacity: 0.62;
}
/* THE GUIDES DO NOT SURVIVE BEING SHRUNK — Jack, comparing his phone to the
   desktop: "the logo is missing a couple lines."
   Nothing is missing. These are in USER UNITS, so they scale with the plate,
   and the plate is 740px on a desktop against ~244px on a 393px phone. That is
   a scale of 1.01 against 0.33, so a 1.2-unit stroke goes from 1.2px to 0.40px
   and the 5/7 dash goes from a 5px mark to a 1.7px one. A 0.4px dotted line at
   62% opacity is not a drafting guide, it is a rumour — and the vertical one
   reads as absent entirely, because it is the only one crossing the black gap
   between the planes with no white edge beside it to give it contrast.
   Scaled up by the reciprocal so the guides land at roughly the weight and
   rhythm a desktop gets: 3.6 units at 0.33 is ~1.2px, and 15/21 is a ~5px mark.
   Not `vector-effect: non-scaling-stroke` — that freezes the DASH in screen
   units too, so the pattern would stop scaling with the drawing and the guides
   would read as a different object from the mark they are measuring. */
@media (max-width: 560px) {
  .rcbuild__guides line { stroke-width: 3.6; stroke-dasharray: 15 21; }
}
.rcbuild__fill .fl--plane { fill: var(--d-text); }
.rcbuild__fill .fl--cap { fill: var(--copper-dark); }

/* The glow. A copper bloom behind the cap, and in v4 it fires on the DOWNBEAT —
   once when the cap seats at the end of the build, and once per idle cycle as
   the tip settles back down, rather than swelling while the tip is lifted. A
   light that blooms on the way UP reads as the mark breathing; on the way DOWN
   it reads as the cap landing, which is the thing that is actually happening.
   It is a separate element rather than a filter on the cap because filters on an
   SVG that is also being scaled and faded are the expensive combination, and
   this one only has to be soft. */
/* SLOW AND WARM (Jack, 2026-08-10). Three separate things were making this read
   as a hard flash rather than as heat, and the timing was only one of them:

   1  IT WAS SMALL AND TIGHT. 34% wide with a 6px blur is a hot spot — a bright
      disc with an edge you can find. Brightness and size are not independent
      here: a small source has to be bright to register at all, so spreading it
      is what lets the peak opacity come down without the light vanishing. It is
      now 44% wide with a 14px blur, and the peak drops from 0.55 to 0.34 in the
      build and to 0.24 at rest.

   2  IT WAS ONE STOP OF #c96a3c, which is the accent copper — a fairly RED
      copper, and red at speed reads as an alarm rather than as warmth. It is
      now an amber core over a copper falloff, the same two-part construction
      the dome's sunrise uses: the yellow is what the eye reads as warm, the
      copper is what keeps it this brand's light rather than a generic glow.

   3  THE FALLOFF WAS FASTER THAN THE RISE — 0.26s up against 0.90s down. That
      is backwards for anything warm: heat arrives quickly and leaves slowly.
      The ratio is now about 1:2.6 the other way. See site.js.

   TRANSFORM RECALCULATED, and it had to be. This offset centres the light on
   the cap and it is a percentage of the ELEMENT's own height — so widening 34%
   -> 44% would have lifted it by 29% of the extra size and floated the glow off
   the top of the mark. Holding the absolute offset constant at 0.0918 x the
   plate's width: 0.27 x 0.34 = 0.0918, and 0.0918 / 0.44 = 0.2086. Hence -21%. */
.plate__glow {
  width: 44%;
  aspect-ratio: 1;
  border-radius: 50%;
  background:
    radial-gradient(circle, rgba(255, 201, 148, 0.30), rgba(255, 188, 133, 0) 54%),
    radial-gradient(circle, rgba(201, 106, 60, 0.34), rgba(201, 106, 60, 0) 76%);
  /* Sits on the cap, which is in the upper third of the plate, not its middle. */
  transform: translateY(-21%);
  opacity: 0;
  pointer-events: none;
  filter: blur(14px);
}

/* The dimension string ("740 PX" under the wordmark) is gone as of v3.2
   (item 3). It measured the plate's real rendered width, which is why it was
   defensible — but it also meant the number changed per monitor, and a figure
   that reads differently on every machine is noise on a cover, not a spec. */

/* ---------- The match line (items 1b, 11) ----------------------------------- */
.matchline {
  position: absolute;
  left: 0; right: 0;
  /* "almost no spacing". This is as close to the edge as it can sit without
     colliding with a phone's home indicator. */
  bottom: clamp(10px, 1.6vh, 22px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  color: var(--copper-dark);
  padding-inline: var(--s5);
}
.matchline__row {
  display: flex;
  align-items: center;
  gap: var(--s4);
  width: 100%;
  max-width: 1400px;
}
/* `flex: 1 1 0` with `min-width: 0` — the rules give up their space first and
   can collapse to nothing. Paired with `flex: 0 0 auto` on the label below,
   this makes it structurally impossible for the two dashed rules to squeeze the
   words out, which is the shape of the bug Sam is seeing on iOS (the arrow
   renders, the text does not). It did NOT reproduce at 375px in Chromium — the
   label measured 238px inside a 327px row — so this is hardening rather than a
   confirmed fix, and it is called out as such. */
.matchline__rule {
  flex: 1 1 0;
  min-width: 0;
  height: 0;
  border-top: 1px dashed currentColor;
  opacity: 0.75;
}
.matchline__label {
  flex: 0 0 auto;
  font-family: var(--font-label);
  font-weight: 600;
  font-size: 11px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  white-space: nowrap;
}
.matchline__tick { width: 18px; height: 11px; color: currentColor; }
@media (max-width: 560px) {
  .matchline__label { font-size: 9px; letter-spacing: 0.16em; }
  .matchline__row { gap: var(--s3); }
}

/* ---------- The cover copy --------------------------------------------------
   Arrives at the end of the sequence, with the wordmark. Sam's item 1 makes the
   first screen the mark alone, so the copy can no longer be legible at rest —
   the match line is what carries "there is more here" until you scroll.      */
/* THE HEADLINE IS TWO LINES, AND THE WIDTH IS WHAT MAKES IT TWO.
   At 46rem the second sentence did not fit on one line at any desktop size, so
   the h1 was always three lines — and a third line is ~47px that has to come
   from somewhere on a short window. Measured at 1280 x 700, which is a 1920 x
   1080 laptop at 150% display scaling and therefore a very ordinary machine:

     wordmark          y 324.8 .. 375.2
     headline          y 369.0 .. 510.0   three lines, 141px
     gap               -6.2px             they OVERLAP

   The second sentence needs ~800px at that size and had 736. Widening to 58rem
   gives it 928 and the headline becomes two lines everywhere above the 860px
   breakpoint — verified at the two places it could still fail: 1365px, where
   the font stops growing at 46.4px and the line needs 853 of 928, and 1009px,
   where the width stops being 92vw. Below that the font is 3.4vw against a
   92vw box, a constant 27em of room for an 18.4em line, so it cannot bind.

   Then `bottom` drops from 12vh to 8vh. Two lines alone would have left the
   headline about 20px clear of the wordmark, which is a near-miss rather than
   a composition. Measured after both changes, at the same 1280 x 700:

     wordmark          y 324.8 .. 375.2
     headline          y 444.0 .. 538.0   two lines, 94px
     gap               +68.8px

   It survives a deliberately extreme window too — at 1280 x 620 the gap is
   still +37.2px, and the copy clears the match line by 8.8px. */
.cover__copy {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: min(58rem, 92vw);
  text-align: center;
  bottom: clamp(48px, 8vh, 112px);
}
.cover__copy h1 {
  font-size: clamp(1.6rem, 3.4vw, 2.9rem);
  color: var(--d-text);
}
.cover__lede {
  margin-top: var(--s4);
  color: var(--d-body);
  font-size: clamp(0.98rem, 1.2vw, 1.08rem);
  max-width: 62ch;
  margin-inline: auto;
}
.cover__meta {
  margin-top: var(--s4);
  font-family: var(--font-label);
  font-size: 11px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--d-muted);
}
@media (max-width: 860px) {
  /* Measured at 375x812: with the copy at 13vh its top landed at y=429 while
     the wordmark's baseline sat at y=419 — ten pixels, on the one screen a QR
     visitor is guaranteed to see. Dropping it to 9vh puts 43px above the copy
     and 29px below it before the match line, which is a composition rather
     than a near-miss. The emblem's own box reaches y=484 at this size, but it
     is at opacity 0 by the time the copy arrives, so the clearance that
     matters is against the wordmark, not against the plate. */
  /* The width stays where it was on a phone. 58rem/92vw is a desktop fix for a
     desktop problem — the headline wraps to four lines here whatever the box
     is, and 92vw would only push the copy into the screen edges. */
  .cover__copy { width: min(46rem, 86vw); bottom: clamp(56px, 9vh, 104px); }
  .cover__copy h1 { font-size: clamp(1.35rem, 5.8vw, 1.9rem); }
  .cover__lede { font-size: 0.94rem; }
}

/* ITEM 8 — the wordmark reaching the edges on a phone.
   The desktop rule's `clamp(280px, 42vw, 740px)` hits its FLOOR on every phone
   (42vw is only 157px at 375), so all phones got a flat 280px plate — 272px of
   wordmark inside a 375px screen, which is 73% of the width with 51px of air
   either side. Not literally clipped, but "R at one edge, P at the other" is a
   fair description of how it reads, and it is far tighter than the desktop
   proportion. 62vw restores roughly the same ratio of mark to screen that a
   desktop gets, and the vh term keeps it sane in landscape. */
@media (max-width: 560px) {
  .plate { width: min(62vw, 42vh); }

  /* THE WORDMARK IS DECOUPLED FROM THE PLATE ON A PHONE — Jack: "the ridgecap
     word mark should be bigger on mobile."

     `.cover__word` is `width: 100%` of `.plate`, so until now the wordmark and
     the emblem were one size decision. They should not be: they are never on
     screen together at full strength — the whole cover is a handover from one
     to the other — and they have completely different proportions. The emblem
     is 735 x 407 and fills its box; the wordmark is 1417 x 137, so at the same
     WIDTH it is a tenth of the height. Sizing it to the mark's box means the
     thing the visitor is left looking at is set by the thing that has just
     faded out.

     62vw stays on the plate. That number was solved for the EMBLEM — the note
     above is about the mark reaching the edges — and it is still right for it.
     The wordmark goes to 78vw: 292px on a 375px screen against 233px, a quarter
     larger, with 41px of air each side.

     THE OLD NOTE ABOVE SAYS 272px OF WORDMARK ON A 375px SCREEN READ AS "R at
     one edge, P at the other", which is more than this. That judgement was made
     when the wordmark could only be widened by widening the plate, so it was
     really a judgement about the whole lockup growing. Sizing the two
     separately is what makes 78vw available without the emblem coming with it,
     and it is Jack's call on his own brand either way.

     It overflows `.plate`, which is intended and safe: the plate is a grid with
     `place-items: center` so a wider child stays centred, nothing on the way up
     to `main` clips vertically, and `main`'s `overflow-x: clip` means even a
     mistake here can never add horizontal scroll.

     `max-width: none` IS LOAD-BEARING and this file has been caught by it once
     before — see the same line on `.dome__sky/.dome__glow/.dome__arc`. The reset
     at the top sets `svg { max-width: 100% }`, so an <svg> can never be drawn
     wider than its containing block no matter what `width` says. Without this,
     `min(78vw, 60vh)` computes to 292px, gets silently capped at the plate's
     233px, and the rule looks like it simply did not apply. Measured exactly
     that before adding it. */
  /* AND THE TRACK HAS TO BE PINNED TO THE PLATE'S WIDTH, or the centring is a
     lie. `.plate` is a one-cell grid whose column is implicitly `auto`, and an
     `auto` track sizes to its largest item — so a wordmark wider than the plate
     grew the TRACK to 288px while the plate's own box stayed 233px, and the
     overflow all went to the right. Measured: 30px off centre, sitting 2px from
     the plate's left edge instead of hanging equally either side.
     `minmax(0, 1fr)` holds the track at exactly the plate's width, so
     `justify-items: center` has a fixed box to centre against and the overhang
     is symmetric. */
  .plate { grid-template-columns: minmax(0, 1fr); }
  .cover__word { width: min(78vw, 60vh); max-width: none; }
}

/* ── THE FINISHED COVER ON A TALL PHONE (2026-10-03) ───────────────────────
   Feedback from a phone, relayed by Jack: "There's a lot of space at the top.
   I would make the titles bigger and take up more space."

   Measured at 375 x 812 with the build finished: the emblem has faded, the
   wordmark sits dead centre (y 392–420), and the copy hangs off the bottom
   edge (y 475–751) — so the 314px between the header and the wordmark are
   empty black. site.js names the cure in its note on the header logo:
   re-compose the finished cover so the copy is not bottom-anchored.

   So, on a tall portrait phone only:
     · the plate lifts 18svh. Nothing in the motion layer touches `.plate`
       itself, which is why the short-viewport block below may lift it too;
     · the copy hangs off the WORDMARK instead of the bottom edge — its top is
       the lifted centre, plus half the wordmark (78vw × 137/1417 ÷ 2 =
       3.77vw), plus 40px. However many lines it wraps to, it cannot climb
       into the wordmark, and the slack is shared above and below the group
       instead of all sitting on top of it;
     · the headline grows from 5.8vw to 7.4vw (21.75px → 27.75px at 375).

   Keyed to min-height 701px so the short-viewport block keeps sole charge of
   anything shorter. Measured after: see the commit that added this. */
@media (max-width: 560px) and (min-height: 701px) {
  .plate { transform: translateY(-18svh); }
  .cover__copy {
    top: calc(50% - 18svh + 3.77vw + 40px);
    bottom: auto;
  }
  .cover__copy h1 { font-size: clamp(1.6rem, 7.4vw, 2.15rem); }
}


/* ── SHORT VIEWPORTS ───────────────────────────────────────────────────────
   EVERY RULE ABOVE IS KEYED TO WIDTH, AND THE COVER'S PROBLEM IS HEIGHT. The
   copy is positioned from the BOTTOM of the cover while the plate is centred in
   it, so the shorter the viewport, the further the copy climbs into the mark.
   No width query can see that coming, and two very different devices hit it:

       829 x 390  landscape phone   copy top 64px ABOVE the wordmark's bottom
       320 x 568  small portrait    copy top 64px ABOVE the wordmark's bottom

   Identical failure, opposite proportions — which is exactly why the first
   attempt at this block was wrong. It carried `min-width: 561px` to keep it
   away from portrait phones, on the assumption that the width queries already
   had them covered. They do not: at 320 x 568 the copy is 283px tall on a
   568px screen, half the viewport, and the lede alone is 122px of it (eight
   lines in a 275px column). Small portrait phones are SHORT phones.

   So this is keyed to height alone. 700px catches a 568 and a 667 device as
   well as landscape; a 390 x 844 phone is above it and keeps the portrait
   composition unchanged.

   Four moves, and they share the work rather than leaning on one:
     · the plate shrinks, so the mark takes less of a screen with none to spare
     · it LIFTS by 6vh — `place-items: center` parks it across the middle,
       which is right everywhere else and is most of the problem here. This is
       the move that buys clearance type alone could not, and it keeps the
       centred composition rather than abandoning it. Safe: nothing in the
       motion layer touches `.plate` itself, only its children.
     · the copy drops a step and sits closer to the bottom edge
     · the match line goes

   THE MATCH LINE GOES because there is no room — with the copy ending ~24px off
   the bottom, a scroll cue beneath it would overlap — and because it is the one
   element in the cover that is purely an affordance. On a short screen the copy
   runs to the bottom edge, which says "there is more" by itself. Nothing
   carrying information is hidden. */
@media (max-height: 700px) {
  .plate { width: min(clamp(180px, 44vw, 520px), 38vh); transform: translateY(-6vh); }
  .cover__copy { width: min(46rem, 86vw); bottom: clamp(20px, 4vh, 44px); }
  .cover__copy h1 { font-size: clamp(1.15rem, 4.4vw, 1.5rem); }
  .cover__lede { font-size: 0.84rem; }
  .cover__meta { font-size: 0.74rem; }
  .matchline { display: none; }
}

/* A SHORT PORTRAIT PHONE gets the tall phone's composition, scaled down
   (2026-10-03). The block above lifts the plate 6vh and keeps the copy on the
   bottom edge, which on a 375 x 667 phone still left ~200px of black over the
   wordmark and an 18px headline. So the copy hangs off the wordmark here too,
   the lift is 16svh, and the headline is 6.4vw. Measured at 375 x 667 and
   320 x 568 — the two sizes the note above names. After the short-viewport
   block on purpose: same specificity, so source order decides. */
@media (max-width: 560px) and (max-height: 700px) {
  .plate { transform: translateY(-16svh); }
  .cover__copy {
    top: calc(50% - 16svh + 3.77vw + 32px);
    bottom: auto;
  }
  .cover__copy h1 { font-size: clamp(1.2rem, 6.4vw, 1.6rem); }
}

/* "Nothing to install · Nothing to learn" wraps on a phone; balanced, it
   breaks after the dot instead of leaving one word on a line of its own. */
.cover__meta { text-wrap: balance; }

/* ============================================================================
   THE DOME
   ============================================================================
   The boundary between the cover and the live half of the page — and in v3.2
   it became an actual boundary rather than a picture of one.

   v3.1 had this as a separate block sitting ABOVE the surface, which is exactly
   why Sam read it as "a divider just added using an image": the dot field began
   underneath it, so there was a dead band between the curve and the first dot,
   and the arc was decorating a join instead of being one. It now lives INSIDE
   the surface, the canvas spans the whole thing, and `.dome__sky` paints black
   over everything above the curve. The dots therefore run right up to the
   underside of the arc and stop, because that is where the page stops — one
   continuous live field whose top edge happens to be a curve.

   Four layers, because no single element can do all of it:
     .dome__sky     black fill above the curve. This is the split.
     .dome__glow    the light, masked by the same curve so it shines UP only.
     .dome__arc     SVG, preserveAspectRatio: none. A stretched arc is still a
                    smooth arc, so it spans any width. A stretched BADGE would
                    be an ellipse, which is why the badge is not inside it.
     .dome__badge   absolutely positioned at the apex, uniformly scaled.
   ========================================================================== */
/* THE ONE SHAPE, DEFINED ONCE.
   Three different layers need to know where the curve is: the black sky above
   it, the mask that stops the glow shining downward, and the arc itself. They
   are the same quadratic — ends at y=300, control at y=40, apex at y=170 of a
   0 0 1440 320 box.

   v4 FLATTENED IT. The control was y=-60, which put the apex at y=120: a rise
   of 180 units off the ends, and too much. It bulged rather than sat, and
   because `.surface` starts its content below the whole dome box while the
   curve peaks in the middle, the height of that bulge was also the height of
   the dead black band under the apex. Control y=40 gives an apex at y=170, a
   rise of 130, and takes 40 units of the box — 12.5% of the dome's height —
   straight out of that gap. Two complaints, one number.
   The two data URIs below fill the region ABOVE that curve;
   `preserveAspectRatio='none'` plus `background-size: 100% 100%` stretches them
   to any width, so the curve stays glued to the drawn arc at every viewport.
   If you edit the path, edit it in all three places or they will drift. */
.dome {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: var(--dome-h);
  z-index: 1;
  pointer-events: none;

  /* ── THE CROP — how much wider than the screen the curve is DRAWN ────────
     1 = end to end, the whole dome in frame. Above 1 the curve is drawn wider
     than the viewport and centred, so its steep shoulders run off both edges
     and only the gentle middle is in frame. Nothing about the curve changes —
     it is the same quadratic at every size — you are just standing closer to
     it.

     WHY IT IS NEEDED, measured at 375 x 812:
     the layers are stretched with preserveAspectRatio: none, so the horizontal
     scale is viewport/1440 while the vertical scale is dome-h/320. On a phone
     those disagree by 2.5x, and the arc that leaves the edge of a desktop at
     20.1 degrees leaves the edge of a phone at 51.7 — rising 118.8px across
     the screen instead of sweeping. That is a bubble, not a horizon.

     The value is not a taste call, it is solved for: with a crop of m the
     visible edge slope is 1.266 / m squared, so m = 1.85 puts a 375px phone
     back on the desktop's 20 degrees, and m = 1.45 does the same for a tablet.

     ONE KNOB. The three layers below take their width from it, and the arc's
     edge fade divides by it, so they cannot disagree about where the frame is. */
  --dome-gx: 1;
}

/* All three curve layers are the SAME box, always — that is what stops the
   black sky, the masked glow and the drawn arc from drifting apart when the
   crop changes. At --dome-gx: 1 this is identical to `inset: 0`.
   They overflow .dome horizontally on purpose; .surface clips them. */
.dome__sky,
.dome__glow,
.dome__arc {
  position: absolute;
  top: 0;
  left: 50%;
  width: calc(var(--dome-gx) * 100%);
  height: 100%;
  /* CENTRED BY MARGIN, NOT BY TRANSFORM, and the reason is one property on the
     sky. These carried `transform: translateX(-50%)` until the grid had to be
     continued down to the horizon; a transformed element makes
     `background-attachment: fixed` resolve against ITSELF rather than the
     viewport, so the sky's grid would have scrolled with the page while the
     real `.grid` — which is `position: fixed` — stayed put, and the two would
     have sheared against each other at the seam.
     The arithmetic is identical. Width is gx x CB, so half of it is 0.5·gx·CB;
     `left: 50%` puts the left edge at 0.5·CB and this pulls it back by exactly
     that half, leaving the centre on 0.5·CB at every value of gx. All three
     layers keep the same box, which is the invariant this block exists for. */
  margin-left: calc(var(--dome-gx) * -50%);
  /* THE RESET AT THE TOP OF THIS FILE SETS `svg { max-width: 100% }`, and it
     silently caps the arc at the viewport while the two divs either side of it
     widen — so the drawn line stays steep over a cropped sky and a cropped
     glow, which is worse than not cropping at all. Measured: the arc came back
     331.5px narrower than the layers it has to agree with. */
  max-width: none;
}

/* Black above the curve, AND THE DRAFTING GRID CARRIED DOWN ONTO IT.
   This is what makes the dome a boundary rather than an ornament: the dot field
   underneath spans the full surface, and this stops it existing above the line.
   Below the curve — dots. Above — the grid, continuous with the page above.

   Jack: "make it so the squares in the background go all the way down to the
   line so it dosnt feel like an empty black space." They could not, because
   `.surface` is opaque — deliberately, it is what stops the fixed grid showing
   through the dot field — and this layer then painted flat black on top of it.
   Between the last of the grid and the arch there were 190-300px of nothing.

   THE SHAPE MOVED FROM background-image TO mask-image, which is the whole
   trick. As a background it WAS the black; as a mask it clips whatever this
   element paints, so the element can now paint black plus grid lines and both
   get cut to the same curve. It is also byte-identical to the mask on
   `.dome__glow` below — the sky and the light are now provably the same shape
   rather than two shapes that agree.

   `background-attachment: fixed` IS LOAD-BEARING. `.grid` is `position: fixed`
   with `background-position: center center`, so its lines are anchored to the
   viewport centre. Anchoring these to the viewport too is the only way the two
   stay in phase while the page scrolls — otherwise the grid slides against
   itself across the seam at the top of `.surface`, which is far worse than the
   empty band it replaces. This is also why the layers above are centred with a
   margin instead of a transform; see the note there.

   0.036 RATHER THAN `.grid`'s 0.042, and it is a deliberate approximation.
   `.grid` carries a viewport-centred vignette, so its lines are already dimmed
   to somewhere around 0.76-0.95 of full where the dome sits. A mask cannot be
   viewport-anchored the way a background can, so that vignette is not
   reproducible here — 0.036 is the middle of that range, which leaves the two
   within about one RGB level of each other anywhere along the seam instead of
   three at the extremes. */
.dome__sky {
  background-color: #000;
  background-image:
    linear-gradient(rgba(255, 255, 255, 0.036) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.036) 1px, transparent 1px);
  background-size: var(--cell) var(--cell);
  background-position: center center;
  background-attachment: fixed;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320' preserveAspectRatio='none'%3E%3Cpath d='M0 0H1440V300Q720 40 0 300Z' fill='%23ffffff'/%3E%3C/svg%3E");
          mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320' preserveAspectRatio='none'%3E%3Cpath d='M0 0H1440V300Q720 40 0 300Z' fill='%23ffffff'/%3E%3C/svg%3E");
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

/* THE GLOW SHINES UP ONLY — Sam's item 1, and the mask is the whole mechanism.
   The gradients themselves are radial and would spill in every direction; the
   mask is the same above-the-curve shape, so light can exist above the line and
   nowhere below it. That is what makes it read as a sun behind a planet's
   circumference rather than a lamp buried in the page: on a real horizon the
   body is opaque and the light is only ever above it.
   Three gradients because one ellipse cannot be a source and a wash at once —
   a hot core that falls off fast, a bloom around it, and a wide low spread
   along the line. All three sit on the apex so they cannot drift apart. */
.dome__glow {
  /* EVERY HORIZONTAL RADIUS IS DIVIDED BY THE CROP, and it has to be. These
     are percentages of this element, and this element is --dome-gx times wider
     than the screen — so without the division a cropped dome would spread the
     sun sideways by the same factor and turn a source into a wash. Dividing
     holds the glow at exactly the same width in real pixels no matter how much
     of the curve is in frame. The vertical radii are untouched: the element's
     height never changes. */
  /* CUT HARD FOR THE UPDRAFT — Jack: "the area near the icon is too bright and
     static." These two complaints were the same object. This wash peaks at the
     apex and never moves, and it was drowning the field that does: measured at
     the icon, the static wash contributed 90 of 106 luminance — 85% of what you
     saw there — against 25-30% out on the shoulders, which is exactly where the
     light read as alive and where it did not.
     Dimming the moving layer near the centre was tried and is the wrong fix; it
     makes the ratio worse, and the arithmetic is on `dip` in ember.js. Cutting
     the STATIC layer fixes both complaints with one change, because at the apex
     brightness and staticness were the same gradient. Measured at u = 0.5:
         wash 0.56   mean luminance 106,  modulation 18-40%
         wash 0.22   mean  76,            modulation 79%
         wash 0.16   mean  68,            modulation 94%   <- here
         wash 0.10   mean  61,            modulation 110%
     0.16 also stops the apex being a hotspot at all: 68 there against 66 at
     u=0.38. Below about 0.10 the dome starts losing its base warmth on the
     shoulders, where the field alone cannot carry it. */
  background:
    radial-gradient(ellipse calc(13% / var(--dome-gx)) 30% at 50% 53.125%, rgba(255, 218, 190, 0.16), rgba(255, 180, 132, 0) 70%),
    radial-gradient(ellipse calc(30% / var(--dome-gx)) 46% at 50% 53.125%, rgba(201, 106, 60, 0.26), rgba(201, 106, 60, 0) 72%),
    radial-gradient(ellipse calc(88% / var(--dome-gx)) 34% at 50% 53.6%, rgba(168, 92, 46, 0.18), rgba(168, 92, 46, 0) 76%);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320' preserveAspectRatio='none'%3E%3Cpath d='M0 0H1440V300Q720 40 0 300Z' fill='%23ffffff'/%3E%3C/svg%3E");
          mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 320' preserveAspectRatio='none'%3E%3Cpath d='M0 0H1440V300Q720 40 0 300Z' fill='%23ffffff'/%3E%3C/svg%3E");
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  /* The lit state. The scrubbed tween drives this up from 0 as the dome comes
     into view; with no JS it is simply already lit, which is a finished page. */
  opacity: 1;
}

/* THE UPDRAFT. The wash above is the hearth; this is what comes off it.
   Deliberately NOT given its own mask, opacity or crop — it is inside
   `.dome__glow` precisely so it inherits all three. See the note on the markup.

   The wash came down twice as this landed — 0.70/0.58/0.30 originally, then
   0.56/0.46/0.24 when the updraft first arrived and was only meant to add to
   it, and now 0.16/0.26/0.18 once it turned out the wash was the thing making
   the apex read as a bright static blob. The two layers are a pair: the wash is
   the base warmth, this is the part that moves, and if you raise one you have
   to look at the other. The full arithmetic is on the background above.

   `screen` RATHER THAN NORMAL, and it matters more than it looks. Painted
   normally, a 0.2-alpha warm pixel over the bright core of the wash AVERAGES
   with it — so the updraft would darken the hottest part of the dome and
   brighten only the dim edges, which is the exact inverse of how light behaves
   and reads as a grey film over the middle. Screen can only ever lighten. On a
   near-black backdrop it is indistinguishable from adding, which is what the
   field's own internal maths already assumes. */
.dome__ember {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  mix-blend-mode: screen;
}

/* THE ARC'S EDGE FADE, and it only exists once the dome is cropped.
   The fade built into #arcfade is a gradient across the PATH, so it dies out
   at the path's own ends — which is correct while the whole dome is in frame
   and wrong the moment it is not: cropped, the line would meet the screen edge
   at roughly 44% opacity and simply stop, which reads as a broken line rather
   than one continuing past the frame.
   This mask is sized to the VISIBLE window instead — 100% of the element
   divided by the crop is exactly the viewport — so the arc always fades out at
   the edge of the screen, whatever fraction of the curve is on it. At
   --dome-gx: 1 the mask is the full element and changes nothing, so desktop
   renders exactly as before. */
.dome__arc {
  -webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16%, rgba(0,0,0,1) 84%, rgba(0,0,0,0) 100%);
          mask-image: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16%, rgba(0,0,0,1) 84%, rgba(0,0,0,0) 100%);
  -webkit-mask-size: calc(100% / var(--dome-gx)) 100%;
          mask-size: calc(100% / var(--dome-gx)) 100%;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

/* The crop, per device. See the derivation on .dome — these are the values that
   put a phone and a tablet back on the desktop's ~20 degree edge slope.

   BOTH CAME DOWN IN v4, because the curve they were correcting got gentler.
   The edge slope of a quadratic is 2(P1 - P0) — at the old control y=-60 that
   was -720/1440 = 0.500 in viewBox units, and at y=40 it is -260/1440 = 0.361.
   The crop needed goes as the square root of the slope it has to cancel, so
   each value scales by sqrt(0.361/0.500) = 0.850: 1.45 -> 1.23 and 1.85 ->
   1.57, rounded to the values below. Leaving them alone would have over-cropped
   an already-flatter curve and pushed the shoulders off screen for nothing. */
@media (max-width: 860px) { .dome { --dome-gx: 1.25; } }
@media (max-width: 560px) { .dome { --dome-gx: 1.55; } }

.dome__badge {
  position: absolute;
  left: 50%;
  /* The apex is at 170/320 = 53.125% of the box. Same number as the path. */
  top: 53.125%;
  transform: translate(-50%, -50%);
  width: clamp(54px, 6vw, 76px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: #0a0b0d;
  border: 1px solid rgba(201, 106, 60, 0.55);
  box-shadow: 0 0 0 7px rgba(0, 0, 0, 0.9), 0 0 38px rgba(201, 106, 60, 0.34);
  display: grid;
  place-items: center;
  color: var(--d-text);
}
.dome__badge svg { width: 52%; height: auto; }

/* ============================================================================
   THE LIVE SURFACE — item 7, second half
   ============================================================================
   OPAQUE. That is the entire fix for "get rid of the lines behind the live
   dots". The grid is `position: fixed` and therefore behind everything on the
   page including this section, so the only way to stop it showing through the
   dot field is for the thing in front of it to not be transparent. v2 made this
   wrapper transparent on purpose — so the fixed table WOULD show through — and
   the result is the crowding Sam is describing: a 68px grid, a dot field on a
   24px pitch, and two columns of type all occupying the same pixels.

   One canvas across products and trial, not one each: two fields drift on
   independent clocks and put a visible seam exactly at the boundary.
   ========================================================================== */
.surface {
  position: relative;
  background: var(--ink-deep);
  overflow-x: clip;
  /* The dome is absolutely positioned over the top of this element, so the
     first section needs to start below it. One variable drives the dome's
     height, this padding and the badge's position — they cannot disagree. */
  --dome-h: clamp(190px, 26vh, 300px);
  padding-top: var(--dome-h);
  /* THE CLOSING GAP LIVES HERE, NOT ON THE LAST CHILD'S MARGIN, and it stays
     here now that the last child has changed.

     A bottom margin on whatever ends this element COLLAPSES THROUGH it: there
     is no bottom padding, no bottom border and no block formatting context —
     `overflow-x: clip` with `overflow-y: visible` does not make one. It escapes
     and becomes a gap between the surface and the copper band, showing `body`
     rgb(0,0,0), which is DARKER than this element's own rgb(16,17,20) and so
     reads as a black stripe rather than as absence, with the fixed `.grid`
     visible through it. Measured when it happened: surface bottom 2912.5,
     reviews top 2934.9, 22.4px. The dot field is `inset: 0` here, so it stopped
     22.4px short of the copper for the same one reason.
     Padding cannot collapse. Keep the closing gap on this line. */
  padding-bottom: clamp(var(--s4), 1.6vw, 28px);
}
/* THE DOT FIELD DIES BEFORE THE SEAM — first half of the topo fix.
   The complaint was that the contour band "looks slapped on, you can see the
   dots behind it a little bit before it switches". That is literally what was
   happening: this canvas ran at full strength to the last pixel of .surface and
   the copper band started at the very next one, so two different live surfaces
   met at a hard horizontal line with no transition between them. Two animated
   textures butted edge to edge read as two images pasted together, which is
   exactly the note.
   The field fades out just before the boundary, so by the time it arrives there
   is nothing left to cut off. Paired with the contours fading IN from the top of
   the band below, the join becomes a dissolve from one material into another
   instead of a splice.

   A FIXED DISTANCE, NOT A PERCENTAGE, and this is the durable half of it. `85%`
   means the fade is however long the surface happens to be — it measured 1045px
   at the time, so 15% was 157px, but that number moved the moment the trial
   section was deleted and would move again with any content change. A join does
   not care how tall the thing above it is.

   ── AND IT IS A CURVE, NOT A RAMP ────────────────────────────────────────────
   Jack: "make the dots fade into the review area a little bit better, it looks
   like there is too much blank space now." The dots were not stopping early —
   measured on the composited result, the lowest visible one was 12px off the
   copper. What was wrong was the SHAPE of the falloff. A straight 120px ramp
   collapses the dots' contrast against the surface long before it ends:

       108px above the copper   delta 65   (full strength is 72)
        84px                    delta 51
        60px                    delta 38
        36px                    delta 24
        12px                    delta 10

   The eye reads that locally, not absolutely — a delta-10 dot immediately below
   a field of delta-65 ones registers as nothing — and the dot grid is on a 24px
   pitch, so the empty rows between dots dominate as soon as the dots themselves
   go weak. The result is 60px that measure "not blank" and look blank.

   So it now holds FULL strength to 56px and spends the fade in the last 28,
   which keeps real presence against the copper and still leaves a soft edge
   rather than a cut. The stop at 10px/0.28 is what stops the last dot row
   landing on the boundary at full weight, which is the hard splice this mask
   was added to prevent in the first place. */
.surface > .dotfield {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  z-index: 0;
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%,
    #000 calc(100% - 56px),
    rgba(0, 0, 0, 0.72) calc(100% - 28px),
    rgba(0, 0, 0, 0.28) calc(100% - 10px),
    transparent 100%);
          mask-image: linear-gradient(to bottom,
    #000 0%,
    #000 calc(100% - 56px),
    rgba(0, 0, 0, 0.72) calc(100% - 28px),
    rgba(0, 0, 0, 0.28) calc(100% - 10px),
    transparent 100%);
}
.surface > .band { position: relative; z-index: 1; }

.band { padding-block: clamp(var(--s8), 7vw, 116px); }
/* TIGHTENED IN v4. Both of Jack's spacing notes were the same measurement seen
   from two ends: 116px of band padding on top of the dome's own dead height
   under the apex, and a hundred more between the last product and what followed
   it. The dome half is fixed by flattening the arch — see .dome — so this only
   has to carry the rest.
   The bottom value now closes the whole surface, since the products are the
   last thing in it. It is deliberately smaller than the top: the copper band
   below is a hard colour change and does not need a run-up, where the dome
   above is soft and does. */
.band--index {
  padding-top: clamp(var(--s6), 3.4vw, 60px);
  padding-bottom: clamp(var(--s5), 2.2vw, 40px);
}

.eyebrow {
  font-family: var(--font-label);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--copper-dark);
}

/* ---------- Products -------------------------------------------------------- */
.index__head {
  display: grid;
  gap: var(--s5);
  align-items: end;
  margin-bottom: var(--s7);
}
@media (min-width: 861px) {
  .index__head { grid-template-columns: minmax(0, 1fr) minmax(0, 26rem); gap: var(--s8); }
}
.index__head h2 { font-size: clamp(1.9rem, 3.4vw, 2.9rem); }
.sindex__fit { color: var(--d-muted); min-width: 0; }

/* ONE COLUMN, NOT A GRID OF CARDS.
   Two products side by side in 340px columns is two slabs in a lot of nothing,
   and it forces the product name down to 1.9rem to fit the column — which is
   how the name ended up the same size as its own status pill. Full-width rows
   let the name run at display scale, which is the actual point of the section:
   these are two finished tools, not a catalogue.
   A hairline between entries, and one above the first, so the list reads as
   ruled rather than boxed. */
.sindex {
  display: grid;
  border-top: 1px solid var(--d-hairline);
}
.srow { min-width: 0; border-bottom: 1px solid var(--d-hairline); }
.srow__link {
  display: grid;
  /* Sheet number in its own margin column on desktop, so the names all start on
     one line and the numbers read as a column of references rather than as
     labels stuck to the front of each name. Collapses to one column below. */
  grid-template-columns: 6.5rem minmax(0, 1fr);
  align-items: baseline;
  column-gap: var(--s5);
  row-gap: var(--s3);
  padding: clamp(var(--s5), 2.6vw, var(--s7)) 0;
  text-decoration: none;
  /* NO background, no border, no radius. The dot field is directly behind this
     and it stays visible — the legibility problem the old opaque panel was
     solving is handled by /dotfield.js's calm zones instead, which quiet the
     dots behind the prose only. See the calmSelectors list in site.js. */
  transition: padding-left var(--dur) var(--ease);
}
/* The whole row shifts a few pixels on hover — the only hover affordance here,
   and it is the row moving rather than a colour changing, because there is no
   panel left to tint. */
.srow__link:hover { padding-left: var(--s3); }
.srow__no {
  grid-column: 1;
  font-family: var(--font-label);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.18em;
  color: var(--copper-dark);
  white-space: nowrap;
}
.srow__name {
  grid-column: 2;
  font-family: var(--font-display);
  font-weight: 700;
  /* Display scale. This is the size the section heading runs at, deliberately:
     the products are the content, "Available products." is only the label on
     them. */
  font-size: clamp(2rem, 4.2vw, 3.1rem);
  line-height: 1.06;
  color: var(--d-text);
  letter-spacing: -0.03em;
  transition: color var(--dur) var(--ease);
}
.srow__link:hover .srow__name { color: var(--copper-dark); }
.srow__what {
  grid-column: 2;
  color: var(--d-body);
  font-size: 1.0625rem;
  max-width: 62ch;
  min-width: 0;
}
/* Audience and status on one quiet line under the description. ONE uppercase
   treatment in the whole row, and it is this pair — the sheet number is small
   caps copper but not shouting, and everything else is sentence case. */
.srow__meta {
  grid-column: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s3) var(--s5);
  margin-top: var(--s2);
  font-family: var(--font-label);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.srow__for { color: var(--d-muted); }

/* RC-003 — THE HELD SHEET. It has to read as a number being kept open, not as a
   third product, so everything that gives the two real rows their presence is
   turned down and nothing is added: no border, no dashes, no "coming soon"
   badge. Just the same row, quieter.

   THE SHEET NUMBER STAYS COPPER. That is the whole idea — the number is real
   and reserved, it is the CONTENT that is not there yet. Muting it too would
   make the row look like a mistake rather than a decision.

   There is no hover because there is nothing to click: `.srow__link` is an <a>
   on the two products and a <span> here, so the hover rules below simply never
   match. These two declarations exist for the day someone makes it a link
   without reading the markup note. */
.srow--held .srow__name { color: var(--d-muted); }
.srow--held .srow__what { color: var(--d-muted); }
.srow--held .srow__link { cursor: default; padding-left: 0; }
/* The status, as text. It was a bordered capsule, which is a badge for its own
   sake — the word is the information and the box around it was decoration.

   NAMED --near AND --far, NOT --live AND --soon. The old names encoded a claim:
   `--live` said a thing was available, and it was on the product that is
   furthest from being so. Neither product can be used by another company yet,
   so the only honest distinction left is which one is closer, and the names now
   say exactly that and nothing more. The accent goes to the nearer one — it is
   the loudest state on the page and it was pointing at the wrong product. */
.srow__state--near { color: var(--copper-dark); }
.srow__state--far  { color: var(--d-muted); }
.srow__state--far::before,
.srow__state--near::before {
  content: '';
  display: inline-block;
  width: 5px; height: 5px;
  margin-right: 0.7em;
  vertical-align: 0.15em;
  border-radius: 50%;
  background: currentColor;
}
@media (max-width: 720px) {
  .srow__link { grid-template-columns: minmax(0, 1fr); row-gap: var(--s2); }
  .srow__no, .srow__name, .srow__what, .srow__meta { grid-column: 1; }
  .srow__no { margin-bottom: var(--s1); }
}

/* The trial block's rules are gone with the block — `.cta`, `.cta__mail` and
   the `band--cta` padding. See the note in index.html for why the section went.
   `.btn--accent` and `.btn--lg` are KEPT: the header's Sign in still uses
   `.btn--ghost .btn--sm`, and the accent pair is the one styled call to action
   this design system has, so deleting it would only mean rewriting it the day a
   product is genuinely open. */

/* ============================================================================
   REVIEWS — item 9
   ============================================================================
   Still reserved, still blank on purpose: Ridgecap has no customers yet and
   inventing one is the fastest way to lose a contractor who checks with peers.

   What it gains is the live topographic surface, in BLACK, per Sam. Contours
   are the survey drawing a project actually starts from, and on flat copper
   they read as a printed drawing rather than as an effect — which is why black
   works here and would not on graphite. The band is the only place on the page
   with a light-enough field to carry black line work.

   The band clips its own canvas and /topo.js is mounted in host-measured mode,
   because this is a normal in-flow section, not a fixed full-viewport layer.
   ========================================================================== */
.band--reviews {
  position: relative;
  background: var(--copper);
  color: #fff;
  text-align: center;
  overflow: hidden;
  isolation: isolate;
}
.band--reviews > .topo {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  z-index: 0;
  /* TWO MASKS, INTERSECTED — and they are doing two unrelated jobs.

     1  The radial hole keeps the contours clear behind the type. The first pass
        held them off until 78% of the way out, which on a 391px band meant the
        only visible line work was in the far left and right thirds — the band
        read as flat copper with something happening off in the corners. The
        hole is now just big enough for the words.

     2  The linear ramp is the second half of the topo fix (v4). The contours
        used to start at full strength on the band's very first row, one pixel
        below a dot field that also ran at full strength to its last row — the
        hard splice that made this read as pasted in. They come up over the
        band's first stretch instead, which is the copper arriving before the
        drawing on it does.

        SHORTENED 18% -> 9%, at the same time the dot field's falloff was
        recurved, and for the same complaint: "it looks like there is too much
        blank space now." The two fades are one join and they were BOTH long.
        18% of a ~335px band is 60px of near-empty copper sitting directly under
        60px of near-empty surface, so the transition was ~120px of nothing with
        texture at each end. Halving this closes the copper half; the curve on
        the dot field closes the dark half. Move one and look at the other. */

  /* ⛔ THE COMMENT OPENER ON THIS BLOCK WAS MISSING, AND IT HAD CONSEQUENCES.
     The block above closes on the "18% to 9%" line, so everything from here
     down to the closer below was not a comment at all — the CSS parser read
     four lines of English prose as declarations inside the .topo rule. Error
     recovery skips to the next semicolon, which silently ate the
     -webkit-mask-image declaration on the line below it.

     Present since before the cover-sequence work; the opener and closer counts
     are unequal by one at 6ef683a too. Nothing reported it, because an
     unbalanced comment is not an error in any tool being run here — it shows up
     only as a declaration that mysteriously fails to apply.

     NOTE FOR ANYONE EDITING THIS FILE: do not write a comment closer inside
     comment prose. Comments do not nest, so the first one ends the block early
     and the rest of the sentence becomes CSS. Fixing this the first time
     reintroduced the same bug that way, in this very comment.

     mask-composite: intersect is what makes a pixel need BOTH masks to show,
     rather than the later one simply replacing the earlier. The -webkit-
     property is a different syntax with different keywords, so it gets its own
     declaration — `source-in` is that prefix's spelling of the same operation. */
  -webkit-mask-image:
    radial-gradient(ellipse 46% 62% at 50% 50%, transparent 4%, #000 56%),
    linear-gradient(to bottom, transparent 0%, #000 9%);
          mask-image:
    radial-gradient(ellipse 46% 62% at 50% 50%, transparent 4%, #000 56%),
    linear-gradient(to bottom, transparent 0%, #000 9%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
.band--reviews > .topo canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.band--reviews .shell { position: relative; z-index: 1; }
.band--reviews h2 { color: #fff; font-size: clamp(1.9rem, 3.4vw, 2.9rem); margin-block: var(--s3) var(--s5); }
.band--reviews .eyebrow { color: #ffe0cc; }
.reviews__note { color: #fbe7db; max-width: 54ch; margin-inline: auto; }

/* ============================================================================
   REQUEST A DEMO
   ============================================================================
   BACK TO GRAPHITE, and the reason is contrast rather than taste. The copper
   band above is the only light field on the page; a form on it would have to
   restate every input colour to stay legible on #a85c2e, and the footer under
   it is dark anyway — so a light band between two dark ones would leave the
   copper reading as a divider rather than as a section. `--ink-deep` matches
   the products surface, so this reads as a return to the sheet.

   ⛔ DARK-MODE CONTRAST, THE RULE THIS SITE HAS BROKEN TWICE — AND ONCE MORE
   HERE, IN DRAFT. The fill cannot define the field: `--panel` (#191c22) on an
   `--ink-deep` (#101114) band measures 1.11:1, which is invisible. So the
   BORDER is what makes a field a field, and a border is a UI component under
   WCAG 1.4.11, which wants 3:1 against what is behind it.

   The first version of this section used `--d-hairline-strong` (#3d424e) for
   that border because it is the file's existing "strong" hairline. MEASURED IN
   THE BROWSER IT IS 1.88:1 — under the bar, and the comment here originally
   claimed 3.0:1 from arithmetic I had not checked. That is precisely the
   failure mode this site keeps repeating: a contrast value asserted rather
   than measured.

   `--field-line` exists because of that. It is not decoration and it is not
   interchangeable with the hairline tokens: those are for RULES between blocks,
   which are decoration and may be faint, while this is the edge of a control
   somebody has to find and click. Measured, not calculated — see the figures on
   the token itself.
   ========================================================================== */
.band--demo {
  background: var(--ink-deep);
  border-top: 1px solid var(--d-hairline);
  /* The border of an interactive control on `--ink-deep`. 3.38:1 measured in
     the browser against the band, so it clears WCAG 1.4.11's 3:1 for non-text
     UI with a little room. Do not swap this for `--d-hairline-strong`, which
     looks similar and measures 1.88:1.

     ⚠️ THE APP USES #3d424e FOR THIS AND WE DELIBERATELY DO NOT MATCH IT. That
     is the app's `--border-strong`, and it is right THERE because it sits on
     the app's `--surface`, a lighter dark than this band. Copying the hex would
     import the look and lose the contrast — the same value measures 1.88:1
     against `--ink-deep`. Match the app's SHAPE, not its colours; the surfaces
     underneath are not the same. */
  --field-line: #616878;

  /* ── SHARPER, TO MATCH THE CLOSEOUT APP ──────────────────────────────────
     Jack: "Can you make these a little bit sharper, like how the boxes are in
     the closeout app."

     Read out of the app's live `src/styles/` rather than guessed, because the
     design-system folder is stale. The app's `.input` and `.btn` both use
     `--radius-md`, which its scale sets to 5px. This band was on v4's `--r-md`,
     which is 8px. Those 3px are the whole of "sharper": a marketing radius on
     what is really a piece of application UI.

     SCOPED TO THIS BAND ON PURPOSE. `--r-md` is v4's global and also shapes the
     header's Sign in button and every other control on the page. Jack pointed
     at the form, so only the form moves. Making the whole page match the app is
     one token, but it is a different decision and it changes chrome he has
     already signed off on. */
  --field-radius: 5px;
}
/* Two columns on a wide screen — the argument on the left, the form on the
   right — collapsing to one below 900px. `start` rather than `stretch` so the
   intro does not grow to the form's height and float its own text. */
.demo__inner {
  display: grid;
  gap: clamp(var(--s6), 5vw, 88px);
  align-items: start;
}
@media (min-width: 900px) {
  .demo__inner { grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr); }
}
.demo__intro h2 {
  color: var(--d-text);
  font-size: clamp(1.7rem, 3vw, 2.5rem);
  margin-block: var(--s3) var(--s5);
}

/* The home page's section headings on a phone (2026-10-03, the same feedback
   as the cover): larger, so each one spans the screen like a title rather
   than a label. After the three rules above on purpose — same specificity,
   so source order decides. */
@media (max-width: 560px) {
  .index__head h2,
  .band--reviews h2 { font-size: 2.2rem; }
  .demo__intro h2 { font-size: 2.05rem; }
}
.demo__note { color: var(--d-body); max-width: 46ch; }
.demo__meta {
  margin-top: var(--s5);
  font-family: var(--font-label);
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--d-muted);
}

/* ---------- The product choice ---------------------------------------------
   A fieldset with the browser's own border and padding removed. `min-width: 0`
   is not decoration: a fieldset has an intrinsic minimum width that ignores
   grid, and without it a long legend can push the whole column wider than its
   track and produce a horizontal scrollbar on a phone. */
.demo__products { border: 0; padding: 0; margin: 0 0 var(--s5); min-width: 0; }
.demo__legend {
  padding: 0;
  font-family: var(--font-label);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--d-muted);
  margin-bottom: var(--s3);
}
.demo__hint {
  margin: 0 0 var(--s3);
  font-size: 0.86rem;
  color: var(--d-muted);
}
/* Two, not three — "Both" is gone and is expressed by ticking both. */
.demo__choices { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s3); }
.demo__choice {
  position: relative;
  display: grid;
  gap: var(--s1);
  padding: var(--s4) var(--s3);
  background: var(--panel);
  border: 1px solid var(--field-line);
  border-radius: var(--field-radius);
  cursor: pointer;
  text-align: center;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.demo__choice:hover { border-color: var(--copper-dark); }
/* THE RADIO IS NOT `display: none`. A hidden-that-way input cannot be focused,
   so the whole group would drop out of the keyboard tab order and the arrow
   keys that move between radios would do nothing. It is made invisible but
   still focusable, and the visible state is drawn by the label around it. */
.demo__choice input {
  position: absolute;
  opacity: 0;
  width: 1px; height: 1px;
  margin: 0;
  pointer-events: none;
}
/* ── THE TICK ────────────────────────────────────────────────────────────────
   The one thing on the card that says "you may choose more than one". Without
   it these are two identical panels that look exactly like the radios they used
   to be, and somebody who assumes single-select never tries the second.

   It is `aria-hidden` and purely decorative: the real checkbox is still in the
   accessibility tree, and a screen reader already announces "checkbox, not
   checked". This square exists for the people who cannot hear that. */
.demo__tick {
  position: absolute;
  top: 10px; right: 10px;
  width: 16px; height: 16px;
  border: 1px solid var(--field-line);
  /* --radius-sm in the app's scale. A tick box wants to be squarer than the
     control it sits on, or it reads as a dot. */
  border-radius: 3px;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
/* The check itself: two borders of a rotated box. Drawn rather than a glyph so
   it needs no font and cannot arrive late. */
.demo__tick::after {
  content: '';
  position: absolute;
  left: 4px; top: 0px;
  width: 5px; height: 9px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0.6);
  opacity: 0;
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
/* THE SELECTED STATE AVOIDS `:has()` — FOR BROWSER SUPPORT, NOT BECAUSE IT IS
   BROKEN. The first version of this note claimed `:has()` "did not apply here",
   after the tick, the checkmark and the card border all measured unchanged with
   the box genuinely checked. That diagnosis was WRONG and it is recorded here
   because it was expensive.

   Every property that appeared dead is a TRANSITIONED one, and the only one
   that worked — `color` on the sheet number — is the only one with no
   transition on it. The verification browser was not compositing, so its
   animation clock never advanced and no transition ever left its start value.
   `:has()` was working the whole time. Re-measured with
   `transition: none !important` forced, every one of them changes correctly.

   THE RULE THAT WOULD HAVE CAUGHT IT IMMEDIATELY: when a state change looks
   like it did not apply, kill transitions before believing it.

   The class stays anyway, on its own merits: `:has()` is absent in Safari
   before 15.4 and in older Firefox, and on a public form "which one did I pick"
   cannot be a progressive enhancement — a visitor who cannot see their own
   selection cannot use the control. The tick uses the SIBLING combinator, which
   needs no ancestor query at all; the card, which genuinely does need its
   ancestor styled, is driven by an `is-selected` class that site.js toggles. The
   form already requires JS to submit, so that adds no new dependency. */
.demo__choice input:checked ~ .demo__tick {
  background: var(--copper);
  border-color: var(--copper);
}
.demo__choice input:checked ~ .demo__tick::after {
  opacity: 1;
  transform: rotate(45deg) scale(1);
}

.demo__choice-no {
  font-family: var(--font-label);
  font-size: 10px;
  letter-spacing: 0.18em;
  color: var(--d-muted);
}
.demo__choice-name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.02rem;
  color: var(--d-text);
}
/* Class, not `:has()` — see the note on the tick above. site.js keeps it in step
   with the checkbox on every change. */
.demo__choice.is-selected {
  border-color: var(--copper-dark);
  background: var(--panel-hi);
}
.demo__choice.is-selected .demo__choice-no { color: var(--copper-dark); }
/* The focus ring goes on the LABEL, because the input it belongs to is the
   invisible one above. Without this the group is keyboard-navigable but gives
   no sign of where you are.

   `:focus-within` rather than `:has(input:focus-visible)`: it is the purpose-
   built selector for exactly this and it predates `:has()` by years in every
   engine, so the same support argument as the selected state applies. The cost
   is that it also fires on a mouse click, where `:focus-visible` would not — on
   a card the size of a tap target that reads as feedback rather than noise. */
.demo__choice:focus-within {
  outline: 2px solid var(--copper-dark);
  outline-offset: 2px;
}

/* ---------- Fields ---------------------------------------------------------- */
.demo__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s4); }
.demo__field { display: grid; gap: var(--s2); margin: 0; }
.demo__field--wide { grid-column: 1 / -1; }
@media (max-width: 560px) {
  .demo__grid { grid-template-columns: 1fr; }
  .demo__choices { grid-template-columns: 1fr; }
  .demo__choice { display: flex; align-items: baseline; gap: var(--s3); text-align: left; }
}
.demo__field label {
  font-family: var(--font-label);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--d-body);
}
.demo__opt {
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: none;
  color: var(--d-muted);
}
.demo__field input,
.demo__field textarea {
  width: 100%;
  font-family: var(--font-sans);
  font-size: 1rem;
  color: var(--d-text);
  background: var(--panel);
  border: 1px solid var(--field-line);
  border-radius: var(--field-radius);
  padding: 12px 14px;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease);
  /* A textarea is inline-block by default, which leaves a descender gap under
     it inside a grid cell, and it is resizable on both axes — dragging it wider
     breaks the column. Vertical only. */
  resize: vertical;
}
.demo__field textarea { min-height: 96px; }

/* ── THE FOCUS TREATMENT IS THE APP'S TOO ───────────────────────────────────
   The other half of why the app's boxes read differently, and it is not the
   radius. The app's `.input:focus` drops the outline entirely and uses the
   accent as the BORDER plus a soft 3px ring at 40% accent — so the field
   deepens and glows rather than gaining a second hard rectangle sitting a pixel
   outside the first. Copied here, 40% included.

   `outline: none` IS ONLY DEFENSIBLE BECAUSE SOMETHING REPLACES IT. Removing an
   outline and putting nothing back is the standard way to make a form unusable
   by keyboard. The forced-colors block is the case a box-shadow cannot cover:
   Windows High Contrast discards shadows entirely, so the outline returns there
   and that is what makes dropping it safe rather than merely pretty. */
.demo__field input:focus,
.demo__field textarea:focus {
  outline: none;
  border-color: var(--copper-dark);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--copper-dark) 40%, transparent);
}
@media (forced-colors: active) {
  .demo__field input:focus,
  .demo__field textarea:focus,
  .demo__choice:focus-within {
    outline: 2px solid Highlight;
    outline-offset: 1px;
  }
}
/* `:user-invalid`, not `:invalid`. `:invalid` matches an empty required field
   from the moment the page loads, so the form would greet everyone with four
   red boxes before they had typed anything. `:user-invalid` waits until the
   field has actually been interacted with and left wrong. */
.demo__field input:user-invalid,
.demo__field textarea:user-invalid { border-color: #e2795a; }

/* Off-screen, NOT hidden — see the note on the markup. `display: none` would
   defeat the purpose, because the point is that a bot fills it in and submits
   it. Height and overflow keep it from adding scroll length. */
.demo__hp {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  overflow: hidden;
}

/* The button follows the fields. Leaving it on v4's 8px next to 5px inputs
   would read as a mistake rather than as a choice — and the app puts `.btn` on
   the same `--radius-md` as `.input`, so matching them here is what the app
   does, not just what looks tidy. */
.demo__actions .btn { border-radius: var(--field-radius); }

.demo__actions {
  margin-top: var(--s5);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s4);
}
.demo__status { font-size: 0.95rem; color: var(--d-body); margin: 0; }
.demo__status--ok { color: var(--copper-dark); }
.demo__status--bad { color: #e2795a; }
.demo__fine {
  margin-top: var(--s5);
  font-size: 0.86rem;
  color: var(--d-muted);
  max-width: 52ch;
}
.demo__fine a { color: var(--d-body); text-underline-offset: 3px; }
.demo__fine a:hover { color: var(--copper-dark); }

/* A submitted form is disabled while it is in flight, so the state has to read
   as "working", not as "broken". */
.demo__form.is-sending .btn { opacity: 0.6; pointer-events: none; }

/* The confirmation that replaces the form. It has to hold roughly the form's
   visual weight or the footer jumps up the screen the moment it is submitted. */
.demo__done {
  padding: var(--s6) var(--s5);
  background: var(--panel);
  border: 1px solid var(--copper-dark);
  border-radius: var(--field-radius);
  color: var(--d-body);
  line-height: 1.7;
}
.demo__done strong { color: var(--d-text); font-family: var(--font-display); }
.demo__status a { color: var(--copper-dark); text-underline-offset: 3px; }

/* ============================================================================
   THE OTHER PAGES — Closeout, Roster, Terms, Privacy and the 404
   ============================================================================
   Only the home page is dark. Jack, 2026-10-02: the product pages should not
   look like the home page, everything else should be much simpler, and
   nothing on these pages may look generated. So every other page is one white
   sheet between the same dark header and footer, built under the three rules
   the home page's sheet index was rebuilt under when Jack said it still looked
   AI (the long comment above .sindex in index.html says why):

     · NO BOX. Nothing is a rounded, bordered panel on a tinted fill. Content
       sits on the page and rules separate it, the way a drawing sheet does.
     · NO PILL. A status is a word of text — copper for the near one, grey for
       the far one — never a bordered capsule.
     · ONE UPPERCASE. Sheet numbers (RC-001, RC-002) are the only letter-spaced
       capitals. Every other label is set in sentence case.

   The structure is the decoration: a heavy ink rule opens a schedule and a
   hairline separates its rows, the same two weights a printed sheet uses.
   Copper is spent on the primary button, a near status, links, and the one
   row on the Roster screen that needs somebody to act. Nowhere else.

   IT WORKS BY REMAPPING THE DARK TOKENS INSIDE <main>, so anything written
   against --d-text, --d-body or --copper-dark reads correctly here without a
   second rule. The header and footer sit outside <main> and keep the dark
   values. The generator puts `is-light` on <body> of every page it writes.

   Measured on white / --paper: --ink 17.8 / 16.5, --body 8.9 / 8.3, muted
   #61646f 5.9 / 5.5, --copper-text 6.7 / 6.2, white on the --copper button
   4.96. ⛔ --copper-dark (#c96a3c) is 3.7:1 on white and must never be text on
   these pages, which is why it is remapped away rather than reused.
   ========================================================================== */

/* The current page's own nav link. Not a colour change alone — a rule under it,
   so it still reads as "you are here" for anyone who cannot separate the two
   greys. `aria-current="page"` carries it for everyone else. */
.nav__wide.is-current,
.nav__drop a.is-current {
  color: var(--d-text);
  border-bottom: 1px solid var(--copper-dark);
}

body.is-light { background: var(--surface); }

.is-light main {
  --d-text: var(--ink);
  --d-body: var(--body);
  --d-muted: #61646f;
  --d-hairline: var(--hairline);
  --d-hairline-strong: var(--hairline-strong);
  --copper-dark: var(--copper-text);
  --copper-dark-hover: var(--copper-deep);
  /* The Closeout app's control radius, so a button here is the same shape as
     the one on the demo form and the one in the product. */
  --r-ctl: 5px;
  background: var(--surface);
  color: var(--d-body);
}

/* The header is fixed and transparent over the home page's black cover. Over a
   white page that would be white type on white, so here it is a solid band
   that takes up its own room (sticky, not fixed) and keeps one height —
   site.js still toggles is-stuck, and there is deliberately nothing for it to
   change. */
.is-light .nav,
.is-light .nav.is-stuck {
  position: sticky;
  background: var(--ink-deep);
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
  border-bottom-color: var(--d-hairline);
  padding-block: var(--s4);
}

/* ---------- The head of a sheet --------------------------------------------- */
.sheethead { padding-block: clamp(56px, 8vw, 120px) clamp(64px, 8vw, 112px); }
.sheethead__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  column-gap: clamp(40px, 6vw, 112px);
  align-items: start;
}

/* What a title block would say, in one line: sheet, name, state. Cells are
   divided by a hairline, as they are in a title block, rather than by dots. */
.sheetref {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  row-gap: 8px;
  margin-bottom: clamp(20px, 2.6vw, 32px);
  font-family: var(--font-label);
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.2;
  color: var(--d-muted);
}
.sheetref > * + * {
  margin-left: 14px;
  padding-left: 14px;
  border-left: 1px solid var(--hairline-strong);
}
.sheetref__no {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink);
}
.sheetref a { color: inherit; text-decoration: none; }
.sheetref a:hover { color: var(--ink); }
.state--near { color: var(--copper-text); }
.state--far { color: var(--d-muted); }

.sheethead h1 {
  max-width: 15ch;
  font-size: clamp(2.6rem, 6vw, 4.6rem);
  line-height: 1.02;
  letter-spacing: -0.035em;
  text-wrap: balance;
}
.lede {
  max-width: 52ch;
  margin-top: clamp(20px, 2.4vw, 28px);
  font-size: clamp(1.06rem, 1.3vw, 1.2rem);
  line-height: 1.6;
  color: var(--body);
}
.lede + .lede { margin-top: 16px; }
/* The longhand on purpose: the `text-wrap` shorthand also resets the wrap MODE,
   which silently overrode white-space: nowrap on anything it matched (the
   "31 of 39 on site" count broke in two on a phone). */
.is-light main p,
.is-light main li,
.is-light main figcaption { text-wrap-style: pretty; }

.sheetactions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 18px 32px;
  margin-top: clamp(28px, 3.2vw, 40px);
}
.is-light main .btn { border-radius: var(--r-ctl); }
.is-light main .btn--lg { padding: 14px 26px; font-size: 1rem; }

/* The second way out is a sentence with an arrow, not a second button: two
   buttons side by side is the template, and only one of them is the point. */
.textlink {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-label);
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink);
  text-decoration: none;
  transition: color var(--dur) var(--ease);
}
.textlink::after {
  content: '';
  width: 15px;
  height: 10px;
  flex: none;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 15 10'%3E%3Cpath d='M0 5h13.5M9.5 1l4 4-4 4' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3C/svg%3E") center / contain no-repeat;
          mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 15 10'%3E%3Cpath d='M0 5h13.5M9.5 1l4 4-4 4' fill='none' stroke='%23000' stroke-width='1.6'/%3E%3C/svg%3E") center / contain no-repeat;
  transition: transform var(--dur) var(--ease);
}
.textlink:hover { color: var(--copper-text); }
.textlink:hover::after { transform: translateX(3px); }

/* The figure starts level with the headline, not with the reference line —
   the two columns share a top edge where the eye actually lands. */
.sheetfig { margin: 0; padding-top: clamp(0px, 3.6vw, 54px); }
.sheetfig figcaption {
  max-width: 48ch;
  margin-top: 14px;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--d-muted);
}
@media (max-width: 960px) {
  .sheethead__grid { grid-template-columns: minmax(0, 1fr); row-gap: 64px; }
  .sheetfig { padding-top: 0; }
}

/* ---------- Schedules ---------------------------------------------------------
   The product, shown the way a drawing shows a schedule: a heavy rule to open
   it, a header row, hairlines between rows, an ink rule to close it. It is a
   real <table>, so a screen reader gets rows and columns, not a picture. */
.sched__top {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  padding-bottom: 14px;
  border-bottom: 2px solid var(--ink);
}
.sched__title {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.sched__meta { margin-top: 3px; font-size: 0.875rem; color: var(--d-muted); }
.sched__count {
  font-size: 0.875rem;
  color: var(--d-muted);
  text-align: right;
  white-space: nowrap;
}
.sched__count b {
  display: block;
  font-family: var(--font-display);
  font-size: 2.1rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--ink);
}
.sched table {
  width: 100%;
  border-collapse: collapse;
  font-variant-numeric: tabular-nums;
}
.sched th {
  padding: 10px 0;
  border-bottom: 1px solid var(--hairline-strong);
  font-family: var(--font-label);
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--d-muted);
  text-align: left;
  white-space: nowrap;
}
.sched td {
  padding: 13px 0;
  border-bottom: 1px solid var(--hairline);
  color: var(--ink);
  vertical-align: baseline;
}
.sched tbody tr:last-child td { border-bottom: 1px solid var(--ink); }
.sched .sched__n {
  width: 3.4rem;
  padding-right: 18px;
  font-family: var(--font-label);
  font-weight: 600;
  text-align: right;
}
.sched__sub {
  display: block;
  margin-top: 2px;
  font-size: 0.8125rem;
  color: var(--d-muted);
}
.sched .sched__st {
  padding-left: 16px;
  font-family: var(--font-label);
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--d-muted);
  text-align: right;
  white-space: nowrap;
}
.sched .sched__st--short { color: var(--ink); font-weight: 600; }
.sched .sched__st--late { color: var(--copper-text); font-weight: 600; }

/* ---------- Sections: the label in the margin, the argument beside it -------
   An editorial split instead of a stack of centred bands. The margin label
   says what the section is; the headline beside it says what it claims. */
.sheetsec {
  border-top: 1px solid var(--hairline);
  padding-block: clamp(64px, 8vw, 112px);
}
.sheetsec__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr);
  column-gap: clamp(32px, 6vw, 112px);
}
.sheetsec__label {
  padding-top: 0.5em;
  font-family: var(--font-label);
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--d-muted);
}
.sheetsec__body { max-width: 62ch; }
@media (min-width: 961px) {
  .sheetsec__label { position: sticky; top: 104px; align-self: start; }
}
.sheetsec h2 {
  font-size: clamp(1.75rem, 3vw, 2.5rem);
  line-height: 1.1;
  letter-spacing: -0.03em;
  text-wrap: balance;
}
.sheetsec h2 + p { margin-top: 20px; }
.sheetsec p + p { margin-top: 16px; }
@media (max-width: 960px) {
  .sheetsec__grid { grid-template-columns: minmax(0, 1fr); row-gap: 14px; }
  .sheetsec__label { padding-top: 0; }
}

/* Numbered points, set as drawing notes: an ink rule to open the list, the
   mark in its own column, hairlines between. On Roster's "how it works" the
   marks are times of day rather than numbers, because that is the sequence. */
.sheetnotes {
  margin-top: clamp(32px, 4vw, 48px);
  border-top: 1px solid var(--ink);
}
.sheetnotes li {
  display: grid;
  grid-template-columns: 5rem minmax(0, 1fr);
  column-gap: 20px;
  padding-block: 24px;
  border-bottom: 1px solid var(--hairline);
}
.sheetnotes__mark {
  padding-top: 3px;
  font-family: var(--font-label);
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--d-muted);
  font-variant-numeric: tabular-nums;
}
.sheetnotes h3 {
  font-size: 1.15rem;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.015em;
}
.sheetnotes h3 + p { margin-top: 8px; }
@media (max-width: 560px) {
  .sheetnotes li { grid-template-columns: minmax(0, 1fr); row-gap: 6px; }
}

/* ---------- Legal pages: /terms/ and /privacy/ --------------------------------
   A document to be read, not a page to be scrolled past: one column at a
   reading measure, the section numbers hanging in the margin so the text
   keeps one left edge, and a contents list that stays beside it on a wide
   screen. */
.legaldoc {
  border-top: 1px solid var(--hairline);
  padding-block: clamp(48px, 6vw, 88px) clamp(80px, 9vw, 136px);
}
.legaldoc__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr);
  column-gap: clamp(32px, 6vw, 112px);
  align-items: start;
}
/* On a phone it is simply not there: fourteen links above the first word
   would push the terms themselves off the screen, and a phone reader scrolls. */
.toc { position: sticky; top: 104px; }
.toc__head {
  padding-bottom: 12px;
  border-bottom: 1px solid var(--ink);
  font-family: var(--font-label);
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--ink);
}
.toc ol { margin-top: 8px; }
.toc a {
  display: grid;
  grid-template-columns: 1.9rem minmax(0, 1fr);
  padding-block: 5px;
  font-size: 0.875rem;
  line-height: 1.4;
  color: var(--d-muted);
  text-decoration: none;
  transition: color var(--dur) var(--ease);
}
.toc a span { font-family: var(--font-label); font-weight: 600; font-variant-numeric: tabular-nums; }
.toc a:hover,
.toc a[aria-current="true"] { color: var(--ink); }
.toc a[aria-current="true"] span { color: var(--copper-text); }
@media (max-width: 960px) {
  .legaldoc__grid { grid-template-columns: minmax(0, 1fr); }
  .toc { display: none; }
}

.legal__body { max-width: 66ch; }
.legal__body > * + * { margin-top: 16px; }
.legal__body h2 {
  margin-top: clamp(48px, 5vw, 64px);
  font-size: clamp(1.25rem, 1.8vw, 1.45rem);
  line-height: 1.25;
  letter-spacing: -0.02em;
  scroll-margin-top: 104px;
}
.legal__body h2:first-child { margin-top: 0; }
.legal__body h2 .no {
  display: inline-block;
  min-width: 2rem;
  font-family: var(--font-label);
  font-size: 0.9em;
  font-weight: 600;
  letter-spacing: 0;
  color: var(--d-muted);
}
@media (min-width: 961px) {
  .legal__body h2 { position: relative; }
  .legal__body h2 .no {
    position: absolute;
    right: calc(100% + 18px);
    top: 0.12em;
    min-width: 0;
    text-align: right;
  }
}
.legal__body h3 {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 12px;
  margin-top: 32px;
  font-size: 1.08rem;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.legal__body h3 .state {
  font-family: var(--font-label);
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0;
}
.legal__body strong { color: var(--ink); font-weight: 500; }
.legal__body a {
  color: var(--copper-text);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
.legal__body a:hover { color: var(--copper-deep); }
/* Hostnames and the ?c= tag. The brand retired monospace, so these are the
   label face — enough to read as a literal without a chip around it. */
.legal__body code {
  font-family: var(--font-label);
  font-size: 0.95em;
  font-weight: 500;
  color: var(--ink);
  overflow-wrap: anywhere;
}
.legal__list { display: grid; gap: 10px; }
.legal__list li { position: relative; padding-left: 26px; }
.legal__list li::before {
  content: '';
  position: absolute;
  left: 2px;
  top: 13px;
  width: 12px;
  height: 1px;
  background: var(--ink);
}
.legal__addr { color: var(--ink); line-height: 1.7; }

/* ---------- 404: the set, not three buttons -----------------------------------
   Somebody who reaches a page that does not exist is shown the sheet index,
   in the same vocabulary as the home page's: the cover and the two products. */
.setlist {
  max-width: 760px;
  margin-top: clamp(40px, 5vw, 64px);
  border-top: 2px solid var(--ink);
}
.setlist a {
  display: grid;
  grid-template-columns: 6.5rem minmax(0, 1fr) auto;
  align-items: baseline;
  column-gap: 20px;
  padding-block: 18px;
  border-bottom: 1px solid var(--hairline);
  color: var(--ink);
  text-decoration: none;
}
.setlist__no {
  font-family: var(--font-label);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--d-muted);
}
.setlist__name {
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 2.4vw, 1.75rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.02em;
  transition: color var(--dur) var(--ease);
}
.setlist__what {
  font-family: var(--font-label);
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--d-muted);
  white-space: nowrap;
}
.setlist a:hover .setlist__name { color: var(--copper-text); }
@media (max-width: 560px) {
  .setlist a { grid-template-columns: 4.75rem minmax(0, 1fr); row-gap: 2px; }
  .setlist__what { grid-column: 2; white-space: normal; }
}
.sheetnote {
  max-width: 56ch;
  margin-top: 28px;
  font-size: 0.9375rem;
  color: var(--d-muted);
}
.nowrap { white-space: nowrap; }

/* ---------- On a phone (2026-10-03) ------------------------------------------
   "There's a lot of space at the top. I would make the titles bigger and take
   up more space." Measured at 375: the head band opened with 56px of white
   under the header before the first word, and section titles were 28px. The
   top padding halves, the sections tighten, and every title steps up. */
@media (max-width: 560px) {
  .sheethead { padding-block: 28px 56px; }
  .sheetref { margin-bottom: 16px; }
  .sheethead h1 { font-size: clamp(2.6rem, 11.6vw, 3rem); }
  .sheetsec { padding-block: 52px; }
  .sheetsec h2 { font-size: clamp(2rem, 8.6vw, 2.4rem); }
  .sheetnotes h3 { font-size: 1.3rem; }
  .legal__body h2 { font-size: 1.55rem; }
  .legaldoc { padding-top: 40px; }
}

/* Terms and Privacy get printed, and signed against. Print the document, not
   the website around it. */
@media print {
  .is-light .nav,
  .is-light .foot,
  .is-light .skip,
  .toc { display: none !important; }
  .legaldoc__grid { display: block; }
  .is-light main { color: #000; }
}

/* ---------- Footer ---------------------------------------------------------- */
.foot {
  background: var(--ink-deep);
  border-top: 1px solid var(--d-hairline);
  padding-block: var(--s8);
}
.foot__inner { display: grid; gap: var(--s5); justify-items: center; text-align: center; }
.foot__links {
  display: flex;
  /* The old site's footer had no flex-wrap and overflowed a 375px viewport by
     84px. This one wraps. */
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s3) var(--s6);
}
/* 8px of block padding takes these from 25px to ~41px tall. Not the full 44px
   — that would force the six links to wrap to three rows on a phone — but it
   closes most of the gap on the links a superintendent actually taps. */
.foot__links a { color: var(--d-body); text-decoration: none; font-size: 0.95rem; padding-block: 8px; }
.foot__links a:hover { color: var(--copper-dark); }
.foot__legal { color: var(--d-muted); font-size: 0.85rem; }

/* ============================================================================
   THE PRE-ANIMATION STATE
   ============================================================================
   EVERYTHING PARKED AT OPACITY 0 IS IN THIS ONE BLOCK, and nowhere else in the
   file. That is the whole safety property: there is exactly one class to remove
   to get a complete page back, the motion module removes it on any thrown
   error, and a 3.2s timeout removes it even if nothing throws.

   `html.seq` is set by an inline script in <head>, synchronously, before first
   paint — so the finished mark never flashes for a frame before it builds.
   It is not set at all under prefers-reduced-motion, which means a
   reduced-motion visitor gets the finished page immediately and no branch of
   the motion code has to remember to reveal anything.
   ========================================================================== */
/* `.sindex > *` USED TO BE IN THIS LIST AND IS DELIBERATELY NOT ANY MORE.
   Everything else here belongs to the cover, which the 3.2s build fail-safe
   covers. The product rows do not: they were raised by their own ScrollTrigger,
   and that fail-safe watches the BUILD — once the build completes it stands
   down and leaves `seq` in place, so a build that finished correctly and a
   trigger that then measured wrong left the page's only below-the-fold content
   invisible, with nothing logged. That is what Jack saw on an iPhone.
   They are parked in JS now, immediately before the thing that reveals them, so
   the CSS default is the finished page — which is this file's whole rule. */
html.seq .rcbuild__guides line,
html.seq .rcbuild__fill path,
html.seq .matchline,
html.seq .grid,
html.seq .nav,
html.seq .tblock,
html.seq .cover__copy > * { opacity: 0; }

/* THE HEADER'S LOGO IS NOT PARKED SEPARATELY, and that is a decision rather
   than an omission. It sits inside `.nav`, which IS parked above, so it arrives
   with the header at progress 0.22 and inherits its opacity — no second rule to
   keep in step with the sequence.

   Two revisions did park it separately, to stop a second smaller RIDGECAP being
   on screen while the big one was still in the middle. Jack has seen both and
   chosen the duplication over either fix; the reasoning is written out in full
   in site.js, at the point where the code used to be. */

/* The wordmark's ghost value is its pre-animation value too — it is BEHIND the
   emblem the whole time, and starting it at 0 would make the handover a fade-in
   rather than a resolve. */
html.seq .cover__word { opacity: 0; }

/* The dome starts unlit and is raised by the scrubbed tween. */
html.seq .dome__glow { opacity: 0; }

/* The stroke layer that used to need its own entry here is gone in v4. It was
   drawn by dash-offset rather than opacity and had to be set up in JS, where
   the path lengths are known — one more thing parked invisible waiting on a
   timeline, which is the exact shape of the two bugs this file is built around.
   The build drops the fills instead, and the fills are already covered by
   `html.seq .rcbuild__fill path` above. */

/* ---------- Reduced motion --------------------------------------------------
   Belt and braces. `html.seq` is never added under this preference, so these
   rules should be unreachable — but if anything ever adds it programmatically,
   this is the assertion that a reduced-motion visitor still sees a page.     */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 1ms !important;
  }
  html.seq .rcbuild__guides line,
  html.seq .rcbuild__fill path,
  html.seq .matchline,
  html.seq .grid,
  html.seq .nav,
  html.seq .tblock,
  html.seq .cover__copy > *,
  html.seq .dome__glow { opacity: 1; }
  html.seq .cover__word { opacity: 0.96; }
  /* `.sindex > *` is not listed because it is no longer parked by CSS at all —
     see the note on the `html.seq` block above. Leaving it here would imply a
     parked state that does not exist, and this block's whole job is to be an
     accurate mirror of that one. Under reduced motion the motion module bails
     before it reaches the code that parks the rows, so they are never hidden in
     the first place. */
}
