/* =========================================================================
   desti marketing site styles
   Brand: sage #6B8A2A · cream #FAEEDA · deep ink #2b3318
   Display: Fraunces (variable, true italic) · Body: Outfit
   Shared across index.html, privacy.html, terms.html, support.html
   ========================================================================= */

/* ---------- Tokens ---------- */
:root {
  /* The whole site is one cream palette, so say so: browsers stop
     auto-darkening scrollbars, controls and form widgets. */
  color-scheme: only light;

  --ink: #2b3318;
  --cream: #faeeda;
  --sage: #6b8a2a;
  --sage-deep: #4d6a1c;      /* AA-contrast sage for inline links/small text */
  --paper: #fffdf7;          /* lifted card surface */
  --panel: #f1e3c8;          /* deeper sand for alternating sections */

  /* The app's map ground. NOT brand colors and never to be used as such: the
     app renders Google's DEFAULT palette by a locked owner decision (two
     recolors were proposed and rejected), so the only honest way to show the
     Map tab on this site is to show that palette. Every value below is
     MEASURED off the approved App Store capture at assets/shot-6.webp, over
     its map viewport (x 82-472, y 455-1045).

     Water is the single dominant color of that viewport: 68,254 pixels of
     #6dd3e7, more than the next 11 colors combined.

     Land is not one color, which is the whole point of this ramp. A lightness
     histogram of the viewport's green-hue pixels has two clear peaks, L78
     (#a8e6cc, 21,075 px) and L83 (#bbecd9, 17,271 px), with real shoulders at
     L73 (#95dfc1, 3,857 px), L68 and L87. False-coloring the capture by those
     five tones shows them forming large administrative patches with visible
     boundaries, which is what the owner meant by countries varying in color.
     The site reproduces that at country granularity.

     The three tokens below are that ramp's dominant rungs (2, 3 and 4 of
     five). They are the COUNTRY tints. The two shoulders, L68 and L87, are
     still on the map: they arrive through assets/map-land.webp, whose whole
     lookup table is built from this same measured distribution, as tones
     inside a continuous gradient rather than as a flat plate the size of a
     country. The numbering is kept as the rung number so the ramp stays
     legible against the measurements above.

     The line colors are sampled off the same capture, from the lines
     themselves rather than guessed:

       --map-border   the international boundary, e.g. the US/Canada line
                      through Lake Superior. Measured by scanning across it:
                      it takes land at #aae5cc down to #5e967c at its core
                      over about 1.3 px, and it is neutral against the land
                      rather than a tint of it. Rendered as a 0.4-unit
                      hairline here, this is the value that lands on that.
       --map-admin1   the dotted state line. Isolated by finding gray runs
                      of at most 2 px flanked by mint on both sides: 682 of
                      them across the viewport, mean #99a9a3, darkest
                      quartile #6e7b76.
       --map-label    the administrative label, e.g. KENTUCKY. Darkest pixel
                      of six label boxes, mean #454c49; the shipped value is
                      lighter because a 0.43x downscale of thin glyphs cannot
                      report a color lighter than the ink, only darker.
       --map-sea      the water label, e.g. "Gulf of America". Two boxes,
                      darkest #1a7f93 and #157a8f. */
  --map-water: #6dd3e7;
  --map-land-2: #95dfc1;
  --map-land-3: #a8e6cc;
  --map-land-4: #bbecd9;
  --map-border: #7d9e90;
  --map-admin1: #8fa49c;
  --map-label: #5c6360;
  --map-sea: #1c7f92;

  /* Alpha ramp. One source of truth for the ink tints, derived from --ink
     rather than repeating the literal channels, so changing the brand color
     moves the whole ramp with it. color-mix against transparent in srgb is
     exactly the rgba() these replace: 66% ink + 34% transparent
     un-premultiplies back to the same channels at alpha .66.
     These are for BACKGROUNDS, BORDERS and SHADOWS. */
  --ink-06: color-mix(in srgb, var(--ink) 6%, transparent);
  --ink-14: color-mix(in srgb, var(--ink) 14%, transparent);
  --ink-28: color-mix(in srgb, var(--ink) 28%, transparent);
  --ink-45: color-mix(in srgb, var(--ink) 45%, transparent);
  --ink-66: color-mix(in srgb, var(--ink) 66%, transparent);

  /* There are deliberately NO --cream-NN tokens. Cream tints exist only as
     text on the ink surfaces (footer, factcard, CTA band), and text color on
     an ink surface must be a plain rgba() literal, never a color-mix token.
     `color` is inherited, and a var() whose substituted value the engine
     cannot parse is invalid at COMPUTED-VALUE time: the declaration still
     wins the cascade and then computes to inherit, which on those surfaces
     is ink on ink. A plain rgba() written before it does not save you, because
     it has already been overridden. Measured, not theorized. Write the
     literal. */

  --ink-muted: var(--ink-66);
  --ink-soft: color-mix(in srgb, var(--ink) 42%, transparent);
  --line: var(--ink-14);
  --line-strong: var(--ink-28);
  --sage-tint: color-mix(in srgb, var(--sage) 12%, transparent);
  --shadow-sm: 0 1px 2px var(--ink-06);
  --shadow-md: 0 18px 40px -24px var(--ink-45);
  --shadow-lg: 0 40px 80px -40px color-mix(in srgb, var(--ink) 55%, transparent);

  /* Grain. One feTurbulence, two tints. --grain is the page's own (the body
     has carried it since the first build); --grain-ink is the same noise run
     through an feColorMatrix that paints it cream, for the ink surfaces where
     the plain one is invisible. Both are 160px tiles with stitchTiles, so they
     repeat without a seam. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
  --grain-ink: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0.98 0 0 0 0 0.93 0 0 0 0 0.85 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");

  --display: "Fraunces", "Iowan Old Style", Georgia, serif;
  --body: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  --container: 1140px;
  /* The editorial measure. .container stays the page's default column (nav,
     footer, legal pages); --container-wide is the deliberate breakout used by
     .wrap-wide inside contained panels. */
  --container-wide: 1320px;
  --container-narrow: 760px;
  --radius: 18px;
  --radius-sm: 12px;
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* heavy, decelerating settle */
  --highlight: inset 0 1px 0 rgba(255, 255, 255, 0.7);  /* top bevel on cream cards */
}

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

* { margin: 0; }

html {
  scroll-behavior: smooth;
  /* One number for every anchor on the site: the sticky nav is 72px unstuck
     and 60px compressed, so 84px clears the taller of the two with a little
     air. Set here rather than as scroll-margin-top on each target, which is
     how the legal pages' TOC headings used to do it and how they drifted. */
  scroll-padding-top: 84px;
  -webkit-text-size-adjust: 100%;
  /* Standard properties only. ::-webkit-scrollbar would replace the whole
     native scrollbar, including its overlay/auto-hide behavior on macOS, and
     that costs more than the styling is worth. */
  scrollbar-color: var(--ink-28) transparent;
  scrollbar-width: thin;
}

/* Cross-document transitions between the marketing page and the legal pages.
   Behind prefers-reduced-motion so it is opt-out by default for anyone who
   has asked for less movement. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}

body {
  font-family: var(--body);
  background-color: var(--cream);
  /* faint grain for warmth/depth */
  background-image: var(--grain);
  color: var(--ink);
  font-size: 1.0625rem;
  line-height: 1.7;
  font-weight: 400;
  letter-spacing: 0.005em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

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

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

::selection { background: var(--sage); color: var(--cream); }

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

/* ---------- Layout primitives ---------- */
.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 4vw, 2.5rem);
}

/* Same shape as .container, one step wider. Used inside .section--contained so
   a panel's copy fills the object instead of sitting in a narrow core. */
.wrap-wide {
  width: 100%;
  max-width: var(--container-wide);
  margin-inline: auto;
  padding-inline: clamp(1.25rem, 4vw, 2.5rem);
}

/* Twelve-column editorial grid. Column gap only, on purpose: sections carry
   their vertical rhythm in margins (.section__head has its own margin-bottom),
   so a row-gap here would double up. Compositions that stack add their own. */
.grid-12 {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: clamp(1rem, 2.5vw, 2rem);
}
/* Every child spans the grid unless a composition places it. Zero specificity
   via :where() so a single placement class can win without !important, and so
   the collapsed single-column layout under 980px needs no undo. */
:where(.grid-12) > * { grid-column: 1 / -1; }

.section { padding-block: clamp(4rem, 9vw, 7.5rem); }
.section--panel { background: var(--panel); }
.section--tight { padding-block: clamp(3rem, 6vw, 4.5rem); }

/* Contained section: the band becomes a rounded sand panel inset from the
   viewport edges, so the page alternates full-bleed cream with discrete
   objects instead of running as one continuous centered column. Below 980px it
   flattens back to a plain full-width panel, which is what a single column
   wants and what keeps the copy on the same left edge as every other section. */
.section--contained { background: var(--panel); }
@media (min-width: 981px) {
  .section--contained {
    margin-inline: clamp(1rem, 2.5vw, 2rem);
    border-radius: clamp(20px, 4vw, 32px);
  }
}

/* Pull a section up against the one before it when the two read as a pair.
   Adjacent-sibling only: on its own .section--tight-top does nothing, which is
   deliberate, since the tightening is a statement about the seam between two
   sections rather than a property of the second one. */
.section + .section--tight-top { padding-top: clamp(2rem, 4vw, 3rem); }

/* Offset head, a .grid-12 composition: a hairline across the full grid, the
   heading set against it in columns 1-5 and the prose in 7-12. The placement
   is positional so any two-child block gets the treatment without extra
   classes. Everything that breaks the single column is inside the min-width
   query; under 980px this is just a ruled block with the heading above the
   prose, left aligned like the rest of the page. */
