/* Mailcomm — site styles.
   Hand-authored, no framework, no build step. Everything is same-origin,
   the type included: three San Francisco files are served from
   /assets/fonts/ (see the @font-face block below), with the system stacks
   this site used to rely on kept behind them as the fallback. Nothing is
   fetched from a third party. */

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

   THE SITE IS SET IN SAN FRANCISCO, SELF-HOSTED, SINCE 2026-09-12. Until that
   day --serif was a system serif stack (Georgia and friends) and --sans a
   system sans stack, which meant the site rendered in a different typeface on
   every platform: SF on Apple hardware, Segoe on Windows, Roboto on Android.
   The product owner asked for one typeface everywhere and CONFIRMED THEY HOLD
   THE RIGHTS to this one. Three files are served from /assets/fonts/ and both
   tokens now begin with them.

   WHICH FILES, AND WHY THESE. The source archive carried four different builds
   of this typeface under four historical naming schemes. They are not
   interchangeable, and the choice was made by reading each candidate's own
   name / OS-2 / head / hhea / maxp tables rather than trusting its filename:

     SF-Pro-Text-*.otf        family "SF Pro Text", 2439 glyphs, upem 2048,
       (the "pro" set)        hhea 1950/-494/0, weights 400/500/600 correctly
                              named Regular/Medium/Semibold.        <- CHOSEN
     SFNSText-*.otf           family ".SF NS Text" — note the LEADING DOT, one
       (the "otf" set)        of Apple's hidden system faces — 1231 glyphs, and
                              DIFFERENT VERTICAL METRICS (1980/-432/0), so it
                              does not even produce the same line box.
     SFUIText-*.otf           1231 glyphs, and its weights are split into
       (the "SFUIText" set)   SEPARATE families ("SF UI  Text Med", "SF UI
                              Text SemBd", with a double space) whose
                              subfamilies read "Regular" and "Bold".
     san_francisco-webfont    the archive's only ready-made web font, and
       (.woff/.woff2)         unusable: 186 GLYPHS, one weight, FontForge's
                              FFTM/webf tables still embedded, and hhea ascent
                              4904 / descent -5410 on a 2048 upem — line boxes
                              roughly five times too tall. That is why the
                              work below converts nothing and reuses none of it.

   ONE FAMILY, NOT APPLE'S TEXT/DISPLAY OPTICAL SPLIT, AND THAT IS A DECISION
   RATHER THAN AN OVERSIGHT. Apple ships SF Pro as two optical sizes — Text
   below 20px, Display at and above it — and the more authentic thing would be
   to use both. It is not done here, for reasons specific to this stylesheet:

     1. THIS SITE'S TWO TOKENS ARE NOT A SIZE SPLIT. --serif is the heading
        voice and --sans the body voice. --serif is used at 1.125rem
        (.footer-nav__title, an h2) and at clamp(1.0625rem, 1rem + 0.5vw,
        1.3125rem) (.faq__question) as well as at 3.25rem (.cta-band__title).
        Keying Display off --serif would put the Display face at 17-18px, which
        is the one band it is expressly designed not to be used in. The token
        boundary and the optical boundary are different lines on this site.
     2. THERE IS NO CSS MECHANISM FOR THE REAL SPLIT. font-family cannot be
        conditioned on computed font-size, and most of the type here is a
        clamp() that CROSSES 20px as the viewport changes — .faq__question runs
        17px to 21px within one selector. Any per-selector assignment is
        therefore wrong at some viewport by construction, which is exactly the
        size-breakpoint mismatch such an assignment is meant to prevent.
     3. Of the two, Text is the safe face to use uniformly. Its looser fit
        costs a little at 52px; Display's tight fit costs legibility at 15px,
        and most of the type on this site is small.

     So: SF Pro Text at every size, on both tokens. If the split is ever wanted
     it needs container queries or per-element sizing, and it needs the whole
     re-verification in the plan doc run again — it is a metrics change.

   EXACTLY THREE WEIGHTS, because the stylesheet asks for exactly three: 400
   (the default, and h1-h3), 500 (.btn) and 600 ([data-placeholder]::before).
   NO ITALIC FILE IS SHIPPED: there is no font-style declaration anywhere in
   this file and no <em> or <i> in any template or content file, checked.

   ONE REQUEST THE STYLESHEET DOES NOT MAKE, AND WHAT BECOMES OF IT. The two
   legal pages use <strong>, which the UA stylesheet resolves to weight 700 —
   a requirement a grep of this file cannot see. With 400/500/600 declared, CSS
   font matching answers a 700 request with 600: for a desired weight above 500
   the ascending search runs out first and the descending one then takes the
   next face down. Verified by measurement rather than assumed — <strong> on
   the legal pages sets to exactly the same advance width as an explicit 600,
   so Chrome selects Semibold and does NOT synthesise a bolder face on top of
   it. There is no faux-bold smearing; Semibold instead of Bold is the whole of
   the difference, and it is why a fourth 334KB file is not shipped for seven
   <strong> tags. Ship SF-Pro-Text-Bold.otf at 700 if that read is ever wanted.

   OTF DIRECTLY, NOT WOFF2, AND THE REASON IS MEASURED. No font-conversion
   tooling exists on the machine this was built on, and WOFF2 needs Brotli plus
   the glyph transforms — it cannot be produced by hand. WOFF1 can be, being
   only the same sfnt tables zlib-compressed, and it was considered and
   dropped: WOFF1's compression IS zlib and so is the transport's. The three
   files are 995,536 bytes on disk and 423,062 bytes gzipped, and a hand-rolled
   WOFF1 build would land in the same place while adding a binary encoder
   nobody can re-derive. WOFF2 would be worth roughly a further third and is
   the one real optimisation left here; THE PRODUCT OWNER HAS EXPLICITLY
   DEFERRED ALL PAGE-WEIGHT WORK TO A LATER PASS, so it is recorded and not
   done. Serving the vendor's own bytes also means no conversion step exists
   that could have quietly corrupted a face.

   font-display: swap, weighed rather than defaulted to. `optional` licenses
   the browser to never use the font at all on a slow first visit, which
   defeats the whole point of the change. `block` risks invisible text. `swap`
   always paints something, and its usual cost — a visible reflow when the face
   lands — is unusually small here because the fallback is the stack below,
   which on Apple hardware already IS San Francisco.

   THE FALLBACK CHAINS ARE PREPENDED TO, NOT REPLACED. Both tokens keep the
   exact stacks they carried before, so a visitor whose browser cannot or will
   not fetch these files gets the site as it shipped the day before — including
   --serif's serif chain, which is deliberate. That state is a known-good one
   whose contrast and clearance were measured over many rounds; falling back to
   it is safer than inventing a new fallback nobody has ever checked. */

@font-face {
  font-family: "SF Pro Text";
  src: url("../fonts/SF-Pro-Text-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "SF Pro Text";
  src: url("../fonts/SF-Pro-Text-Medium.otf") format("opentype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "SF Pro Text";
  src: url("../fonts/SF-Pro-Text-Semibold.otf") format("opentype");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

/* ---------------------------------------------------------------- tokens */

/* THE PALETTE IS THE iOS APP'S OWN COLOUR SYSTEM SINCE 2026-09-13. Until that day
   these eleven tokens were an invented warm scheme — cream paper (#fbfaf8), warm
   near-black ink (#14120f), a forest-green accent (#275c4b). The product owner
   asked for the site to carry the REAL Mailcomm app colours instead, read from
   Mailcomm/System/Extensions/ColorExtension.swift and the mcSecondaryColor
   colorset.

   THE APP'S COLOURS ARE ADAPTIVE AND THIS SITE IS NOT, so each token takes the
   resolved value for the appearance its own surface already has: light-mode
   values for the light sections, dark-mode values for the sections that are
   fixed dark (.hero, .band, .closing and .site-footer — see the re-point rule
   directly under :root for why that list is four selectors and not two).
   This is not a light/dark toggle — there is no colour-scheme switch on this
   site — it is one fixed palette assembled from both halves of an adaptive one.

   EVERY VALUE BELOW WAS READ OUT OF UIKIT, NOT OFF A WEB PAGE. A probe built
   against the iPhoneSimulator 26.5 SDK resolved each UIColor with an explicit
   UITraitCollection and printed the components; that is the same runtime the app
   itself renders with, so these are what the app actually paints. THREE OF THE
   VALUES THE BRIEF SUPPLIED WERE WRONG and are corrected here — each is marked
   MEASURED below and the full table is in the plan doc. Do not "restore" any of
   them from memory or from a blog post; re-run the probe.

   --------------------------------------------------------------- light section

   Four tokens are the app's values verbatim, and they are the four that had no
   contrast problem to solve: --ink, --paper, --paper-2, --line.

   THE OTHER THREE COULD NOT BE, AND THIS IS THE ONE PLACE THE MAPPING BREAKS.
   Apple's label ladder is a DE-EMPHASIS ladder, not a contrast ladder: below
   .label it is explicitly not WCAG AA on white, and Apple has never claimed it
   is. This site's ink ladder is the opposite — it is a TEXT ladder, and --ink-2
   sets whole paragraphs (the legal pages' body copy, FAQ answers, section
   intros, the how-it-works card 02 body, the header nav links) while --ink-3
   sets the legal meta line, the contact form's labels and the 404 code. Taken
   name-for-name the mapping lands like this, measured on #FFFFFF:

     --ink-2  UIColor.gray       #808080   3.949:1   FAILS 1.4.3
              (the brief said #8E8E93, which is systemGray, not UIColor.gray —
               #8E8E93 is 3.261:1, worse)
     --ink-3  UIColor.tertiaryLabel        1.723:1   FAILS, catastrophically
              (rgb(60,60,67) at 29.804% over white = #C5C5C7. The brief's "black
               at 18% -> #D1D1D1" was two errors at once: 18% is QUATERNARY
               label's alpha, and the base is not black. #D1D1D1 measures
               1.527:1. Either way it is a separator, not a text colour.)

   So the ladder is rebuilt from the app's own material instead of imported
   whole: Apple composites secondaryLabel, tertiaryLabel and quaternaryLabel from
   ONE base colour, rgb(60,60,67), at three alphas. That base is the app's real
   light-mode label pigment, it is neutral-cool like the rest of this palette,
   and taken at alphas that clear 4.5:1 it gives a ladder that reproduces the
   OLD site's contrast positions almost exactly — so every relative-emphasis
   judgement made in all of today's earlier rounds still holds:

     token    old value / ratio        new value / ratio
     --ink    #14120f  17.924:1   ->   #000000  21.000:1
     --ink-2  #45403a   9.832:1   ->   #3C3C43  10.940:1   base at 100%
     --ink-3  #6d665d   5.428:1   ->   #6B6B70   5.300:1   base at 76%

   (Old ratios are against the old #fbfaf8 paper, new ones against #FFFFFF.)
   #6B6B70 is rgb(60,60,67) composited at 0.76 over --paper and then written out
   as a flat hex, because it must stay opaque: --ink-3 paints on --paper and on
   --paper-2, and an alpha colour would read as two different greys on the two
   fields. The lightest neutral that clears 4.5:1 on white at all is #767676, so
   there is very little room below #6B6B70 — do not lighten either of these
   without re-measuring.

   ---------------------------------------------------------------- dark section

   --dark and --dark-ink are the app's dark-mode values verbatim.

   --dark-ink-2 IS NOT THE SAME VALUE AS --ink-2, and the brief's instruction
   that it should be is the one instruction here that is arithmetically
   impossible to follow. A single non-adaptive grey has to clear 4.5:1 against
   BOTH #FFFFFF and #000000, and exactly two greys in the whole 8-bit range do:
   #757575 (4.608 / 4.558) and #767676 (4.542 / 4.623). Both are hairline passes
   with under 1.3% of margin, and this site has a standing rule against shipping
   those — see .band__overlay, where a floor was raised because 0.59 failed "by
   five thousandths, and silently". So the two roles take two values.
   --dark-ink-2 is UIColor.systemGray, which really is #8E8E93 and really is the
   same in both appearances, and it measures 6.441:1 on --dark. It was 8.978:1 on
   the old field, so it is dimmer than it was and still clear.

   ---------------------------------------------------------------- the accent

   THE BRAND ACCENT IS systemCyan, CONFIRMED BY THE PRODUCT OWNER, and it is
   #00C0E8 in light mode and #3CD3FE in dark — MEASURED. The brief's #32ADE6 /
   #64D2FF are the values systemCyan carried on older iOS; they are not what the
   app paints today.

   CYAN CANNOT BE TEXT ON WHITE, AT ANY OF THOSE VALUES. #00C0E8 measures 2.164:1
   on --paper and #32ADE6 measures 2.544:1 — both fail 4.5:1 for text and both
   fail even the 3:1 that SC 1.4.11 asks of a focus ring. That is a property of
   the hue, not a mistake: Apple tunes systemCyan to be legible AS A FILL, and
   its light-mode value is already the darker of its two.

   So the accent splits by ROLE, which is an ordinary design-system distinction
   and not an invention:

     --accent         the brand FILL. systemCyan. Read by .btn:hover, where it
                      is a large colour surface and where the brand should show.
     --accent-ink     the label on that fill. #000000, NOT the #FFFFFF the brief
                      asked for: white on #00C0E8 is 2.164:1 and fails, black is
                      9.705:1. (The brief's premise — "the app uses white labels
                      on colored system buttons" — is true of the app and is
                      exactly why the app's own buttons do not meet AA.)
     --accent-strong  the accent as TEXT or as a HAIRLINE on a light surface:
                      focus rings and the 404 links. #007EAE is the app's OWN
                      mcSecondaryColor at its light-mode value, so this is still
                      a real Mailcomm colour and still the same hue family. It
                      measures 4.570:1 on --paper, and 4.596:1 for black on it —
                      it clears 4.5:1 in both directions, which is why it can
                      serve as text and as a ring without a second value.

   In the two dark sections all three collapse toward one colour, because
   systemCyan's dark value is enormously contrasty on black (#3CD3FE is
   11.941:1); see the .hero/.closing rule directly below :root. */

:root {
  --ink:           #000000;   /* UIColor.label, light                          */
  --ink-2:         #3C3C43;   /* Apple label base rgb(60,60,67), opaque        */
  --ink-3:         #6B6B70;   /* ...the same base at 76% over --paper          */
  --paper:         #FFFFFF;   /* UIColor.systemBackground, light               */
  --paper-2:       #F2F2F7;   /* UIColor.systemGray6, light                    */
  --line:          #C6C6C8;   /* UIColor.opaqueSeparator, light                */
  --accent:        #00C0E8;   /* UIColor.systemCyan, light        — MEASURED   */
  --accent-ink:    #000000;   /* label on --accent                             */
  --accent-strong: #007EAE;   /* mcSecondaryColor, light — accent as text/line */
  --dark:          #000000;   /* UIColor.systemBackground, dark                */
  --dark-ink:      #FFFFFF;   /* UIColor.label, dark                           */
  --dark-ink-2:    #8E8E93;   /* UIColor.systemGray (same in both appearances) */

  /* Both stacks lead with the self-hosted face and keep, unchanged, the system
     stack each carried before it — see the @font-face block at the top of this
     file for why the two tokens now name the SAME family, and why the fallback
     is prepended to rather than replaced. */
  --serif: "SF Pro Text", ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
  --sans: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  --wrap: 68rem;
  --wrap-narrow: 44rem;
  --gutter: clamp(1.25rem, 4vw, 3rem);
  --rhythm: clamp(4rem, 10vw, 8.5rem);
  --radius: 0.375rem;
  /* The large-surface radius. --radius (6px at the default root) is a control
     radius tuned for 1px-line boxes and reads as no radius at all on a big
     photographic panel, so there is a second size for the two large surfaces on
     the site that need one: the sticky header's top corners, and the
     how-it-works cards (and the screenshot inside them). All three read this
     token, so "the cards match the header" is enforced by the stylesheet rather
     than by three literals agreeing with each other by luck.

     It is 32px, and it was 1.25rem (20px at the default root) until 2026-09-12,
     when the product owner asked for the cards to match the header's 32px. Note
     the unit: --radius is in rem and this is not, deliberately. Every length in
     a how-it-works card is a fixed px length on purpose (see the .steps-scroller
     block), and a rem radius here would also make the cards' drop-shadow extent
     move with the visitor's font size — the blur tail depends on the corner
     radius, measured, see the note on .steps. A fixed px radius is what makes
     that extent one number instead of a table. */
  --radius-lg: 32px;

  /* How far the sticky header's box overlaps the tail of the hero on the home
     page. One number, read in exactly two places — .hero (which grows by it, and
     insets its own content by it) and .page-home .site-header (which is pulled
     up by it) — so the two can never drift apart. See the header rule for what
     the overlap is for. */
  --mc-header-overlap: 50px;

  /* How far the closing CTA band is pulled up under the FAQ section, so that the
     FAQ's rounded bottom corners have something dark behind them to reveal. Read
     in exactly two places, both inside the .cta-band rule — the negative
     margin-top that does the pulling and the padding-top that pays for it — so
     the pull and its compensation can never drift apart and shift the band's own
     copy. See .section--faq for the whole construction.

     IT IS THE SAME 50px AS --mc-header-overlap ABOVE AND IT IS DELIBERATELY A
     SEPARATE TOKEN. The two are the same number by design intent — both seams on
     this page should read at the same depth — and not by construction: one is
     sized by the hero's fold arithmetic, the other has exactly one hard
     requirement, that it stay >= --radius-lg. Binding them would mean a future
     change to the header's rise silently moving the CTA band. This is the same
     call the three 40px insets down the page already make; see the note on
     .band.

     WHY 50 AND NOT 32 (the radius it has to clear). The overlap is invisible
     except through the corner notches: everywhere else the FAQ's opaque --paper
     background covers the band completely, so the only job this number has is to
     be at least as deep as the deepest point of the curve, which is --radius-lg
     above the FAQ's bottom edge. 32 would do it with zero tolerance, and
     --radius-lg is a moving number — it was 20px until 2026-09-12 and is shared
     with the header and the how-it-works cards. 50 leaves 18px of headroom, so a
     radius change up to 50px cannot leak page background into the curve. Extra
     depth costs nothing: it is hidden behind the FAQ. */
  --mc-faq-overlap: 50px;
}

/* THE TWO FIXED-DARK SECTIONS TAKE systemCyan's DARK-MODE VALUE. The app's
   accent is adaptive and these two surfaces are permanently dark, so they get
   the appearance-matched half of it exactly the way --dark and --dark-ink do at
   the top of the page. Using the light value here would be the same category of
   error as painting --ink on --dark.

   RE-POINTING THE TOKENS RATHER THAN LISTING SELECTORS, which is the mechanism
   .closing already uses for --dark-ink-2 (see the note in that rule) and is the
   more robust of the two: anything inside either section that reads an accent
   token is correct by construction, including the focus ring on the footer's
   email field and its submit button, which the base a:focus-visible rule reaches
   without naming. Enumerating selectors would mean any accent-coloured element
   added to the footer later silently takes the light-mode cyan on a black field.

   --accent-strong COLLAPSES ONTO --accent HERE, and that is a real outcome
   rather than laziness. --accent-strong exists only because systemCyan cannot be
   text or a hairline on WHITE; on black that constraint disappears entirely —
   #3CD3FE measures 11.941:1 on --dark, against the 4.5:1 text and 3:1 non-text
   thresholds — so the darker mcSecondaryColor has no work left to do. Keeping
   the name bound means the shared rules (a:focus-visible, .notfound__links)
   need no dark-section variant at all.

   --accent-ink stays #000000 and does not need restating: black on #3CD3FE is
   11.941:1, where white on it would be 1.759:1.

   THREE SELECTORS, AND .band IS THE THIRD ON PURPOSE. The brief named .hero and
   .closing, which are the two the reader thinks of; the lifestyle band belongs
   with them by the same test, and by this file's own test — it paints
   `background: var(--dark)`, it carries a black scrim with a 0.62 floor, and its
   copy is #ffffff over #ece8e1, exactly like the other two. It has no accent
   consumer TODAY: no link, no button, nothing focusable, checked in
   content/home/lifestyle.php. It is listed anyway precisely because that could
   change, and the whole reason this is a token re-point rather than a list of
   selectors is that anything added inside a dark section should be right by
   construction. A link dropped into the band tomorrow would otherwise take a
   focus ring measuring 2.164:1 on a black photograph, and nothing would say so.

   .step--photo is deliberately NOT here, though it also paints var(--dark). A
   card is only dark at its top: its scrim fades to bare photograph by the bottom
   edge, so there is no one appearance to match. Nothing in a card is focusable
   either — the scroller is, and .steps-scroller sits on --paper-2 and correctly
   keeps the light value.

   .site-footer IS THE FOURTH, ADDED 2026-09-13 (round 19). It was left out when
   this rule was written, on the stated grounds that "the other four routes'
   footers sit on a flat --dark field with no accent surface on them, and they
   inherit :root's light values, which they never paint". THAT PREMISE EXPIRED
   THE SAME DAY: the fix for the invisible resting button (see .btn, below) makes
   the footer's submit button an accent FILL, so on /support, /privacy-policy,
   /terms-and-conditions and the 404 the footer now paints an accent surface on
   a black field, and it would have painted the light-mode #00C0E8 there.

   That would not have failed a threshold — #00C0E8 measures 9.706:1 on --dark,
   against 11.940:1 for the dark value — but it is the category error this rule
   exists to prevent, and the focus ring in that same footer was already taking
   the light-mode --accent-strong (#007EAE, 4.596:1 on black, a pass with little
   room). Both are now the dark value and both measure 11.940:1.

   ON THE HOME PAGE THIS SELECTOR IS REDUNDANT AND HARMLESS: there the footer is
   rendered INSIDE .closing (see the note on .closing and $footerWrapClose in
   templates/layout.php), so it already inherited these values from an ancestor.
   Listing it costs one selector and removes the only surface on the site whose
   appearance depended on which route it was rendered on. */
.hero,
.band,
.closing,
.site-footer {
  --accent:        #3CD3FE;   /* UIColor.systemCyan, dark — MEASURED */
  --accent-strong: #3CD3FE;
}

/* ------------------------------------------------------------------ base */

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

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 {
  font-family: var(--serif);
  font-weight: 400;
  line-height: 1.12;
  letter-spacing: -0.015em;
  margin: 0;
  text-wrap: balance;
}

p { margin: 0 0 1.15em; }
p:last-child { margin-bottom: 0; }

a { color: inherit; }

/* --accent-strong, not --accent: a 2px ring is a non-text element and SC 1.4.11
   asks 3:1 of it against what it sits on. systemCyan cannot give that on
   --paper (#00C0E8 is 2.164:1 there), and mcSecondaryColor can (4.570:1). In
   .hero and .closing the two tokens are the same colour anyway — see the rule
   under :root — so this one declaration is correct on every surface on the
   site, including the footer's field and submit button over the photograph. */
a:focus-visible,
button:focus-visible,
summary:focus-visible,
input:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--accent-strong);
  outline-offset: 3px;
  border-radius: 2px;
}

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

ul, ol { margin: 0; padding: 0; }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--ink);
  color: var(--paper);
  padding: 0.75rem 1rem;
  z-index: 100;
}
.skip-link:focus {
  left: 0;
}

/* The home page's second skip link sits after the hero, a full viewport down the
   document. Absolute positioning would reveal it at the top of the *document*,
   so focusing it would drag the viewport back up past the hero to show it —
   the opposite of what a skip link is for. Fixed puts it at the top of the
   viewport the visitor is actually looking at, and means focusing it scrolls
   nothing. Home only; the other pages' single skip link is unchanged. */
.skip-link--nav {
  position: fixed;
}

/* ------------------------------------------------------------ utilities */

/* Present to assistive technology, absent from the page. For a heading that has
   to keep its place in the document outline and give its section an accessible
   name while the design shows no text at all. display: none and the hidden
   attribute are both wrong here — they remove the element from the
   accessibility tree as well as from the screen. */
.u-visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------- layout */

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.wrap--narrow { max-width: var(--wrap-narrow); }

.section { padding-block: var(--rhythm); }

.section__title {
  font-size: clamp(1.875rem, 1.3rem + 2.4vw, 3rem);
}

.section__intro {
  margin-top: 1.1rem;
  color: var(--ink-2);
  font-size: 1.0625rem;
}

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

.btn {
  display: inline-block;
  background: var(--ink);
  color: var(--paper);
  font: inherit;
  font-weight: 500;
  line-height: 1;
  text-decoration: none;
  padding: 0.9rem 1.5rem;
  border: 1px solid var(--ink);
  border-radius: var(--radius);
  cursor: pointer;
  transition: background-color 160ms ease, color 160ms ease;
}
.btn:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn--small { padding: 0.55rem 0.95rem; font-size: 0.875rem; }
.btn--large { padding: 1.05rem 2rem; font-size: 1.0625rem; }

/* THE RESTING BUTTON IS INVISIBLE ON A DARK FIELD. Fixed 2026-09-13 (round 19);
   open since the footer redesign of round 17, diagnosed and left standing in
   round 18 because the treatment was a design decision and that brief was a
   palette brief.

   THE BUG, EXACTLY. The base rule above paints `background: var(--ink)` with a
   1px border of the same colour, and every permanently dark surface on this site
   paints `var(--dark)`. Since the palette change both tokens are #000000, so the
   button and the field behind it are the SAME PIXEL VALUE — 1.0000:1, measured
   on the rendered page, not derived. Before the palette change it was #14120f on
   #14120f, also 1.0000:1: the palette swap neither caused this nor fixed it. The
   white label was the only thing on screen, so the control read as a line of
   text, and the only way to discover it was a button was to hover it, where
   .btn:hover's --accent finally drew a shape.

   WHERE IT BIT: the footer's "Join the waitlist" submit, on the four routes
   whose footer sits on a flat --dark field. On the home page the footer sits on
   the closing photograph instead, so the same markup was always visible there —
   which is why this shipped: the page the work is done on is the one page it
   never showed on.

   THE FIX IS THE RESTING STATE ONLY. --accent is the brand FILL (see the token
   notes above), the button is the largest accent surface on the site and the
   only primary CTA, and in these three sections --accent is systemCyan's dark
   value. Measured on the rendered page: the button is #3CD3FE against a #000000
   field, 11.940:1 of surface separation where there was 1.0000:1, and its label
   is --accent-ink #000000 on that fill, also 11.940:1. Against SC 1.4.11's 3:1
   for a control boundary and 4.5:1 for its label, with room in both.

   THE HOVER INVERTS RATHER THAN DISAPPEARING. Making the resting state --accent
   would otherwise make .btn:hover a no-op — it sets the colour the button is
   already wearing — and hover is currently the ONLY affordance these buttons
   have, so silently deleting it would trade one defect for another. The swap is
   the hero button's treatment read backwards (.hero .btn rests on --paper and
   hovers to --accent; this rests on --accent and hovers to white), so it is a
   pattern the site already has rather than a new one, and it needs no new token:
   --dark-ink on --dark is 21.000:1 and its --ink label on that fill is 21.000:1.

   .hero IS DELIBERATELY NOT IN THIS LIST although it is one of the three dark
   sections. It has had its own .btn rule since the hero was built — white fill,
   --ink label — which is a deliberate, and visible, treatment for the one button
   that sits on a photograph above the fold. It was never part of this bug, and
   adding it here would silently restyle the site's most prominent CTA under
   cover of a contrast fix. .band has no button today (checked in
   content/home/lifestyle.php) and is listed for the same reason it is listed on
   the token re-point above: a button dropped into it tomorrow should be right by
   construction rather than reproduce this exact bug a third time.

   THE LIGHT-SURFACE BUTTONS ARE UNTOUCHED, which is the other half of the fix:
   the header's "Join the waitlist" and /support's "Send message" both sit on
   light fields, keep --ink on --paper, and are reached by none of these
   selectors — 21.000:1 on --paper, 16.826:1 worst case under the frosted
   header. Scoping by dark region rather than by button is what keeps that so. */
.band .btn,
.closing .btn,
.site-footer .btn {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}
.band .btn:hover,
.closing .btn:hover,
.site-footer .btn:hover {
  background: var(--dark-ink);
  border-color: var(--dark-ink);
  color: var(--ink);
}

/* --------------------------------------------------------------- header */

.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  /* --paper at 90%, written out because a token cannot carry its own alpha.
     It tracked #fbfaf8 until the 2026-09-13 palette change and tracks #FFFFFF
     now; if --paper ever moves again, this moves with it. */
  background: rgba(255, 255, 255, 0.9);
  backdrop-filter: saturate(150%) blur(10px);
  border-bottom: 1px solid var(--line);

  /* Cosmetic only. The header already rises out from under the hero and locks
     to top: 0; rounding its top corners just softens that arrival. The radius
     clips the background and the backdrop-filter alike (both are clipped to the
     border box), so the corner notches show whatever is behind the header
     through, unblurred. It does not change the border box, so the
     --mc-header-h clearance site.js publishes is unaffected.

     --radius-lg is 32px, which is the literal this rule carried until
     2026-09-12. The value did not change; it moved into the token so that the
     how-it-works cards, which the product owner asked to match this corner, are
     held to it by the stylesheet rather than by a second literal 32. */
  border-top-left-radius: var(--radius-lg);
  border-top-right-radius: var(--radius-lg);
}

/* The rounding above only reads if something is *behind* the header. Home page
   only, the header is pulled up over the last --mc-header-overlap pixels of the
   hero, so during the rise — before it locks at top: 0 — its box sits over real
   hero photograph: the 90 %-opaque paper fill frosts it, and the two corner
   notches, which the radius carves out of that fill entirely, show it clean.
   The hero grows by the same amount (see .hero), so the header's top edge still
   starts exactly at the fold and nothing but the hero is visible on load.

   A *margin* deliberately, not a transform or a negative top: margins sit
   outside the border box, so the ResizeObserver in site.js measures the same
   height it always did and --mc-header-h — and with it the pillars' header
   clearance — is untouched. Scoped to .page-home because it is the only route
   with a hero above the header; on the other four the header is the first box
   in the body and pulling it up would drag <main> under it. */
.page-home .site-header {
  margin-top: calc(-1 * var(--mc-header-overlap));
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  min-height: 4rem;
  flex-wrap: wrap;
}

.wordmark {
  font-family: var(--serif);
  font-size: 1.25rem;
  letter-spacing: -0.01em;
  text-decoration: none;
}

.site-nav__list {
  display: flex;
  align-items: center;
  gap: clamp(0.75rem, 2.4vw, 1.75rem);
  list-style: none;
}

.site-nav__list a:not(.btn) {
  text-decoration: none;
  font-size: 0.9375rem;
  color: var(--ink-2);
  padding-block: 0.25rem;
}
.site-nav__list a:not(.btn):hover { color: var(--ink); }
.site-nav__list a[aria-current="page"] { color: var(--ink); }

/* ------------------------------------------------- narrow-screen disclosure
   The hamburger and the panel it opens. EVERYTHING THAT TURNS EITHER OF THEM ON
   LIVES IN THE max-width: 46rem BLOCK AT THE FOOT OF THIS FILE — this block only
   describes what the button looks like once something else has given it a box.
   Above the breakpoint `display: none` here is the whole story: no box, no tab
   stop, nothing in the accessibility tree, and the three rules above render the
   header exactly as they did before this existed.

   46rem is the breakpoint the header already used (it is where .site-header__inner
   drops to 3.5rem and the nav list to 0.875rem), reused rather than joined by a
   fifth number. It is a rem query like its neighbours, so it moves with the
   visitor's initial font size, which is right here for the same reason it is
   right there — a visitor at 24px root gets the phone header at 1104px, where the
   nav genuinely no longer fits. site.js matches the SAME STRING through
   matchMedia, so the script and the stylesheet cannot disagree about which mode
   the header is in, whatever the root size.

   NO COLOUR OF ITS OWN. The bars paint currentColor and the button takes the
   nav links' own --ink-2 / --ink pair, so the icon is the same weight as the
   text it replaces and a palette change reaches it without naming it. */
.site-nav__toggle {
  display: none;
  align-items: center;
  justify-content: center;
  /* 44px square: the toggle is the only way to reach the nav on a phone, so it
     is a full-size touch target rather than the 31px its bars would give it.
     It is the tallest thing in the header row at that width, so it — not
     .site-header__inner's 3.5rem min-height — sets the rendered height, which
     is 61px against the 57px an empty row would give. Stable from first paint
     (.has-js is set in the document head, before the body renders), so it is a
     height, not a shift; --mc-header-h picks it up from the real box the way it
     picks up everything else. */
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: 0;
  background: none;
  border: 0;
  color: var(--ink-2);
  cursor: pointer;
}
.site-nav__toggle:hover { color: var(--ink); }
.site-nav__toggle[aria-expanded="true"] { color: var(--ink); }

/* Three 18x2 bars 8px apart, drawn rather than typed: the "≡" character sets at
   the font's own weight and spacing and would not track the 1px hairlines the
   rest of the chrome is built from. The middle bar is the span; the other two
   are its pseudo-elements, so the button holds one child and no extra markup.
   1px of radius is --radius rounded to what a 2px bar can carry. */
.site-nav__bars,
.site-nav__bars::before,
.site-nav__bars::after {
  display: block;
  width: 1.125rem;
  height: 2px;
  background: currentColor;
  border-radius: 1px;
}
.site-nav__bars { position: relative; }
.site-nav__bars::before,
.site-nav__bars::after {
  content: "";
  position: absolute;
  left: 0;
}
.site-nav__bars::before { top: -0.375rem; }
.site-nav__bars::after  { top: 0.375rem; }

/* ----------------------------------------------------------------- hero
   The hero fills the viewport on load and is the only thing in it: the site
   header renders after it (see mc_pre_header() in src/router.php) and scrolls
   into view behind the visitor's thumb rather than sitting on top of the page.

   min-height, not height: if the copy is ever taller than a short landscape
   viewport the section grows instead of clipping. The two declarations are a
   fallback pair — a browser without dynamic viewport units drops the dvh line
   and keeps the vh one, which is the same size minus the mobile-chrome
   correction.

   The + --mc-header-overlap is the hero's half of the header overlap: the
   header is pulled up by that much (see .page-home .site-header), so the hero
   is made that much taller to pay for it. Net effect on the fold is nil — the
   header's top edge still lands exactly at 100dvh — but the last
   --mc-header-overlap pixels of the hero now have the header's box over them.

   padding-bottom of the same amount, because box-sizing is border-box: the
   min-height above already counts padding, so this does not add height a second
   time. It moves the hero's *content* box up by the overlap instead. With
   align-items: flex-end the headline and the "Join the waitlist" button sit on
   that content edge, i.e. exactly at the header's top edge, and .hero__inner's
   own padding-block (>= 3rem) then keeps them that much clear of it. Without
   this the CTA would sit ~2px under the header at 375px wide, where
   .hero__inner's padding clamps to its 3rem floor. */

.hero {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: flex-end;
  min-height: calc(100vh + var(--mc-header-overlap));
  min-height: calc(100dvh + var(--mc-header-overlap));
  padding-bottom: var(--mc-header-overlap);
  overflow: hidden;
  background: var(--dark);
}

.hero__media { position: absolute; inset: 0; z-index: -2; }

.hero__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* PURE BLACK SINCE THE 2026-09-13 PALETTE CHANGE, where it used to be
   rgba(12,11,9) — a warm near-black picked to sit with the old cream scheme.
   Three stops, three alphas, three stop positions: all unchanged. Only the
   pigment moved, and it moved for two reasons. It is now the odd one out
   otherwise: the site's three other scrims (.step--photo .step__overlay,
   .band__overlay, .closing__overlay) are already pure black and always were.
   And this section's own fallback field is background: var(--dark), which is
   #000000 now, so a warm scrim would sit over a neutral base.

   IT CANNOT COST CONTRAST, WHICH IS WHY IT NEEDED NO RE-DERIVATION. Source-over
   compositing is per channel and linear in the source colour, so replacing
   rgb(12,11,9) with rgb(0,0,0) at the SAME alpha makes every composited row
   darker by exactly 12a / 11a / 9a and never lighter. Every one of this hero's
   light-on-dark readings can therefore only improve, and the A/B was measured
   rather than left as an argument — worst (lightest) backdrop pixel under each
   element, real shipped photograph, 1440x900, copy hidden so the capture is the
   backdrop and not the glyphs:

     .hero__title    #ffffff   5.014:1 -> 5.490:1
     .hero__subhead  #ece8e1   5.006:1 -> 5.585:1
     .hero__eyebrow  #e7e3dc   3.610:1 -> 3.987:1

   THE EYEBROW STILL FAILS 4.5:1 AND THAT IS A PRE-EXISTING DEFECT, not one this
   change introduced — it was failing by more before. It is 13px, so it is normal
   text and 4.5:1 is the right threshold; it is not large-scale. Recorded here
   and in the plan doc rather than fixed, because fixing it means either moving
   the top stop (which changes the hero's exposure, a design decision) or
   lightening #e7e3dc toward #ffffff, and neither is a palette change. Note that
   the hero is the one photographic section on this site with NO worst-case-white
   guarantee: its scrim reaches only 0.38 at the top edge, so it depends on the
   photograph actually being dark. That was true before this change too. */
.hero__overlay {
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    linear-gradient(to top, rgb(0 0 0 / 0.86) 0%, rgb(0 0 0 / 0.62) 45%, rgb(0 0 0 / 0.38) 100%);
}

.hero__inner {
  padding-block: clamp(3rem, 8vw, 6rem);
  color: #ffffff;
  max-width: 46rem;
}

.hero__eyebrow {
  font-size: 0.8125rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #e7e3dc;
  margin-bottom: 1.25rem;
}

.hero__title {
  font-size: clamp(2.5rem, 1.4rem + 5vw, 4.75rem);
  color: #ffffff;
}

.hero__subhead {
  margin-top: 1.5rem;
  max-width: 34rem;
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  color: #ece8e1;
}

.hero__actions { margin: 2.25rem 0 0; }

.hero .btn {
  background: var(--paper);
  border-color: var(--paper);
  color: var(--ink);
}
.hero .btn:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }

/* -------------------------------------------------------------- pillars
   The section has two states and the static one is the base.

   Base — reduced motion, or a browser without scroll-driven animation: the
   three screens are a plain responsive grid of panels, nothing is sticky, and
   nothing is taller than its own content. The device frame is display: none,
   and being loading="lazy" inside a box with no layout it is never fetched.

   Enhanced — every rule that makes the section tall, pinned or animated lives
   in the one gated block further down this file, so the base state cannot
   inherit any of it. Note the inversion of this file's usual pattern: motion is
   normally declared and then switched off inside prefers-reduced-motion:
   reduce. Here it is opt-in, which is also what keeps the global reduce block
   (it forces animation-duration: 0.001ms !important on everything) from ever
   reaching a scroll-driven animation — the two media queries cannot both
   match. */

.section--pillars { background: var(--paper); }