.head-offset {
  border-top: 1px solid var(--line-strong);
  padding-top: clamp(1.75rem, 3.5vw, 2.75rem);
  row-gap: clamp(1.5rem, 3vw, 2.25rem);
}
@media (min-width: 981px) {
  .head-offset > :first-child { grid-column: 1 / 6; text-align: right; }
  .head-offset > :first-child + * { grid-column: 7 / -1; }
  /* Mirror the eyebrow's rule to the outside edge so it hangs off the right
     with the text rather than sitting inboard of it. */
  .head-offset > :first-child .eyebrow { flex-direction: row-reverse; }
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--sage-deep);
}
.eyebrow::before {
  content: "";
  width: 1.6em;
  height: 2px;
  background: var(--sage);
  border-radius: 2px;
}
/* The gap under a section eyebrow. Was repeated as an inline style on all four
   section headings; it belongs to the pairing, not to each heading. */
.eyebrow + h2 { margin-top: 1.1rem; }

/* ---------- Typography ---------- */
h1, h2, h3, h4 {
  font-family: var(--display);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--ink);
  font-optical-sizing: auto;
}

.h-display {
  font-size: clamp(3rem, 7.5vw, 6.2rem);
  line-height: .94;
  letter-spacing: -0.032em;
  /* Pin the optical size to the display end of the axis. At this size Fraunces
     wants its high-contrast, tight-fitting display cut, not the text cut that
     font-optical-sizing: auto would interpolate to. */
  font-variation-settings: "opsz" 144;
}

h2 { font-size: clamp(2rem, 4.6vw, 3.15rem); }
h3 { font-size: clamp(1.25rem, 2.4vw, 1.6rem); letter-spacing: -0.015em; }

/* Let the browser even out the rag on headings and keep single-word orphans
   off the last line of body copy. Both degrade to normal wrapping. */
h1, h2, h3 { text-wrap: balance; }
.lede, p { text-wrap: pretty; }

.lede {
  font-size: clamp(1.12rem, 1.9vw, 1.4rem);
  line-height: 1.55;
  color: var(--ink-muted);
  font-weight: 400;
  max-width: 46ch;
}

.section__head { max-width: 56ch; margin-bottom: clamp(2.5rem, 5vw, 3.75rem); }
.section__head .lede { margin-top: 1.25rem; }

/* ---------- Wordmark ---------- */
.wordmark {
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: -0.04em;
  color: var(--sage);
  text-decoration: none;
  line-height: 1;
  display: inline-flex;
  align-items: baseline;
  gap: 0.06em;
}

/* ---------- Buttons ---------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55em;
  font-family: var(--body);
  font-size: 1rem;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.01em;
  padding: 0.95em 1.6em;
  border-radius: 999px;
  border: 1.5px solid transparent;
  cursor: pointer;
  text-decoration: none;
  transition: transform 0.25s var(--ease), background-color 0.25s var(--ease),
    box-shadow 0.25s var(--ease), color 0.25s var(--ease);
}
.btn--primary {
  background: var(--sage);
  color: var(--cream);
  box-shadow: var(--shadow-md);
}
.btn--primary:hover {
  background: var(--sage-deep);
  transform: translateY(-2px);
  box-shadow: var(--shadow-lg);
}
.btn--ghost {
  background: transparent;
  color: var(--ink);
  border-color: var(--line-strong);
}
.btn--ghost:hover { border-color: var(--ink); transform: translateY(-2px); }
.btn--on-ink {
  background: var(--cream);
  color: var(--ink);
}
.btn--on-ink:hover { background: #fff; transform: translateY(-2px); }

/* Nested trailing arrow ("button-in-button"): a circular chip flush to the
   inner right padding, tinted from the button's own text color. */
.btn--arrow { padding-right: 0.6em; }
.btn__chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.7em;
  height: 1.7em;
  margin-left: 0.1em;
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 16%, transparent);
  transition: transform 0.45s var(--ease-out), background-color 0.3s var(--ease);
}
.btn__chip svg { width: 0.95em; height: 0.95em; display: block; }
.btn--arrow:hover .btn__chip {
  background: color-mix(in srgb, currentColor 28%, transparent);
  transform: translate(2px, -1px) scale(1.04);
}
/* Physical press: settle the whole pill, ease the chip back in. */
.btn:active { transform: translateY(0) scale(0.985); }
.btn--arrow:active .btn__chip { transform: translate(1px, 0) scale(1); }

/* ---------- Magnetic CTA ----------
   The two big "Download on the App Store" buttons lean a few pixels toward a
   fine pointer that comes near them. The lean lives on a WRAPPER, never on the
   button: .btn already animates its own transform on hover and on :active, and
   a second writer on that same property would stamp on the press feedback the
   moment the cursor moved. The wrapper translates, the button keeps its lift,
   and the two compose.

   --tx/--ty are written by demo.js and default to 0, so with the script gone
   (or on a touch screen, or under reduced motion, where the script never
   attaches) this is an inert inline-flex box. */
.magnetic {
  display: inline-flex;
  max-width: 100%;
  translate: var(--tx, 0px) var(--ty, 0px);
  transition: translate 0.5s var(--ease-out);
}
/* While the pointer owns it, track nearly 1:1; the 0.5s above is the spring
   home, and leaving it in place during tracking would feel like drag. */
.magnetic.is-pulling { transition: translate 0.12s linear; }

/* The arrow chip overshoots the pill by 40%, so the button reads as a solid
   that the chip is being pulled out of rather than as one flat sticker.
   Written with the individual `translate` property on purpose: the chip's own
   hover and :active states own `transform`, and these two compose instead of
   overwriting each other. */
.magnetic .btn__chip {
  translate: calc(var(--tx, 0px) * 0.4) calc(var(--ty, 0px) * 0.4);
  transition: translate 0.5s var(--ease-out), transform 0.45s var(--ease-out),
    background-color 0.3s var(--ease);
}
.magnetic.is-pulling .btn__chip {
  transition: translate 0.12s linear, transform 0.45s var(--ease-out),
    background-color 0.3s var(--ease);
}

/* ---------- Top nav ---------- */
.nav {
  position: sticky;
  top: 0;
  z-index: 50;
  background: color-mix(in srgb, var(--cream) 82%, transparent);
  backdrop-filter: saturate(140%) blur(12px);
  -webkit-backdrop-filter: saturate(140%) blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color 0.3s var(--ease), background-color 0.3s var(--ease);
}
.nav.is-stuck { border-bottom-color: var(--line); }
.nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  height: 72px;
  transition: height 0.3s var(--ease);
}
/* Compression. Once the page has moved at all the bar gives 12px back to the
   reading area and the wordmark settles with it, so the nav reads as a rail
   the page slides under rather than as a slab parked on top of it. Height and
   font-size only: no padding change, so nothing inside reflows sideways. */
.nav.is-stuck .nav__inner { height: 60px; }
.nav.is-stuck .nav__brand { font-size: 1.4rem; }
.nav__brand {
  font-size: 1.6rem;
  transition: font-size 0.3s var(--ease);
  /* Carried across a cross-document navigation, so the wordmark holds its
     place between the marketing page and the legal pages. Exactly one
     .nav__brand exists per page, which the name requires. */
  view-transition-name: wordmark;
}
.nav__links {
  display: flex;
  align-items: center;
  gap: clamp(1rem, 2.4vw, 2.25rem);
  list-style: none;
  padding: 0;
}
.nav__links a:not(.nav__cta) {
  color: var(--ink);
  text-decoration: none;
  font-weight: 500;
  font-size: 0.97rem;
  position: relative;
  padding-block: 0.3rem;
}
.nav__links a:not(.nav__cta)::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 100%; height: 1.5px;
  background: var(--sage);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.3s var(--ease);
}
.nav__links a:not(.nav__cta):hover::after { transform: scaleX(1); }
/* Wayfinding. The same rule the hover draws, held open for the section the
   reader is actually in, so the underline answers "where am I" and not only
   "what am I pointing at". Driven from JS, which also sets aria-current. */
.nav__links a:not(.nav__cta).is-active::after { transform: scaleX(1); }
.nav__links a:not(.nav__cta).is-active { color: var(--sage-deep); }
.nav__cta { margin-left: 0.5rem; padding: 0.6em 1.2em; font-size: 0.95rem; white-space: nowrap; }

@media (max-width: 760px) {
  /* On phones the sticky nav keeps just the wordmark + CTA; the text links
     (The app / Questions / About) live in the footer. Marked with a class on
     each <li> rather than selected with :has(.nav__cta), so the rule needs no
     :has() support and reads the same on all four pages. */
  .nav__hide-sm { display: none; }
}

/* ---------- Hero ---------- */
.hero {
  position: relative;
  padding-top: clamp(3rem, 7vw, 5.5rem);
  padding-bottom: clamp(4rem, 9vw, 7rem);
  overflow: hidden;
}
.hero::before {
  /* soft sage glow top-right */
  content: "";
  position: absolute;
  top: -30%; right: -10%;
  width: 60vw; height: 60vw;
  max-width: 760px; max-height: 760px;
  background: radial-gradient(circle, rgba(107, 138, 42, 0.18), transparent 62%);
  pointer-events: none;
  z-index: 0;
}
.hero__inner {
  position: relative;
  z-index: 1;
}
.hero__copy { max-width: 44rem; }
.hero h1 { margin-top: 1.5rem; }
/* WONK is scoped to .accent ONLY. The wonky cut swaps in Fraunces' alternate
   letterforms, which is a deliberate accent on this one word and would read as
   a typo anywhere else, so it must never be promoted to a heading-wide rule.
   The 600 weight keeps the italic from out-weighting the roman beside it. */
.hero h1 .accent {
  color: var(--sage);
  font-style: italic;
  font-weight: 600;
  font-variation-settings: "opsz" 144, "WONK" 1;
  padding-right: 0.08em;
}
.hero__lede { margin-top: 1.75rem; }
.hero__actions {
  margin-top: 2.25rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem 1rem;
  align-items: center;
}
.store-note {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-size: 0.92rem;
  font-weight: 500;
  color: var(--ink-muted);
}
.store-note .apple {
  display: inline-flex;
  width: 1.15em; height: 1.15em;
  color: var(--ink);
}

/* Proof strip: three facts about the product, under the hero CTA. Outlined,
   never filled, and a step down in size from the button row on purpose - these
   are captions that answer "is this for me", not a second rank of buttons
   competing with the real one. Nothing in here links anywhere by design. */