.device {
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.device__screens {
  list-style: none;
  display: grid;
  gap: clamp(2.5rem, 5vw, 3.5rem);
  grid-template-columns: 1fr;
}

/* The panel the screenshots used to sit in, kept for the static state. */
.device__screen {
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}

.device__image { width: 100%; }

.device__frame { display: none; }

@media (min-width: 46rem) {
  .device__screens { grid-template-columns: repeat(3, 1fr); }
}

/* ------------------------------------------- pillars — the pinned device
   The enhancement, and the only place in this file that changes the pillars
   section's height, position or transform. Gated twice: the visitor has not
   asked for less motion, and the browser has scroll-driven animation. Failing
   either one leaves the base state above, which is a designed layout, not a
   broken one.

   The mechanic: .pillars-scroller is a tall block that names a view-progress
   timeline; .pillars-stage is one viewport tall and sticky, so it is pinned for
   exactly (scroller height - stage height) = 5.5 frame-heights of scrolling.
   The "contain" range of a view timeline on a subject taller than the
   scrollport is that same interval by definition, so the animation window and
   the pin window are the same window by construction rather than by arithmetic
   that has to be kept in agreement. The animation itself is entirely CSS, and
   nothing listens to scroll or captures it: sticky holds an element, it does not
   hijack the wheel.

   One number is measured in JavaScript and one only: --mc-header-h, the sticky
   header's rendered height, written to :root by a ResizeObserver in site.js.
   It is a refinement, never a dependency — with site.js blocked the 9rem
   default below applies and the section still works. See the --mc-frame-h
   comment for why the measurement is not a constant in this file. */

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {

    .pillars-scroller {
      /* The frame's rendered height is the section's one dimension — every
         distance below is a multiple of it, so the interaction costs the same
         proportion of a screen at 375px as at 1440px. 151.2vw = 78vw x
         1746/901, which caps the frame at 78vw (11vw of margin each side) once
         the viewport is narrower than it is tall.

         The third term is the header clearance, stated geometrically: the frame
         is centred in the stage, so half of (100svh - frame height) sits above
         it, and requiring that to be at least --mc-header-h + 4px is exactly
         frame height <= 100svh - 2 x --mc-header-h - 8px. Written as a min()
         term rather than a separate rule it binds only where it must — the
         computed height is unchanged at 1440x800, 768x900, 430x932, 393x852 and
         375x812, and takes over only on the short phones where 72svh would have
         slid the top of the device under the header.

         --mc-header-h is deliberately NOT a number kept in this file. site.js
         observes .site-header's border box with a ResizeObserver and writes the
         rendered height to :root, so the clearance is derived from what actually
         rendered — at any width, at any root font size, with any nav content, in
         any font stack. Four constants and three width breakpoints lived here
         until 2026-09-11 and went stale twice (the nav-wrap boundary, then the
         root font size); there is nothing left in CSS to re-measure. The history
         is in the plan doc, not here.

         9rem is the no-JS default, and the only number about the header left on
         the site: one deliberately generous value, comfortably above the tallest
         header this layout has ever produced, in rem so it scales with the same
         root font size the header does. It has no breakpoints, it is never the
         normal path, and it can only shrink the frame — with site.js blocked the
         phone is smaller than it needs to be on short viewports and nothing
         overlaps.

         svh, not vh and not dvh: dvh changes as a mobile URL bar collapses,
         which would re-time the pin *during* the interaction. The vh line is
         this file's usual fallback pair, and inside a custom property it is
         inert — custom property values are not unit-checked at parse time, so
         the svh line always wins. It is kept for consistency only; every
         browser that satisfies the @supports condition above shipped svh
         years before it shipped animation-timeline. */
      --mc-frame-h: min(72vh, 151.2vw, calc(100vh - 2 * var(--mc-header-h, 9rem) - 8px));
      --mc-frame-h: min(72svh, 151.2vw, calc(100svh - 2 * var(--mc-header-h, 9rem) - 8px));

      /* Three holds of 1.5 frame-heights plus two passes of 0.5.
         TWELVE keyframe stop percentages below are DERIVED from this number
         (stop% = cumulative distance / units) and cannot be computed from it in
         CSS. Retuning is therefore a two-place edit: change this, then redo the
         stops by hand. At 5.5 the four that bound a pass are
             1.5/5.5 = 27.27%   2.0/5.5 = 36.36%
             3.5/5.5 = 63.64%   4.0/5.5 = 72.73%
         and since 2026-09-19 eight more, the black overlay's four ramp ends and
         the four pass bounds restated to four decimals, which is what its hold
         is bounded by (see .device__screens::after, which carries the
         derivation; these values were retuned the same day when the effect
         changed from a brief pulse to a full-pass hold):
             1.4/5.5 = 25.4545%   1.5/5.5 = 27.2727%
             2.0/5.5 = 36.3636%   2.1/5.5 = 38.1818%
             3.4/5.5 = 61.8182%   3.5/5.5 = 63.6364%
             4.0/5.5 = 72.7273%   4.1/5.5 = 74.5455%
         Twelve derived stop STRINGS from eight distinct cumulative distances:
         four of the overlay's eight are the same distances as the pass bounds,
         written more precisely because they bound a hold rather than a pass.
         3.5 (1x holds) is the obvious alternative if the section reads long.

         The keyframes carry four further stops — 26.36%, 37.27%, 62.73%,
         73.64% — for the parked 2px overshoot (see .device__screen). Those are
         the only ones NOT derived from this number: each merely has to sit
         outside the pass it borders. So retuning means redoing TWELVE derived
         stops and then checking those four are still clear of the recomputed
         pass bounds, not recomputing sixteen. */
      --mc-pillars-units: 5.5;

      height: calc(100vh + var(--mc-pillars-units) * var(--mc-frame-h));
      height: calc(100svh + var(--mc-pillars-units) * var(--mc-frame-h));

      view-timeline-name: --mc-pillars;
      view-timeline-axis: block;
    }

    .pillars-stage {
      position: sticky;
      top: 0;
      height: 100vh;
      height: 100svh;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .device {
      position: relative;
      height: var(--mc-frame-h);
      aspect-ratio: 901 / 1746;
      max-width: none;
      padding-inline: 0;
    }

    /* The black backdrop behind the window, and the reason it exists.

       The frame's real transparent hole is fractionally LARGER than the window
       below. Measured on the live art (iphone-frame-2.png) the cutout is
       728 x 1632 at insets 58 / 86 / 56 / 87, where .device__screens is still
       positioned from the committed frame's 59 / 89 / 58 / 88 — rounds 29 and 30
       of the plan doc, which also proved on the render that the ~1px white line
       inside the bottom and right of the screen is the page's own background
       seen through that 1-3px difference, not anything in the art.

       This closes it without moving the window: a solid layer sized to the hole
       the art actually cuts, painted underneath the screens, so whatever the
       window does not reach reads as bezel instead of as page. The window's own
       insets, radius and overflow are deliberately untouched — the pin geometry,
       the 9-point animation baseline and the radius calc are all measured
       against that box, and re-deriving it would invalidate all three.

       Black is the bezel's own colour, not an approximation: the fully opaque
       frame pixels within 6px outside the cutout, across the middle 50 % of all
       four straight edges (14,184 of them), have median rgb(0,0,0) and mean
       rgb(1.7,1.6,1.5), and rgb(0,0,0) is the commonest value in the innermost
       ring. The backdrop meets the bezel with no step.

       The - 1px on each inset is NOT slop, and the percentages alone do not
       work. Chrome snaps a painted box to whole CSS pixels, and the overlap
       being closed is a third to three quarters of one — so the snap can eat all
       of it, and which way it lands depends on where the frame happens to sit.
       Measured: on the percentages alone the top edge kept its light pixel at
       1440x900 and the bottom edge kept three white ones at 375x812, because the
       box snapped inward at exactly those edges. One whole pixel of over-reach
       puts the snapped edge at least half a pixel outside the hole at every
       viewport, which is what makes this a fix rather than a coincidence. It is
       spent on bezel that is 19–32 CSS px thick, and the bezel is opaque, so it
       costs nothing visible.

       The radius is NOT the window's. Two constraints bound it, both counted
       pixel by pixel against the art: too small and the corners spill past the
       phone's own rounded silhouette onto the page (the cutout's bounding-box
       corners are OUTSIDE the body — a square backdrop paints 265 page pixels
       black); too large and it stops covering the hole's corners, which are a
       squircle and reach further out than any circle through the same points.
       For the box as declared the band is 26–76 frame px and 51 is its centre;
       re-counted with the 1px over-reach at every frame height this layout can
       produce (frames of 388px to 1150px, so 1.5 to 4.5 frame px of growth), 51
       still covers the hole completely and still paints nothing outside the
       body. Expressed against the frame height like the window's radius so it
       scales with the frame instead of distorting.

       ::before, not an element: it is decoration with no content and nothing to
       announce, it cannot leak into the reduced-motion state because the rule
       lives only inside this gate, and being first in tree order it paints under
       both the screens and the frame without a z-index of its own. That is still
       true after 2026-09-16: the only z-index in this section sits on the three
       screens and is sealed inside .device__screens by isolation: isolate, so
       backdrop / screens / frame out here is decided by tree order alone. */
    .device::before {
      content: "";
      position: absolute;
      top: calc(3.3219% - 1px);      /* 58px / 1746 */
      right: calc(9.5450% - 1px);    /* 86px / 901  */
      bottom: calc(3.2073% - 1px);   /* 56px / 1746 */
      left: calc(9.6559% - 1px);     /* 87px / 901  */
      border-radius: calc(var(--mc-frame-h) * 0.02921);  /* 51px / 1746 */
      background: #000;
    }

    /* The clipped window over the frame's screen cutout. Insets and radius are
       measured from iphone-frame.png, not eyeballed — see "The pinned pillars
       section" in README.md for the method and the pixel figures. The 1-3px by
       which they under-reach the live art's hole is covered by .device::before
       above, deliberately rather than by changing these four numbers. */
    .device__screens {
      display: block;
      position: absolute;
      top: 3.3792%;      /* 59px  / 1746 */
      right: 9.8779%;    /* 89px  / 901  */
      bottom: 3.3219%;   /* 58px  / 1746 */
      left: 9.7669%;     /* 88px  / 901  */
      gap: 0;
      overflow: hidden;
      /* 96px / 1746 — expressed against the frame height so the radius scales
         with the frame instead of distorting the way a percentage would. */
      border-radius: calc(var(--mc-frame-h) * 0.05498);

      /* Seals the screens' z-index inside this box, and it is load-bearing, not
         tidiness. position: absolute with z-index: auto is NOT a stacking
         context, and neither is .device (position: relative, no z-index), so
         without this the screens' positive z-index would sort them in the
         nearest real stacking context above — .pillars-stage, sticky, and
         sticky always makes one — where they would meet .device__frame, which
         has no z-index and would lose to any positive value. The bezel and the
         Dynamic Island would stop occluding the screenshots: the whole point of
         the frame. Isolated, the sort below is settled in here and this box
         itself keeps z-index: auto, so it stays in the same tree-order pass it
         has always been in. overflow: hidden clips the screens but does not
         stack them; only this does. */
      isolation: isolate;
    }

    .device__screen {
      position: absolute;
      inset: 0;
      background: none;
      border: 0;
      border-radius: 0;
      animation-timeline: --mc-pillars;
      animation-range: contain 0% contain 100%;
      animation-fill-mode: both;
      animation-timing-function: linear;
    }

    /* DESCENDING z-index — the earlier screen stays on top of the later one.
       This is a fix, not a precaution (2026-09-16): through the 1 -> 2 pass the
       incoming screenshot visibly covered the bottom edge of the one it was
       replacing, and 2 -> 3 is the same mechanism with the same fault latent in
       it.

       The keyframes are exact and were not the bug. Through 27.27% -> 36.36%
       screen 1 runs translateY(0) -> -100% while screen 2 runs 100% -> 0, both
       linear across the identical range, so screen 1's bottom edge and screen
       2's top edge are arithmetically the same edge at every instant. But they
       are two independently computed scroll-driven animations on two separate
       elements, each rasterised to whole device pixels, and nothing in CSS makes
       two of those agree frame for frame — a frame lands a fraction out of step
       and the boxes overlap by up to a pixel. So this does not chase sync, which
       is not winnable; it decides what wins when sync is lost.

       With no z-index the painting order is plain tree order, so the incoming
       screen — always the later sibling — always paints over the outgoing one,
       which is precisely the direction that shows: a slice of the next
       screenshot cutting across the bottom of the current one. Reversed, the
       disputed strip shows the OUTGOING screenshot instead, contiguous with the
       rest of itself, and the overlap becomes invisible by construction however
       far out of step the two animations drift.

       The opposite jitter — a sub-pixel gap rather than an overlap — is not
       something paint order can address in either direction. It exposes
       .device::before, which is black. That reads as invisible today only
       because the current screenshots (content/home/pillars.php) are
       themselves dark/near-black at their edges — the black sliver blends
       into the black content next to it, not because .device::before's own
       colour is inherently safe. A future screenshot with a light edge (see
       "Replacing the pillar screenshots" in this file) would turn the same
       gap into a visible pale-on-black seam. Fixing it would mean
       perturbing the measured cutout geometry (see .device__screens), which
       is out of scope here; flagging it as the thing to re-check if the
       screenshots are ever replaced with lighter artwork.

       At rest this sorts nothing: outside a transition exactly one screen is in
       the window and the others are translated out of it and clipped, so there
       is no overlap to resolve. The values are scoped by .device__screens'
       isolation: isolate — see there for why that is required.

       THE PARKED SCREENS ARE NOT THE SAME BOUNDARY, and until 2026-09-19 this
       comment claimed they were safe ("translated FULLY out of it") when they
       were not. A screen parked at exactly translateY(+/-100%) has its own edge
       sitting exactly on the clip edge, at zero tolerance. --mc-frame-h is a
       min() of viewport units, so the window's height is almost always
       fractional; the parked element's transform-derived position and
       .device__screens' clip rect are snapped to device pixels INDEPENDENTLY,
       and a sub-pixel disagreement puts up to one device pixel of the parked
       screenshot inside the clip. Same class of fault as the seam above —
       nothing in CSS makes two of those agree — applied to the clip boundary
       rather than to the seam, and reproduced by hand in Safari on both
       production and a local server.

       Paint order decides which of the two parked leaks is visible, so the
       2026-09-16 fix above did not fail; it swapped them. Tree order let the
       later sibling cover a leak above the window and expose one below it (the
       next screenshot at the BOTTOM edge); descending z-index covers the one
       below and exposes the one above (the previous screenshot at the TOP
       edge, from 36.36% onward, doubled from 72.73%). Which is exactly what was
       reported. Neither ordering closes both, because the fault is not an
       ordering fault.

       The keyframes therefore park each off-window screen 2px PAST the clip
       edge — calc(100% + 2px) below, calc(-100% - 2px) above — so the snapped
       edge lands outside the window whichever way each of the two independent
       roundings goes. One whole pixel of over-reach would already do it; 2px is
       the same argument .device::before makes above, with twice the margin —
       that rule spends 1px, see its calc(3.3219% - 1px) insets. The overshoot
       is never visible motion: an extra stop 0.91 points outside each pass
       converges the screen back to exactly +/-100% at the instant the pass
       begins or ends, so the 2px of travel happens only while the screen is
       already wholly outside the window, and the pass interval itself is
       unchanged.

       THE RESIDUAL, STATED HONESTLY (corrected 2026-09-19, same day, second
       pass). The tolerance is exactly zero AT each of the four pass boundaries
       — by construction, since the pass intervals had to stay byte-for-byte
       unchanged — and ramps to 2px across the 0.91 points on the outside of
       each. The first version of this comment claimed the descending z-index
       above arbitrates that instant away at every boundary. It does not. It
       arbitrates two of the four, and at the other two it is what EXPOSES the
       leak:

         27.27%  screen 2 sits at exactly +100%, parked BELOW the window.
                 Screen 1 holds the window at z-index 3 and paints over screen
                 2's z-index 2 — covered.
         36.36%  screen 1 sits at exactly -100%, parked ABOVE the window. It
                 keeps z-index 3 over the incoming screen 2's z-index 2, so the
                 leaking screen paints on top — EXPOSED, top edge.
         63.64%  screen 3 sits at exactly +100%, parked BELOW the window.
                 Screen 2 (z-index 2) covers screen 3 (z-index 1) — covered.
         72.73%  screen 2 sits at exactly -100%, parked ABOVE the window. It
                 keeps z-index 2 over screen 3's z-index 1 — EXPOSED, top edge.

       Both exposed boundaries are the TOP edge, and they are the tails of
       exactly the two transitions the product owner reported (MANAGED -> LISTS
       and LISTS -> SWIPE TRIAGE). That is not an argument against the z-index:
       the seam it protects is disputed for the whole of a pass, this is
       disputed only at an instant. It is an argument for stating what is left.

       What is left is narrowed, not closed. Past each exposed boundary the
       overshoot grows linearly, so it is under the ~0.5 CSS px worst-case snap
       error only for the first quarter of the converging interval — 0.2275
       points. The timeline's contain range is 5.5 frame-heights of scroll (see
       --mc-pillars-units), so one point is 0.055 x --mc-frame-h: that quarter
       is about 7 CSS px of scrolling (7.2px at 1440x800 where the frame is
       576px, and 5.4-7.2px across the four reference viewports). Before this
       fix a parked screen sat at zero tolerance for its ENTIRE park — 63.64
       points from 36.36%, 27.27 points from 72.73% — so the exposed scroll
       range is down by roughly 280x and 120x respectively. Two orders of
       magnitude is a real fix and not a complete one; the remainder is
       recorded in .claude/steering/open-items.md as an accepted trade-off of
       the requirement that the pass endpoints stay exactly +/-100%. */
    .device__screen:nth-child(1) { animation-name: mc-pillars-screen-1; z-index: 3; }
    .device__screen:nth-child(2) { animation-name: mc-pillars-screen-2; z-index: 2; }
    .device__screen:nth-child(3) { animation-name: mc-pillars-screen-3; z-index: 1; }

    /* cover, not contain: since 2026-09-15 the screens are real screenshots at
       724 x 1570 (0.4611) against a 724 x 1629 4:9 cutout, so the file is
       scaled UP 1.0376x to the cutout's height and about 1.8% is cropped from
       each side. The 59px the file is short vertically therefore never shows
       as a gap — cover always fills the box. object-position: top matters for
       the other case — a file narrower than 4:9 crops the bottom, and the top
       of a phone screen is the part that has to survive. Neither the crop nor
       the ratio is a layout input: this box is the cutout's, whatever fills
       it. See "Replacing the pillar screenshots" in README.md. */
    .device__image {
      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: top;
    }

    /* Last in source order and painted over the window, so the opaque bezel and
       the Dynamic Island occlude the screenshots. */
    .device__frame {
      display: block;
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
    }

    /* THE TRANSITION BLACKOUT (2026-09-19). A black scrim over the window that
       covers the WHOLE of each pass: it ramps 0 -> 1 over 0.1 H BEFORE the
       incoming screen starts to move, holds at opacity 1 for the entire 0.5 H
       the two screens are travelling, then ramps 1 -> 0 over 0.1 H AFTER the
       incoming screen has come to rest. Two blackouts in the whole section, one
       per transition. Nothing is visible inside the cutout while anything is
       moving; the three holds play in the clear.

       SUPERSEDED RATIONALE, recorded because this rule reversed direction once
       and the old reasoning argued for a placement this shape does not have.
       Until 2026-09-19 this was a BRIEF PULSE — a 0.25 H trapezoid centred on
       each pass END boundary, leaving three quarters of every pass in the clear
       on the argument that #pillars carries no copy, so the slide is the only
       thing that says "three views of one app on one device". The product owner
       tested that live and rejected it: a pulse centred on a boundary spends
       most of its width in the holds either side, so the flash read as landing
       on a screen that was sitting still rather than on the screen that was
       moving. He specified this shape directly, and confirmed the consequence
       when it was put to him plainly: the slide motion is no longer visible
       during either pass at all, and he wants full black anyway, "to block the
       scrolling underneath". That is a decision, not an oversight. Every
       superseded figure below is marked where it still appears.

       The @keyframes is still named mc-pillars-flash. The name is now a
       misnomer — 0.5 H of solid black is a hold, not a flash — and is kept
       deliberately: renaming it would touch README, the plan doc, two steering
       entries and a verification step that all name it, for no behavioural gain.

       ::after, NOT a fourth <li>. .device__screens is a <ul> whose three
       children are announced as a list of three images, and a fourth element
       would change that count for a screen reader, for decoration with nothing
       to announce. A pseudo-element is not in the accessibility tree, is not
       focusable, and — because content: "" is declared HERE, inside the gate,
       and nowhere else — does not exist at all in the base state, where
       .device__screens is a grid and a fourth item would add a phantom column
       at >= 46rem.

       z-index: 4 — above the screens' 3/2/1 and still BELOW .device__frame.
       Not a contradiction: the sort happens inside .device__screens'
       isolation: isolate (see there), so 4 only outranks the three screens, in
       this box. Out in .device, the window and the frame are sorted by tree
       order alone and the frame is last, so the bezel and the Dynamic Island
       keep occluding the flash — the phone reads as a phone with a dark screen,
       not as a black rectangle. Hanging this rule off .device instead would
       promote it into .pillars-stage's stacking context and black out the bezel:
       the same trap the .device__screens comment describes for the screens.

       #000 is the literal .device::before uses, so at full opacity the window
       and the 1-3px ring of backdrop around it read as one unbroken black.
       opacity: 0 is declared on the rule as well as at both ends of the
       keyframes, so a browser that satisfies the @supports condition but never
       starts the animation shows nothing rather than a permanently black phone.

       WHY THE WHOLE PASS, AND WHY THE BOUNDARY QUESTION IS MOOT. The scrim
       exists to block what is moving underneath it, so it is on for exactly as
       long as something is moving and off whenever every screen is at rest.
       That settles by construction the question the old pulse had to answer —
       which of the four pass boundaries to sit on. All four are inside a
       covered window now: the two the descending z-index arbitrates (27.27%,
       63.64%, the pass starts, "already safe" under the old logic and therefore
       deliberately left uncovered) and the two it EXPOSES (36.36%, 72.73%, the
       pass ends; the per-boundary table on .device__screen above). There is
       nothing left to choose, and the whole rejected-alternatives argument the
       pulse carried here — pass start, pass midpoint, two pulses per pass — is
       gone with it rather than left standing against a shape it does not
       describe.

       IT MASKS THAT RESIDUAL; IT DOES NOT FIX IT, and it makes neither earlier
       fix removable. Both exposed boundaries sit at opacity 1, and that is
       where the residual is at its WORST — the 2px park overshoot is exactly
       zero AT the boundary. What changed against the old pulse is only the
       tail: the overshoot stays under the ~0.5px snap error for 0.2275 points
       past each boundary, and those 0.2275 points now fall inside the ramp
       DOWN, across which the scrim falls linearly 1 -> 0.8749 while the
       overshoot grows 0 -> ~0.5px. The old pulse held a flat 1 there. So this
       shape covers the residual's worst instant identically, its converging
       tail slightly less (>= 0.875 rather than 1, at positions where the leak
       is already sub-half-pixel and shrinking), and every other position in the
       section vastly more. At the park stops (37.27%, 73.64%), where the
       overshoot has fully converged and no leak is geometrically possible at
       all, the scrim is at 0.5 — it was 0.75 under the pulse, and it is
       immaterial at either figure.

       Neither earlier fix becomes removable on the strength of this. The park
       overshoot protects a parked screen for the WHOLE of its park, and this
       is at opacity 0 across most but nowhere near all of that, by a figure
       that differs per park: of screen 2's 27.27 points from 72.73% only
       1.8182 are covered (72.7273 -> 74.5455), but of screen 1's 63.64 points
       from 36.36% a full 14.5455 are — 1.8182 at 36.3636 -> 38.1818, plus the
       entire 12.7273-point pass-2 blackout (61.8182 -> 74.5455), which screen
       1 spends parked while screens 2 and 3 take the window. Do not carry one
       number across both parks. The descending z-index arbitrates the
       mid-window seam through the whole pass and is what keeps the outgoing
       screen on top if this overlay is ever removed, reduced or fails to start.
       All three mechanisms pair; none replaces another. The practical cost is
       the same one the pulse had and is now larger: this conceals the one
       defect class the section's manual check exists to catch, across the whole
       of both passes rather than just their tails, so that check must be run
       with this rule's ANIMATION-NAME disabled. Disabling the rule's own
       opacity declaration does nothing — animation-fill-mode: both means the
       keyframes supply opacity at every position on the timeline and override
       it. README's pinned-pillars verification list carries the working step.

       THE STOPS, derived with the section's own rule: stop % = cumulative
       distance / --mc-pillars-units, at 5.5. Each blackout is a symmetric
       trapezoid spanning a whole pass — 0 -> 1 over 0.1 H ending as the pass
       begins, 1 through the pass's 0.5 H, 1 -> 0 over 0.1 H beginning as the
       pass ends. 0.7 H per blackout, 0.5 H of it solid.

           1.4/5.5 = 25.4545%   pass 1 ramp up begins, opacity 0
           1.5/5.5 = 27.2727%   pass 1 begins, opacity 1
           2.0/5.5 = 36.3636%   pass 1 ends, opacity 1
           2.1/5.5 = 38.1818%   pass 1 ramp down ends, opacity 0
           3.4/5.5 = 61.8182%   pass 2 ramp up begins
           3.5/5.5 = 63.6364%   pass 2 begins
           4.0/5.5 = 72.7273%   pass 2 ends
           4.1/5.5 = 74.5455%   pass 2 ramp down ends

       At 1440x800 (H = 576) that is a 57.6px ramp, 288px solid, 403.2px per
       blackout, out of a 3168px timeline; 56.7/283.5/396.9px at 375x812 and
       43.5/217.5/304.5px at 375x667. Across the whole section, 25.45% of the
       timeline carries any darkening and 18.18% is solid black — up from 9.09%
       and 1.82% under the pulse, and back to what the feature's first draft
       specified before the pulse narrowed it.

       Multiples of H, not a literal 50px, for the reason every other distance
       in this section is: it must cost the same fraction of a screen at 375px
       as at 1440px. It also could not be written any other way — a
       <keyframe-selector> takes a percentage, never a calc(), and the timeline
       spans 5.5 x --mc-frame-h of scroll, which is a different pixel count at
       every viewport, so no percentage expresses a fixed pixel ramp.

       Four decimals where the screens' four pass stops are written to two, and
       four of the eight above are the SAME cumulative distances (1.5, 2.0, 3.5,
       4.0 H) written more precisely. Deliberate, and not an inconsistency to
       tidy in either direction: the two rules are different @keyframes on
       different elements and nothing requires them to agree. The gap is 0.0027
       points, about 0.085 CSS px of scroll at 1440x800. It has an effect at
       exactly two of the four boundaries, where rounding puts the screens'
       written edge a hair outside this hold — at 27.27% the scrim is at 0.9985
       rather than 1 as screen 2 starts to move, and at 72.73% it is at 0.9985
       as screen 2 finishes leaving. 0.15% of transparency across 0.085px of
       scroll is below anything that can render. Rounding these two stops
       towards the hold's interior instead would close even that, at the cost of
       two stops that are no longer the plain cumulative/units every other
       derived stop in the section is; the derivation was kept.

       These stops share the timeline with the screens' own but live in a
       DIFFERENT @keyframes rule on a DIFFERENT element, so a stop of one landing
       inside a ramp of the other — the park stops at 37.27% and 73.64% inside
       this blackout's trailing ramp — is an overlap, not a collision: there is
       nothing to keep in agreement and nothing to maintain.

       No will-change. Three frame-sized screens plus a permanently promoted
       fourth layer is exactly the cost Fix C of the 2026-09-19 bug plan
       declined, and an opacity animation does not need it. */
    .device__screens::after {
      content: "";
      position: absolute;
      inset: 0;
      z-index: 4;
      background: #000;
      opacity: 0;
      animation-name: mc-pillars-flash;
      animation-timeline: --mc-pillars;
      animation-range: contain 0% contain 100%;
      animation-fill-mode: both;
      animation-timing-function: linear;
    }

    /* Y axis only — no SCREEN fades, scales, blurs or changes colour; the one
       opacity animation in the section is the black flash's, on
       .device__screens::after, and no screenshot is ever anything but
       translated. Screen 1 is already at rest when the stage pins, so the frame
       is never empty on arrival.

       The four stops that bound a pass — 27.27%, 36.36%, 63.64%, 72.73% — are
       derived from --mc-pillars-units and are UNCHANGED; every pass interval is
       byte for byte what it was. The extra stops at 26.36% / 37.27% / 62.73% /
       73.64% carry the 2px park overshoot described on .device__screen above,
       and are not derived from anything: each is 0.91 points clear of the pass
       it borders (0.05 frame-heights of scroll, chosen to be comfortably outside
       it), and they do not have to be recomputed if --mc-pillars-units changes —
       only kept clear of the recomputed pass bounds. calc(100% + 2px) is the
       parked value below the window, calc(-100% - 2px) the one above it; plain
       +/-100% survives only at the instant a pass starts or ends. */
    @keyframes mc-pillars-screen-1 {
      0%      { transform: translateY(0); }
      27.27%  { transform: translateY(0); }
      36.36%  { transform: translateY(-100%); }
      37.27%  { transform: translateY(calc(-100% - 2px)); }
      100%    { transform: translateY(calc(-100% - 2px)); }
    }

    @keyframes mc-pillars-screen-2 {
      0%      { transform: translateY(calc(100% + 2px)); }
      26.36%  { transform: translateY(calc(100% + 2px)); }
      27.27%  { transform: translateY(100%); }
      36.36%  { transform: translateY(0); }
      63.64%  { transform: translateY(0); }
      72.73%  { transform: translateY(-100%); }
      73.64%  { transform: translateY(calc(-100% - 2px)); }
      100%    { transform: translateY(calc(-100% - 2px)); }
    }

    @keyframes mc-pillars-screen-3 {
      0%      { transform: translateY(calc(100% + 2px)); }
      62.73%  { transform: translateY(calc(100% + 2px)); }
      63.64%  { transform: translateY(100%); }
      72.73%  { transform: translateY(0); }
      100%    { transform: translateY(0); }
    }

    /* Two symmetric trapezoids, each spanning the whole of one pass plus a
       0.1 H ramp at either end. Derivation, placement, and the 2026-09-19
       reversal from the brief centred pulse this rule used to carry, are on
       .device__screens::after above. The explicit 0% and 100% stops are
       redundant against the ramps — the overlay is already at 0 from 0% to
       25.4545%, from 38.1818% to 61.8182% and from 74.5455% to 100% — and are
       written anyway so the rule reads as a closed interval and a stop appended
       later cannot silently extend an implicit hold to the end of the section. */
    @keyframes mc-pillars-flash {
      0%        { opacity: 0; }
      25.4545%  { opacity: 0; }
      27.2727%  { opacity: 1; }
      36.3636%  { opacity: 1; }
      38.1818%  { opacity: 0; }
      61.8182%  { opacity: 0; }
      63.6364%  { opacity: 1; }
      72.7273%  { opacity: 1; }
      74.5455%  { opacity: 0; }
      100%      { opacity: 0; }
    }
  }
}

/* --------------------------------------------------------- how it works */

.section--how {
  background: var(--paper-2);

  /* The header nav links to /#how-it-works, so this section is a jump target,
     and a jump target under a sticky header needs its own clearance: the
     browser puts the target's border-box top at the top of the scrollport,
     where the header is already sitting. The section used to get that clearance
     by accident, from the visible .section__head above the cards; when the
     heading became visually hidden the head box went with it, and at 375px —
     where the nav wraps and the header is at its tallest, while --rhythm is at
     its 4rem floor — the first card landed partly behind the header.

     On the section, not on the visually hidden <h2>: scroll-margin applies to
     the element being scrolled into view, and that is #how-it-works itself. The
     <h2> carries #how-title, which nothing links to, and it is position:
     absolute and 1px tall besides — scroll-margin there would do nothing.

     The same measured header height the pillars use, with the same 9rem no-JS
     default. Reusing the variable rather than approximating it is worth it
     because the header's height genuinely varies (nav wrap, root font size),
     but the precision matters far less here than it does for the pillars'
     frame: this only decides where a jump lands, and the section's own --rhythm
     top padding sits below it as breathing room. Erring high costs nothing but
     blank paper above the cards, which is why the generous fallback is right in
     this direction too. */
  scroll-margin-top: var(--mc-header-h, 9rem);
}

/* The four steps are a horizontally scrolling row of four cards, and the whole
   mechanic is CSS: overflow-x on the wrapper, scroll-snap on it and on the
   cards. Nothing here is driven, timed or stepped by site.js.

   THREE FIXED CARD SIZES, ONE PER BAND (2026-09-12, round 8). The card is still
   a fixed pixel box — no aspect-ratio, no min-height, no clamp(), nothing fluid
   — and every length inside it is still a fixed pixel length. There are simply
   three constants now instead of one, selected by the two media queries at the
   foot of this block:

     BAND A  < 736px      272 x 672   padding 32 / 24 / 224   box 224 x 416, y 32...448
     BAND B  736-1087px   624 x 640   padding 32 / 32 / 288   box 560 x 320, y 32...352
     BAND C  >= 1088px    764 x 695   padding 32 / 32 / 312   box 700 x 351, y 32...383

   (Card 02 carries a 1px border, being the one card with no photograph behind
   it, so its own boxes are 2px narrower and shorter than the table says and its
   figure reserve is different again — see .step--figure.)

   A percentage, a vw or a rem anywhere in the card's geometry would move one
   edge of the text zone while the box around it stayed put, which is the single
   root cause of the two defects this design replaces: text landing in the thin
   end of the scrim, and text landing on the screenshot. The type is the one
   deliberate exception — it stays in rem so it still answers the visitor's own
   font-size setting. Band A steps that type down one notch (25/20px -> 20/16px
   at the default root, still in rem); see .step__title.

   WHY THREE SIZES AND NOT ONE SCALED. The card's height is set by the type, and
   the type does not shrink with the card: a narrower card needs MORE height for
   the same copy, not less. Card 04's 129-character body sets in 3 lines on Band
   C's 700px measure and 7 lines on Band A's 224px measure. 764 x 695 scaled
   proportionally to 272 wide would be 247 tall and would clip half its copy.

   WHY THE BREAKPOINTS ARE IN px AND MUST STAY THERE. 736px is 46rem and 1088px
   is 68rem (--wrap) at the default root — both are the site's own numbers — but
   they are written as px on purpose. A media query evaluates relative units
   against the INITIAL font size, not the page's, so `min-width: 46rem` is 736px
   for a visitor at the default setting and 1104px for a visitor on Chrome's
   "Very Large" (24px) setting: a rem breakpoint would hand a 1000px desktop
   window the PHONE card purely because the visitor enlarged their text, and
   would put a rem back into the row's width chain, which round 5 measured as
   finally gone. The site's other two 46rem queries stay rem: a min-width one
   gating the pillars' device-screens grid column count, and a max-width one
   gating the footer's column count, the header's min-height and the nav list's
   font size. Moving with the visitor's font size is harmless or right in both.
   (The nav list's GAP is a 30rem query, not a 46rem one.) Card geometry is the
   one place a rem gate is wrong. Container queries were ruled out: they would
   need container-type back on .steps-scroller, which the 2026-09-11 amendment
   deleted by name, and the scroller is full-window anyway, so its inline size
   IS the viewport width less a scrollbar.

   The media feature `width` includes a classic scrollbar and the scrollport does
   not, so on a platform with 15px non-overlay scrollbars a band change fires up
   to 15px "early" in scrollport terms. Checked at both boundaries: at 736 / 721
   Band B's card 01 ends at 664 with a 33px peek; at 1088 / 1073 Band C's card 01
   ends at 804 with a 245px peek. Neither boundary clips.

   The row spans the whole window, not .wrap's 68rem. That is a 2026-09-12
   reversal of the 2026-09-11 "the scroller stays inside .wrap" decision, on the
   product owner's instruction, and it is done by taking .steps-scroller out of
   .wrap in the template rather than by a width:100vw / margin-left:calc(50% -
   50vw) breakout: nothing else needed that wrapper around the row, so there was
   nothing to break out of. (.wrap survived that day around the product-clip
   figure below the row; later on 2026-09-12 the owner deleted that figure
   outright — poster, caption and wrapper together — so no .wrap and no second
   element of any kind is left in this section: the scroller is now the only
   thing the section contains besides its visually hidden heading.)

   What the next card shows is leftover, not a tuned sliver:
   W - 40 - card - 24, where W is the scrollport's width — the window's own
   width, less a classic vertical scrollbar on platforms that still have one.
   Every number in that chain is a fixed pixel length, so it is entirely
   independent of the root font size. Per band:

     BAND A  leftover W - 336   card 01 whole from W = 312   peek from W = 337
     BAND B  leftover W - 688   card 01 whole from W = 664   peek from W = 689
     BAND C  leftover W - 828   card 01 whole from W = 804   peek from W = 829

   Band A's card is whole at a 320px scrollport with 8px to spare, so CARD 01 IS
   WHOLE AND A PEEK IS LIVE AT EVERY WIDTH IN EVERY BAND — 39px of peek at 375,
   48px at 736, 612px at 1440. That is the property the three sizes buy; the
   single 764px card was clipped at every viewport below 804. Band C's 804 / 829
   thresholds survive as arithmetic only: Band C starts at 1088, so neither is
   reachable in live behaviour. See "The how-it-works scroller" in README.md for
   the full tables. */
.steps-scroller {
  /* The two lengths that do NOT vary by band, declared once here and
     re-declared in neither media query. */
  --mc-step-gap: 24px;
  /* The row's inset from the window edge, and the one place the 40px lives.
     Deliberately NOT --gutter: --gutter is clamp(1.25rem, 4vw, 3rem) and so
     ranges 20...48px across viewports, and the product owner asked on
     2026-09-12 for a literal fixed 40px at every viewport. It is the only
     length in this section that is not either the card's own geometry or the
     24px gap. It does not vary by band either (round 8): the cost that might
     have justified varying it — 40px of a 375px window being expensive when the
     card does not fit anyway — disappeared when Band A's card became whole from
     a 312px scrollport, and scroll-padding-inline would have had to be varied
     with it for no gain. */
  --mc-step-inset: 40px;

  /* --------------------------------------------------------- BAND A tokens.
     Mobile-first: these eight are the < 736px values, and the two media queries
     at the foot of this block re-declare them for Bands B and C. Nothing else
     in the stylesheet writes them. Each one's derivation lives with the rule
     that consumes it. */
  --mc-step-w: 272px;
  --mc-step-h: 672px;
  --mc-step-pad-i: 24px;
  --mc-step-pad-b: 224px;              /* box ends y 448, 10px above the measured 458 body holding depth */
  --mc-step-figure-top: 288px;
  /* --mc-step-figure-w HAS NO CONSUMER SINCE 2026-09-16, when .step__figure took
     a flat 250px width in every band. All three declarations are kept rather
     than deleted: they are still named as part of this section's token set by
     README.md and by the plan doc's acceptance criteria, and removing them is a
     documentation change as much as a stylesheet one — a separate, reviewable
     edit, not a side effect of the figure fix. Grepped before this note was
     written: nothing in public/, templates/, content/ or src/ reads it.
     --mc-step-figure-top is NOT in the same position — .step--figure's
     padding-bottom still consumes it. */
  --mc-step-figure-w: 192px;           /* orphaned 2026-09-16 — cleanup candidate */
  --mc-step-scrim-a: 0.70;
  --mc-step-scrim-knee: 420px;         /* 62.5% of 672 */

  overflow-x: auto;
  /* Leading inset: the first card starts a fixed 40px from the window's left
     edge. Padding on the scroll container is the leading half only — leading
     padding is the one end of a scroll container every engine has always
     counted. The trailing half is a flex-line spacer (see .steps::after), for
     the measured reason recorded there. */
  padding-inline-start: var(--mc-step-inset);
  /* Without this the inset would exist at load and then disappear: a card's
     snap area aligns to the SNAPPORT, which is the scrollport reduced by
     scroll-padding, so with scroll-padding 0 the engine would snap card 01's
     left edge to the scroller's border edge — scrollLeft 40 — and eat the
     leading inset on the first mandatory snap. Reducing the snapport by the
     same 40px makes card 01 rest at scrollLeft 0, and every later card rest
     40px from the window edge too, so the inset reads at every snap position
     and not just at load. That is the start side, and it is the load-bearing
     one: every card is scroll-snap-align: start, so the end side of the
     snapport is never an alignment reference.

     The end side is set for a different reason, and only bites where a card is
     wider than the window. There, the snap area is bigger than the snapport, so
     every position at which the card covers the snapport is a valid rest, and
     the last of those has card 04's right edge flush with the scrollport's —
     which would put the trailing inset permanently out of reach on a phone, the
     row springing back off it after every fling. Reducing the snapport by 40px
     on the end side too makes maximum scroll itself a valid rest, so the row
     ends with the same 40px on both sides at every viewport. Measured at 375px:
     rest-at-end 2793 (inset hidden) without it, 2833 = maximum (inset showing)
     with it. */
  scroll-padding-inline: var(--mc-step-inset);
  /* A horizontal swipe that reaches either end must not chain to the document
     and fire the browser's back gesture — that takes the visitor off the site. */
  overscroll-behavior-inline: contain;
  scroll-snap-type: x mandatory;
  /* NO SCROLLBAR, on the product owner's instruction of 2026-09-14. It used to
     read `scrollbar-width: thin` with a --line thumb, kept deliberately visible
     so that a visitor on a wheel mouse with no horizontal axis, on a platform
     with classic scrollbars, had a way to reach cards 2 and 3. The owner judged
     the indicator unnecessary and it is gone.

     `none`, NOT the deletion of the two declarations. Deleting them would hand
     the scroller back to the UA default, which on a classic-scrollbar platform
     is a WIDER bar than the thin one being removed — the opposite of the
     instruction. `none` is the only value that actually removes it.

     What replaces it for that visitor: the scroller is tabbable
     (tabindex="0", role="group", named — see templates/pages/home.php) and
     takes the arrow keys, and Shift+wheel scrolls it horizontally. So the row
     is still reachable without a horizontal axis; what is lost is the visual
     cue that it scrolls at all. That cost is recorded here rather than argued
     away: it is the owner's call, and reversing it is one value.

     No ::-webkit-scrollbar companion rule. `scrollbar-width` is honoured by
     every engine that paints a classic scrollbar at rest (Chrome 121+,
     Firefox 64+, Safari 18.2+); on macOS and iOS the overlay scrollbar is
     already invisible at rest, so an engine old enough to miss the property
     shows nothing to remove. */
  scrollbar-width: none;
  /* NOT the room for the cards' drop shadow — that is .steps' padding-block
     below, deliberately a separate declaration on a separate element so the two
     can never be conflated or removed together. This one existed solely as
     clearance between the row and the scrollbar; with no scrollbar to clear it
     would have gone, per the standing instruction in its own previous comment,
     except that .steps' 92px bottom padding clears the shadow's 82px extent by
     only 10px and this 12px is what keeps the scroll container's clip edge off
     it with room to spare.

     MEASURED BY RENDER BOTH WAYS in round 23, on the page rather than by
     arithmetic — an earlier draft of this paragraph asserted the pair of numbers
     below off the 92 - 82 subtraction and called it measured, which it was not.
     Same instrument as the shadow extent documented below: headless Chrome 153,
     GPU raster, devicePixelRatio 1, the sampling column taken at the horizontal
     centre of card 01 and walked down until the pixel returns exactly to
     --paper-2 rgb(242,242,247), geometry re-read after the override and
     immediately before the capture. Run with this declaration, then again with
     `padding-bottom: 0` forced at runtime. THE SHADOW IS UNCUT EITHER WAY: its
     last painted row lands 22px above the clip edge with this line and 10px
     above it without. All three bands agree — 82px of shadow below the card
     every time, and 22.00 / 22.23 / 22.39px of headroom at 1440x900, 390x844 and
     768x800, the fractions being the clip edge's own subpixel position and not a
     per-band difference.

     THE 22 IS AT THE STOCK 16px ROOT and moves with the visitor's font size,
     because 0.75rem is a rem: the same measurement reads 28.23px at a 24px root.
     The 10px is the figure that does not move. This declaration stays as the
     margin on that 10px, which is the smallest clearance anywhere in this
     section. */
  padding-bottom: 0.75rem;
}

/* The base focus-visible rule covers a, button, summary, input and textarea
   only. The scroller is a div, and it is focusable on purpose (see the template
   comment), so it needs the same ring. */
.steps-scroller:focus-visible {
  outline: 2px solid var(--accent-strong);
  outline-offset: 3px;
}

/* The room the cards' drop shadow needs, and the only thing this padding is
   for. It lives on the list rather than on .steps-scroller so it stays clear of
   the scroller's own padding-bottom, which is the scrollbar's clearance and a
   different concern (see the note there).

   It has to exist at all because .steps-scroller sets overflow-x: auto, and per
   CSS Overflow the other axis then computes to auto too rather than staying
   visible. That makes the scroller a scroll container clipping at its PADDING
   box, so any part of a card's shadow that falls outside the row's own box is
   cut off unless there is padding inside the scroll container to hold it.
   (.step's own overflow: hidden is unrelated — that clips the card's background
   photograph and figure to the border radius. An element's box-shadow is not a
   descendant, so it is not subject to the element's own overflow.)

   THE EXTENT IS 34px ABOVE AND 82px BELOW UNDER GPU RASTERISATION, and it is ONE
   pair of numbers rather than a table — one pair across all three card sizes as
   well as all root font sizes. Measured by rendering — the scroll container
   unclipped with a runtime override, the card composited over this section's
   flat --paper-2 field, and a column sampled away from the card's corners until
   the pixel returns EXACTLY to rgb(242,242,247) — --paper-2, which the
   2026-09-13 palette change moved from rgb(242,239,233) — with the card's own rows
   self-located inside the capture. Invariant across root font sizes
   12/14/16/18/20/21/22/23/24/26/28/32px, viewports 320/375/768/1024/1440/1920,
   and devicePixelRatio 1, 2 and 3 (34.0 / 82.0 at every one of them).

   WHICH RASTERISER, STATED, because round 7 spent itself discovering that the
   answer changes the numbers. GPU raster is Chrome's default and is what a real
   visitor sees, and it is the larger of the two, so it is the binding figure for
   any padding that has to contain the shadow. Under SOFTWARE raster
   (--disable-gpu) the same shadow measures 23 / 71. Both are legitimate — two
   rendering paths, not a disagreement. Every shadow figure in this file and in
   README.md is the GPU one unless it says otherwise, and no table mixes the two.
   (The diagnostic symptom of a mis-taken capture is `above + below` staying
   pinned at 116 while the split slides — that is a Page.captureScreenshot with
   captureBeyondViewport taken after a layout-changing override, which silently
   offsets the sampled column.)

   THE EXTENT DOES NOT DEPEND ON THE CARD'S SIZE, which is derivable rather than
   hopeful and is why round 8's three card sizes did not turn this back into a
   table. A box-shadow's painted tail is a blur of the SHADOW RECT — the border
   box offset by 0 24px and shrunk 25px on every side — so what governs the tail
   is sigma (30px, from the 60px blur) and the shadow rect's corner radius, which
   is 32 - 25 = 7px. None of those three changes between bands: the radius stays
   32, the offset, blur and spread are untouched, and the rect stays comfortably
   positive even on the smallest card (272 x 672 -> a 222 x 622 shadow rect).
   Measured at all three card sizes: 34 / 82 at every one, GPU raster.

   It is a single pair BECAUSE --radius-lg is a fixed 32px. The blur tail depends
   on the corner radius, and while --radius-lg was 1.25rem the radius moved with
   the visitor's font size and the extent moved with it — measured 37/85 at roots
   12-20, then 31/79, 38/86, 37/85, 36/84, 33/81, 31/79, 33/81 at 21/22/23/24/26/
   28/32, GPU raster throughout. That is a +-4px jitter with no clean threshold,
   and root 22 was worse than root 16. Pinning the radius pins the tail. (A
   previous version of this comment claimed a clean step from 40/88 to 23/71
   "exactly between a 20px and a 24px root" as Skia switching blur paths. Neither
   the step nor those numbers reproduce AS A ROOT-SIZE EFFECT; corrected
   2026-09-12 after re-measuring. Round 8 can now say where 23/71 came from: it
   is this shadow's SOFTWARE-raster extent, recorded above. Round 7 separately
   found a step at radius 26, which is exactly |spread| + 1 — the radius at which
   the shadow rect stops being a sharp rectangle (26 - 25 = 1px) and Skia's
   rounded-rect blur, with its shorter tail, takes over. Round 5's 40/88, round
   6's per-root table and round 7's sawtooth are all the same curve read at
   different radii on different backends.)

   IT IS ALSO ROUGHLY DERIVABLE, so it is not an unmodellable engine quirk. CSS
   defines the blur radius as 2 sigma, so 60px is sigma = 30px, and Skia's blur
   support is about 3 sigma = 90px. The shadow rect's top edge is 49px below the
   card's top and its bottom edge 1px above the card's bottom: 90 - 49 = 41px
   above and 90 - 1 = 89px below, as an upper bound. The painted extent is the
   GPU-raster 34/82 rather than that bound because the outermost rows carry less
   than one 1/255 level over this field and
   so paint nothing at all, and because a rounded-rect blur has a slightly
   shorter tail than the sharp-rect one. Arithmetic gets within a few pixels; the
   measurement is what the padding below is set from.

   44 and 92 are NOT retuned to match, and are NOT re-declared per band. They
   were derived from the 40/88 figure this comment used to carry — itself not
   reproducible under GPU raster; the largest extent the sweep above ever
   produced was 38/86, at a 22px root. Either way they contain today's GPU 34/82
   with 10px to spare at each end and the software-raster 23/71 with 21px to
   spare, and shrinking them would move the whole section up by 16px — an
   unrequested layout change on top of a cosmetic radius change. Left as they
   are, deliberately, and left as ONE pair across all three bands, which is one
   fewer constant to keep honest; the pair is now generous rather than snug, and
   that is recorded here rather than left to be rediscovered. In px, not rem: the
   shadow they contain is a fixed px length and must not move with the root font
   size the way the rest of this section's type does. */
.steps {
  list-style: none;
  display: flex;
  gap: var(--mc-step-gap);
  padding-block: 44px 92px;
}

/* The trailing half of the 40px inset, so the row does not come to rest with
   card 04 flush against the window edge.

   An empty flex item, not the obvious symmetric `padding-inline-end` on the
   scroller. All three of the tidier constructions were measured in Chromium 153
   against this row, and all three are silently dropped: `padding-inline-end` on
   .steps-scroller, `padding-inline-end` on .steps, and `margin-inline-end` on
   .step:last-child each leave scrollWidth at 3168 — the flex items overflow the
   scroll container and none of that end space joins the scrollable overflow
   region. This is the same engine behaviour the 2026-09-11 notes declined to
   depend on, and it is still real. A pseudo-element is a flex item like any
   other: it is laid out on the line, so its width counts, in every engine and
   with nothing to verify. Measured: scrollWidth 3168 -> 3208, trailing gap 40px.

   The basis is the inset minus the gap because the flex `gap` already puts
   --mc-step-gap between card 04 and this item; 24 + 16 = the 40px inset. Both
   numbers come from the two tokens above, so nothing here is a magic 16. */
.steps::after {
  content: "";
  flex: 0 0 calc(var(--mc-step-inset) - var(--mc-step-gap));
}

.step {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  flex: 0 0 var(--mc-step-w);
  height: var(--mc-step-h);
  scroll-snap-align: start;
  /* One fixed size per band, from the tokens on .steps-scroller:
     272 x 672 / 624 x 640 / 764 x 695.

     THE BOTTOM PADDING IS THE SCRIM'S GUARANTEE, and since round 8 it is a rule
     rather than three numbers: the card's bottom padding is set so the content
     box ends AT LEAST 8px ABOVE THAT BAND'S BODY HOLDING DEPTH — the deepest
     row at which #ece8e1 still clears 4.5:1 over a pure-white photograph (see
     .step--photo .step__overlay). Any copy that fits the box therefore passes
     contrast, by construction, and the card has one guarantee instead of two
     independent ones.

     8px is the rule because 8px is what all three bands actually buy and what
     the site's acceptance criterion asks for. The three margins are NOT equal
     and two of them are not 12: Band A's is 10px, measured. The per-band
     figures, holding depths MEASURED from painted pixels:

       BAND A  pad-b 224  box 224 x 416, y 32...448  holding depth 458  margin 10
       BAND B  pad-b 288  box 560 x 320, y 32...352  holding depth 381  margin 29
       BAND C  pad-b 312  box 700 x 351, y 32...383  holding depth 395  margin 12

     (Nominal arithmetic predicts 460 / 383 / 395; the two-row shortfall on A
     and B is the dither and quantisation this file characterises below. An
     earlier draft of this comment stated the rule as "at least 12px" and gave
     Band A's depth as 459 — both were wrong, and the rule is the one that
     mattered.)

     Band C's 312 is up from the 256 it shipped with, and that is the round-8 fix
     for the hole this file used to record here — a box running to y 439 against
     a holding depth of 395, i.e. the last 44px of the text zone uncovered. It
     changes nothing that is painted: no shipped copy reaches even y 228.

     A card that carries a figure overrides this — its bottom padding is the
     screenshot's, not the scrim's; see .step--figure. */
  padding: 32px var(--mc-step-pad-i) var(--mc-step-pad-b);
  /* A photographic panel this size reads as square at --radius (6px), which is a
     control radius tuned for 1px-line boxes. --radius-lg is the large-surface
     size, and since 2026-09-12 it is 32px — the sticky header's own corner
     radius, on the product owner's instruction that the two match. It was
     1.25rem (20px at the default root) before that. The screenshot inside the
     card takes the same token (see .step__figure-image); nothing in this section
     uses --radius. */
  border-radius: var(--radius-lg);
  background: var(--dark);
  /* The one box-shadow in the stylesheet — there was no prior shadow anywhere to
     take a house style from, so the colour is stated rather than inherited.
     var(--ink) is the site's darkest ink, and on this element it is also what
     omitting the colour would have produced: an omitted box-shadow colour is
     currentColor, not black, and .step inherits color from body, which is
     var(--ink). Verified: both forms compute to
     `rgb(0, 0, 0) 0px 24px 60px -25px`. Writing it out means a future colour
     change on the card cannot silently repaint the shadow.

     IT WAS rgb(20, 18, 15) UNTIL THE 2026-09-13 PALETTE CHANGE, when --ink
     became pure black, so the shadow is very slightly darker than the one every
     extent figure in this file was measured against. That was re-measured rather
     than reasoned about, because the reserved padding depends on it.

     THE EXTENT DID NOT MOVE. Measured as an A/B on ONE rasteriser — the same
     build rendered twice, once with this shadow over --paper-2 and once with the
     old rgb(20,18,15) shadow over the old rgb(242,239,233) field — the painted
     tail reaches the field 71 rows below the card in BOTH runs. Because it is an
     A/B, the result does not depend on which rasteriser ran it: whatever the
     backend, the two pigments produce the same extent, so the GPU-raster 34 / 82
     recorded throughout this file stands unchanged too. (For the record the
     machine this was run on rasterises headless Chrome in SOFTWARE, and 71 is
     within a row of the 72 this file already records as the software figure —
     which is the cross-check that the harness was measuring the right thing.)
     It would have taken a shift of more than 10px at either end to matter — see
     the 44 / 92 reserve on .steps — and the pigment moved by 20/255 of one
     channel at the shadow's darkest point, nowhere near a quantisation level at
     the tail.

     The row's scroll container clips at its padding box, so this shadow only
     renders whole because .steps reserves room for it — see the measured
     padding-block there before changing any of 24 / 60 / -25. */
  box-shadow: 0 24px 60px -25px var(--ink);
}

/* No background photograph: the card is a panel on this section's --paper-2
   field — --paper fill, 1px --line border, rounded. That treatment used to be
   shared with .clip__frame, which the product owner deleted on 2026-09-12, so
   this is now the only place on the site that uses it. */
.step:not(.step--photo) {
  background: var(--paper);
  border: 1px solid var(--line);
}

.step__media { position: absolute; inset: 0; z-index: -2; }
.step__image { width: 100%; height: 100%; object-fit: cover; }

/* Top-down scrim: pure black, darkest on the card's top edge and reaching
   genuine alpha 0 — bare photograph — at the card's bottom edge.

   THE SHAPE IS THE SHIPPED, APPROVED ONE, and it passes WCAG 1.4.3. Settled
   2026-09-12 (round 5) after three earlier attempts; the product owner kept
   their own shape — pure black, three stops, 0.8 at the top edge, true 0 at the
   bottom edge — and took the one adjustment needed to make it legible: the
   middle stop moved from 0.30 at 50% to 0.62 at 55%. Full history of rounds 1-5:
   the 2026-09-12 notes in .claude/features/2026-09-10-product-website.md.

   Superseded, for the record, so nobody re-litigates them:
     round 1  rgba(12,11,9,.6) 0 / 0 347.5px          FAIL, worst 1.10:1
     round 2  rgba(12,11,9,.82) 0 / .74 340 / 0 695   pass, but not the shape
                                                      the owner wanted
     round 3  rgba(0,0,0,.8) 0% / .3 50% / 0 100%     FAIL, worst 2.94:1
     round 5  0.8 0% / .62 55% / 0 100%               PASS, worst 6.71:1

   ROUND 8 RE-DERIVED IT PER BAND, AND HAD TO. The gradient's stops used to be
   percentages, which makes it scale-invariant: on any card height it holds the
   body's 4.5:1 floor down to 57.3% of the card and the title's to 61.2%. The
   TEXT inside it is not scale-invariant — its depth is set by the type, in
   absolute pixels — so a shorter or narrower card pushes the same copy
   proportionally deeper into the same gradient. On a 272 x 672 card with the
   shipped scrim, card 04's copy at a 24px root would ink to y 390.8 against a
   holding depth of 385: a fail, on a gradient that passes 24 of 24 checks at
   Band C. So each band gets its own knee depth and knee alpha. Only those two
   numbers move; the shape does not.

   AND THE STOPS ARE IN PIXELS NOW, not percentages, so a future height change
   cannot silently move a stop — and the last percentage of the card's height
   leaves the section with them. The final stop reads --mc-step-h, so a card
   height and its scrim's bottom edge cannot drift apart.

     BAND  card h   stops                             body holds  title holds  box ends  margin
     A     672      0.8 @ 0 / 0.70 @ 420 / 0 @ 672    y 460       y 480        y 448     12px
     B     640      0.8 @ 0 / 0.66 @ 352 / 0 @ 640    y 383       y 407        y 352     31px
     C     695      0.8 @ 0 / 0.62 @ 382.25 / 0 @ 695 y 395 *     y 423 *      y 383     12px

     * measured; the others are nominal, and the margin column is nominal with
       them. The MEASURED holding depths and margins are 458/10, 381/29 and
       395/12 — see "MEASURED HOLDING DEPTHS" below, which is the table to quote.
       Band C's 382.25px is exactly 55% of 695px, so the desktop card's painted
       scrim is unchanged to the bit — that row is the shipped value written
       out, not a new value.

   THE HOLDING DEPTHS ARE SOLVED, NOT GUESSED: over a pure-white photograph the
   body #ece8e1 reaches 4.5:1 at alpha 0.5885 and the title #ffffff at alpha
   0.5347 (computed for PURE BLACK ink, fractionally kinder than the site's own
   near-black, so every floor is slightly conservative). For a knee of alpha A at
   depth K on a card of height H, the body holding depth is
   y = H - 0.5885(H - K)/A.

   THE RULE THAT FALLS OUT OF THIS, stated as a rule rather than as three
   numbers: THE CARD'S BOTTOM PADDING IS SET SO THE CONTENT BOX ENDS AT LEAST
   8px ABOVE THE BAND'S BODY HOLDING DEPTH. Any copy that fits the box therefore
   passes contrast, by construction. 8px, not 12: the measured margins are
   10 / 29 / 12px (see the table further down), so 12 is true of B and C and
   false of A, and 8 is the floor all three clear and the one the acceptance
   criterion asks for. Band C did not satisfy the rule at all before round 8 —
   its box ran to y 439 against a holding depth of 395, so the last 44px of the
   zone was uncovered — and .step's pad-b 256 -> 312 closes it. Nothing shipped
   went near it (the deepest inked row is y 228 at a 24px root), so nothing that
   is painted changed.

   A NOTE ON THE +-0.1 IN EVERY FIGURE BELOW. Chromium dithers this gradient, so
   a single row of the scrim carries two adjacent 8-bit levels — at y = 160, for
   instance, exactly half the row is 70 and half is 71, which read as 7.73:1 and
   7.61:1 for the body. Every contrast number in this comment takes the LIGHTEST
   level present in the row, i.e. the worse of the two. Sampling one pixel
   instead gives a figure up to 0.13 higher, and that is the whole explanation
   for the small disagreements between rounds.

   ALL 36 CONTRAST CHECKS PASS — 3 photo cards x 2 elements x 2 roots x 3 bands.
   Measured from painted pixels over a pure-white photograph, at each element's
   own deepest inked row, GPU raster, headless Chrome 153, transitions and smooth
   scrolling frozen, with each card self-located inside its capture and scrolled
   clear of the sticky header (sampling a card row through the header's frosted
   fill is the way to get a wrong answer here, and did once).

     BAND  root  card 01          card 03          card 04          worst
     A     16    11.90 / 8.62     11.90 / 8.89     11.73 / 8.49
     A     24    11.90 / 7.49     11.55 / 7.49     11.55 / 7.14     7.14 (04 body)
     B     16    11.37 / 7.98     11.55 / 8.36     11.37 / 7.98
     B     24    11.20 / 6.71     11.37 / 7.14     11.20 / 7.14     6.71 (01 body)
     C     16    11.20 / 7.61     11.37 / 8.10     11.20 / 7.61
     C     24    11.03 / 6.71     11.20 / 7.37     11.03 / 6.81     6.71 (01 body)

   Band C's twelve are bit-for-bit the values round 5 measured, which is the
   check that the tokenised pixel gradient reproduces the approved percentage
   one. Worst of all 36 is 6.71:1 — 49% clear of 4.5:1, not a hairline pass.

   WHERE THE TEXT ACTUALLY IS (shipped copy, deepest inked row, body):

     BAND  16px root                24px root                     binding
     A     y 211 (card 04)          y 380 (card 04)               alpha 0.70588
     B     y 160 (cards 01, 04)     y 273 (card 01)               alpha 0.69020
     C     y 160 (cards 01, 04)     y 228 (card 01)               alpha 0.69020

   Band A is the one to watch and the reason the type steps down there: at a
   24px root the copy reaches 57% of the card, against 43% on B and 33% on C.

   THE BOTTOM EDGE DOES DO WHAT IT SAYS, ON ALL THREE BANDS. Verified from
   pixels, not arithmetic, over a pure-white test photograph, source-over
   inverted as a = (255 - painted)/255:

     BAND  painted rows  row 0     knee                  last row   first clear
     A     672           0.80000   0.69804 @ d = 420     d = 671, alpha 0   d = 671
     B     640           0.80000   0.65882 @ d = 352     d = 639, alpha 0   d = 639
     C     695           0.80000   0.61961 @ d = 382     d = 694, alpha 0   d = 694

   Every knee lands within half a quantisation level of nominal (0.70 / 0.66 /
   0.62), and in every band the row above the last still reads 254 — alpha
   0.00392, one level short — so the last row is also the FIRST fully clear one.
   (Rounds 3 and 5 both said "first fully clear row at d = 693" for Band C, which
   contradicted the sentence beside it; corrected 2026-09-12.)

   MEASURED HOLDING DEPTHS, against each band's content-box bottom:

     BAND  body holds to  title holds to  box ends  margin
     A     y 458          y 478           y 448     10px
     B     y 381          y 405           y 352     29px
     C     y 395          y 423           y 383     12px

   All three keep the content box above the body's holding depth, which is the
   rule stated at .step — and the rule is "at least 8px", not "at least 12px",
   because Band A's margin is 10. Nominal predicted 460 / 383 / 395; the
   shortfall on A and B is dither and quantisation, the same margin this file has
   characterised since round 5.

   WHAT IS DIRECTLY BELOW THE CARD IS NOT THE FIELD — IT IS THIS CARD'S OWN
   SHADOW. The row one pixel under the card's bottom edge measures rgb(126,126,
   129), and --paper-2 rgb(242,242,247) is first reached 83 rows down under GPU
   raster. (Both COLOURS were re-measured after the 2026-09-13 palette change,
   which moved the field and darkened the shadow's pigment to pure black — the
   row before it read rgb(135,133,129). The ROW COUNT did not move: an A/B of the
   two pigments on one rasteriser puts the field 71 rows down in both, see the
   note on .step's box-shadow.) Rounds 3
   and 5 said the field "sits immediately below it", which was true when it was
   written and stopped being true the moment .step gained a box-shadow in the
   same round. The 83 rows agree with the 82px GPU-raster shadow extent recorded
   on .steps (under software raster the field would arrive 72 rows down).

   AND THE COPY BUDGET HOLDS, BY CONSTRUCTION NOW rather than by luck: the
   content box ends above the holding depth in every band, so any copy that fits
   the box passes contrast. Depth, not character count, remains the real
   invariant — wrapping follows word lengths, so a sample of unusually wide words
   goes deeper — which is why the three box bottoms (y 448 / y 352 / y 383), not
   the character ceilings in README.md, are the numbers to design against.

   Rendered only on a card that has a photograph behind it. */
.step--photo .step__overlay {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(to bottom,
    rgb(0 0 0 / 0.8) 0,
    rgb(0 0 0 / var(--mc-step-scrim-a)) var(--mc-step-scrim-knee),
    rgb(0 0 0 / 0) var(--mc-step-h));
}

/* SUPERSEDED IN PART, 2026-09-16. Everything below about WHERE THE FIGURE SITS
   AND HOW WIDE IT IS is history: .step__figure no longer reads
   --mc-step-figure-top or --mc-step-figure-w — it has no block-axis offset at
   all and one flat 250px width — so the "figure top", "figure-card content box"
   and "clear" columns of the table below, and the whole clip paragraph with its
   192 / 288 / 352 renders, describe a geometry that stopped shipping on that
   date. The rule this comment introduces is UNCHANGED and still live: the
   padding-bottom calc below is the surviving consumer of --mc-step-figure-top,
   which is why that token stays. What it now reserves is dead space rather than
   the figure's band — the figure's clearance from the copy is exact and
   automatic under the static position, so this reserve no longer guarantees
   anything a reader should design against. See .step__figure for what does.
   Left whole rather than rewritten: the derivation is the record of how the
   card got here.

   THE CLEARANCE IS A CONSTRUCTION AGAIN (round 8), and this is the rule that
   makes it one. A card that carries a screenshot is not bounded by the scrim —
   it is bounded by the screenshot — so it reserves a different bottom padding
   from a card without one, and the 16px it clears by is written in the SAME
   EXPRESSION as the anchor it clears. The two cannot desynchronise: change the
   card height or the anchor and the reserve follows, on every band and at every
   root font size, for any copy that fits the box.

   This restores the 2026-09-11 guarantee, with the 100%-of-card-height anchor
   replaced by a per-band pixel token. What it closes, as a side effect rather
   than as its purpose: between the first 2026-09-12 polish round and round 8 the
   figure was anchored at calc(65% - 240px) while the bottom padding stayed an
   unconditional 256px, so the two numbers were independent and the gap was
   whatever the copy happened to leave. Measured on card 02 before this rule
   landed — headless Chrome 153, GPU raster, shipped 56-character body: +107.84px
   at a 16px root, +72.55px at a 24px root; and at the README's then-documented
   240-character budget, +8.84px at a 16px root and an OVERLAP OF 125.45px at a
   24px root, with the body setting --ink-2 (#45403a then, #3C3C43 since the
   2026-09-13 palette change) over the dark phone screenshot.

   WHAT THIS RULE GUARANTEES, AND WHAT IT DOES NOT. It gives a figure card a real
   content box again and puts the figure OUTSIDE it: before, the figure began at
   y 211.44 while the content box ran to y 439, so the figure sat INSIDE the box
   and copy that merely fitted the box could still paint over the screenshot.
   Now the box ends 18px above the figure in every band, so copy that fits the
   box cannot reach the screenshot at all. What it does NOT do is cap or clip the
   body — nothing here bounds .step__body to the content box — so copy that
   exceeds the band's figure-card budget still overflows and still lands on the
   screenshot. Re-measured on THIS build: a 240-character body on card 02 at
   Band C and a 24px root overlaps the figure by 125.45px, the same figure as
   before the fix. The no-overlap property is therefore conditional on the copy
   budget at .step__body below, and that budget is enforced by README.md's prose,
   not by anything in this stylesheet.

   .step--figure comes back to carry it. The 2026-09-11 amendment deleted the
   modifier because "the bottom reserve is now unconditional — every card carries
   padding-bottom: 256px, figure or not — so the modifier has no consumer". That
   was sound while the reserve WAS the figure band. It stopped being so when the
   figure moved to 65%, and it cannot be true across three bands: the reserve a
   figure card needs (copy 16px clear of the screenshot) and the reserve a photo
   card needs (copy inside the scrim's holding depth) are different numbers on
   every band. One declaration cannot be both.

   Per band, and note that every box below carries a border correction: card 02 is
   the one card with no photograph, so .step:not(.step--photo) gives it a 1px
   border, and an absolutely positioned child is placed against the PADDING box
   while the padding-bottom is measured from that same box — so the border adds
   2px to the reserve on a bordered card. The construction is 16px + 2 x
   border-width; on card 02 as it ships that is 18px, measured. On a figure card
   WITH a photograph behind it there is no border and it is exactly 16px.

     BAND  inset       figure top   reserve     figure-card content box           clear
     A     288px       y 289        400px       222 x 238, y 33...271             18px
     B     224px       y 225        432px       558 x 174, y 33...207             18px
     C     210.4375px  y 211.4375   500.5625px  698 x 160.4375, y 33...193.4375   18px

   Both middle columns are the same edge: `inset` is --mc-step-figure-top, which
   an absolutely positioned child resolves against the PADDING box, and `figure
   top` is that edge in the card's own (border-box) coordinates — 1px lower,
   because of the border. The content boxes account for the same border on all
   four sides, which is why they are 2px narrower and 2px shorter than the
   photo-card boxes at .step, and they start at y 33 rather than y 32. Clearance
   is figure top minus content-box bottom: 289 - 271 = 225 - 207 =
   211.4375 - 193.4375 = 18px, on every band, at every root font size.

   BAND C'S ANCHOR IS THE SHIPPED calc(65% - 240px) RESOLVED, so the desktop
   figure does not move: 65% resolves against the containing block, which is the
   card's PADDING box (695 - 2px of border = 693px), not its border box, so the
   shipped value computes to 693 x 0.65 - 240 = 210.45px and Chrome lays it out
   at 210.4375px. (The round-8 spec quoted 211.75px, having taken 65% of 695;
   that would have moved the desktop screenshot down 1.31px, which the "Band C is
   unchanged" guarantee does not allow. The measured value is used instead.)

   EVERY FIGURE IS STILL CLIPPED BY THE CARD'S BOTTOM EDGE — the composition the
   product owner chose — and the clip was checked rather than assumed. The
   1179 x 2556 asset renders 192 x 416.2 / 288 x 624.4 / 352 x 763.1 against
   visible bands of 383 / 415 / 483.5625px, so it overruns by 33.23 / 209.36 /
   279.55px and the image's bottom corners never appear. (The visible band is the
   card's height less the figure's top edge IN CARD COORDINATES — 289 / 225 /
   211.4375, not the inset — so the border is counted here too.) Density
   improves as the card shrinks: 6.14x on Band A, 4.09x on B, 3.35x on C.

   Band A's figure is 192px, i.e. 70.6% of the card, against 46% on the other two
   bands. 46% of 272 is 125px, at which a phone screenshot is a smear. That
   proportion is a per-band constant like everything else here, and the reason it
   differs is a legibility judgement, stated rather than smoothed over.

   (The old 240px "figure show" token is deleted. It had been nominal since the
   anchor moved, and both jobs it used to do — the anchor and the bottom reserve
   — are done by --mc-step-figure-top and the calc below.) */
.step--figure {
  padding-bottom: calc(var(--mc-step-h) - var(--mc-step-figure-top) + 16px);
}

/* The second, optional image: centred, raised, and clipped by the card's own
   overflow rather than by any decorative effect.

   2026-09-16 — ONE WIDTH, NO ANCHOR, and this rule no longer has a per-band
   length of any kind. `width` is a flat 250px in every band (it was
   --mc-step-figure-w: 192 / 288 / 352px) and `inset-block-start` is deleted
   outright (it was --mc-step-figure-top), so with no block-axis offset declared
   the box takes its STATIC POSITION — it lands exactly where it would have sat
   in flow, immediately under .step__body. The product owner's instruction, on
   this finding: the 2026-09-16 asset swap put the screenshot's distinguishing
   content (the filing sheet) 66.16% down the file, and the old anchor-plus-width
   pair cut it off — Band B kept a 1.92px hairline of it and Band C put it
   21.31px BELOW the card's bottom edge. The remedy is the card's geometry, not a
   re-crop of the asset.

   EVERY TABLE ABOVE THAT GIVES A PER-BAND FIGURE TOP OR FIGURE WIDTH IS
   HISTORY FROM THIS DATE — see the superseded markers on .step--figure and
   .step__figure-image. Nothing in them was wrong when it was written; none of it
   describes what ships.

   WHAT THE STATIC POSITION BUYS. Measured by laying card 02's two text blocks
   out against this card's real boxes in AppKit (the shipped copy, the system
   sans for the body and a serif for the title, at the three roots this file
   designs against) rather than in a browser — headless Chrome would not run on
   the machine this was done on. The same model reproduces the two BEFORE numbers
   quoted above to 0.01px, which is the cross-check that earns its forward
   figures a place here. Figure top is in card coordinates, the asset renders
   250 x 541.98, and the sheet begins 358.58px down it:

     ROOT  BAND  body lines  figure top  visible band    sheet row  card bottom
     16    A     3           y 144.20    527.80 (97.4%)  y 502.78   672
     16    B     1           y 103.60    536.40 (99.0%)  y 462.18   640
     16    C     1           y 103.60    591.40 ( 100%)  y 462.18   695
     24    A     4           y 239.40    432.60 (79.8%)  y 597.98   672
     24    B     2           y 188.40    451.60 (83.3%)  y 546.98   640
     24    C     2           y 188.40    506.60 (93.5%)  y 546.98   695

   The sheet clears the card's bottom edge in every cell — by 74px in the
   tightest (Band A at a 24px root) and 233px in the loosest. Band C shows the
   WHOLE screenshot, bottom corners included, at a 16px and a 20px root, which is
   the first time any band has (see .step__figure-image's note on the bottom
   corner pair, now conditional rather than never).

   THAT LAST ONE IS ALSO THE ONE COST, AND IT IS STATED RATHER THAN SMOOTHED
   OVER. "Every figure is clipped by the card's bottom edge" — the composition
   .step--figure's note records as the product owner's own choice — no longer
   holds on Band C at the two smaller roots: the image ends 49.42px above the
   card's bottom edge at a 16px root and 31.77px above it at 20px, so the
   screenshot floats on bare --paper there instead of bleeding off the edge. At a
   24px root Band C clips again (by 35.38px), and Bands A and B clip at every
   root (14.18px and 5.58px at a 16px root, more as the root grows). The figure
   fix was chosen over a re-crop knowing this; if the bleed is wanted back on
   Band C it is a composition decision of its own, not a regression to patch
   here.

   AND THE OVERLAP GUARANTEE GOT STRONGER, NOT WEAKER. The static position IS the
   copy's bottom edge, so the figure tracks the copy instead of standing at a
   fixed anchor the copy could run past: the overlap .step--figure's note records
   as "conditional on the copy budget" — 125.45px on card 02 at Band C and a 24px
   root with a 240-character body — cannot happen at all now, at any length, on
   any band. What replaces it is a CLIPPING budget, and it is the number to
   design against: every extra line of body copy pushes the figure down one line
   height. Band A at a 24px root binds, and it tolerates 5 body lines before the
   sheet meets the card's bottom edge. It ships 4. */
.step__figure {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  width: 250px;
}

/* SUPERSEDED IN PART, 2026-09-16: every 192 / 288 / 352 below is a width this
   figure no longer has. The image is 100% of a parent that is now a flat 250px
   in every band, so it renders 250 x 541.98 everywhere, and 32px of radius is
   12.8% of it — one number, not three. The bottom corner pair is no longer
   "not visible in any band": at a 16px and a 20px root Band C clears the whole
   541.98px image inside its 695px card, so the corners paint there. That is the
   reason the declaration was kept whole, arriving sooner than expected. The
   paragraphs below are left as the record of the per-band geometry.

   All four corners, at the same --radius-lg the card and the header carry. The
   value here has been 32px since the 2026-09-12 figure reposition; on 2026-09-12
   (round 6) it stopped being a literal and became the token, which is the whole
   of that round's change to this rule.

   The bottom pair is not visible on the shipped asset in any band: the
   1179x2556 screenshot renders 416.2 / 624.4 / 763.1px tall against visible
   bands of 383 / 415 / 483.5625px — the card's height less the figure's top edge
   in card coordinates (289 / 225 / 211.4375), which is 1px below the inset
   because card 02 carries a border — so it always runs past the card's bottom
   edge and the card's own overflow clips it. The tightest of the three is Band
   A, which still overruns by 33.23px. The declaration is kept whole rather than
   trimmed to the top pair so a shorter asset rounds correctly without a
   stylesheet edit.

   The radius does not vary by band, because --radius-lg does not: it is the
   header's radius, and the header's radius does not change with viewport, so
   varying it would break the match the product owner asked for at exactly the
   widths where the header is most visible. It also keeps the drop shadow one
   pair of numbers (see .steps). 32px is 16.7% of a 192px image on Band A, 11.1%
   of 288px on Band B and 9.1% of 352px on Band C, against 11.8% / 5.1% / 4.2% of
   the card's own width — the same radius deliberately reads as a stronger round
   on the smaller box. Checked by rendering: the figure is a phone screenshot, so
   a device-like corner is the right read, and it is the corner the card's own
   edge repeats. */
.step__figure-image {
  width: 100%;
  height: auto;
  border-radius: var(--radius-lg);
}

/* 34rem is the measure the deleted section-head rule used to hold, relocated
   here to do the same job: without it Band C's copy would set across the full
   700px content box, about 70 characters at the body's 20px. In rem it grows
   with the type, so the line count stays roughly constant as the root font size
   rises — which is what keeps the card's fixed vertical budget stable. On Bands
   A and B the content box is narrower than 34rem at every root, so there it is
   inert and the box does the measuring. */
/* THE TYPE IS THE ONE THING IN THIS SECTION THAT IS NOT CONTAINER SIZING, and
   Band A steps it down one notch: title 1.5625rem -> 1.25rem, body 1.25rem ->
   1rem below 736px (see the media queries at the foot of this block, which
   restore the larger pair for Bands B and C).

   IT IS FORCED BY ARITHMETIC, NOT TASTE. Holding the desktop scale on a 272px
   card puts card 04's deepest inked row at y 568 at a 24px root, which is 85% of
   the card. No three-stop scrim that reaches alpha 0 at the card's bottom edge —
   the shape the product owner approved — can hold 4.5:1 that deep without
   becoming a full-height plateau, and the card would have to be about 1420px
   tall for the standard shape to cover it. The step-down puts the same row at
   y 390.8. The two alternatives were priced and declined: a 272 x ~790 card with
   a knee at 0.74 / 500px, which leaves almost no bare photograph at the bottom;
   or cutting cards 01 and 04's bodies to ~90 characters, which the brief rules
   out by fixing the copy.

   BOTH SIZES STAY IN rem, so the accessibility property that matters — the card
   answering the visitor's own font-size setting — is untouched. 1rem body also
   matches the site's own body copy (clamp(1rem, 0.96rem + 0.2vw, 1.0625rem))
   rather than sitting 25% above it on a phone, and the title:body ratio stays
   the clean 1.25 the type round settled on.

   1.5625rem is 25px at the site's default root, 13.6% up from the 1.375rem
   (22px) this card shipped with — the product owner asked for "a little" larger
   on 2026-09-12 without a number. It keeps the title on a whole pixel at the
   default root. */
.step__title { font-size: 1.25rem; margin-bottom: 0.6rem; max-width: 34rem; }

/* The body is the last thing in a card's flow in every state, and the space
   below it is the reserve — the scrim's on a photo card, the screenshot's on a
   figure card — not a paragraph margin. Both sizes stay in rem deliberately: a
   px size would not move when a visitor raises their browser's font size, which
   is the exact axis this card's geometry has to survive.

   THE COPY BUDGET, PER BAND AND PER CARD TYPE. The card does not grow, so copy
   that does not fit is clipped or lands on the screenshot. A photo card is
   bounded by the scrim (its copy must stay inside the content box, which .step
   places above the holding depth); a figure card is bounded by the screenshot
   (its copy must stay inside the shorter box .step--figure leaves). Measured at
   a 24px root, the binding one, with ordinary English prose:

     BAND  photo-card body   figure-card body   title (2 lines)
     A     190 chars         77 chars           ~32 chars
     B     219 chars         84 chars           ~66 chars
     C     277 chars         108 chars          ~82 chars

   THE SITE-WIDE CEILING IS THE MINIMUM OF THE THREE, because one set of copy
   serves all three bands, and Band A binds every column: PHOTO-CARD BODY <= 160
   CHARACTERS, FIGURE-CARD BODY <= 72, TITLE <= 32. Those are the numbers
   README.md hands a content editor, set below the measured maxima because
   character count is a proxy — a sample of unusually wide words goes deeper than
   an average one. (The round-8 spec derived 160 / 80 / 32 from a 0.45em-per-
   character model; measurement puts the figure-card column at 77 on Band A, so
   the ceiling is 72 rather than 80. The other two columns clear their derived
   figures.) The shipped bodies run 56-133 characters and card 02's is 56, so
   everything on the page clears with room. */
.step__body {
  margin: 0;
  color: var(--ink-2);
  font-size: 1rem;
  max-width: 34rem;
}

/* Over a photograph the card takes the hero's own pairing: white title,
   #ece8e1 body. */
.step--photo .step__title { color: #ffffff; }
.step--photo .step__body { color: #ece8e1; }

/* ------------------------------------- the row's prev/next control (2026-09-14)
   A frosted capsule under the cards holding two circular buttons. It scrolls the
   row for a visitor with a mouse or a trackpad, who since the scrollbar was
   removed on 2026-09-14 had no on-screen indication that the row scrolls at all.
   See the markup comment in templates/pages/home.php for who it is for and who
   it is deliberately not for.

   NOTHING HERE IS A NEW VISUAL IDEA, and that is on purpose — every value is
   either a token or a literal this file already carries:

     the frosting      rgba(255,255,255,0.9) + saturate(150%) blur(10px), which
                       is .site-header's fill and filter, literal for literal.
                       It is written out rather than tokenised for exactly the
                       reason the header's is: a custom property cannot carry its
                       own alpha, and --paper is #FFFFFF. If --paper moves, BOTH
                       places move — they are named in each other's comments so
                       the pair cannot be half-updated.
     the hairline      --line, the site's 1px separator, as on the header and the
                       nav panel.
     the capsule       --radius-lg. NOT a 999px literal. --radius-lg is 32px and
                       the capsule is 56px tall at a 16px root, so a border
                       radius of 32 exceeds half the height and the engine scales
                       it down to exactly half — a true capsule, from the site's
                       own large-surface token, with no second number to keep
                       honest. At a 24px root the capsule is 80px tall and 32px
                       is a generous round-rect rather than a full capsule: that
                       is accepted, because the alternative is a literal that
                       stops tracking the token the header and the cards share.
     the colour        --ink-2 resting / --ink on hover, which is the pair
                       .site-nav__toggle takes, so the chevrons carry the same
                       weight as the site's other drawn icon.

   THE BUTTON IS 2.625rem AND THE CAPSULE IS rem-PADDED, so the control grows
   with the visitor's font size. That is the opposite of the cards it sits under,
   whose every length is a fixed px on purpose — and the difference is
   deliberate. The card's px geometry exists so the text zone and the box move
   together (see the .steps-scroller block); this is a control, and the site's
   other controls (.btn, .site-nav__toggle) are all rem. A 42px circle is the
   reference size at the stock root, one notch under the toggle's 44px touch
   target because this control is never offered to a thumb.

   IT IS NOT IN THE SCROLLER. .steps-scroller is a scroll container that clips at
   its padding box; a control inside it would scroll away with the cards. It is a
   sibling, so it is fixed under the row and centred on the section, not on the
   row's scrollable width. margin-inline: auto on a max-content box is the whole
   of the centring — the section is an ordinary block and the control is an
   ordinary block child of it.

   THE TOP MARGIN IS 0, AND THE GAP IT INHERITS WAS MEASURED RATHER THAN
   ASSUMED. .steps already pays 92px of bottom padding for the cards' drop
   shadow and .steps-scroller another 0.75rem, against a shadow whose measured
   extent is 82px below the card — so with no margin of its own the capsule's
   top edge lands exactly on the scroller's clip edge, which is 22px past the
   last painted shadow row at a 16px root. Rendered at three bands x three
   roots, with the row revealed so the reveal's own translateY is not in the
   measurement: 22.00px at a 16px root, 25.00px at 20px and 28.00px at 24px,
   identical at 390x844, 768x800 and 1440x900 in each. (It scales because
   .steps-scroller's padding-bottom is a rem and the 82px shadow is not — the
   same property, and the same 22/28 pair, that declaration's own note records.)
   A margin here would push the control into the section's --rhythm padding and
   buy nothing the 92 + 12 above it has not already paid for. */
.steps-nav {
  /* The no-JS resting state. .has-js below is what gives it a box; see the
     markup comment for why a control whose only action is a scripted scroll
     must not exist without JS. */
  display: none;
  width: max-content;
  margin-inline: auto;
  gap: 0.375rem;
  padding: 0.375rem;
  background: rgba(255, 255, 255, 0.9);      /* --paper at 90%; see .site-header */
  backdrop-filter: saturate(150%) blur(10px);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
}

.has-js .steps-nav { display: flex; }

/* HIDDEN OUTRIGHT ON A COARSE POINTER. A thumb already scrolls this row by
   swiping it, so on a phone or a tablet these two buttons are a second way to do
   something the visitor is already doing, taking space under the cards for it.

   THE SELECTOR IS REPEATED AND THE QUERY SITS AFTER THE RULE IT OVERRIDES, both
   deliberately: a media query adds no specificity, so `.steps-nav` alone inside
   it would lose to `.has-js .steps-nav` above at (0,2,0) whatever the order, and
   an equal-specificity override only wins on source position. Same mechanism,
   and same reason, as the two band queries at the foot of this block.

   `pointer: coarse` is the PRIMARY pointing device, so a laptop with a
   touchscreen — whose primary pointer is the trackpad — still gets the control.
   A device that reports no pointer at all (`pointer: none`) matches neither
   this query nor `pointer: fine`, which is why the rule is written as a coarse
   override of a shown default rather than as a `pointer: fine` opt-in: a
   keyboard-only visitor keeps two real tab stops instead of losing them to a
   media feature they do not match. */
@media (pointer: coarse) {
  .has-js .steps-nav { display: none; }
}

.steps-nav__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.625rem;
  height: 2.625rem;
  padding: 0;
  background: none;
  border: 0;
  border-radius: 50%;
  color: var(--ink-2);
  cursor: pointer;
  transition: color 160ms ease, opacity 160ms ease;
}
.steps-nav__btn:hover { color: var(--ink); }

/* The site has no other disabled control, so this is the convention rather than
   a copy of one: reduced opacity, the default cursor, and no hover response.
   The `disabled` attribute is what does the real work — the button leaves the
   tab order and announces as unavailable — and these three lines only make that
   visible. Opacity rather than a colour, so it cannot drift from --ink-2.

   THE FAINTNESS IS DELIBERATE AND IT IS NOT A CONTRAST FAILURE. --ink-2 at 35%
   over the capsule's near-white fill lands around rgb(185,185,187), which is
   well under 4.5:1 — and that is allowed: SC 1.4.3 and SC 1.4.11 both exempt
   inactive user-interface components explicitly. The point of the state is that
   the control cannot be used, and a disabled button that still reads at full
   strength is the more misleading of the two. The information is not carried by
   the dimming alone either: the `disabled` attribute is what assistive
   technology and the keyboard both go by. */
.steps-nav__btn:disabled {
  opacity: 0.35;
  cursor: default;
}
.steps-nav__btn:disabled:hover { color: var(--ink-2); }

/* Two strokes meeting at a point, drawn at the same 2px weight as the header's
   hamburger bars so the site's two drawn icons match. stroke: currentColor, so
   the button's own colour and its disabled opacity reach it without naming it
   twice. The 16x16 viewBox scales with the button, so the chevron grows with
   the root font size along with everything else here. */
.steps-nav__chevron {
  width: 1rem;
  height: 1rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ---------------------------------------------- the two band boundaries.
   Exactly two media queries gate this section, both in px (see the note at the
   top of the block for why px and not rem), and they sit at the FOOT of the
   block rather than beside .steps-scroller for one mechanical reason: a media
   query adds no specificity, so an override only wins on source order. The base
   .step__title, .step__body and .steps-scroller rules above are Band A; these
   two blocks have to come after all of them to take effect at all.

   The 1088px block re-declares only what differs from the 736px block —
   --mc-step-pad-i and the two type sizes carry over by source order. */

@media (min-width: 736px) {          /* BAND B — 46rem's value, written in px */
  .steps-scroller {
    --mc-step-w: 624px;
    --mc-step-h: 640px;
    --mc-step-pad-i: 32px;
    --mc-step-pad-b: 288px;          /* box ends y 352, 29px above the measured 381 holding depth */
    --mc-step-figure-top: 224px;
    --mc-step-figure-w: 288px;       /* orphaned 2026-09-16 — see the Band A note */
    --mc-step-scrim-a: 0.66;
    --mc-step-scrim-knee: 352px;     /* 55% of 640 */
  }
  .step__title { font-size: 1.5625rem; }
  .step__body  { font-size: 1.25rem; }
}

@media (min-width: 1088px) {         /* BAND C — 68rem's value (--wrap), in px */
  .steps-scroller {
    --mc-step-w: 764px;
    --mc-step-h: 695px;
    --mc-step-pad-b: 312px;          /* box ends y 383, 12px above the measured 395 holding depth */
    /* = the shipped calc(65% - 240px) resolved against card 02's 693px padding
       box, so the desktop screenshot does not move. See .step--figure. */
    --mc-step-figure-top: 210.4375px;
    --mc-step-figure-w: 352px;       /* orphaned 2026-09-16 — see the Band A note */
    --mc-step-scrim-a: 0.62;
    --mc-step-scrim-knee: 382.25px;  /* = the shipped 55% of 695px */
  }
}

/* THE CLIP FIGURE IS GONE (2026-09-12, round 11). The product owner deleted the
   <figure class="clip"> — poster frame and caption both — from
   templates/pages/home.php directly, and .clip / .clip__frame / .clip__image /
   .clip__caption went with it, along with the 40px-inset derivation those rules
   carried. Nothing on the site uses those four class names any more (grepped
   across templates/, content/, src/ and public/ before deleting).

   Two consequences worth leaving a marker for, because both are silent:

   1. The 40px left edge that ran down this stretch of the page is now shared by
      two elements, not three — --mc-step-inset on .steps-scroller above and
      margin-inline on .band below. Each still writes its own literal, for the
      reason .band's note gives; the figure was never what bound them.
   2. .section--how now contains nothing but the scroller and its visually
      hidden heading, so the section's --rhythm bottom padding is the only thing
      between the last card and the lifestyle band.

   If a product clip ever comes back, it comes back as a design decision of its
   own and not as a revival of this rule — see "Product clips" in README.md. */

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

/* THE BAND IS INSET AND ROUNDED SINCE 2026-09-12. It was full-bleed, edge to
   edge of the window, from the first commit until then; the product owner asked
   for it to sit 40px in from both window edges with the page's field showing
   down either side.

   margin-inline: 40px IS THE WHOLE OF THE INSET, and it is deliberately the
   dullest of the three constructions that would do it. The section is an
   ordinary block-level child of <main> in normal flow, so a margin is simply
   what "inset from both edges" means here — no max-width + auto-margin pair
   (which sets a cap this section does not want: it should stay 80px narrower
   than the window at 2560px as much as at 768px), and no extra wrapper element
   (nothing needs a second box). The full-bleed sections either side are
   unaffected because a margin on this one changes nothing about them.

   THE 40px IS THE SAME 40px THE HOW-IT-WORKS ROW USES — see --mc-step-inset on
   .steps-scroller, the product owner's fixed inset from the window edge at every
   viewport, chosen there over --gutter precisely because it does not vary. It is
   a literal here rather than a shared token because the two are the same number
   by the owner's instruction, not by construction: the row's inset is a scroll
   geometry and this is a section margin, and binding them together would mean a
   future change to one silently moving the other. If they are ever meant to
   track each other, that is the moment to promote it to a token — said here so
   the choice is visible rather than assumed.

   It does not shrink at narrow widths, for the same reason the row's does not.
   At 320px it costs 80px of a 320px window, and the copy column that survives is
   200px at a 16px root and 180px at a 24px root — measured, tight, and legible;
   the band grows taller to absorb it. That is the cramped end of the range and
   it is recorded in README.md.

   THE RADIUS IS --radius-lg, the site's large-surface radius: 32px, the sticky
   header's corner and the how-it-works cards'. The token is read rather than the
   literal written, so this box cannot drift away from the other two. The band
   already carried overflow: hidden — for the cover-fitted photograph — so the
   radius clips the image and the scrim with no further work; that declaration
   stopped being decorative on the day the radius arrived. */
.band {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: center;
  min-height: clamp(20rem, 46vw, 30rem);
  margin-inline: 40px;
  margin-top: 24px;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--dark);
}

.band__media { position: absolute; inset: 0; z-index: -2; }
.band__image { width: 100%; height: 100%; object-fit: cover; }

/* THE SCRIM, AND WHY IT IS NOT THE HOW-IT-WORKS SCRIM'S SHAPE.

   The product owner asked for "the same overlay style" as a how-it-works photo
   card — pure black, top-down, 0.8 at the top edge, fading away below (see
   .step--photo .step__overlay) — in place of the flat rgba(12,11,9,0.66) veil
   this band carried until 2026-09-12. The colour, the direction and the 0.8 top
   stop are carried over verbatim. THE FADE TO ALPHA 0 IS NOT, and it could not
   be, for the same structural reason a fluid box always defeats it:

   A how-it-works card is a FIXED box (272x672 / 624x640 / 764x695) with its copy
   pinned to the TOP, so the bottom of the card is empty and the scrim can reach
   bare photograph there. This band is fluid AND its copy is CENTRED —
   align-items: center against a min-height, with symmetric padding-block on
   .band__inner — so the copy occupies the middle and the band grows around it.
   Measured across 17 widths x 3 root sizes (16 / 20 / 24) with the final copy
   in place, its height runs 320.00px to 816.81px, a 2.55x range, and the
   DEEPEST INKED ROW — measured from painted pixels, not from the box — sits at
   66.67% of the band's height at its shallowest (1088px+, 16px root) and 89.74%
   at its deepest (320px wide, 24px root, where the 180px copy column wraps the
   description to 13 lines and the band stands 817px tall, well past its own
   480px min-height). The FIRST inked row runs 9.67% to 33.75% over the same
   sweep, so at worst only a tenth of the band is clear of ink at either end.

   That kills the card's shape arithmetically. A three-stop gradient with a knee
   at the card's 55% and true 0 at 100% retains only
   (1 - 0.8974) / (1 - 0.55) = 0.22800 of its knee alpha at 89.74%, so it would
   need a knee alpha of 2.5811 to hold #ece8e1 at 4.5:1 there, and 2.3451 to hold
   #ffffff. Neither exists, and this is not a near miss: rendered at that shape,
   20 of 20 measured cells fail and the description bottoms out at 1.134:1.

   PUSHING THE KNEE BELOW THE COPY WAS TRIED, AND IT IS THE ONE REJECTION THAT
   IS NOT SHARED WITH THE OTHER SECTIONS ON THIS SITE. A knee at
   calc(100% - 3rem) — 3rem being padding-block's own floor, so the knee sits at
   or below the content box's bottom edge by construction — does pass, at every
   cell, 0 failures. It was rejected on two counts, both measured:

     1. The margin is zero BY CONSTRUCTION and 5px in fact. The knee lands
        exactly on the content box's bottom edge at the minimum padding, so the
        only clearance the copy has is glyph descent: 5px at 320px / 16px root.
        The floor below has no such dependency at all.
     2. What it buys is a rim, not a reveal. The fade zone is 3rem — 48px at a
        16px root, 72px at 24px — against a band 320px to 817px tall, i.e. 5.9%
        to 14.6% of the box, and the bottom 32px of it is inside --radius-lg's
        corner curve. Rendered at 1440px it reads as a light leak along the
        bottom edge rather than as the card's reveal.

   Worth noting WHY that rejection had to be re-argued here rather than
   inherited: the objection that kills a bottom fade elsewhere on this site is
   that the section's bottom edge butts a solid field, so bare photograph there
   is a seam. That objection does NOT apply to this band any more — it is inset
   and rounded now, floating on the page's own field rather than butting one at
   its side edges. (Not quite "on --paper" throughout, and the difference is
   recorded rather than glossed: the band has no BLOCK margin, so it still butts
   .section--how above and .section--faq below, and those two carry --paper-2 and
   --paper respectively. The 40px side strips fall through to the body's --paper,
   which makes the two sections' field change faintly visible along the band's
   top edge — ΔRGB 13/13/8 since the 2026-09-13 palette change, 9/11/15 before
   it. See "The lifestyle band" in README.md.) The fade
   fails here for its own reasons, not the inherited one.

   SO THE GRADIENT IS MONOTONE AND ITS LAST STOP IS A FLOOR, and that buys a
   guarantee the card's geometry-derived tables cannot give a fluid box: alpha
   never falls below the floor ANYWHERE in the band, at ANY height, so contrast
   does not depend on the section's height, on how the copy wraps, or on the root
   font size. There is no table to re-derive when the copy changes.

   THE FLOOR IS 0.62, AND IT IS SET BY THE DESCRIPTION'S COLOUR. Over a
   worst-case pure-white photograph, black at alpha a composites to a (1 - a)
   grey, and 4.5:1 needs:

     #ffffff (title)        a >= 0.53468
     #ece8e1 (description)  a >= 0.58850   <- binding
     #8E8E93                a >= 0.84171   (--dark-ink-2; #b9b3a9, the token's
                                            value before 2026-09-13, needed
                                            0.72371 — the case against using the
                                            token here got stronger, not weaker)

   0.62 clears the binding requirement by 0.03150 of alpha — Chrome paints it at
   0.61961 after 8-bit quantisation, still 0.03111 clear — and gives 5.072:1 for
   the description and 6.193:1 for the title AT THE FLOOR, against a photograph
   that is pure white. That is the floor's worth, not the band's: the real
   photograph is far darker than white, and no copy reaches the bottom edge, so
   the worst contrast anything actually paints at is 5.392:1 (description, 320px
   / 24px root) and 9.291:1 (title, 1024px+ / 16px root) over a pure-white
   photograph, and 6.03:1 / 10.083:1 over the real one. 204 of 204 checks pass —
   17 widths x 3 roots x 2 backgrounds (pure white and the real photograph) x
   2 colours.

   AND 0.62 IS THE FIRST SAFE VALUE ABOVE A CLIFF, which is why it is not lower.
   Quantisation makes this discrete, not continuous: 0.59 paints grey 105 and
   gives the description 4.495:1 — it FAILS, by five thousandths, and it fails
   silently. 0.60 paints grey 102 and gives 4.702:1, clearing by 4.5%. 0.62
   paints grey 97 and clears by 12.7%. The two hundredths between 0.60 and 0.62
   are worth nothing visually and are the whole margin against subpixel
   antialiasing on the description's stems, which are thinner than the title's.

   0.62 IS NOT THE 0.66 THIS BAND USED TO CARRY, and the difference is not the
   flattering one. Stated plainly rather than spun: the old flat veil was 0.66 at
   every row, this gradient crosses 0.66 at 77.78% of the band, and its mean
   alpha is 0.710. THE BAND IS DARKER ON AVERAGE THAN IT WAS, darker over its top
   78% and lighter only over its bottom 22%. That is a direct consequence of
   adopting the card's 0.80 top stop, which is what "the same overlay style" asks
   for and is the one number in this rule that was not derived here. What changed
   is the READ, not the exposure: the photograph now resolves out of the dark as
   the eye travels down the band instead of sitting under an even grey, and the
   rounded bottom corners land on its most visible part. If the owner wants the
   band lighter overall, the top stop is the number to move, not the floor —
   the floor is a contrast bound and 0.62 is already one cliff above failing.

   IT IS A SHALLOWER FADE THAN A CARD'S, AND IT HAS TO BE. With the copy filling
   up to 89.74% of the band there is only the top and bottom padding left to fade
   across, and every row the copy touches is bound by the 0.58850 above. That is
   geometry, not a defect, and it is recorded here so the next reader does not
   "fix" it by steepening it. The one change that WOULD restore a card's fade is
   making this band fixed-height with its copy pinned to the top, which is a
   redesign of a shipped section and not a scrim parameter. */
.band__overlay {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(to bottom,
    rgb(0 0 0 / 0.8) 0,
    rgb(0 0 0 / 0.62) 100%);
}

.band__inner { padding-block: clamp(3rem, 8vw, 5rem); }

/* The band's heading, new on 2026-09-12 with the final copy. It is not a new
   treatment: it is EXACTLY what .band__statement was until that day — the same
   serif, the same clamp, the same line-height, the same white, the same 34rem
   measure — so the band's display voice at the top of the box is unchanged and
   only its role in the outline is. The single big serif line the section always
   showed is now a heading with a paragraph under it. */
.band__title {
  font-family: var(--serif);
  font-size: clamp(1.5rem, 1rem + 2.2vw, 2.5rem);
  line-height: 1.25;
  color: #ffffff;
  max-width: 34rem;
}

/* The description, which is the old .band__statement demoted from display type
   to body type now that a heading sits above it. Every value here is
   .hero__subhead's, copied deliberately rather than invented: the hero is the
   site's other full-bleed photographic section with a heading and a supporting
   line under it, so this band and that one now read as the same construction at
   different scales.

   #ece8e1, not #ffffff, and not var(--dark-ink-2). It was #ffffff while it WAS
   the display line; as a supporting paragraph under a white heading it takes the
   site's over-photograph body colour, which .hero__subhead and
   .step--photo .step__body both already use — the other half of the very pairing
   the referenced how-it-works card uses. That choice is what sets the scrim's
   floor: see .band__overlay, where #ece8e1 needs alpha 0.58850 against a
   worst-case white photograph and is the binding constraint on the whole
   gradient. var(--dark-ink-2) (#8E8E93 since 2026-09-13, #b9b3a9 before) would
   need 0.84171, at which the photograph the band was just given is not merely
   dimmed but gone. */
.band__statement {
  margin: 1.5rem 0 0;
  max-width: 34rem;
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  color: #ece8e1;
}

/* ------------------------------------------------------------------ faq */

/* THE FAQ SECTION IS A ROUNDED CARD LAYERED OVER THE CTA BAND (2026-09-12,
   round 11). The product owner asked for 32px on the section's bottom corners
   "so it will look as it is above the cta-band section" — the corners are meant
   to reveal the dark band underneath, not to notch out to blank page.

   A BORDER-RADIUS ON ITS OWN WOULD NOT HAVE DONE THAT, and this page already
   knows why: a corner only reveals what is genuinely positioned behind it. The
   FAQ's bottom edge butts the CTA band's top edge with nothing between them, so
   rounding the corners alone would carve two notches down to the body's --paper
   — the same colour as the FAQ itself. Invisible. The site solved the identical
   problem earlier the same day for the sticky header over the hero, and this is
   that construction run in the other direction; see --mc-header-overlap.

   THE MIRROR IS NOT A COPY, because the DOM order is reversed and that changes
   which element needs help.

     header/hero   the element in front (.site-header) renders AFTER the element
                   behind (.hero). Later tree order already paints it on top in
                   the overlap zone, so no z-index was needed. The element
                   BEHIND grew to pay for the overlap.

     faq/cta       the element in front (.section--faq) renders BEFORE the
                   element behind (.cta-band). Tree order is now working against
                   us: block-level backgrounds paint in tree order, so pulling
                   .cta-band up would paint its dark field OVER the bottom 50px
                   of the FAQ — covering the corners rather than backing them,
                   which is the opposite of what was asked for.

   SO THE FAQ IS GIVEN THE PRECEDENCE ITS TREE POSITION DENIES IT. position:
   relative + z-index: 1 moves this section out of the block-background pass and
   into the positioned-descendants pass, which paints later, so it lands in front
   of .cta-band's untouched, unpositioned background. That is the whole of the
   stacking work — .cta-band is deliberately left non-positioned, because the
   moment both elements are positioned the order becomes a pair of numbers to
   keep in agreement instead of one number against a default.

   z-index: 1, not more. The only other z-index this section could ever meet is
   the sticky header's 20, and the header must keep winning: it scrolls over this
   section like every other. 1 clears the unpositioned page and nothing else.
   Verified by rendering — the header still paints over the FAQ at every scroll
   position.

   NOT SCOPED TO .page-home, unlike the header rule, and the difference is real
   rather than an oversight: .site-header is on all five routes, so pulling it up
   unconditionally would have dragged <main> under it on the four without a hero.
   .section--faq and .cta-band exist only on the home page — /support's FAQ list
   is .section--support — so there is no other route for either rule to reach.

   THE RADIUS IS --radius-lg, NOT A LITERAL 32px. The owner asked for 32, and 32
   is what the token holds; taking the token means this corner is the header's
   corner, which is the point — the header rounds its top by it as it rises out
   of the hero, and this section rounds its bottom by it as it sits over the CTA
   band. The same gesture at the two ends of the page, held equal by the
   stylesheet rather than by two literals agreeing by luck.

   WHAT THE FAQ'S OWN CONTENT PAYS FOR THIS: nothing. The section's --rhythm
   bottom padding is clamp(4rem, 10vw, 8.5rem) = 64...136px, so the last
   .faq__item's bottom border sits at least 64px above the section's bottom edge
   against a 32px curve — the curve cannot reach it at any viewport. And the
   curve is at the window's left and right edges while the questions are inside
   .wrap--narrow, so it is nowhere near the copy horizontally either. Rendered at
   375, 768 and 1440 to confirm rather than assumed. */
.section--faq {
  background: var(--paper);
  border-bottom-left-radius: var(--radius-lg);
  border-bottom-right-radius: var(--radius-lg);
  position: relative;
  z-index: 1;
}

.faq { margin-top: clamp(2rem, 5vw, 3rem); border-top: 1px solid var(--line); }

.faq__item { border-bottom: 1px solid var(--line); }

.faq__question {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.5rem;
  padding: 1.35rem 0;
  font-family: var(--serif);
  font-size: clamp(1.0625rem, 1rem + 0.5vw, 1.3125rem);
  line-height: 1.3;
}
.faq__question::-webkit-details-marker { display: none; }

.faq__question::after {
  content: "+";
  flex: none;
  font-family: var(--sans);
  font-size: 1.25rem;
  line-height: 1;
  color: var(--ink-3);
  transition: transform 160ms ease;
}
.faq__item[open] .faq__question::after { content: "\2212"; }

.faq__answer {
  padding-bottom: 1.6rem;
  max-width: 40rem;
  color: var(--ink-2);
}

/* ------------------------------------------------------- closing region */

/* THE CLOSING COPY AND THE FOOTER ARE ONE PHOTOGRAPHIC SECTION (2026-09-12,
   round 12). The product owner asked for the CTA band and the footer to read as
   a single section over one shared background image — on the home page only.
   `.closing` is that container: it holds the photograph, the scrim, the closing
   <section class="cta-band"> and, after it, the shared <footer>.

   IT IS A SIBLING OF <main>, NOT A WRAPPER AROUND THE EXISTING BAND, and that
   is a parser fact rather than a preference. A div opened inside <main> cannot
   be closed after </main>: the `</main>` end tag generates implied end tags and
   pops the stack until <main> is popped, taking the div with it, so the footer
   lands outside the container and the trailing `</div>` is discarded. Verified
   in Chrome. The container therefore has to start after </main>, which is why
   the closing copy moved out of templates/pages/home.php and into
   templates/partials/closing-open.php, emitted by mc_footer_wrap_open(). The
   hero already sits outside <main> for the same class of reason.

   HOME ONLY, WITH NO .page-home SCOPE NEEDED. `.closing` is emitted by a hook
   that returns '' for every template but `home`, so the element simply does not
   exist on the other four routes and every rule below is unreachable there.
   That is the same reasoning .section--faq and .cta-band already use, and it is
   why the two overrides this rule puts on the SHARED footer (see
   `.closing .site-footer`) cannot leak: they are descendant selectors under an
   element that only the home page has.

   THE OVERLAP MOVED UP HERE FROM .cta-band, and it had to. Round 11 pulled the
   CTA band 50px under the FAQ so the FAQ's rounded bottom corners had something
   dark behind them. If the negative margin had stayed on .cta-band it would now
   pull the band up INSIDE this container — .closing establishes a block
   formatting context via overflow: hidden, so the margin cannot escape it —
   leaving the photograph's top edge, and the notches, at the FAQ's bottom edge
   with page background showing through the curve again. The whole container is
   what has to rise. .cta-band keeps the padding that pays for it, so the copy
   does not move; see the rule below.

   background: var(--dark) is the base the photograph sits on: the fallback if
   the image fails or has not loaded, and the colour behind the scrim's own
   transparency. It is what .cta-band and .site-footer both used to paint for
   themselves, moved to the one element that now owns the field.

   overflow: hidden IS LOAD-BEARING HERE, unlike on .cta-band before it, where
   it was inert and kept only for consistency. The photograph animates — a slow
   zoom and pan, see .closing__image — so it deliberately extends past this
   box's edges for the whole of its cycle, and this is what clips it. .hero and
   .band clip the same way for the same reason.

   IT IS AT LEAST A VIEWPORT TALL (2026-09-13, round 14), so the page closes on a
   full-height photographic section the way it opens on one. This is the hero's
   rule at the other end of the document, written the same way, including the
   overlap term — see below for why that term is not optional here either.

   min-height, not height: on a phone, or at a large root font size, this
   region's own content is already far taller than the window (1840px at 320px
   wide with a 24px root) and it must grow, not clip. The two declarations are
   the site's established fallback pair: a browser without dynamic viewport
   units drops the dvh line and keeps the vh one.

   THE + --mc-faq-overlap IS THE SAME CORRECTION .hero MAKES WITH
   --mc-header-overlap, for the same reason and with the same arithmetic. The
   top --mc-faq-overlap pixels of this box are underneath the FAQ's opaque
   --paper field — that is the whole point of the negative margin above — and
   are never seen except through the two corner notches. A bare 100dvh would
   therefore paint a region whose VISIBLE height is one viewport minus 50px, and
   "at least the window's height" would be measurably false by exactly the
   overlap. Adding it back makes the visible field exactly one viewport, which
   is what a full-height section means. Note that unlike .hero this rule needs
   no compensating padding: the hero's copy is pinned to its content edge, so it
   pays the overlap back as padding-bottom; here the overlap sits at the TOP,
   above content that is bottom-anchored, so it is absorbed by the slack.

   THE CONTENT IS ANCHORED TO THE BOTTOM, AND THAT IS A DECISION, NOT A DEFAULT.
   Once the box can be taller than its content there is slack to place, and the
   four candidates are not equal:

     top (the block default) — the legal line stops mid-window and the page ends
       in up to 755px of empty photograph. The document's last element must be
       at the document's bottom edge; anything else reads as a page that failed
       to finish loading.
     centre — same failure, halved.
     space-between — puts the entire slack BETWEEN the closing copy and the
       footer, which is the one place on this page it must never go. Round 12
       existed to delete the seam between those two halves; a 755px void does
       the seam's job better than the 1px border ever did.
     bottom — the slack lands above the copy, as bare photograph, and the copy
       and footer stay one contiguous stack ending exactly at the page's bottom
       edge. This is also the only one of the four that mirrors .hero, which
       bottom-aligns for its own reason (align-items: flex-end against a
       row-direction container; the axis differs, the gesture does not).

   So: a column flex container with justify-content: flex-end. The slack becomes
   a clear zone at the top of the photograph, which the round-12 scrim
   derivation specifically noted this region did not have — it is inked almost
   end to end — so the picture gets somewhere to be a picture, above the copy
   rather than behind it.

   WHAT FLEX DOES NOT DISTURB, checked rather than assumed. .closing__media and
   .closing__overlay are absolutely positioned with inset: 0, so they are not
   flex items and their boxes are unchanged. .cta-band and .site-footer become
   flex items and stretch to full width exactly as blocks did; neither carries a
   block margin, and the container was already a block formatting context via
   overflow: hidden, so no margin collapsing behaviour changes. The automatic
   minimum size of a column flex item is its content size, so neither can be
   squashed when the content is the taller of the two. */
.closing {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: calc(100vh + var(--mc-faq-overlap));
  min-height: calc(100dvh + var(--mc-faq-overlap));
  overflow: hidden;
  margin-top: calc(-1 * var(--mc-faq-overlap));
  background: var(--dark);

  /* THE OVER-PHOTOGRAPH BODY COLOUR, APPLIED BY RE-POINTING THE TOKEN RATHER
     THAN BY LISTING SELECTORS. Everything dim in this region reads
     var(--dark-ink-2) (#8E8E93 since the 2026-09-13 palette change, #b9b3a9
     before it): the closing support line, the footer's signup
     note, its field label, its nav links and its legal line — which since the
     2026-09-13 footer redesign is a copyright statement plus two legal links
     that inherit the colour from it rather than restating the token. The token
     is correct against the flat var(--dark) field these elements sit on
     everywhere else — 6.441:1 today, 8.98:1 before the palette change — and it
     cannot work over a photograph: black at alpha `a` over a
     worst-case pure-white photograph composites to a (1 - a) grey, and #8E8E93
     needs a >= 0.84171 for 4.5:1 (#b9b3a9 needed 0.72371), at which the
     photograph is effectively gone. The palette change made this re-point MORE
     necessary, not less.

     #ece8e1 needs 0.58850, which is what makes a visible photograph possible at
     all, and it is not a new colour: it is this site's over-photograph body
     colour, already used by .hero__subhead, .step--photo .step__body and
     .band__statement. This is that convention applied to a footer that has just
     become an over-photograph surface.

     RE-POINTING THE TOKEN, NOT ENUMERATING THE FIVE SELECTORS, is deliberate
     and is the more robust of the two. Four of those five selectors belong to
     the footer, which is shared markup this round must not modify; listing them
     here would mean any dim element added to the footer later silently fails
     its contrast in this one region, and would mean this file naming internals
     it does not own. Re-pointing says the thing that is actually true: inside
     the photographic region, the site's dim-on-dark token IS the site's
     over-photograph body colour. Anything that reads the token is correct by
     construction. It is scoped to .closing and so cannot reach the other four
     routes' footers, which keep the token's own value on their flat dark
     field — #8E8E93, measured at 6.441:1 there. */
  --dark-ink-2: #ece8e1;
}

.closing__media { position: absolute; inset: 0; z-index: -2; }

/* THE SLOW ZOOM AND PAN, AND WHY IT IS ON THE <img> AND NOT ON ANYTHING ELSE.

   The product owner asked for the photograph to animate to
   `translate3d(0, -60px, 0) scale(1.25)` from its resting state over 20
   seconds. The transform is on the image itself, which is the innermost element
   of the three-part shape: .closing__media stays a stable positioning box, and
   .closing__overlay is the media wrapper's SIBLING rather than its child, so
   the scrim does not move, scale or pan with the picture. That is a
   requirement, not a tidiness point, and it is what keeps the contrast
   derivation below valid — the scrim covers whatever part of the photograph has
   drifted under the copy, because the scrim never drifts.

   IT PLAYS ONCE AND HOLDS. `forwards`, no iteration count, no direction —
   the product owner's call, answering round 12's open question 3. Round 12 had
   shipped `infinite alternate` and argued against a one-shot on a timing
   ground: a time-based animation starts at page load, this section is at the
   bottom of a ~9,000px page, and twenty seconds is comfortably less than the
   time it takes to scroll there, so most visitors arrive after the animation
   has finished and meet a static, fully-zoomed frame. That observation is still
   true and is not a defect — the end state IS the intended resting state now,
   and the requested transform is reached at exactly 20s either way. It is
   recorded here so nobody re-derives it as a bug. Fixing it properly would mean
   starting the animation on intersection, which is a scroll-timing/JS change
   nobody has asked for.

   `forwards` and not `both`: there is no animation-delay, so the backwards fill
   has nothing to apply to and the two are identical in effect here. `forwards`
   is the narrower of the two and says exactly what is meant — hold the last
   keyframe.

   ease-in-out, so the single pass eases out of rest and settles into its final
   frame rather than stopping dead.

   No will-change. An animated transform is composited anyway, and pinning a
   permanent layer on a below-the-fold image for the life of the page buys
   nothing measurable and costs memory on every visit.

   Disabled outright under prefers-reduced-motion: reduce — see the reduced
   motion block at the foot of this file, and READ THE NOTE THERE BEFORE
   TOUCHING EITHER. The explicit `animation: none; transform: none` there is now
   load-bearing rather than belt-and-braces: the blanket reduce rule only forces
   duration to 0.001ms and iteration-count to 1, it does not clear the fill
   mode, so on its own it would run this animation to completion in a
   millisecond and `forwards` would pin the image at full zoom — the exact
   opposite of what reduced motion is for. The per-selector override is what
   keeps the image at its natural size, and it must stay.

   CONTAINMENT. scale(1.25) about the default 50% 50% origin overhangs the box
   by 12.5% on each edge; the -60px lifts the whole thing, so the BOTTOM edge is
   the one that can run short. At animation progress t the image's bottom edge
   sits at H + t x (0.125H - 60) for a box of height H, so it covers the box at
   every t in [0,1] exactly when H >= 480px. The one-shot hold rests at t = 1
   permanently, which is the worst case in that range and therefore the case the
   bound was already derived for; going one-shot does not weaken it, it just
   means the worst case is now the steady state rather than a moment in a cycle.
   That bound is cleared with room to spare at every viewport measured, and the
   min-height added in round 14 only widens the margin: the shallowest .closing
   measured is 862.30px (1024x768 at a 16px root), which clears 480px by 1.80x,
   and the tallest is 1896.86px (320x568 at a 24px root). Re-measured 2026-09-14
   (round 25) across 8 viewports x 3 roots on the photograph now in the repo: 24
   of 24 cells cover the box at the held end state, tightest bottom slack
   47.79px. (This comment said 818px and README said 818.00px twice and 852.84px
   once; all three were pre-round-17 numbers. The H >= 480px bound itself has not
   moved.) .closing's overflow: hidden clips the other three edges. */
.closing__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  animation: mc-closing-pan 20s ease-in-out forwards;
  animation-iteration-count: 1;
}

@keyframes mc-closing-pan {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(0, -60px, 0) scale(1.25); }
}

/* THE SCRIM. Same construction as .band__overlay, and the same two numbers, for
   the same reason — but the derivation is this region's own, because the
   geometry is not the lifestyle band's.

   A MONOTONE GRADIENT WITH A FLOOR, not the how-it-works card's fade to
   transparent. A card is a fixed box with its copy pinned to the top, so its
   bottom is empty and a fade can reach bare photograph there. This region is
   the opposite case in every respect: it is fluid, and it is inked almost end
   to end — the closing copy occupies its top, the footer's signup form, nav and
   legal line occupy the rest, and the deepest inked row is the legal line at
   roughly 95% of the box. There is no clear zone for a fade to land in.

   What a floor buys is a guarantee no geometry-derived table can give a fluid
   box: alpha never falls below the floor ANYWHERE, so contrast does not depend
   on the region's height, on how the copy wraps, on the root font size, or on
   whether the footer grows a nav link. There is no table to re-derive.

   THE FLOOR IS 0.62 AND IT IS SET BY THE BODY COLOUR, which is #ece8e1 here for
   the reason given on .closing above. Over a worst-case pure-white photograph:

     #ffffff (the closing title, the footer's two headings)  a >= 0.53468
     #ece8e1 (every dim element, via --dark-ink-2)           a >= 0.58850  <- binding

   0.62 clears the binding requirement by 0.0315 of alpha; Chrome paints it at
   0.61961 after 8-bit quantisation, still 0.0311 clear. The margin is discrete
   rather than continuous and that is why the floor is not lower: 0.59 paints
   grey 105 and gives #ece8e1 4.495:1 — it fails, by five thousandths, and
   silently.

   0.80 at the top is the site's established top stop, carried from
   .step--photo .step__overlay through .band__overlay. Both numbers being
   .band__overlay's is a real outcome and not a copy-paste: the binding colour
   is the same, the floor requirement is therefore the same, and the top stop is
   a house value. Written as literals rather than shared with .band__overlay
   because the two derivations are independent — this region's could move
   without the band's, and a shared token would hide that. */