.proof-strip {
  margin-top: 1.6rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.55rem 0.6rem;
}
.proof-strip .chip {
  display: inline-flex;
  align-items: center;
  padding: 0.42em 0.95em;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--ink-muted);
}
.hero__meta {
  margin-top: 2.5rem;
  display: flex;
  gap: clamp(1.25rem, 4vw, 2.75rem);
  flex-wrap: wrap;
}
.hero__meta .stat { display: flex; flex-direction: column; }
.hero__meta .stat b {
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.6rem;
  color: var(--ink);
  line-height: 1;
}
.hero__meta .stat span {
  font-size: 0.82rem;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  margin-top: 0.35rem;
}

/* ---------- Hero: line mask ---------- */
/* Each line of the slogan rides up out of its own clip. The start state is
   scoped to .js, set by a synchronous one-liner in <head>, so a page whose
   script is off or blocked never shows a headline parked below its own mask.
   Runs once per load: a CSS animation, not a transition on a scroll class. */
.line { display: block; overflow: hidden; }
/* The clip has to give the descenders and the italic's overhang somewhere to
   live, and the negative margin gives that padding back to the line height so
   the two lines still set at .94 leading. 8% is well inside the 112% start
   offset, so the text is still fully hidden before it rises. */
.line { padding-block: 0.08em; margin-block: -0.08em; }
/* The bottom needs more than the top, and 0.08em was not enough of it: the
   mask was cutting the tail off the "p" in "next trip." (measured, not
   guessed - the clipped line's scrollHeight ran 4px past its clientHeight at
   every width). Fraunces' descenders reach about 0.135em below the last
   line's box at this leading, so 0.14em clears them with a hair to spare, and
   the matching negative margin hands the room straight back to the flow: the
   two lines still set at .94 leading and nothing below the headline moves.
   Still well inside the 112% start offset, so the text is fully hidden before
   it rises. Verified at 320, 768 and 1440: scrollHeight === clientHeight. */
.line { padding-bottom: 0.14em; margin-bottom: -0.14em; }
.line__in { display: block; }
.js .line__in {
  transform: translateY(112%);
  animation: line-rise 0.9s var(--ease-out) forwards;
}
.js .line:first-child .line__in { animation-delay: 0.05s; }
.js .line:first-child + .line .line__in { animation-delay: 0.14s; }
@keyframes line-rise { to { transform: none; } }

/* ---------- Hero: accent words in the lede ---------- */
/* The three verbs the product is made of, set in the display italic and
   colored sage, and nothing else. They were ovals (outlined pills) until the
   2026-08-29 feedback round: a bordered capsule around a word inside a running
   sentence reads as a control the visitor can press, and three of them in one
   lede read as chrome. The emphasis is the typeface and the color; the word
   keeps the size and the line rhythm of the sentence it is in. */
.accent-word {
  font-family: var(--display);
  font-style: italic;
  font-weight: 600;
  color: var(--sage-deep);
  font-size: inherit;
}

/* ---------- Hero: two columns, copy beside the demo ---------- */
/* Everything that splits the hero lives inside the query, so the phone layout
   (copy, then the demo under it) needs nothing undone. */
.hero__cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.9rem 1rem;
}
.hero__demo { position: relative; z-index: 1; }

@media (max-width: 980px) {
  .hero__demo { margin-top: clamp(2rem, 6vw, 2.75rem); }
}

@media (min-width: 981px) {
  .hero__inner {
    display: grid;
    grid-template-columns: 1fr 0.95fr;
    gap: clamp(2rem, 4.2vw, 3.75rem);
    /* Top aligned, not centered, and that is load bearing: the rail grows
       when the visitor makes their first pick, and a centered row would
       re-center both columns underneath the cursor mid-click. Growing
       downward moves nothing that is already on screen. */
    align-items: start;
  }
  .hero__copy { max-width: none; }
  /* The headline shares the fold with the demo now, so it is sized to its
     column rather than to the viewport: at the full-width cap of 6.2rem,
     "Rank the world." overhangs a 510px column by a third of a line. */
  .hero__copy .h-display { font-size: clamp(2.9rem, 4.7vw, 4.35rem); }
  /* Scoped to .hero so the closing CTA band, which shares .hero__actions,
     keeps its own centered single-button layout. */
  .hero .hero__actions { flex-wrap: nowrap; align-items: flex-start; }
  .hero .hero__cta { flex: 1 1 auto; min-width: 0; }
  .hero .qr { flex: 0 0 auto; }
}

/* Boarding-pass stub beside the hero CTA. Desktop only: a QR code you cannot
   scan with the device in your hand is furniture. 981px rather than 980 so it
   can never appear while the hero is still one column. */
.qr { display: none; }
@media (min-width: 981px) {
  .qr {
    display: grid;
    justify-items: center;
    gap: 0.4rem;
    margin: 0;
    padding: 0.6rem 0.65rem 0.55rem;
    background: var(--paper);
    border: 1px solid var(--line);
    border-radius: 14px;
    box-shadow: var(--highlight), 0 1px 2px var(--ink-06);
  }
  .qr img { width: 88px; height: 88px; }
  .qr figcaption {
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--ink-soft);
  }
}

/* ---------- Settle the Debate ---------- */
.debate { position: relative; }
.debate__q {
  font-family: var(--display);
  font-weight: 600;
  font-size: clamp(1.05rem, 1.7vw, 1.3rem);
  line-height: 1.25;
  letter-spacing: -0.012em;
  margin-bottom: 0.9rem;
}
.debate__q .accent { font-style: italic; color: var(--sage-deep); }

/* The rank-screen status line. Small, muted and set in the body face, exactly
   as it is in the app (assets/shot-2.webp), where it is the quiet line under
   the question rather than a second headline. Withheld until demo.js is live
   for the same reason the rail is: with no script the pair below is a still
   illustration, and "Comparison 1 of ~3" would be describing a run that is not
   running. The question closes up against it only in the live state, so the
   scriptless spacing is the one that shipped before. */
.debate__status { display: none; }
.debate--live .debate__status {
  display: block;
  font-size: 0.82rem;
  line-height: 1.35;
  color: var(--ink-soft);
  margin-bottom: 0.9rem;
  font-variant-numeric: tabular-nums;
}
.debate--live .debate__q { margin-bottom: 0.3rem; }

.debate__cards {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(0.7rem, 1.5vw, 1rem);
  position: relative;
}
.debate__card {
  appearance: none;
  display: block;
  width: 100%;
  aspect-ratio: 3 / 4;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: clamp(14px, 1.6vw, 18px);
  /* Sand, not white: a card whose photo has not decoded yet should read as a
     surface of this page rather than as a hole in it. */
  background: var(--panel);
  overflow: hidden;
  position: relative;
  cursor: pointer;
  outline-offset: 4px;
  box-shadow: var(--shadow-md);
  transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out);
}
.debate__card:hover { transform: translateY(-4px) scale(1.015); box-shadow: var(--shadow-lg); }
.debate__card:active { transform: scale(0.985); }
.debate__art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.debate__card::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(20, 26, 8, 0.62), rgba(20, 26, 8, 0) 58%);
  pointer-events: none;
}
.debate__label {
  position: absolute;
  left: 0.85rem;
  right: 0.7rem;
  bottom: 0.7rem;
  z-index: 1;
  text-align: left;
}
.debate__label .nm {
  display: block;
  font-family: var(--display);
  font-weight: 700;
  font-size: clamp(1.05rem, 1.9vw, 1.5rem);
  line-height: 1.05;
  letter-spacing: -0.015em;
  color: #fffdf7;
  text-shadow: 0 2px 12px rgba(20, 26, 8, 0.45);
}
.debate__label .co {
  font-size: 0.66rem;
  font-weight: 600;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: rgba(255, 253, 247, 0.85);
}
.debate__or {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--display);
  font-style: italic;
  font-weight: 600;
  font-size: 0.95rem;
  line-height: 1;
  padding: 0.4em 0.8em;
  border-radius: 999px;
  box-shadow: 0 4px 14px rgba(43, 51, 24, 0.25);
  pointer-events: none;
}

/* The no-script half of the stage. Hidden by the .js class in <head> rather
   than by demo.js, so it cannot flash for a frame before the deferred script
   arrives. */
.debate__static {
  margin: clamp(0.9rem, 2vw, 1.15rem) 0 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 1rem;
  align-items: center;
}
.debate__static img { width: clamp(84px, 17vw, 120px); height: auto; }
.debate__static figcaption { font-size: 0.94rem; color: var(--ink-muted); line-height: 1.5; }
.js .debate__static { display: none; }

/* The rail is the only surface on this page that renders a score, and its
   title and caption live inside it, so a numeral can never appear here
   without the words that say whose it is and where it came from. That is
   also why the whole block is withheld until demo.js is live rather than
   shipping an empty labeled shell. */
.debate__rail { display: none; }
.debate--live .debate__rail {
  display: block;
  margin-top: clamp(0.9rem, 2vw, 1.15rem);
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: 1rem 1.1rem 0.9rem;
  box-shadow: var(--highlight), 0 1px 2px var(--ink-06);
}
.debate__rail-title {
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--sage-deep);
}
.debate__rail-empty {
  color: var(--ink-soft);
  font-size: 0.92rem;
  padding-top: 0.5rem;
}
.debate__rows { list-style: none; margin: 0; padding: 0; display: grid; }
.debate__row {
  display: grid;
  grid-template-columns: 1.35rem 1fr auto;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.42rem 0;
  /* The body's 1.7 leading is set for prose; five of these rows in a hero
     column is a table, and it wants table leading. */
  line-height: 1.35;
  border-top: 1px solid var(--line);
}
.debate__row:first-child { border-top: 0; }
.debate__pos {
  font-family: var(--display);
  font-weight: 700;
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-soft);
}
.debate__name { font-weight: 600; font-size: 0.98rem; }
/* A bare numeral in the gradient's own color: the app's plain treatment for a
   score on a light surface. The mid-range lands on amber and reads low
   contrast on cream; that is a recorded decision, and the fix is never to
   darken the gradient or swap in a substitute color. */