.closing__overlay {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(to bottom,
    rgb(0 0 0 / 0.8) 0,
    rgb(0 0 0 / 0.62) 100%);
}

/* THE TWO THINGS THE SHARED FOOTER GIVES UP INSIDE THIS CONTAINER, and nothing
   else. templates/partials/footer.php is untouched and renders identical markup
   on all five routes; these two declarations are the whole of the difference on
   the home page.

   background — the footer paints var(--dark), which is opaque and would hide
   the photograph completely. .closing carries that colour now.

   border-top — a 1px rgba(255,255,255,0.08) rule across the top of the footer.
   On the other four routes it separates the footer from the page. Here it would
   draw a line across the middle of a single photograph, which is precisely the
   seam the owner asked to remove; it is the one declaration standing between
   "one continuous section" and "two boxes stacked". The footer's own internal
   divider above .footer-legal is left alone: that one is inside the footer and
   still separating what it always separated. */
.closing .site-footer {
  background: transparent;
  border-top: 0;
}

/* -------------------------------------------------------------- cta band */

/* THE BAND IS PULLED UP UNDER THE FAQ, AND PAYS FOR IT — but since 2026-09-12
   (round 12) only the paying half is here. The negative margin that does the
   pulling moved to .closing, which is the element that now carries the field
   and the photograph and therefore the element that has to rise; see the note
   there. This band keeps the padding-top that compensates for it, because the
   thing being compensated is this band's own copy.

   It is still one movement, not two changes. .closing's negative margin slides
   the whole region's top border edge --mc-faq-overlap higher, under the FAQ;
   the same amount added to this band's top padding pushes the closing copy back
   down by exactly as much. Net effect on the headline and the supporting line is
   nil — measured, they sit at the same y they did before the overlap existed —
   and the net effect on total page height is nil too, because the region grows
   by precisely what the margin took away. The band's own top clearance is
   therefore untouched: the copy still has the full clamp(4rem, 9vw, 6.5rem)
   between it and the region's visible top. The two halves must keep reading one
   token: if they ever disagree, the closing copy moves and the page height
   changes, silently and in the same direction. They now live in two rules,
   which makes that easier to break — said here so it is visible.

   --mc-cta-pad exists only so the clamp is written once. padding-block's two
   values would otherwise repeat it, and a top and bottom padding that are meant
   to differ by exactly the overlap should not be able to drift in their base
   value as well.

   NO background AND NO position ANY MORE. The field belongs to .closing, which
   paints var(--dark) behind the photograph; a background here would sit in
   front of the picture and hide the top third of it. Leaving this element
   unpositioned is also still what lets .section--faq's z-index: 1 win against a
   default rather than against a competing number — .closing is positioned, but
   it has no z-index, so it paints in the same pass and still loses to 1.

   padding-bottom is the gap between the closing copy and the footer's first
   heading, and it is the only thing separating them now that there is no seam
   and no colour change between the two. */
.cta-band {
  --mc-cta-pad: clamp(4rem, 9vw, 6.5rem);
  color: var(--dark-ink);
  padding-block: calc(var(--mc-cta-pad) + var(--mc-faq-overlap)) var(--mc-cta-pad);
  text-align: center;
}

.cta-band__title {
  font-size: clamp(1.875rem, 1.2rem + 3vw, 3.25rem);
  color: #ffffff;
}

.cta-band__support {
  margin: 1.35rem auto 0;
  max-width: 34rem;
  color: var(--dark-ink-2);
}

/* --------------------------------------------------------------- footer */

.site-footer {
  background: var(--dark);
  color: var(--dark-ink);
  padding-block: clamp(3rem, 7vw, 4.5rem);
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

/* TWO GRID ITEMS, FOUR CONCEPTUAL COLUMNS. The footer holds the signup and three
   nav columns, and this grid still has exactly two children: the signup, and the
   <nav> that contains all three columns as its own sub-grid (see .footer-navs).

   Not four tracks here with the columns as direct children, which would need
   `display: contents` on the <nav> to flatten it — and `display: contents` on an
   element carrying a landmark role is the one place that property is still known
   to drop semantics in some engines. The landmark is worth more than the flatter
   markup, and a nested grid costs nothing: the two levels never need to align.

   THE RATIO IS REVERSED, 1 : 1.35 WHERE IT WAS 1.4 : 1. The nav side used to be
   one column of five links and could take the smaller share; it is now three
   columns and needs the larger one. The old ratio is what would have broken this
   layout silently: at 768 it hands the nav side 275px, and three columns whose
   widest unbreakable word ("Company", 80.02px at the default root) need 360px
   with this gap. Measured at the same 768 the new ratio gives 379px — clear.

   Below the ratio's reach, nothing here needs to hold: .footer-navs derives its
   own column count from the width it is actually given, so a visitor at a large
   root font size gets fewer nav columns rather than an overflow. */
.footer-grid {
  display: grid;
  gap: clamp(2.5rem, 6vw, 4rem);
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
  align-items: start;
}

/* THE NAV COLUMN COUNT IS DERIVED, NOT DECLARED. `auto-fit` fits as many
   minmax() tracks as the container's real width allows and collapses the rest,
   so "three columns" is a consequence of there being room for three rather than
   an assertion that gets falsified at 320px or at a 24px root font size.

   Why that matters here, measured rather than assumed. The three headings and
   five links set in SF Pro Text at the default root are: Company 80.02, Connect
   71.61, Product 67.03, Contact us 76.92, Features 61.56, LinkedIn 59.30,
   Support 57.08, FAQ 29.23. The binding number is 80.02px, and three columns of
   that plus two 1.5rem gaps need 288px. A 320px phone gives this element 280px
   of content width. A declared `repeat(3, 1fr)` would therefore have overflowed
   or wrapped "Company" on the narrowest common phone, with 0.02px of margin —
   and would have failed outright the moment a visitor enlarged their text, since
   the type scales with the root and the 320px viewport does not.

   6.5rem (104px) is the min track: comfortably above the 80.02px it has to hold,
   and it puts the 3-column threshold at 360px of container width. That lands the
   fold where it should be — 320 and 375 get two columns with "Connect" starting
   a second row, everything from roughly 400px up gets three. In rem, not px,
   deliberately: what the track has to contain is rem-sized type, so the switch
   should move with the visitor's font size. At a 24px root a 320px phone gets
   one column, which is the right answer there and one no fixed count can give.

   min(100%, 6.5rem) rather than a bare 6.5rem so that the single remaining track
   can never be wider than its container. With `repeat()` the count is at least 1,
   and a bare min would let that last track exceed a container narrower than
   6.5rem and push a horizontal scrollbar onto the page.

   Two gap values: 1.5rem between columns, 2.25rem between rows, because the row
   gap only exists in the folded state and there it separates two headings, which
   need more air than two adjacent columns do. */
.footer-navs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 6.5rem), 1fr));
  gap: 2.25rem 1.5rem;
  align-items: start;
}