.debate__score {
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.12rem;
  line-height: 1.1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
}
/* The rail shows a top five; this is the tail of the list, and it points at
   the map, which is where the whole set actually pays off. */
.debate__more {
  font-size: 0.78rem;
  color: var(--ink-soft);
  padding-top: 0.45rem;
  border-top: 1px solid var(--line);
}
.debate__rail-cap {
  font-size: 0.76rem;
  line-height: 1.5;
  color: var(--ink-soft);
  margin-top: 0.7rem;
}
/* Announced, never drawn. */
.debate__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.debate__reset {
  background: none;
  border: 0;
  padding: 0;
  margin-top: 0.35rem;
  font: inherit;
  font-size: 0.78rem;
  color: var(--sage-deep);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* Out of the flow entirely until it is earned, rather than reserved as three
   invisible lines: the column is top aligned, so the payoff arriving pushes
   only the section below it and nothing the visitor is looking at. It also
   keeps a screen reader from reaching a conclusion the visitor has not got
   to yet. Animation, not transition, because display cannot be transitioned. */
.debate__payoff {
  display: none;
  margin-top: 1rem;
  font-family: var(--display);
  font-style: italic;
  font-weight: 500;
  font-size: clamp(0.98rem, 1.5vw, 1.12rem);
  line-height: 1.45;
  color: var(--ink);
}
.debate__payoff.is-in {
  display: block;
  animation: payoff-in 0.7s var(--ease-out) both;
}
@keyframes payoff-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}
.debate__payoff a { font-style: normal; font-weight: 600; white-space: nowrap; }

/* Sits directly under the pair it credits, in the corner of the stage, faint
   and small on purpose. Credits belong on the page; they do not belong in the
   composition. */
.debate__credit {
  margin-top: 0.45rem;
  text-align: right;
  font-size: 0.6rem;
  line-height: 1.4;
  letter-spacing: 0.04em;
  color: var(--ink-soft);
  opacity: 0.7;
}

/* On a phone the rail turns into a scrollable strip of ranked chips so it can
   sit under the cards without pushing the payoff off the section. The FLIP in
   demo.js measures both axes for exactly this reason. */
@media (max-width: 760px) {
  .debate__rows {
    display: flex;
    gap: 0.4rem;
    overflow-x: auto;
    /* Trailing padding, so the last chip can clear the fade below instead of
       ending up permanently dimmed at the end of the scroll. It has to be
       WIDER than the fade (2rem), or the chip still finishes inside it: at
       1.75rem the last 5px of the final chip stayed dimmed. */
    padding: 0.15rem 2.25rem 0.4rem 0;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .debate__rows::-webkit-scrollbar { display: none; }
  /* The clipped chip has to read as "there is more", not as a chip that got
     cut off. The mask is on the scroll container, so it stays put over the
     right edge while the chips move under it; at the end of the scroll it is
     sitting over the trailing padding, so nothing is left faded. Only applied
     when demo.js has confirmed the strip actually overflows. */
  .debate__rows.is-scrollable {
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
    mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
  }
  /* A focusable scroll region needs a ring of its own; the chips inside are
     not focusable, so without this the focus would be invisible. */
  .debate__rows:focus-visible {
    outline: 2.5px solid var(--sage);
    outline-offset: 2px;
    border-radius: 999px;
  }
  .debate__row {
    display: inline-grid;
    grid-template-columns: auto auto auto;
    gap: 0.42rem;
    flex: 0 0 auto;
    padding: 0.3rem 0.7rem;
    border-top: 0;
    border-radius: 999px;
    background: var(--panel);
  }
  .debate__name { font-size: 0.9rem; white-space: nowrap; }
  .debate__score { font-size: 1rem; }
}

/* ---------- Watch your map fill in ---------- */
/* This panel lands directly on top of another contained panel, and two rounded
   objects flush against each other read as one panel with a pinch in it. The
   gap matches the inline inset those panels already carry, so the page keeps
   one rhythm. Only above 981px, where contained panels are objects at all. */
@media (min-width: 981px) {
  .map-panel { margin-bottom: clamp(1rem, 2.5vw, 2rem); }
}
/* The figure carries no chrome of its own any more. The card IS the map view:
   the ground runs edge to edge inside a rounded, clipped panel, exactly as the
   Map tab does under its segmented control. A cream mat around a map view is
   what made this section read as an illustration rather than as the product,
   so the padding is gone and the caption sits outside the card, on the panel. */
.map__frame { margin: 0; position: relative; }
/* No media query on purpose: the min-width is the whole rule. Wherever the
   frame is at least 760px the map simply fills it and the scroll container
   has nothing to scroll; below that the map holds its width and pans, because
   a world map squeezed to 318px cannot carry a legible 0-to-10 score and a
   map that cannot show its scores is not the payoff this section promises. */
.map__scroll {
  overflow: auto hidden;
  overscroll-behavior-x: contain;
  border: 1px solid var(--line);
  border-radius: clamp(16px, 2.4vw, 24px);
  background: var(--map-water);
}
.map__svg { display: block; width: 100%; min-width: 760px; height: auto; }
/* The app's map ground, which is the Google DEFAULT palette the owner locked
   in, sampled from the approved App Store capture (see the token block up
   top for the measurements). Countries are grouped into one path per shade
   rather than one path per country: three nodes instead of 176, and because
   every country is still its own closed subpath inside its group, the border
   stroke below draws each outline exactly as it would ungrouped.

   Both the fill layer and the border layer are <use> references to the same
   three paths in <defs>. Nothing inside a <use> shadow tree is selectable
   from here, so the paths carry no class of their own and take fill and
   stroke by INHERITANCE from the <use> element. That is the whole reason
   the layers can be separated for free: the landcover raster sits between
   them without washing out a border it is drawn under. */
.map__water { fill: var(--map-water); }
.map__fills { stroke: none; }
.map__land--2 { fill: var(--map-land-2); }
.map__land--3 { fill: var(--map-land-3); }
.map__land--4 { fill: var(--map-land-4); }
.map__borders {
  fill: none;
  stroke: var(--map-border);
  stroke-width: 0.4;
  stroke-linejoin: round;
}
/* Lakes are the same water as the ocean in the capture, and they sit above
   the landcover raster because they are holes in the land, not land. */
.map__lake { fill: var(--map-water); }
/* The capture's interior line is dotted, not solid, and lighter than the
   international one. Round caps make each dash read as a dot at this size. */
.map__admin1 {
  fill: none;
  stroke: var(--map-admin1);
  stroke-width: 0.35;
  stroke-dasharray: 0.7 1.3;
  stroke-linecap: round;
}
/* Google Maps sets its labels in Roboto. The face is fetched only when the
   map arms (demo.js), so the eager page weight is unchanged; until then, and
   with no script at all, these fall back to the system sans, which carries
   the same all-caps letter-spaced shape. Sizes are in USER UNITS, so a label
   scales with the map exactly as the ground does and the offline collision
   layout stays valid at every width.

   paint-order: stroke is how a map halos a label: the white stroke is painted
   first and the fill covers its inner half, so the letterform keeps its own
   weight instead of being eroded by its own outline. */
.map__labels {
  font-family: Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
  paint-order: stroke;
  stroke: #fff;
  stroke-opacity: 0.72;
  stroke-linejoin: round;
  pointer-events: none;
}
.map__label {
  fill: var(--map-label);
  font-size: 9.6px;
  font-weight: 500;
  letter-spacing: 0.96px;
  stroke-width: 1.25px;
  text-anchor: middle;
  dominant-baseline: middle;
}
/* The small size class: Google's size-by-importance convention, doing the one
   job it exists for. A name that cannot clear its neighbors, the demo markers
   and the crop at 9.6 units often can at 7, so several countries that had no
   label at all now have a quieter one instead of none.

   Same voice one step down - same face, weight, color and halo - with the
   tracking pulled from 0.10em to 0.06em because spacing that reads as
   generous at the full size reads as loose here, and the halo cut to 0.9px so
   it does not erode the smaller letterforms. Which names take this class is
   decided offline by the generator, not at runtime. */
.map__label--sm {
  font-size: 7px;
  letter-spacing: 0.42px;
  stroke-width: 0.9px;
}
/* Water labels in the capture carry no halo and no letter-spacing: they are
   set in mixed case in a muted teal, a quieter voice than the land labels. */
.map__label--sea {
  fill: var(--map-sea);
  font-size: 10.4px;
  font-weight: 400;
  letter-spacing: 0;
  stroke: none;
}
/* The landcover raster. Its href is empty in the markup and is set by demo.js
   when the section comes within a QUARTER of a viewport (rootMargin 25%, not
   the 50% the floating crops use), so it costs nothing on a visit that never
   scrolls this far. The quarter is measured, not picked: the map frame sits
   about 1347px down, so on an ordinary 900px desktop viewport a half-viewport
   margin already reaches it and this and the Roboto fetch would both happen
   on load for a visitor who never scrolls. The vector tints underneath are
   the no-script ground and stay visible through it at this opacity. */
.map__cover {
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
}
/* 0.45 is measured, not chosen. The raster's own lookup table is a histogram
   match onto the capture's land lightness, and this is the opacity at which
   the COMPOSITE of raster over country tints reproduces it: p10 73.65,
   p50 78.44, p90 83.29 against the capture's 73.53 / 78.43 / 83.33. Changing
   it without re-deriving the raster's gain moves the ground off the app's. */
.map__cover--in { opacity: 0.45; }
.map__note,
.map__hint {
  margin-top: clamp(0.7rem, 1.6vw, 1rem);
  font-size: 0.85rem;
  color: var(--ink-soft);
}
@keyframes map-pin-drop {
  from { opacity: 0; transform: translateY(-16px); }
  to { opacity: 1; transform: none; }
}
.map__pin--drop {
  animation: map-pin-drop 0.6s var(--ease-out) both;
  animation-delay: var(--pd, 0s);
}

/* ---------- Sticky download bar (phones) ---------- */
.stickybar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 60;
  padding: 0.7rem clamp(1rem, 4vw, 1.5rem);
  padding-bottom: calc(0.7rem + env(safe-area-inset-bottom));
  background: color-mix(in srgb, var(--cream) 90%, transparent);
  backdrop-filter: saturate(140%) blur(12px);
  -webkit-backdrop-filter: saturate(140%) blur(12px);
  border-top: 1px solid var(--line);
  transform: translateY(115%);
  visibility: hidden;
  transition: transform 0.35s var(--ease-out), visibility 0s linear 0.35s;
}
.stickybar.is-in {
  transform: none;
  visibility: visible;
  transition: transform 0.35s var(--ease-out), visibility 0s;
}
.stickybar .btn { width: 100%; }
/* The home page's phone CTA only. On any wider screen the hero button and the
   closing band are both reachable without it. */