.footer-signup__title {
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);
  color: #ffffff;
}

.footer-signup__note {
  margin-top: 0.75rem;
  color: var(--dark-ink-2);
  font-size: 0.9375rem;
}

/* THE BUTTON SITS UNDER THE FIELD AT EVERY WIDTH, on the product owner's
   instruction. It used to be a flex sibling of the field (`flex: 1 1 15rem` on
   the field, wrap allowed) which put the two side by side wherever the column
   was wider than about 27rem and stacked them below that — one layout that read
   two different ways depending on the viewport.

   This is a grid rather than a flex column because the site already has exactly
   this form shape and solves it exactly this way: .contact-form on /support is
   `display: grid; gap; justify-items: start` with `.field { width: 100% }`, and
   its submit button already sits under its fields. Reusing that construction
   means the two forms on this site now stack for the same reason in the same
   words, instead of by two different mechanisms that agree by luck.

   THE BUTTON MATCHES THE FIELD ON BOTH AXES, on the product owner's instruction
   of 2026-09-14, and that reverses what this comment argued until that date. It
   read: "justify-items: start is what keeps the button its own width and flush
   with the field's left edge. Stretch would make it span the whole column, which
   at 1440 is a 395px 'Join the waitlist' button." The 395px is real — it is the
   field's own width at 1440 — and it is now the point rather than the objection:
   the owner wants one control shape repeated, not a wide field with a small
   button parked under its left corner. The phone already looked like that
   (.signup-form .btn was width: 100% under 30rem since round 17); this makes the
   whole column agree with the phone instead of only the phone agreeing with
   itself.

   justify-items: start stays, and the width is set on the button instead. Two
   reasons: the honeypot .hp-field is a grid item too and has no business being
   stretched, and one explicit `width: 100%` on the button is the same mechanism
   .signup-form .field already uses, so the two items in this grid get their
   width the same way. The 30rem copy of that declaration is gone with it —
   it now says the same thing the base rule says.

   The HEIGHT match is line-height, not a magic number. Both controls take
   `font: inherit`, so they agree on family and size already; they disagreed
   because .btn then pins line-height: 1 while .field__input keeps the inherited
   body value. Restoring `inherit` on the button inside this form makes its line
   box the field's line box, and `padding-block: 0.85rem` is the field's own
   padding, copied from .field__input below rather than invented — so
   border + padding + line box are term-for-term equal and the two heights are
   equal at every root size and every viewport, with nothing to re-tune if the
   type scale moves. Measured at 16/20/24px roots: 55.77/69.00/82.38px, field and
   button alike, against 46.89/58.00/69.19px for the button before this. */