@media (min-width: 760px) { .stickybar { display: none; } }

/* ---------- Logo strip / trust ---------- */
.marquee-note {
  text-align: center;
  font-size: 0.85rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  font-weight: 600;
}

/* ---------- Explainer copy ----------
   The .split two-column grid this used to sit in is gone: the explainer prose
   is a .grid-12 child now, so the treatment belongs to the copy block rather
   than to a layout no markup asks for any more. */
.body-lg { font-size: 1.12rem; color: var(--ink-muted); }
.body-lg strong { color: var(--ink); font-weight: 600; }
.body-lg p + p { margin-top: 1.1rem; }

/* ---------- The app: six chapters ----------
   Replaced the numbered .steps cards and the five .card tiles. Both are gone
   from the markup, so their rules are gone from here.

   THE CROP. assets/shot-*.webp are App Store marketing art: a DRAWN phone,
   already framed, on a colored ground, under a baked caption. So nothing here
   draws a phone. Each shot is positioned inside a window sized to the device
   region of the source file, in the file's own 552x1200 pixel coordinates:

     --dev-x/--dev-y   top-left of the drawn device in the source
     --dev-w/--dev-h   its size in the source

   The image is then scaled to (source width / --dev-w) and pulled up and left
   by the offsets. Every offset is a percentage, and percentage margins - top
   included - resolve against the CONTAINING BLOCK'S WIDTH, which is what lets
   both axes be written in the same units and stay correct at any phone width.
   img{max-width:100%} has to be undone or the scale silently clamps to 1.

   All six shots share one crop because they come off one template. They are
   per-chapter custom properties anyway, so a re-export that moves one of them
   can be corrected without touching the rest. */
.chapters__ui { position: relative; }

/* The radios themselves are never seen; the labels are the control. Kept
   focusable and in the a11y tree (not display:none) so the group is a real
   radio group: arrow keys move through the chapters. */
.chapters__radio {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.chapters__tabs {
  display: flex;
  flex-wrap: nowrap;
  gap: 0.3rem;
  padding: 0.32rem;
  width: max-content;
  max-width: 100%;
  overflow-x: auto;
  scrollbar-width: none;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: color-mix(in srgb, var(--paper) 70%, transparent);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
  margin-bottom: clamp(2rem, 4vw, 3.25rem);
}
.chapters__tabs::-webkit-scrollbar { display: none; }
.chapters__tab {
  flex: 0 0 auto;
  padding: 0.55em 1.05em;
  border-radius: 999px;
  font-size: clamp(0.78rem, 1.5vw, 0.95rem);
  font-weight: 600;
  line-height: 1;
  color: var(--ink-muted);
  cursor: pointer;
  white-space: nowrap;
  user-select: none;
  transition: background-color 0.3s var(--ease), color 0.3s var(--ease);
}
.chapters__tab:hover { color: var(--ink); }
/* Six labels, not five, and the strip is one line at every width the site
   supports. It CAN scroll (overflow-x: auto above) but scrolling is the
   safety net, not the plan: a control the reader has to discover by dragging
   is a control they will not find. Measured, not guessed - at 320px the six
   labels come to 333px against 278px of room, so the two steps below buy back
   the 55px in padding, gap and, at the narrowest, type size. 390px (the
   iPhone the owner tests on) clears it on the first step alone. */
@media (max-width: 560px) {
  .chapters__tabs { gap: 0.24rem; }
  .chapters__tab { padding: 0.55em 0.6em; }
}
@media (max-width: 380px) {
  .chapters__tabs { gap: 0.16rem; padding: 0.24rem; }
  .chapters__tab { font-size: 0.72rem; padding: 0.55em 0.42em; }
}

.chapters__stage {
  display: grid;
  gap: clamp(2rem, 4vw, 3.5rem);
  align-items: center;
}
.chapters__phones {
  --phone-w: clamp(224px, 25vw, 296px);
  display: grid;
  justify-items: center;
}

.shot {
  /* Measured off the files, not guessed: the drawn frame's dark pixels run
     x 63-488, y 272-1155 in all six, which is one template. */
  --dev-x: 63; --dev-y: 272; --dev-w: 426; --dev-h: 884;
  grid-area: 1 / 1;
  position: relative;
  margin: 0;
  width: var(--phone-w);
  opacity: 0;
  visibility: hidden;
  /* Visibility is delayed off so the outgoing shot can finish fading; on the
     way in it flips immediately. Without it every chapter's alt text sits in
     the a11y tree at once. */
  transition: opacity 0.3s var(--ease), visibility 0s linear 0.3s;
}
.shot__frame {
  display: block;
  width: 100%;
  aspect-ratio: var(--dev-w) / var(--dev-h);
  overflow: hidden;
  /* The drawn corner turns over about 63 source px of a 426px-wide device.
     A circular radius is a shade fuller than the squircle it is standing in
     for, which errs on the side of trimming the frame rather than leaving a
     sliver of the artwork's background in the corner. */
  border-radius: calc(var(--phone-w) * 0.147);
  box-shadow: 0 40px 70px -34px color-mix(in srgb, var(--ink) 60%, transparent);
}
.shot__frame img {
  /* 552 is the source file's own width. */
  width: calc(552 / var(--dev-w) * 100%);
  max-width: none;
  height: auto;
  margin-left: calc(var(--dev-x) / var(--dev-w) * -100%);
  margin-top: calc(var(--dev-y) / var(--dev-w) * -100%);
}

/* No native drag ghost on the cropped art. These <img>s are DELIBERATELY
   bigger than the window they sit in - the frame crops the App Store artwork
   down to the drawn device, and the baked marketing caption is cropped away
   rather than deleted. Start a browser's own image drag on one and it hands
   the pointer a translucent copy of the WHOLE file, caption and colored ground
   and all, which is exactly the composition the crop exists to hide. The same
   goes for the two decorative background-image cards, whose sources are the
   same screenshots. draggable="false" is on the markup as well; this covers
   the WebKit path, which reads the CSS property rather than the attribute. */
.shot img,
.crop,
.vignette__card {
  -webkit-user-drag: none;
  user-select: none;
  -webkit-user-select: none;
}

/* Floating detail crops. These are regions of the real screenshot, never a
   rebuilt or invented control. Desktop only; they are decoration, and beside a
   full-width phone on a handset they would only crowd it.

   THE SOURCE IS NOT assets/shot-*.webp ANY MORE. Those files are 552px wide,
   which is the right size for a phone drawn at 296 CSS px but far too little
   for a detail pulled OUT of it and drawn larger: the old rules cut the Taste
   Match pill out of the 552px file at 1:1 and it read as a blurred sticker
   (owner-reported, 2026-08-29). The App Store art is refetchable at its native
   1287x2796 through the mzstatic size rewrite, so each crop is now cut from
   THAT and committed as its own file. Each asset is therefore already the
   exact region, at better than 2x its rendered size, and the rule only has to
   place it. No --sx/--sy/--sw/--sh math survives; do not reintroduce it.

   .crops-armed is put on the #app section by demo.js the first time the
   section comes within half a viewport (see module 5b). Until then the card is
   not rendered at all, which is what keeps its background image (and the
   vignette's) off the desktop page load: a background-image on a displayed
   element is fetched at layout time, with no lazy attribute to hold it back,
   so it defeated the loading="lazy" on the <img> version of the very same
   file. With no script the card never appears, and that is deliberate: it is
   aria-hidden decoration, and an imageless paper rectangle is worse than
   nothing. */
.crop {
  position: absolute;
  display: none;
  background-repeat: no-repeat;
  /* The asset IS the crop, so it is drawn edge to edge. */
  background-size: 100% 100%;
}
@media (min-width: 981px) { .crops-armed .crop { display: block; } }
.crops-armed .crop--taste { background-image: url("assets/crop-taste.webp"); }

/* A crop is drawn ABOVE the phone's own scale (296/426 = 0.70): one drawn
   smaller than the screen beside it reads as a duplicate rather than as a
   detail pulled out of it. The old 1.0 ceiling came from the 552px source and
   is gone with it; the ceiling now is the asset's own resolution, and the size
   below is exactly half the asset's pixels, so it lands 1:1 on a DPR 2 screen.

   Two rules, both learned from cards that had to be pulled:
   1. A crop must not sit near its own original. The phone frame shows source
      y 272-1155, which is the WHOLE drawn device screen, so a region cut from
      any of these files is always on the phone as well. Distance and a
      different silhouette are the only things that stop it reading as a
      rendering fault.
   2. A crop must not cover anything worth seeing.

   Which leaves exactly one, on the Friends chapter. The others were each
   built, rendered and pulled:
   - Rank: the rank flow is two full-bleed photographs and a progress bar.
     Nothing survives being cut out of it.
   - Explore: the Hvar tile cloned itself 110px from the original and covered
     the Dubrovnik tile beside it. Re-cut as the Explore filter chip row and
     hung off the bottom of the phone, well away from its origin, it stopped
     reading as a fault but started reading as nothing: near-white chips inside
     a paper card on cream, saying nothing about "every rec tells you why".
     A crop has to earn the space, and that one did not.
   - Save: has the vignette instead.
   - Feed: the feed card is the whole screen; any window onto it is a second
     copy of what the phone is already showing, 200px away.
   - Map: the map is one continuous image, so any window onto it clones a pin
     cluster sitting a hundred pixels away. That is what the first pass
     shipped, and the segmented control above it had already been tried and
     pulled in the pass before. */
.crop--taste {
  /* The 77% Taste Match pill. 318x57 native, drawn at exactly half that.
     No border: the asset carries a capsule alpha channel cut to the pill's own
     shape, so the shadow is a drop-shadow that follows the capsule rather than
     a box around a rectangle. The 4px paper frame that used to sit here was
     the "unwanted border" in the same report as the blur.
     It hangs off the left and the part that laps back over the phone lands on
     the empty margin beside the avatar, not on anything readable. */
  width: 159px;
  aspect-ratio: 318 / 57;
  left: -36%; top: 15%;
  transform: rotate(-3deg);
  filter: drop-shadow(0 14px 18px color-mix(in srgb, var(--ink) 28%, transparent));
}

/* One chapter at a time. This is the whole switch: six sibling selectors, no
   :has(), no script. */
.chapter { display: none; }
/* :not(.is-scrolly) is load bearing. Without it this rule (one id, four
   classes) outranks .is-scrolly .chapter and the ACTIVE chapter alone would
   stay display:block in scroll mode while its five neighbors went flex. */
.chapters__ui:not(.is-scrolly) #ch-1:checked ~ .chapters__stage .chapter[data-ch="1"],
.chapters__ui:not(.is-scrolly) #ch-2:checked ~ .chapters__stage .chapter[data-ch="2"],
.chapters__ui:not(.is-scrolly) #ch-3:checked ~ .chapters__stage .chapter[data-ch="3"],
.chapters__ui:not(.is-scrolly) #ch-4:checked ~ .chapters__stage .chapter[data-ch="4"],
.chapters__ui:not(.is-scrolly) #ch-5:checked ~ .chapters__stage .chapter[data-ch="5"],
.chapters__ui:not(.is-scrolly) #ch-6:checked ~ .chapters__stage .chapter[data-ch="6"] { display: block; }
/* The lit chapter in scroll mode (and a no-op in tab mode, where the other
   five are not rendered at all). */
#ch-1:checked ~ .chapters__stage .chapter[data-ch="1"],
#ch-2:checked ~ .chapters__stage .chapter[data-ch="2"],
#ch-3:checked ~ .chapters__stage .chapter[data-ch="3"],
#ch-4:checked ~ .chapters__stage .chapter[data-ch="4"],
#ch-5:checked ~ .chapters__stage .chapter[data-ch="5"],
#ch-6:checked ~ .chapters__stage .chapter[data-ch="6"] { opacity: 1; }
#ch-1:checked ~ .chapters__stage .shot[data-ch="1"],
#ch-2:checked ~ .chapters__stage .shot[data-ch="2"],
#ch-3:checked ~ .chapters__stage .shot[data-ch="3"],
#ch-4:checked ~ .chapters__stage .shot[data-ch="4"],
#ch-5:checked ~ .chapters__stage .shot[data-ch="5"],
#ch-6:checked ~ .chapters__stage .shot[data-ch="6"] {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.3s var(--ease), visibility 0s;
}
#ch-1:checked ~ .chapters__tabs [for="ch-1"],
#ch-2:checked ~ .chapters__tabs [for="ch-2"],
#ch-3:checked ~ .chapters__tabs [for="ch-3"],
#ch-4:checked ~ .chapters__tabs [for="ch-4"],
#ch-5:checked ~ .chapters__tabs [for="ch-5"],
#ch-6:checked ~ .chapters__tabs [for="ch-6"] {
  background: var(--sage);
  color: var(--cream);
}
/* The focus ring belongs on the label, since the input it names is the size of
   a pixel. */
#ch-1:focus-visible ~ .chapters__tabs [for="ch-1"],
#ch-2:focus-visible ~ .chapters__tabs [for="ch-2"],
#ch-3:focus-visible ~ .chapters__tabs [for="ch-3"],
#ch-4:focus-visible ~ .chapters__tabs [for="ch-4"],
#ch-5:focus-visible ~ .chapters__tabs [for="ch-5"],
#ch-6:focus-visible ~ .chapters__tabs [for="ch-6"] {
  outline: 2.5px solid var(--sage);
  outline-offset: 3px;
}

/* The numeral hangs over the rule: negative margin pulls the headline's
   hairline up into the digits, and z-index keeps the digits in front of it.

   In FRONT of it was not enough. z-index settled the paint order but the rule
   still ran edge to edge underneath, so it emerged from both sides of every
   glyph and read as a strike-through of the chapter number (owner-reported,
   2026-08-29). The fix is a mask, not a reposition: the numeral shrinks to its
   own width and paints the page color behind itself, so the rule is simply
   absent where the digits are and starts, cleanly, just past them. The gap is
   the padding.

   Boarded against the alternative, which was to drop the rule below the
   numeral's block entirely (margin-bottom: 0). That also stops the collision,
   but it un-does the composition: the numeral floats free above a full-width
   rule instead of sitting into it, and the whole chapter grows a line taller.
   This keeps the overlap the design was built around and only removes the part
   of the rule that was never meant to be seen.

   width: max-content rather than a display change, so the numeral stays a
   block and the negative margin keeps behaving. The mask is var(--cream)
   because that is what body paints and nothing between here and body paints
   anything else - checked, not assumed. Print gets the other option instead
   (see @media print), since paper is white and browsers drop backgrounds. */
.chapter__no {
  display: block;
  width: max-content;
  position: relative;
  z-index: 1;
  font-family: var(--display);
  font-weight: 700;
  font-variation-settings: "opsz" 144;
  font-size: clamp(4rem, 8vw, 7rem);
  line-height: 0.74;
  letter-spacing: -0.045em;
  color: var(--sage);
  background: var(--cream);
  padding-right: 0.18em;
  margin-bottom: -0.14em;
}
.chapter h3 {
  border-top: 1px solid var(--line-strong);
  padding-top: clamp(1.1rem, 2.2vw, 1.6rem);
  font-size: clamp(1.55rem, 3.1vw, 2.3rem);
}
.chapter p {
  margin-top: 1.1rem;
  max-width: 44ch;
  color: var(--ink-muted);
  font-size: clamp(1.02rem, 1.4vw, 1.14rem);
}

@media (min-width: 981px) {
  .chapters__stage { grid-template-columns: minmax(0, 0.88fr) minmax(0, 1fr); }
}
@media (max-width: 980px) {
  /* Headline first, then the screen it is describing. */
  .chapters__texts { order: 1; }
  .chapters__phones { order: 2; }
}

/* Desktop scroll-through, added by demo.js only when the viewport is wide
   enough and the visitor has not asked for less motion. Every chapter is on
   screen at once, the phone column stays put, and the observer flips the same
   radios the tab strip does. */
.is-scrolly .chapters__stage { align-items: start; }
/* The tail matters: a sticky element stops at the bottom of its containing
   block, so without this the phone gets shoved up under the tab strip while
   the last chapter is still being read. The padding buys the column enough
   height for the phone to stay parked. */
.is-scrolly .chapters__texts { display: grid; padding-bottom: 12vh; }
.is-scrolly .chapter {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 62vh;
  /* The five chapters you are not reading are dimmed, not hidden, and dimmed
     text is still text. 0.32 put the body copy at 1.48:1 on cream; 0.45 is as
     far as the dim can be lifted before the lit chapter stops standing out.
     It is still short of 4.5:1, so anyone who has asked their system for more
     contrast gets no dim at all. */
  opacity: 0.45;
  transition: opacity 0.45s var(--ease);
}
@media (prefers-contrast: more) {
  .is-scrolly .chapter { opacity: 1; }
}
/* In scroll mode the strip stops being only a control and becomes the index:
   it tracks where you are and jumps you to a chapter. It parks under the nav
   (72px), and the phone parks under it. */