.signup-form {
  margin-top: 1.5rem;
  display: grid;
  gap: 0.75rem;
  justify-items: start;
}

.signup-form .field { width: 100%; }

.signup-form .btn {
  width: 100%;
  padding-block: 0.85rem;
  line-height: inherit;
}

.field { display: block; }

.field__label {
  display: block;
  font-size: 0.8125rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-bottom: 0.5rem;
  color: var(--dark-ink-2);
}

.field__input {
  width: 100%;
  font: inherit;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.85rem 0.9rem;
}

.field__input--area { resize: vertical; }

.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form-result__message {
  margin: 1rem 0 0;
  padding: 0.75rem 0.9rem;
  border-radius: var(--radius);
  font-size: 0.9375rem;
}

/* THE SUCCESS MESSAGE IS ACCENT-COLOURED, AND SO IT HAD TO MOVE WITH THE ACCENT.
   rgba(39,92,75,...) is #275c4b — the OLD --accent — written out with an alpha,
   and #dff0e8 was its pale companion. Neither was an independent colour: they
   were the accent restated, and leaving them would have left this the only green
   thing on a cyan site. They are re-hued onto mcSecondaryColor / systemCyan
   dark, keeping the alpha structure and the contrast positions the old pair had.

   The ERROR message below is deliberately NOT touched: red is a semantic colour
   that never derived from --accent, and the palette change gives it no reason to
   move. Whether it should become the app's own mcRed is a separate question and
   is recorded as one in the plan doc.

   This variant sits in the footer, on --dark or on the closing photograph.
   Measured over the worst case of the two (flat #000000, where the tint is
   darkest): the text reads 14.630:1 against its own composited panel, against
   13.833:1 for the pair it replaces. */
.form-result__message--success {
  background: rgb(0 126 174 / 0.22);
  border: 1px solid rgb(60 211 254 / 0.5);
  color: #d6eefa;
}

.form-result__message--error {
  background: rgba(150, 60, 45, 0.22);
  border: 1px solid rgba(220, 140, 120, 0.5);
  color: #f8e2dc;
}

/* .footer-nav is a column inside .footer-navs now, not the <nav> itself. The
   three selectors below are unchanged and deliberately so: the column keeps the
   class it had, so every rule written against it — and the round 12 note that
   enumerates the footer's dim elements — still describes the same boxes. */
.footer-nav__title {
  font-size: 1.125rem;
  color: #ffffff;
  margin-bottom: 1rem;
}

.footer-nav__list { list-style: none; }
.footer-nav__list li { margin-bottom: 0.6rem; }
.footer-nav__list a {
  color: var(--dark-ink-2);
  text-decoration: none;
  font-size: 0.9375rem;
}
.footer-nav__list a:hover { color: #ffffff; text-decoration: underline; }

/* THE LEGAL LINE IS TWO GROUPS ON ONE ROW, and it is a flex container rather
   than one run of text with separators in it. The copyright statement keeps the
   requested middot inside itself ("© 2026 Mailcomm · All Rights Reserved");
   between that statement and the two links, the separation is layout — the row's
   gap and space-between — not a character.

   That is the whole reason for the structure. The three parts do not fit on one
   line at 320px: 243.00 + 84.94 + 81.22 = 409.16px of text against 280px of
   content width, measured at the default root. They therefore have to wrap, and
   a wrapped run of "a · b · c" leaves a separator hanging at the end of a line
   with nothing after it. Wrapping two flex items instead just puts the links on
   their own line, left aligned under the statement.

   space-between is inert once they wrap: each flex line holds one item, and a
   single item on a line sits at its start. So the pair reads as "statement left,
   links right" while there is room for one line and as a left-aligned stack once
   there is not, with no media query.

   The element is a <div> now rather than a <p> — it contains a <p> and a <ul>,
   which a <p> cannot. The class is unchanged, so the round 12 note about the
   divider above .footer-legal, and its --dark-ink-2 colour in the closing
   region, both still land on this element. */
.footer-legal {
  margin: clamp(2.5rem, 6vw, 3.5rem) 0 0;
  padding-top: 1.5rem;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  color: var(--dark-ink-2);
  font-size: 0.8125rem;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem 1.5rem;
}

/* The base rule gives every <p> a 1.15em bottom margin and only exempts
   :last-child. This one is a first child, so without this it would push the
   links' baseline down by 15px inside the flex row. */
.footer-legal__copy { margin: 0; }

.footer-legal__links {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 1.5rem;
}

/* color: inherit, not var(--dark-ink-2) restated. These links sit inside
   .footer-legal, which already reads the token, and inheriting means the closing
   region's re-point to #ece8e1 reaches them the same way it reaches the line
   around them. The hover treatment is .footer-nav__list a's, so every link in
   this footer behaves identically. */
.footer-legal__links a {
  color: inherit;
  text-decoration: none;
}
.footer-legal__links a:hover { color: #ffffff; text-decoration: underline; }

/* ------------------------------------------------------- support/contact */

.section--support .faq { margin-top: 2.5rem; }

.contact {
  margin-top: clamp(3rem, 7vw, 4.5rem);
  padding-top: clamp(2rem, 5vw, 3rem);
  border-top: 1px solid var(--line);
}

.contact__title { font-size: 1.625rem; margin-bottom: 0.75rem; }
.contact__note { color: var(--ink-2); }

.contact-form {
  margin-top: 1.75rem;
  display: grid;
  gap: 1.25rem;
  justify-items: start;
}
.contact-form .field { width: 100%; }
.contact-form .field__label { color: var(--ink-3); }

/* The light-field half of the same re-hue — see .form-result__message--success.
   #00506F is mcSecondaryColor darkened to hold the position #1d4a3c held on the
   old scheme: 7.744:1 against its own composited panel, against 8.244:1 before.
   --accent-strong itself would only reach 4.006:1 here, because the panel is a
   tinted --paper rather than --paper. */
.contact .form-result__message--success {
  background: rgb(0 126 174 / 0.1);
  border-color: rgb(0 126 174 / 0.35);
  color: #00506f;
}
.contact .form-result__message--error {
  background: rgba(150, 60, 45, 0.09);
  border-color: rgba(150, 60, 45, 0.35);
  color: #7a2f22;
}

/* ---------------------------------------------------------------- legal */

.legal__title { font-size: clamp(2rem, 1.4rem + 2.6vw, 3rem); }

.legal__meta {
  margin-top: 0.9rem;
  color: var(--ink-3);
  font-size: 0.875rem;
  letter-spacing: 0.04em;
}

.legal__body { margin-top: clamp(2rem, 5vw, 3rem); }

.legal__body h2 {
  font-size: clamp(1.25rem, 1.1rem + 0.8vw, 1.625rem);
  margin: 2.5rem 0 0.85rem;
}

.legal__body p { color: var(--ink-2); }

.legal__body ul {
  margin: 0 0 1.15em;
  padding-left: 1.25rem;
  color: var(--ink-2);
}
.legal__body li { margin-bottom: 0.4rem; }

.legal__crosslink {
  margin-top: clamp(2.5rem, 6vw, 3.5rem);
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  color: var(--ink-3);
  font-size: 0.9375rem;
}

/* ------------------------------------------------------------------ 404 */

.section--notfound { padding-block: clamp(5rem, 14vw, 9rem); }

.notfound__code {
  font-family: var(--serif);
  font-size: 3rem;
  color: var(--ink-3);
  margin-bottom: 0.5rem;
}

.notfound__links {
  list-style: none;
  margin-top: 2rem;
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
}
/* --accent-strong, not --accent: this is link TEXT on --paper and needs 4.5:1.
   systemCyan gives 2.164:1 there; mcSecondaryColor gives 4.570:1. */
.notfound__links a { color: var(--accent-strong); }

/* --------------------------------------------------- placeholder marking
   Stand-in copy awaiting the product owner's final wording is marked in the
   DOM with data-placeholder and made visually obvious here, so nothing can be
   mistaken for finished marketing copy. Delete this block on content handoff. */

[data-placeholder] {
  position: relative;
  outline: 1px dashed rgba(125, 115, 100, 0.55);
  outline-offset: 0.5rem;
}

[data-placeholder]::before {
  content: "placeholder copy";
  position: absolute;
  top: -1.35rem;
  left: 0;
  font-family: var(--sans);
  font-size: 0.5625rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(125, 115, 100, 0.9);
  pointer-events: none;
}

.hero [data-placeholder],
.band [data-placeholder],
.cta-band [data-placeholder],
.step--photo [data-placeholder] {
  outline-color: rgba(255, 255, 255, 0.4);
}
.hero [data-placeholder]::before,
.band [data-placeholder]::before,
.cta-band [data-placeholder]::before,
.step--photo [data-placeholder]::before {
  color: rgba(255, 255, 255, 0.72);
}
.cta-band [data-placeholder]::before { left: 50%; transform: translateX(-50%); }

/* ---------------------------------------------------------- scroll reveal
   The initial hidden state is applied only when JS is running (the .has-js
   class), so with JS disabled every section is visible from the start. */

.has-js [data-reveal] {
  opacity: 0;
  transform: translateY(1.25rem);
  transition: opacity 600ms ease, transform 600ms ease;
}

.has-js [data-reveal].is-revealed {
  opacity: 1;
  transform: none;
}

/* --------------------------------------------------------- reduced motion */

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

  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  .has-js [data-reveal] {
    opacity: 1;
    transform: none;
  }

  /* The closing region's photograph does not zoom or pan, and it renders at its
     natural size rather than at either end of the animation.

     DO NOT DELETE THIS AS REDUNDANT WITH THE BLANKET RULE ABOVE. It was
     redundant while the pan looped with no fill mode; it stopped being
     redundant the moment the pan became one-shot `forwards`. The blanket rule
     forces animation-duration and animation-iteration-count only — it does not
     touch animation-fill-mode — so by itself it would play the whole pan in
     0.001ms and then hold the final keyframe, leaving a reduced-motion visitor
     staring at a permanently zoomed, 60px-lifted photograph.

     `animation: none` resets animation-name, and a null name means no animation
     runs and no fill applies, whatever the blanket rule does to the other
     longhands. `transform: none` then states the resting geometry outright.
     See .closing__image. */
  .closing__image {
    animation: none;
    transform: none;
  }
}

/* -------------------------------------------------------------- narrow */

@media (max-width: 46rem) {
  .footer-grid { grid-template-columns: 1fr; }
  .site-header__inner { min-height: 3.5rem; padding-block: 0.5rem; }
  .site-nav__list { font-size: 0.875rem; }

  /* ------------------------------------------ the nav becomes a disclosure
     GATED ON .has-js, THE SAME WAY [data-reveal] IS. The class is written onto
     <html> by an inline script in the document head, after the stylesheet link
     and before the body renders, so with JS running the header is in this mode
     from first paint — no flash of the flat nav, no shift when site.js (defer)
     later attaches. With JS blocked NONE of these rules apply: no hamburger, no
     panel, the flat list wrapping onto a second header line exactly as it has
     since the site was built. A disclosure whose script never runs would put
     three links and the primary CTA behind a dead button.

     THE PANEL IS OUT OF FLOW, AND THAT IS LOAD-BEARING, not tidiness. site.js
     publishes --mc-header-h from a ResizeObserver on .site-header's BORDER BOX,
     and the pinned pillars section sizes its phone against it. An in-flow panel
     would grow that box by ~200px every time the menu opened, so opening the
     menu would resize the pillars' frame. Absolutely positioned, the panel is
     not part of its ancestor's box at all, and --mc-header-h is the same number
     open or closed — measured, not assumed. It resolves against .site-header
     itself, which is a containing block because it is position: sticky, and
     inherits that element's z-index: 20, so it paints over the page. top: 100%
     puts it directly under the header's bottom border.

     THE FILL IS THE HEADER'S OWN, literal for literal: --paper at 90% with the
     same saturate/blur behind it, so the panel reads as the header box
     continuing rather than as a second surface with its own colour. Its bottom
     corners take --radius-lg, the header's top corners, for the same reason.
     The inline padding is --gutter, which is what .wrap pays, so the panel's
     items line up with the wordmark above them. */
  .has-js .site-nav__toggle { display: inline-flex; }

  .has-js .site-nav__list {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0.25rem;
    padding: 0.5rem var(--gutter) 1rem;
    /* OPAQUE --paper, NOT THE HEADER'S 90% FILL, AND THIS WAS MEASURED RATHER
       THAN CHOSEN. The panel first shipped in this round wearing the header's
       own rgba(255,255,255,0.9) + saturate/blur, on the reasoning that it
       should read as the header box continuing. Rendered on /support it does
       not: the page's own "Support" heading and body copy show straight
       through the panel, and show through SHARP. A scan across an empty gap
       between two menu rows read #FFFFFF, #FEFEFE, #F6F6F6, #F1F1F1, #E9E9E9,
       #E6E6E6 in 8px steps — letterform edges, not a blur gradient.

       WHY THE BLUR DOES NOT FIRE: .site-header itself has a backdrop-filter,
       and an element with one is a BACKDROP ROOT for its descendants. The
       panel's backdrop is therefore what has been painted inside the header —
       nothing — rather than the page behind it, so the filter has nothing to
       work on and the 10% of transparency is plain transparency. Hoisting the
       panel out of the header would fix the blur and break the far more
       important thing, --mc-header-h (see above), so the transparency goes
       instead. A navigation menu is not a scrim; it should be readable over
       whatever it lands on, which on five routes is five different things. */
    background: var(--paper);

    /* The panel carries the header's bottom hairline as its own TOP one, and
       that is not decoration. An absolutely positioned box resolves top: 100%
       against its containing block's PADDING box, and .site-header's padding
       box stops 1px short of its border box — so the panel's first pixel row
       lands exactly on the header's border-bottom and, painting later, covers
       it. Measured: panel y = header y + height - 1 on all five routes. This
       repaints that same 1px in the same token at the same y, so the seam under
       the wordmark row survives the menu opening instead of vanishing into the
       panel. Its own border-bottom then closes the panel at the other end. */
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
    border-bottom-left-radius: var(--radius-lg);
    border-bottom-right-radius: var(--radius-lg);

    /* BOUNDED TO WHAT IS BELOW THE HEADER, AND SCROLLABLE INSIDE THAT. Sized
       only by its four rows the panel ran off the bottom of a short viewport:
       the rows are rem-sized, so at a 24px root they are 66px each and the
       panel is 326px tall, and any viewport shorter than 391px — every phone
       in landscape, and every phone whose owner has enlarged their text — put
       "Join the waitlist", the site's only call to action, below the fold.
       Measured before this rule existed: 4 of 19 root x viewport combinations
       failed, worst 71px under (375x320 at a 24px root). It could not be
       recovered by the visitor either. .site-header is sticky, so scrolling the
       page carries the panel down with it rather than revealing more of it, and
       .focus() does not scroll a focused element into a viewport it never
       reaches — so the CTA was not merely awkward, it was unreachable by
       pointer and by keyboard alike (WCAG 2.4.11).

       THE TERM IS --mc-header-h, the same measured number the pillars section
       uses, rather than a second copy of the header's height in this file —
       which is the mistake that went stale twice historically. The panel's top
       edge is the header's PADDING-box bottom, 1px above its border box (see
       the border-top note above), so 100svh - --mc-header-h lands the panel's
       own bottom border exactly 1px clear of the fold. box-sizing is
       border-box, so the max-height bounds the bordered box and that 1px is
       real. svh, with a vh fallback for engines that lack it, is this file's
       existing convention (see --mc-frame-h) and the conservative choice: the
       viewport at its shortest, so the panel fits whether the browser's chrome
       is showing or retracted, instead of being resized mid-scroll by dvh.

       THE FALLBACK IS ALL BUT UNREACHABLE and is still written to scale. site.js
       publishes --mc-header-h on load, and the toggle that reveals this panel
       does nothing until site.js has run, so the variable is set before the
       panel can ever be seen; only a browser without ResizeObserver gets here.
       3.75rem + 1px is that state's real height, in rem so it tracks the root
       size like the box it stands in: .site-header__inner's 2.75rem toggle plus
       its 0.5rem padding-block, plus the header's 1px bottom border.

       The rows do NOT collapse into this. <li> is the flex item and the 44px
       minimum is on the <a> inside it, but the li keeps min-height: auto and so
       takes a content-based automatic minimum size of that same 44px — it
       cannot flex-shrink below the touch target, so the constraint produces the
       scroll rather than four squashed rows. Measured, not reasoned: the row
       heights below the fix are 44/55/66px at 16/20/24px roots, identical to
       the ones above it. */
    max-height: calc(100vh - var(--mc-header-h, calc(3.75rem + 1px)));
    max-height: calc(100svh - var(--mc-header-h, calc(3.75rem + 1px)));
    overflow-y: auto;

    /* 5px IS THE FOCUS RING, AND THIS IS THE COST OF THE LINE ABOVE. Making
       the panel a scroll container also made it a CLIP container: an
       overflow: auto box clips its content at its PADDING box, and an outline
       is ink overflow, so it is clipped rather than scrolled to. When focus
       lands on an item the browser scrolls it in with "nearest" alignment,
       which aligns the item's BORDER box flush against that padding-box edge
       — and the ring lives 5px outside the border box, so 5px of it is left on
       the wrong side. Measured on the bounded panel before this rule existed:
       FOUR of the nineteen narrow root x viewport combinations failed, 15
       passed, and the count is written down because the failures are what makes
       BOTH edges load-bearing:

         20px/375x320  Tab      CTA, bottom edge, 5px   (and Product, top, 3px)
         24px/375x320  Shift+Tab  FAQ, TOP edge, 5px    (bottom edge clear)
         24px/667x375  Tab      CTA, bottom edge, 5px   (and Product, top, 5px)
         24px/844x390  Tab      CTA, bottom edge, 5px   (top edge clear)

       Identical to the pixel on /support, /privacy-policy and
       /terms-and-conditions, so this is the panel's geometry and not a page's.

       READ THE SECOND ROW BEFORE SIMPLIFYING THIS TO ONE EDGE. At 24px/375x320
       the panel is at its scroll limit when the CTA is focused (scrollTop 97 of
       97), so the browser cannot settle the CTA flush with the bottom clip edge
       and the panel's own 1rem padding-bottom covers that ring. That is the
       BOTTOM edge only, and it is incidental geometry rather than a guarantee.
       Walking back up with Shift+Tab moves the panel to scrollTop 84, which is
       nowhere near a limit, and FAQ's border box comes to rest exactly on the
       TOP clip edge with the full 5px of ring outside it. An end-only variant
       of this declaration was measured and leaves that cut standing.

       scroll-padding, not a taller padding or a scroll-margin on each item:
       it insets the scrollport for scroll-into-view ONLY, so the settle
       position leaves room for the whole ring while the panel's own layout,
       its rows and its scroll range are all untouched. Both edges, because the
       clip is symmetric and because one of the four failures is a top-edge
       failure and nothing else — drop the start side and 24px/375x320 goes back
       to a sliced ring on FAQ.

       5px = outline-width 2px + outline-offset 3px, the ring declared on
       a:focus-visible near the top of this file. If that ring ever changes
       width or offset, this number changes with it. */
    scroll-padding-block: 5px;

    /* visibility, not display or the hidden attribute: it takes the panel out
       of the tab order and out of the accessibility tree like display: none
       does, but it can be transitioned. Held at 0s so it flips at the START of
       the opening transition (the panel must be focusable the instant site.js
       moves focus into it) and delayed to the END of the closing one. */
    visibility: hidden;
    opacity: 0;
    transform: translateY(-0.5rem);
    transition: opacity 160ms ease, transform 160ms ease, visibility 0s linear 160ms;
  }

  .has-js .site-nav[data-open="true"] .site-nav__list {
    visibility: visible;
    opacity: 1;
    transform: none;
    transition: opacity 160ms ease, transform 160ms ease, visibility 0s;
  }

  /* 44px rows, the toggle's own target size. The plain links centre their text
     in that height instead of padding to it, so the number is stated once. */
  .has-js .site-nav__list a:not(.btn) {
    display: flex;
    align-items: center;
    min-height: 2.75rem;
    padding-block: 0;
  }

  /* Full bleed and centred, which is the call the signup form's button already
     makes on a phone (see the 30rem block below): at this width the primary CTA
     takes the measure rather than sitting at its own width against it. */
  .has-js .site-nav__list .btn {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 2.75rem;
    margin-top: 0.25rem;
  }

  /* THIS CANNOT LIVE IN THE reduced-motion BLOCK ABOVE. It would be the same
     specificity as the rules it has to beat and would lose on source order, and
     the blanket `transition-duration: 0.001ms !important` there does not reach
     transition-DELAY — so the panel's visibility would still flip 160ms after a
     reduced-motion visitor closed the menu. Declared here, after the rules it
     overrides, it is the whole motion removed rather than sped up: no fade, no
     slide, the panel simply is or is not there. */
  @media (prefers-reduced-motion: reduce) {
    .has-js .site-nav__list,
    .has-js .site-nav[data-open="true"] .site-nav__list {
      transform: none;
      transition: none;
    }
  }
}

@media (max-width: 30rem) {
  .site-nav__list { gap: 0.75rem; }

  /* This block used to hold three declarations and now holds none of them.
     Two — flex-direction: column and align-items: stretch — were what forced the
     signup form to stack on phones while it sat side by side everywhere else;
     the form has been a stacking grid at every width since round 17, so they
     described a layout that no longer exists and went then.

     The third was `.signup-form .btn { width: 100% }`, the phone-only full-bleed
     call to action. It went on 2026-09-14 because the base rule now says exactly
     that at every width (see .signup-form), so keeping it here would be one
     declaration in two places with nothing to distinguish them — the kind of
     duplicate that survives one of its two edits. The phone's rendering is
     unchanged: same property, same value, from one rule instead of two. */
}