.is-scrolly .chapters__tabs {
  position: sticky;
  top: calc(72px + 0.85rem);
  z-index: 4;
  background: color-mix(in srgb, var(--cream) 90%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}
.is-scrolly .chapters__phones {
  position: sticky;
  top: calc(72px + 5.4rem);
  align-self: start;
}
/* A laptop window is not 900px tall. Shrink the phone rather than let the
   sticky column run off the bottom of the screen. */
@media (min-width: 981px) and (max-height: 860px) {
  .chapters__phones { --phone-w: clamp(200px, 22vw, 244px); }
}

/* ---------- The Save chapter: the saved-posts vignette ----------
   A crop of the same Amalfi screenshot, taped down at an angle with an arrow
   back to the phone. Decoration: aria-hidden in the markup, and demo.js gives
   it a few pixels of drag on fine pointers only. */
/* Same deferral as the crops, and for the same reason: the card is a
   background-image, so it would fetch shot-5 at layout time and undo the lazy
   loading on the screenshot beside it. The whole vignette (card AND the arrow
   pointing at it) waits for .crops-armed, so no-script visitors get nothing
   here rather than an empty paper rectangle with an arrow aimed at it. */
.vignette { display: none; }
.crops-armed .vignette {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  margin-top: clamp(1.6rem, 3vw, 2.2rem);
}
/* Same source change as the crops above: cut from the native 1287x2796 art and
   committed as its own file rather than windowed out of the 552px shot-5.

   The window itself was wrong as well as soft, and the reason is worth
   keeping. The rule declared a 143x202 window at --s 0.86, which is 123x174 -
   but the reset at the top of this file makes every box border-box, so those
   were the BORDER box and the 6px frame ate 12px off each axis. The window
   that actually painted was 111x162, i.e. source x 98-227, y 562-750, against
   a tile that runs x 99.5-239.8, y 564.4-764.4. It was flush-cut through the
   photo on the right and the bottom while showing page above and to the left:
   a sliced screenshot rather than the saved post it is ("not cropped
   correctly", owner, 2026-08-29).

   So this rule is content-box ON PURPOSE. The frame is a mount around the
   picture, not a bite out of it, and width/aspect-ratio below describe the
   PICTURE. Put it back on border-box and the crop silently squashes again.

   crop-inspo.webp is the whole tile, rounded corners included, with an even
   ~6 native px of the app's own background on all four sides, so nothing is
   sliced and no neighboring element bleeds in. 338x478 native, drawn 120px
   wide (2.8x). */
.vignette__card {
  box-sizing: content-box;
  display: block;
  flex: 0 0 auto;
  width: 120px;
  aspect-ratio: 338 / 478;
  background-repeat: no-repeat;
  background-color: var(--paper);
  background-size: 100% 100%;
  border: 6px solid var(--paper);
  border-radius: 5px;
  box-shadow: var(--shadow-md);
  transform: rotate(3.5deg);
  touch-action: pan-y;
}
.crops-armed .vignette__card { background-image: url("assets/crop-inspo.webp"); }
.vignette__arrow {
  order: -1;
  flex: 0 0 auto;
  width: clamp(74px, 9vw, 110px);
  height: auto;
  opacity: 0.4;
}
@media (max-width: 980px) {
  /* The phone is below the copy at this width, so the arrow turns to point at
     it instead of off the side of the screen. */
  /* The arrow is drawn pointing left (180 degrees). CSS rotates clockwise, so
     -98deg swings it to just past straight down, which is where the phone is
     once the columns stack. */
  .vignette { justify-content: flex-start; }
  .vignette__arrow { order: 1; transform: rotate(-98deg); }
}

/* ---------- Manifesto ----------
   One sentence, the page's only full-bleed sand band, set off-center in
   columns 3-10 with the asterisk hanging in the margin. Fraunces italic at
   opsz 144: the display cut, which is what earns the size. */
.manifesto__grid { row-gap: 0.4rem; align-items: start; }
.manifesto p {
  font-family: var(--display);
  font-style: italic;
  font-weight: 500;
  font-variation-settings: "opsz" 144;
  font-size: clamp(2.2rem, 5.5vw, 4.2rem);
  line-height: 1.06;
  letter-spacing: -0.022em;
  max-width: 20ch;
  color: var(--ink);
  text-wrap: balance;
}
.manifesto__mark {
  display: block;
  color: var(--sage);
  font-size: clamp(1.9rem, 4.5vw, 3.6rem);
  line-height: 1;
  user-select: none;
}
@media (min-width: 981px) {
  .manifesto__mark {
    grid-column: 2 / 3;
    justify-self: end;
    margin-top: 0.15em;
  }
  .manifesto p { grid-column: 3 / 11; }
}

/* ---------- About ---------- */
.about__grid {
  display: grid;
  grid-template-columns: 1fr 0.85fr;
  gap: clamp(2rem, 6vw, 5rem);
  align-items: center;
}
.about p { color: var(--ink-muted); font-size: 1.08rem; }
.about p + p { margin-top: 1.15rem; }
.about p strong { color: var(--ink); font-weight: 600; }
.factcard {
  background: var(--ink);
  color: var(--cream);
  border-radius: var(--radius);
  padding: 2.25rem 2rem;
  position: relative;
  overflow: hidden;
  box-shadow: var(--shadow-lg), inset 0 1px 0 color-mix(in srgb, var(--cream) 12%, transparent);
}
/* Same cream grain as the CTA band and the footer, so the three ink surfaces
   are one material. Explicit z-index above the card's own inset highlight and
   below its content. */
.factcard::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--grain-ink);
  opacity: 0.05;
  pointer-events: none;
  z-index: 2;
}
.factcard > * { position: relative; z-index: 3; }
.factcard dl { display: grid; gap: 1.15rem; }
.factcard dt {
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* Plain rgba, never a token: see the note on .footer below. */
  color: rgba(250, 238, 218, 0.6);
  margin-bottom: 0.2rem;
}
.factcard dd {
  font-family: var(--display);
  font-weight: 600;
  font-size: 1.18rem;
}
.factcard dd a { color: var(--cream); }
.factcard .rule { height: 1px; background: color-mix(in srgb, var(--cream) 16%, transparent); }

/* ---------- FAQ ---------- */
/* Hairline-ruled rows, no cards: the answers are prose, and boxing each one
   would make five objects out of what should read as a single column. Native
   <details>, so keyboard operation, find-in-page and the open/closed state are
   the browser's job and not a script's. */
.faq__list { max-width: var(--container-narrow); }
.faq__item { border-top: 1px solid var(--line); }
.faq__item:last-child { border-bottom: 1px solid var(--line); }
.faq__item summary {
  position: relative;
  padding: 1.15rem 2.25rem 1.15rem 0;
  font-family: var(--display);
  font-weight: 600;
  font-size: clamp(1.1rem, 2.2vw, 1.35rem);
  letter-spacing: -0.01em;
  color: var(--ink);
  cursor: pointer;
  /* Both are needed: list-style covers Firefox, the pseudo-element covers
     Safari's marker, which list-style alone does not remove. */
  list-style: none;
}
.faq__item summary::-webkit-details-marker { display: none; }
.faq__item summary:hover { color: var(--sage-deep); }
/* Plus that becomes a minus: two rules, the vertical one faded out by [open].
   No icon asset and no script, and it animates for free. */
.faq__item summary::before,
.faq__item summary::after {
  content: "";
  position: absolute;
  right: 0.15rem;
  top: 50%;
  width: 0.85rem;
  height: 2px;
  border-radius: 2px;
  background: var(--sage);
  transform: translateY(-50%);
}
.faq__item summary::before {
  transform: translateY(-50%) rotate(90deg);
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
.faq__item[open] summary::before { opacity: 0; transform: translateY(-50%) rotate(0deg); }
.faq__item p {
  padding-bottom: 1.4rem;
  max-width: 62ch;
  color: var(--ink-muted);
}

/* ---------- Postcard marquee ----------
   The last look at the world before the ask. Full-bleed, one row, drifting
   left forever. Transform only (no width or scroll animation), so it never
   costs a layout; demo.js parks it when it is off screen and leaves it alone
   entirely under prefers-reduced-motion, where it is simply a row of
   postcards. The strip is aria-hidden in the markup: it says nothing the page
   does not already say. */
.postcards { position: relative; padding-top: clamp(1rem, 3vw, 2.5rem); }
.postcards__viewport {
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
.postcards__track {
  display: flex;
  gap: clamp(0.7rem, 1.6vw, 1.15rem);
  width: max-content;
  will-change: transform;
}
.postcard {
  position: relative;
  flex: 0 0 auto;
  margin: 0;
  width: clamp(176px, 19vw, 248px);
  border-radius: var(--radius-sm);
  overflow: hidden;
  box-shadow: var(--highlight), 0 14px 30px -20px var(--ink-45);
}
.postcard img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  display: block;
}
/* Cream veil, so the strip belongs to this page rather than reading as ten
   stock photos, and so the caption has something to sit on. */
.postcard::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to top,
    rgba(250, 238, 218, 0.92),
    rgba(250, 238, 218, 0.5) 30%,
    rgba(250, 238, 218, 0) 64%
  );
  pointer-events: none;
}
.postcard::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--grain);
  pointer-events: none;
}
.postcard figcaption {
  position: absolute;
  left: 0.75rem;
  bottom: 0.5rem;
  z-index: 2;
  font-family: var(--display);
  font-style: italic;
  font-weight: 600;
  font-size: clamp(0.95rem, 1.4vw, 1.1rem);
  letter-spacing: -0.01em;
  color: var(--ink);
}
.postcards__credit {
  margin-top: 0.6rem;
  text-align: right;
  padding-inline: clamp(1.25rem, 4vw, 2.5rem);
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: 0.45;
}

/* ---------- CTA band ---------- */
/* Layers, bottom to top: sage glow (::after, z 0), stamps and strokes (z 1),
   grain (::before, z 2), copy (z 3). The two pseudo-elements need explicit
   z-index or ::after paints over ::before by DOM order and the grain lands
   under the glow instead of over it. */
.cta-band {
  background: var(--ink);
  color: var(--cream);
  border-radius: clamp(20px, 4vw, 32px);
  padding: clamp(2.75rem, 7vw, 5rem) clamp(1.75rem, 5vw, 4rem);
  text-align: center;
  position: relative;
  overflow: hidden;
  box-shadow: var(--shadow-lg), inset 0 1px 0 color-mix(in srgb, var(--cream) 10%, transparent);
}
.cta-band::after {
  content: "";
  position: absolute;
  bottom: -40%; left: 50%;
  transform: translateX(-50%);
  width: 80%; height: 80%;
  background: radial-gradient(circle, rgba(107, 138, 42, 0.4), transparent 60%);
  pointer-events: none;
  z-index: 0;
}
.cta-band::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--grain-ink);
  opacity: 0.05;
  pointer-events: none;
  z-index: 2;
}
.cta-band > * { position: relative; z-index: 3; }
.cta-band > .cta-band__stamp,
.cta-band > .cta-band__strokes { z-index: 1; }
.cta-band__strokes {
  position: absolute;
  left: 50%;
  top: 8%;
  width: min(880px, 116%);
  height: auto;
  transform: translateX(-50%);
  pointer-events: none;
}
.cta-band__stamp {
  position: absolute;
  width: clamp(96px, 13vw, 156px);
  height: auto;
  opacity: 0.17;
  pointer-events: none;
}
.cta-band__stamp--a { left: -2%; bottom: -7%; transform: rotate(-13deg); }
/* Sage on ink starts lower than the beach stamp's gold, so it gets a little
   more of the layer to read at the same weight. */
.cta-band__stamp--b { right: 0%; top: -5%; opacity: 0.24; transform: rotate(14deg); }
.cta-band h2 { color: var(--cream); max-width: 18ch; margin-inline: auto; }
.cta-band .lede { color: rgba(250, 238, 218, 0.78); margin: 1.25rem auto 2rem; }
.cta-band .hero__actions { justify-content: center; }
.cta-band .store-note { color: rgba(250, 238, 218, 0.7); }
.cta-band .store-note .apple { color: var(--cream); }

/* ---------- Footer ---------- */
.footer {
  background: var(--ink);
  /* Light-on-dark text is a plain rgba() literal, deliberately. `color` is
     inherited, so a color-mix token here would compute to inherit on any
     engine that cannot parse it (ink on ink, an invisible footer, legal pages
     included) and a fallback declaration written above it would already have
     lost the cascade. Do not "tokenize" these back. */
  color: rgba(250, 238, 218, 0.72);
  padding-block: clamp(3rem, 6vw, 4.5rem) 2rem;
  position: relative;
  /* Keeps the grain clipped to the band. Nothing inside is meant to bleed
     past the edge any more: see .footer__mark. */
  overflow: hidden;
}
.footer::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--grain-ink);
  opacity: 0.05;
  pointer-events: none;
  z-index: 2;
}
.footer > .container { position: relative; z-index: 3; }
/* The wordmark, once, at the size it deserves, and WHOLE. It used to be
   pushed through the footer's bottom edge with a translateY the overflow
   above then cut; the crop read as a rendering fault rather than a flourish,
   so the mark now sits in full with air under it. The margins are the whole
   layout: the top one separates it from the fine print, the bottom one adds
   to the footer's own 2rem so the last thing on the page has room to breathe.
   The bottom deliberately runs DEEPER than the top: the mark is a solid
   bottom-heavy slab with no descenders, so measuring the two gaps equal
   leaves it sitting optically low. Do not "balance" them back to matching
   numbers. It is decoration: aria-hidden, empty alt, and it links to nothing.
   Index-only: the legal pages' footers end at .footer__bottom. */
.footer__mark {
  display: block;
  width: 100%;
  max-width: none;
  height: auto;
  margin-top: clamp(2.25rem, 4.5vw, 3.5rem);
  margin-bottom: clamp(0.5rem, 2.5vw, 2.5rem);
  pointer-events: none;
  user-select: none;
}
.footer a { color: rgba(250, 238, 218, 0.72); text-decoration: none; }
.footer a:hover { color: var(--cream); }
.footer__top {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr 1fr;
  gap: clamp(1.75rem, 4vw, 3rem);
  padding-bottom: 2.75rem;
  border-bottom: 1px solid color-mix(in srgb, var(--cream) 16%, transparent);
}
.footer__brand .wordmark { color: var(--cream); font-size: 1.9rem; }
.footer__brand p {
  margin-top: 0.9rem;
  font-size: 0.95rem;
  max-width: 30ch;
  color: rgba(250, 238, 218, 0.6);
}
.footer__col h4 {
  font-family: var(--body);
  font-weight: 600;
  font-size: 0.78rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(250, 238, 218, 0.5);
  margin-bottom: 1rem;
}
.footer__col ul { list-style: none; padding: 0; display: grid; gap: 0.65rem; }
.footer__col a { font-size: 0.97rem; }
.footer__bottom {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
  justify-content: space-between;
  align-items: center;
  padding-top: 2rem;
  font-size: 0.88rem;
  color: rgba(250, 238, 218, 0.55);
}
.footer__bottom .sep { color: rgba(250, 238, 218, 0.3); }

/* ---------- Ink surfaces: selection and focus ----------
   The page's selection is cream on sage and its focus ring is sage, and both
   of those disappear against ink. Inverted here. Plain literals, never a
   token or a color-mix: same rule as every other color on these surfaces. */
.footer::selection,
.footer ::selection,
.cta-band::selection,
.cta-band ::selection,
.factcard::selection,
.factcard ::selection {
  background: #faeeda;
  color: #2b3318;
}
.footer :focus-visible,
.cta-band :focus-visible,
.factcard :focus-visible {
  outline: 2.5px solid #faeeda;
  outline-offset: 3px;
}

/* ---------- Legal pages ---------- */
.legal-hero {
  padding-top: clamp(2.5rem, 5vw, 4rem);
  padding-bottom: clamp(1.5rem, 3vw, 2.5rem);
  border-bottom: 1px solid var(--line);
}
.legal-hero h1 {
  font-size: clamp(2.2rem, 5.5vw, 3.4rem);
  letter-spacing: -0.03em;
  margin-top: 1rem;
}
.legal-hero .updated {
  margin-top: 1rem;
  font-size: 0.92rem;
  color: var(--ink-muted);
}
.legal-hero .updated b { color: var(--ink); font-weight: 600; }

.legal-layout {
  display: grid;
  grid-template-columns: 230px 1fr;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: start;
  padding-block: clamp(2.5rem, 5vw, 4rem);
}
.toc {
  position: sticky;
  top: 92px;
  font-size: 0.9rem;
}
.toc h4 {
  font-family: var(--body);
  font-weight: 600;
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-bottom: 0.9rem;
}
.toc ol { list-style: none; padding: 0; display: grid; gap: 0.55rem; counter-reset: toc; }
.toc a {
  color: var(--ink-muted);
  text-decoration: none;
  display: block;
  line-height: 1.35;
  transition: color 0.2s var(--ease);
}
.toc a:hover { color: var(--sage-deep); }

.prose { max-width: var(--container-narrow); }
.prose h2 {
  font-size: clamp(1.4rem, 2.6vw, 1.85rem);
  letter-spacing: -0.015em;
  margin-top: 2.75rem;
  margin-bottom: 0.9rem;
  /* No scroll-margin-top here: html's scroll-padding-top clears the sticky nav
     for every anchor on the site, this one included. */
  display: flex;
  gap: 0.6rem;
  align-items: baseline;
}
.prose h2 .num {
  font-size: 0.92rem;
  font-family: var(--body);
  font-weight: 700;
  color: var(--sage);
  letter-spacing: 0;
}
.prose h2:first-child { margin-top: 0; }
.prose h3 {
  font-size: 1.15rem;
  margin-top: 1.6rem;
  margin-bottom: 0.5rem;
}
.prose p { margin-bottom: 1rem; color: var(--ink); }
.prose ul, .prose ol { margin: 0 0 1.15rem 1.2rem; padding: 0; }
.prose li { margin-bottom: 0.5rem; padding-left: 0.3rem; }
.prose li::marker { color: var(--sage); }
.prose strong { font-weight: 600; }
.prose a { color: var(--sage-deep); font-weight: 500; }
.prose .callout {
  background: var(--sage-tint);
  border: 1px solid color-mix(in srgb, var(--sage) 32%, transparent);
  border-radius: var(--radius-sm);
  padding: 1.1rem 1.3rem;
  margin: 1.5rem 0;
  font-size: 0.98rem;
}
.prose .callout p:last-child { margin-bottom: 0; }
.prose table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.25rem 0 1.6rem;
  font-size: 0.95rem;
}
.prose th, .prose td {
  text-align: left;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--line);
  vertical-align: top;
}
.prose th {
  background: var(--panel);
  font-weight: 600;
  font-size: 0.82rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.prose td a { word-break: break-word; }
.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  font-weight: 500;
  text-decoration: none;
  color: var(--sage-deep);
}
.back-link:hover { text-decoration: underline; }

/* ---------- Reveal animation ---------- */
/* The hidden start state is keyed to .will-reveal, which ONLY the script ever
   applies, and only to elements the reader has to scroll to. Nothing is hidden
   by CSS alone, so a page whose JS is slow, blocked or broken paints in full
   rather than showing blank sections. Do not re-key this to [data-reveal]. */
.will-reveal { opacity: 0; transform: translateY(24px); filter: blur(6px); }
[data-reveal] {
  transition: opacity 0.85s var(--ease-out), transform 0.85s var(--ease-out),
    filter 0.85s var(--ease-out);
  transition-delay: var(--d, 0s);
}
[data-reveal].is-in { opacity: 1; transform: none; filter: none; }

/* ---------- Responsive ---------- */
@media (max-width: 980px) {
  .hero__copy { max-width: 100%; }
  .about__grid { grid-template-columns: 1fr; }
  .footer__top { grid-template-columns: 1fr 1fr; }
  .legal-layout { grid-template-columns: 1fr; }
  .toc { position: static; padding-bottom: 1.5rem; border-bottom: 1px solid var(--line); }
}

@media (max-width: 560px) {
  .nav__links { gap: 1rem; }
  .footer__top { grid-template-columns: 1fr; }
  .hero__meta { gap: 1.5rem; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
  }
  .will-reveal { opacity: 1; transform: none; filter: none; }
  /* The blanket rule above zeroes durations but not DELAYS, so an animation
     with fill:both would still hold its start frame for the length of its
     stagger. Both of these are cancelled outright instead: the headline is
     simply there, and the pins simply appear where they belong. demo.js
     skips the FLIP and the card pop on the same query. */
  .line__in { transform: none !important; animation: none !important; }
  .map__pin--drop { animation: none !important; }
}

/* ---------- Print (legal pages) ---------- */
@media print {
  /* .postcards and the floating crops are decoration; paper gets the words. */
  .nav, .footer, .toc, .cta-band, .stickybar, .postcards, .crop, .vignette { display: none !important; }
  /* Same reason as [data-reveal] below: what is on the screen goes on the
     paper. The headline must never print stuck under its own mask. */
  .line { overflow: visible !important; }
  .line__in { transform: none !important; animation: none !important; }
  /* Belt and braces with the script's beforeprint handler: a section that
     never got revealed must still reach the paper. */
  [data-reveal] { opacity: 1 !important; transform: none !important; filter: none !important; }
  /* Same principle for a closed answer: it is on the page, so it goes on the
     paper. The pseudo-element is what modern Chrome hides the content with;
     the display rule covers the engines that still hide the slot. */
  .faq__item > p { display: block !important; }
  .faq__item::details-content { content-visibility: visible !important; }
  body { background: #fff; color: #000; }
  /* The chapter numeral masks the headline's hairline with a cream rectangle
     on screen, and neither half of that survives paper: the page is white and
     browsers strip backgrounds by default, so the mask would print as either a
     cream block or nothing at all, and "nothing at all" puts the rule straight
     back through the digits. Paper takes the other option instead - the rule
     drops below the numeral - which needs no background to be correct. */
  .chapter__no { background: none !important; width: auto; margin-bottom: 0; padding-right: 0; }
  .legal-layout { grid-template-columns: 1fr; }
  .prose { max-width: none; }
}
