/* ==========================================================================
   NomadCheck — site stylesheet
   Single stylesheet. Tokens are defined in tokens.css and loaded first.
   Organise with section comments; never add another CSS file.
   ========================================================================== */


/* ==========================================================================
   MEDIA SLOT — SWAP CONTRACT
   --------------------------------------------------------------------------
   Every place a real app screenshot will eventually go is a .media-slot
   figure holding one placeholder span:

     <figure class="media-slot" style="aspect-ratio: 9 / 19.5">
       <span class="media-slot__placeholder"
             data-file="screen-explore.png"
             data-dim="phone @3x"></span>
     </figure>

   When the real PNG lands, replace THE SPAN and nothing else. Theme-aware
   screenshots ship as a stacked pair — one Nightfall, one Daybreak — and
   cross-fade via .theme-shot (see below):

     <figure class="media-slot" style="aspect-ratio: 9 / 19.5"
             role="img" aria-label="…">
       <img class="theme-shot" data-theme-shot="nightfall"
            src="assets/img/screen-explore_nightfall.png" alt=""
            width="…" height="…" loading="lazy">
       <img class="theme-shot" data-theme-shot="daybreak"
            src="assets/img/screen-explore_daybreak.png" alt=""
            width="…" height="…" loading="lazy">
     </figure>

   The <figure> keeps every bit of framing, sizing, reveal, and animation
   styling, so the swap never touches a class, a wrapper, an aspect-ratio, or
   a line of JS. Nothing in this file styles the placeholder and the image
   differently in any way that affects layout — the image lands in the exact
   box the placeholder occupied, so the swap is also zero layout shift.

   Rules for the swapped-in <img>:
     - width/height are the PNG's real pixel dimensions (a 9/19.5 phone shot
       at @3x is 1170 × 2535). They stop CLS before CSS loads.
     - loading="lazy" below the fold; loading="eager" fetchpriority="high"
       for anything in the hero.
     - alt is empty on each layer; the figure carries role="img" and
       aria-label so assistive tech meets one image, not two.
     - Aspect ratios: phone screenshots 9 / 19.5, og-image 1200 / 630.
   ========================================================================== */


/* ==========================================================================
   RESET
   ========================================================================== */

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

* {
  margin: 0;
}

img,
picture,
svg,
video {
  display: block;
  max-width: 100%;
}


/* ==========================================================================
   BASE TYPOGRAPHY
   ========================================================================== */

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

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-text);
  font-size: var(--text-base);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
  color: var(--text-bright);
}

h1 { font-size: var(--text-h1); }
h2 { font-size: var(--text-h2); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }

p {
  max-width: var(--max-prose);
}

a {
  color: var(--accent-text);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

:where(a, button, input, textarea, select):focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}


/* ==========================================================================
   SITE SHELL
   Header, footer, skip link, and the brand wordmark. Page sections come
   later; this is only what the semantic shell markup needs to be readable
   and on-brand.
   ========================================================================== */

.skip-link {
  position: absolute;
  left: var(--space-4);
  top: var(--space-4);
  z-index: 60; /* above the fixed header */
  padding: var(--space-2) var(--space-4);
  background: var(--surface);
  color: var(--text);
  border-radius: var(--radius-sm);
  transform: translateY(-150%);
}

.skip-link:focus {
  transform: translateY(0);
  text-decoration: none;
}

/* The header floats over the page so the hero background can run underneath
   it. Its height therefore never affects page flow, and shrinking it on
   scroll cannot shift a single pixel of content.
   Sections that need to start below it inherit main's padding; a full-bleed
   hero cancels it with `margin-block-start: calc(var(--header-h) * -1)` and
   pads itself back out by the same amount. */
.site-header {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 50;
  background-color: transparent;
  border-bottom: 1px solid transparent;
  transition:
    transform var(--dur-fast) var(--ease-out),
    background-color var(--dur-fast) var(--ease-in-out),
    border-color var(--dur-fast) var(--ease-in-out);
}

main {
  padding-block-start: var(--header-h);
}

.site-header.is-scrolled {
  background-color: var(--surface-glass);
  border-bottom-color: var(--border);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}

/* Hidden on scroll down below 900px only — mobile-first, so the transform is
   the default and the desktop breakpoint takes it away. Keeping the width
   rule in CSS means a resize while hidden can never strand the header. */
.site-header.is-hidden {
  transform: translateY(-100%);
}

.site-nav {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  max-width: var(--max-content);
  margin-inline: auto;
  padding-inline: var(--space-5);
  /* Confined to the fixed bar: this can move the wordmark, nothing else. */
  padding-block: calc((var(--header-h) - var(--header-line)) / 2);
  transition: padding-block var(--dur-fast) var(--ease-in-out);
}

/* Every child is pinned to --header-line so nothing in the bar can outgrow
   --header-h and make the padding calc above resolve negative. */
.site-nav__links {
  display: none; /* shown from 640px — see below */
  gap: var(--space-6);
  margin: 0;
  padding: 0;
  list-style: none;
}

.site-nav__links a {
  display: block;
  line-height: var(--header-line);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  font-weight: 600;
  transition: color var(--dur-fast) var(--ease-in-out);
}

.site-nav__links a:hover {
  color: var(--accent-text);
  text-decoration: none;
}

/* The theme toggle and the CTA travel together as one grid child, so the
   three-column bar at 900px stays three columns rather than wrapping the
   toggle onto a second row and blowing past --header-h. */
.site-nav__actions {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-inline-start: auto;
}

/* Under 640px the bar carries the wordmark and the CTA only. The three
   anchors are one screen-swipe away in the hero and repeated in the footer,
   so dropping them here beats crowding a 360px bar. */
@media (min-width: 640px) {
  .site-nav__links {
    display: flex;
  }
}

/* Wide enough to centre the links properly rather than letting them drift
   with the width of the wordmark. */
@media (min-width: 900px) {
  .site-nav {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
  }

  /* Keeps the hit area on the wordmark itself, not the whole grid column. */
  .site-nav .wordmark {
    justify-self: start;
  }

  .site-nav__links {
    justify-content: center;
  }

  .site-nav__actions {
    justify-self: end;
  }
}

.site-header.is-scrolled .site-nav {
  padding-block: calc((var(--header-h-scrolled) - var(--header-line)) / 2);
}

@media (min-width: 900px) {
  .site-header.is-hidden {
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-header,
  .site-nav,
  .site-nav__links a {
    transition: none;
  }
}

/* --- launch badge --------------------------------------------------------
   Sits where the store CTA used to, so it inherits that slot's constraints:
   pinned to --header-line like every other child of the bar, and narrow
   enough that a 360px bar still holds wordmark + badge + toggle. It gets
   roomier from 640px, where there is width to spare.
   The tinted fill is opaque on purpose — the header is transparent over the
   hero, and a translucent pill would pick up whatever scrolled behind it. */
.launch-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  /* Height is pinned rather than derived from padding: every child of the bar
     has to measure exactly --header-line, or the padding calc on .site-nav
     resolves against the wrong number once .is-scrolled shrinks the bar.
     border-box means the 1px border is inside that figure. */
  block-size: var(--header-line);
  padding-inline: var(--space-3);
  border: 1px solid var(--hub-border);
  border-radius: var(--radius-full);
  background-color: var(--accent-tint);
  color: var(--accent-text);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  white-space: nowrap;
}

@media (min-width: 640px) {
  .launch-badge {
    gap: var(--space-3);
    padding-inline: var(--space-4);
  }
}

/* Fixed box so the pulse below cannot reflow the label. */
.launch-badge__dot {
  position: relative;
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: var(--radius-full);
  background-color: var(--accent);
  box-shadow: 0 0 8px var(--accent);
}

/* The halo is a pseudo-element rather than a shadow on the dot itself, so
   the pulse is transform + opacity only and never repaints the pill. */
.launch-badge__dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-color: var(--accent);
  opacity: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .launch-badge__dot::after {
    animation: launch-badge-pulse 2.4s var(--ease-out) infinite;
  }
}

@keyframes launch-badge-pulse {
  0%       { transform: scale(1);   opacity: 0.5; }
  70%,100% { transform: scale(2.6); opacity: 0; }
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--text-xl);
  letter-spacing: var(--tracking-tight);
  color: var(--text-bright);
  text-decoration: none;
}

.wordmark:hover {
  text-decoration: none;
}

/* Pins the bar's content height so --header-h is the real height and the
   padding calc above can never resolve negative. */
.site-nav .wordmark {
  line-height: var(--header-line);
}

.wordmark-nomad { color: var(--text-bright); }
.wordmark-check { color: var(--accent-text); }

/* The shell of the footer. Its contents are SECTION 15 below, and every page
   in the site — index, both legal pages, 404 — ships that same footer. */
.site-footer {
  border-top: 1px solid var(--border);
  margin-top: var(--space-9);
}


/* ==========================================================================
   BUTTONS
   The site-wide CTA pair. Both store buttons, the header CTA, the Pro CTA and
   the final CTA are all this one pattern — never restyle a button locally.
   NOTE B in tokens.css: text on the mint fill is --accent-contrast, never
   white. --btn-lift is the hover translate, zeroed under reduced motion so
   the rule can stay in one place.
   ========================================================================== */

.btn {
  --btn-lift: 0;

  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-5);
  border: 1px solid transparent;
  border-radius: var(--radius-full);
  font-family: var(--font-display);
  font-size: var(--text-base);
  font-weight: 600;
  line-height: var(--leading-snug);
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  transform: translateY(var(--btn-lift));
  transition:
    background-color var(--dur-fast) var(--ease-in-out),
    border-color var(--dur-fast) var(--ease-in-out),
    color var(--dur-fast) var(--ease-in-out),
    transform var(--dur-fast) var(--ease-out);
}

.btn:hover {
  text-decoration: none;
}

.btn > svg {
  inline-size: 1.15em;
  block-size: 1.15em;
  flex: none;
  fill: currentColor;
}

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

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

.btn--secondary {
  background-color: var(--surface);
  border-color: var(--border-strong);
  color: var(--text);
}

.btn--secondary:hover {
  border-color: var(--accent);
  color: var(--text-bright);
}

/* Bar-height variant: no block padding, so the header line pins it exactly. */
.btn--sm {
  padding-block: 0;
  padding-inline: var(--space-4);
  font-size: var(--text-sm);
  line-height: var(--header-line);
}

@media (prefers-reduced-motion: no-preference) {
  .btn:hover {
    --btn-lift: -2px;
  }
}

/* --btn-lift already stays 0 under reduced motion, so the transform never
   moves — but the colour terms would still cross-fade on hover. The rest of
   the stylesheet nulls its transitions out here rather than letting them run,
   so this one follows suit. */
@media (prefers-reduced-motion: reduce) {
  .btn {
    transition: none;
  }
}


/* ==========================================================================
   THEME TOGGLE
   Compact icon button in the sticky header, sat beside the CTA. main.js
   cycles THEMES on click; the icon reflects the theme that is on, not the one
   a click would bring. Hidden until `.js` is set — a button that cannot do
   anything should not be in the tab order.
   ========================================================================== */

.theme-toggle {
  display: none;
}

/* Square on --header-line, the same measure every other child of the bar is
   pinned to, so the toggle can never make --header-h's padding calc go
   negative. */
.js .theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: var(--header-line);
  block-size: var(--header-line);
  flex: none;
  padding: 0;
  background-color: transparent;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
  color: var(--text-muted);
  cursor: pointer;
}

.js .theme-toggle:hover {
  border-color: var(--accent);
  color: var(--text-bright);
}

.theme-toggle svg {
  inline-size: 1.1rem;
  block-size: 1.1rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Moon is the default so any future dark theme reads correctly without a
   rule of its own; only the light theme swaps it. */
.theme-toggle__icon--sun,
[data-theme="daybreak"] .theme-toggle__icon--moon {
  display: none;
}

[data-theme="daybreak"] .theme-toggle__icon--sun {
  display: block;
}

@media (prefers-reduced-motion: no-preference) {
  .theme-toggle {
    transition:
      border-color var(--dur-fast) var(--ease-in-out),
      color var(--dur-fast) var(--ease-in-out);
  }
}


/* ==========================================================================
   MEDIA SLOT
   The box a real screenshot will land in. See the SWAP CONTRACT at the top of
   this file. The figure owns size and framing; the child is interchangeable.
   ========================================================================== */

/* No width of its own — the section decides how wide the slot is, and
   aspect-ratio on the figure turns that width into a height. */
.media-slot {
  /* One cell, every child in it. Later framing (a phone shell, a glow, a
     second offset screenshot) stacks by adding a sibling, with no absolute
     positioning and no wrapper around the image. */
  display: grid;
  grid-template: 1fr / 1fr;
  margin: 0;
  border-radius: var(--radius-lg);
  overflow: hidden;
  /* Shows through a transparent PNG and covers the gap while a lazy image
     decodes, so the slot is never a hole in the layout. */
  background: var(--surface-deep);
}

.media-slot > * {
  grid-area: 1 / 1;
  inline-size: 100%;
  block-size: 100%;
}

/* The figure's aspect-ratio is authoritative: a screenshot whose real ratio
   drifts is cropped, never allowed to resize the box it was given. */
.media-slot > img {
  object-fit: cover;
}

/* --- theme-aware screenshot layers --------------------------------------
   Stacked Nightfall / Daybreak PNGs in one .media-slot cell. Opacity alone
   picks the active theme, keyed off [data-theme] on <html> — so the right
   shot is correct at first paint (blocking resolver), correct with JS off,
   and switches in the same frame the rest of the page retints. Src is never
   rewritten, so a switch cannot flash a blank while something decodes.

   The :not([data-theme]) case is belt-and-braces: scripting disabled means
   the attribute is never written, and tokens.css resolves bare :root to
   Nightfall, so the Nightfall layer is the one to show. */

.theme-shot {
  opacity: 0;
}

html:not([data-theme]) .theme-shot[data-theme-shot="nightfall"],
[data-theme="nightfall"] .theme-shot[data-theme-shot="nightfall"],
[data-theme="daybreak"] .theme-shot[data-theme-shot="daybreak"] {
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .theme-shot {
    transition: opacity 350ms var(--ease-in-out);
  }
}

/* --- placeholder --------------------------------------------------------
   Scaffolding. Deleted one slot at a time as real screenshots arrive, and
   gone from the codebase entirely once the last one is in.
   `border`, not `outline`: it follows border-radius everywhere, sits inside
   the figure's overflow clip, and can't be confused with a focus ring. */
.media-slot__placeholder {
  display: grid;
  place-content: center;
  padding: var(--space-4);
  border: 2px dashed var(--border-strong);
  border-radius: inherit;
  background: var(--surface-raised);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  line-height: var(--leading-snug);
  text-align: center;
  user-select: none;
}

/* Filename on one line, dimensions on the next. pre-line honours the \A;
   anywhere keeps a long filename inside a narrow phone slot. */
.media-slot__placeholder::after {
  content: attr(data-file) "\A" attr(data-dim);
  white-space: pre-line;
  overflow-wrap: anywhere;
}


/* ==========================================================================
   PHONE FRAME
   The device shell, drawn entirely in CSS — no image asset for the frame.
   It wraps a .media-slot and adds nothing but padding, so the screenshot
   swap contract is untouched. Reused by every feature section.
   ========================================================================== */

.phone {
  padding: var(--space-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  background-color: var(--surface-shell);
  box-shadow: var(--shadow-lg);
}


/* ==========================================================================
   SECTION 01 — HERO
   Full-viewport split: copy left, device mockups right, stacking to copy-first
   below 900px. The section cancels main's header padding so its background
   runs under the fixed bar, then pads itself back out by the same amount —
   the header never affects flow, so shrinking it shifts nothing.
   ========================================================================== */

.hero {
  /* Content takes the free space and centres inside it; the scroll hint
     always lands on the last row, hard against the bottom of the viewport. */
  display: grid;
  grid-template-rows: 1fr auto;
  min-block-size: 100svh;
  margin-block-start: calc(var(--header-h) * -1);
  padding-block-start: var(--header-h);
  overflow: hidden; /* clips the field SVG and the drifting frames */

  /* "Nightfall gradient": the theme background lifted by the accent glow
     behind the mockups, on the side of the section that carries no text. */
  background-color: var(--bg);
  background-image: var(--glow-accent);
  background-repeat: no-repeat;
  background-position: 82% 30%;
  background-size: 58rem 46rem;
}

.hero__inner {
  display: grid;
  gap: var(--space-8);
  align-items: center;
  max-width: var(--max-content);
  margin-inline: auto;
  padding-block: var(--space-8);
  padding-inline: var(--space-5);
  inline-size: 100%;
}

@media (min-width: 900px) {
  .hero__inner {
    grid-template-columns: 55fr 45fr;
    gap: var(--space-7);
  }
}

/* --- background field ---------------------------------------------------
   Great-circle flight path over a compass field. Behind the content but
   inside the [data-glow] isolation, so the cursor orb lights it too. */

.hero__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.hero__field svg {
  inline-size: 100%;
  block-size: 100%;
}

.hero-field__compass {
  opacity: 0.06;
}

.hero-field__compass > * {
  fill: none;
  stroke: var(--text);
  stroke-width: 1.5;
}

/* The path itself reads at its own strength — at 6% the arc and its endpoints
   disappear, and the flight path is the brand motif, not texture. */
.hero-field__path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-dasharray: 1 14;
  opacity: 0.45;
}

.hero-field__node {
  fill: var(--accent);
  opacity: 0.8;
}

/* --- copy ---------------------------------------------------------------- */

.hero__title {
  margin-block-end: var(--space-5);
}

/* Each word is its own overflow clip so the word inside can start below the
   line and rise into it. padding/margin pair gives descenders room to render
   without letting the clip show a sliver of the next line. */
.hero__word {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-block-end: 0.14em;
  margin-block-end: -0.14em;
}

.hero__word > span {
  display: block;
}

.hero__sub {
  margin-block-end: var(--space-6);
  color: var(--text-muted);
  font-size: var(--text-lg);
}

.hero__actions {
  display: grid;
  gap: var(--space-3);
  margin-block-end: var(--space-4);
}

@media (min-width: 640px) {
  .hero__actions {
    grid-auto-flow: column;
    justify-content: start;
  }
}

.hero__microcopy {
  color: var(--text-subtle);
  font-size: var(--text-sm);
}

/* --- waitlist -------------------------------------------------------------
   One surface holding the mail icon, the field, and the button. The input
   carries no chrome of its own — the container is the control, so the whole
   thing reads as a single object and the button sits flush inside it. */

.waitlist {
  margin-block-end: var(--space-5);
  max-inline-size: var(--max-prose);
}

.waitlist__field {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-2) var(--space-3);
  align-items: center;
  padding: var(--space-2);
  padding-inline-start: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface);
  box-shadow: var(--shadow-md);
}

/* The field is deliberately inert on focus: no ring on the input, no colour
   shift on the container. Suppressing the global :focus-visible outline takes
   only class specificity, since that rule is wrapped in :where().

   Note for whoever reads this next: this is the one control on the site with
   no visible focus indicator, which is a WCAG 2.4.7 failure. It is a design
   decision, not an oversight — restore it by putting the ring back on the
   container rather than on the input:

     .waitlist__field:focus-within {
       border-color: var(--accent);
       box-shadow: inset 0 0 0 1px var(--accent), var(--shadow-md);
     } */
.waitlist__input:focus-visible {
  outline: none;
}

.waitlist__icon {
  inline-size: 1.25rem;
  block-size: 1.25rem;
  fill: none;
  stroke: var(--text-subtle);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Visually hidden: the placeholder is not a name, and a field labelled only
   by one loses its name the moment anything is typed. */
.waitlist__label {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.waitlist__input {
  min-inline-size: 0; /* grid items floor at min-content without this */
  padding-block: var(--space-2);
  border: 0;
  background: none;
  color: var(--text);
  font-family: inherit;
  font-size: var(--text-base);
  line-height: var(--leading-snug);
}

/* Autofill. Chrome paints its own background on an autofilled field and
   ignores background-color, which is what the pale slab inside the pill was.
   An inset shadow the full height of the box is the one thing it does honour,
   so the field is repainted in the container's own surface and the slab
   disappears. Kept in the two rules below rather than one list: `:autofill`
   is the standard selector but older engines only know the prefixed form, and
   an unknown selector would invalidate the whole list.

   Chrome re-asserts the background on hover, focus, and active, so each has
   to be named. --surface here is the same token .waitlist__field paints with,
   so it tracks the theme rather than pinning a colour. */
.waitlist__input:autofill,
.waitlist__input:autofill:hover,
.waitlist__input:autofill:focus,
.waitlist__input:autofill:active {
  box-shadow: inset 0 0 0 100px var(--surface);
  -webkit-text-fill-color: var(--text);
}

.waitlist__input:-webkit-autofill,
.waitlist__input:-webkit-autofill:hover,
.waitlist__input:-webkit-autofill:focus,
.waitlist__input:-webkit-autofill:active {
  box-shadow: inset 0 0 0 100px var(--surface);
  -webkit-text-fill-color: var(--text);
}

.waitlist__input::placeholder {
  color: var(--text-subtle);
  opacity: 1; /* Firefox dims placeholders on top of the colour */
}

/* Below 640px the button takes its own row across the full width; from there
   it returns to the end of the field's row. */
.waitlist__submit {
  grid-column: 1 / -1;
}

@media (min-width: 640px) {
  .waitlist__field {
    grid-template-columns: auto 1fr auto;
  }

  .waitlist__submit {
    grid-column: auto;
  }
}

.waitlist__note {
  margin-block-start: var(--space-3);
  color: var(--text-subtle);
  font-size: var(--text-sm);
}

.waitlist__perks {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  margin-block-start: var(--space-3);
  padding: 0;
  list-style: none;
}

.waitlist__perk {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.waitlist__perk svg {
  inline-size: 0.9rem;
  block-size: 0.9rem;
  flex: none;
  fill: none;
  stroke: var(--accent-text);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* --- waitlist confirmation dialog -----------------------------------------
   A real <dialog>: the top layer, the backdrop, Escape, the focus trap, and
   the inert page behind are all the platform's. Only the panel is styled.
   ::backdrop reads --backdrop from :root by inheritance; engines predating
   that inheritance simply show no wash, and the panel still reads on its own
   surface and shadow. */

.waitlist-dialog {
  max-inline-size: min(28rem, calc(100% - var(--space-6)));
  margin: auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text);
}

.waitlist-dialog::backdrop {
  background-color: var(--backdrop);
}

.waitlist-dialog__panel {
  display: grid;
  justify-items: start;
  gap: var(--space-4);
  padding: var(--space-6);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  background-color: var(--surface-shell);
  background-image: var(--glow-accent);
  background-repeat: no-repeat;
  background-position: 100% -60%;
  background-size: 26rem 22rem;
  box-shadow: var(--shadow-lg);
}

/* Mark trails the title on the same line. No width and no space-between —
   the head shrinks to its contents, so the disc sits against the end of the
   text rather than against the edge of the panel.

   align-items: center rather than baseline — the disc has no text in it to
   align to, and the title is one short line. */
.waitlist-dialog__head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

.waitlist-dialog__mark {
  display: grid;
  place-items: center;
  inline-size: 2.75rem;
  aspect-ratio: 1;
  flex: none; /* a wrapping title must not squash the disc into an oval */
  border-radius: var(--radius-full);
  background-color: var(--accent-tint);
  color: var(--accent-text);
}

.waitlist-dialog__mark svg {
  inline-size: 1.375rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.waitlist-dialog__title {
  font-size: var(--text-2xl);
}

.waitlist-dialog__body {
  color: var(--text-muted);
}

.waitlist-dialog__email {
  color: var(--text-bright);
  font-weight: 600;
  overflow-wrap: anywhere; /* a long address must not widen the panel */
}

.waitlist-dialog__close {
  margin-block-start: var(--space-2);
}

@media (prefers-reduced-motion: no-preference) {
  .waitlist-dialog[open] {
    animation: dialog-rise var(--dur-base) var(--ease-out) both;
  }

  .waitlist-dialog[open]::backdrop {
    animation: dialog-fade var(--dur-base) var(--ease-in-out) both;
  }
}

@keyframes dialog-rise {
  from { opacity: 0; transform: translateY(12px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}

@keyframes dialog-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --- device mockups ------------------------------------------------------ */

.hero__stage {
  display: grid;
  perspective: 1200px;
}

.hero__tilt {
  display: grid;
  grid-template: 1fr / 1fr;
  justify-items: center;
}

.hero__phone {
  grid-area: 1 / 1;
  inline-size: min(16rem, 72%);
}

/* Below 900px only the front frame shows, flat and untilted. */
.hero__phone--back {
  display: none;
}

@media (min-width: 900px) {
  .hero__tilt {
    transform: rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
    transform-style: preserve-3d;
  }

  .hero__phone {
    inline-size: min(18rem, 76%);
  }

  .hero__phone--back {
    display: block;
    transform: translate(30%, -9%) rotate(-6deg) scale(0.9);
    opacity: 0.5;
  }

  .hero__phone--front {
    transform: rotate(3deg);
  }
}

/* --- scroll hint --------------------------------------------------------- */

.hero__hint {
  justify-self: center;
  padding-block-end: var(--space-5);
  color: var(--text-subtle);
  opacity: 1;
  transition: opacity var(--dur-base) var(--ease-in-out);
}

.hero__hint svg {
  inline-size: var(--space-6);
  block-size: var(--space-6);
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hero__hint.is-faded {
  opacity: 0;
}

/* initHeroScrollHint is deliberately not gated on reduced motion — a chevron
   still pointing down a scrolled page is wrong, not decorative. The state
   change stays; only the fade to it goes, so the hint cuts straight out. */
@media (prefers-reduced-motion: reduce) {
  .hero__hint {
    transition: none;
  }
}

/* --- motion --------------------------------------------------------------
   All of it is CSS keyframes, so the H1 reveal and the float run with JS
   off. Under reduced motion none of these rules match and every element is
   already in its final state — no start transform to undo. */

@media (prefers-reduced-motion: no-preference) {
  .hero__word > span {
    transform: translateY(100%);
    animation: hero-word-rise 700ms var(--ease-out) calc(var(--i) * 60ms) both;
  }

  .hero__rise {
    opacity: 0;
    animation: hero-rise var(--dur-slow) var(--ease-out) calc(300ms + var(--i) * 80ms) both;
  }

  /* Two frames, 2s out of phase, so the pair never drifts in sync. A negative
     delay starts the back frame mid-cycle instead of holding it still. */
  .hero__phone > .phone {
    animation: phone-float 8s var(--ease-in-out) infinite;
  }

  .hero__phone--back > .phone {
    animation-delay: -2s;
  }

  .hero__hint {
    animation: hero-hint-bounce 2s var(--ease-in-out) infinite;
  }
}

@keyframes hero-word-rise {
  to { transform: translateY(0); }
}

@keyframes hero-rise {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}

@keyframes phone-float {
  0%, 100% { transform: translateY(-5px); }
  50%      { transform: translateY(5px); }
}

@keyframes hero-hint-bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(6px); }
}


/* ==========================================================================
   SECTION 02 — STAT BAR
   A full-bleed raised band under the hero, hairlined top and bottom. Three
   stats side by side from 640px with a divider between them; below that they
   become three rows with the figure and its label on one line, which is far
   less vertical space than three stacked pairs of a clamp(…, 4rem) number.
   ========================================================================== */

.stat-bar {
  background-color: var(--surface-deep);
  border-block: 1px solid var(--border);
}

/* The kicker sits on the bottom edge of the band, so the stats are only
   centred in it if the space the kicker takes is mirrored back above them.
   Both sides are derived from the tokens the kicker itself uses — its line
   box plus the gap — so they stay in step if either one changes. */
.stat-bar__inner {
  --kicker-space: calc(var(--text-sm) * var(--leading-body) + var(--space-3));

  max-width: var(--max-content);
  margin-inline: auto;
  padding: calc(var(--space-4) + var(--kicker-space)) var(--space-5) var(--space-4);
}

.stat-bar__list {
  display: grid;
  gap: var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.stat {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
}

/* The count-up starts at "0+" and grows into the authored string, so the box
   reserves the finished width up front — otherwise every frame of the
   animation nudges the label sitting beside it. Tabular figures (set globally
   on [data-count-to]) make 1ch exactly one digit, and 6.5ch clears the widest
   of the three, "2,500+". All three reserve the same width so the labels line
   up as a column in the stacked layout; centred, the slack doesn't show. */
.stat__value {
  min-inline-size: 6.5ch;
  color: var(--accent-text);
  font-family: var(--font-display);
  font-size: var(--text-stat);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}

.stat__label {
  color: var(--text-label);
  font-size: var(--text-sm);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

/* max-width: none overrides the global prose measure — this is one short
   line under a full-width row, not a paragraph. */
.stat-bar__kicker {
  max-width: none;
  margin-block-start: var(--space-3);
  color: var(--text-muted);
  font-size: var(--text-sm);
  text-align: center;
}

@media (min-width: 640px) {
  .stat-bar__list {
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: 0;
  }

  /* Divider on the item rather than between grid tracks: with gap: 0 the
     hairline lands exactly on the boundary, and the item's own inline padding
     keeps the figures off it. */
  .stat + .stat {
    border-inline-start: 1px solid var(--border);
  }

  .stat {
    flex-direction: column;
    align-items: center;
    gap: var(--space-1);
    padding-inline: var(--space-5);
    text-align: center;
  }
}


/* ==========================================================================
   SECTION 03 — THE PROBLEM
   Copy left, the tab-clutter graphic right, one column below 900px. The
   section is itself the two-column grid so [data-reveal-stagger] on it has
   exactly two children to walk.
   ========================================================================== */

/* Full-bleed, so [data-glow] lights the whole band edge to edge; the
   max-content centring and the two-column grid live on __inner. The glow is
   also why the reveal-stagger sits on __inner rather than the section: the
   cursor-orb layer is appended to the section, and a stagger there would try
   to animate it too. */
.problem {
  padding-block: var(--section-y);
  /* Backstop for the tab stack: at rest the cards sit inside the content
     column, but a fan-out on a narrow window can reach past it. `clip`, not
     `hidden` — it never becomes a scroll container, and it leaves the other
     axis visible, so no card can hand the page a horizontal scrollbar. */
  overflow-x: clip;
}

.problem__inner {
  display: grid;
  gap: var(--space-7);
  align-items: center;
  max-width: var(--max-content);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

.problem__title {
  margin-block-end: var(--space-5);
}

.problem__body {
  color: var(--text-muted);
  font-size: var(--text-lg);
}

.problem__body + .problem__body {
  margin-block-start: var(--space-4);
}

.problem__body strong {
  color: var(--accent-text);
  font-weight: 600;
}

.problem__kicker {
  margin-block-start: var(--space-5);
  color: var(--text-bright);
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 600;
  line-height: var(--leading-snug);
}

@media (min-width: 900px) {
  .problem__inner {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-8);
  }
}

/* --- the tab stack -------------------------------------------------------
   Five fake browser tabs, scattered by transform only: every card sits in the
   same grid cell, so nothing here is positioned absolutely and the stack
   costs one predictable box in the layout.

   Per card, set in the markup:
     --i      landing order, back to front
     --x --y  resting offset, as a share of the card's own width and height
     --r      resting rotation in degrees
     --fx --fy --fr  how much further it travels when the stack is hovered
     --blur --fade   depth: the back of the stack is softer and fainter

   The offsets are percentages of the card, and the card is a percentage of
   the stack, so the whole spread scales with the column and needs no
   breakpoint. At rest the widest card reaches 0.54 of the stack's width from
   its centre — the section's own inline padding absorbs the remainder, so at
   320px the stack still sits inside the viewport. */

.tab-stack {
  --fan: 0;

  display: grid;
  grid-template: 1fr / 1fr;
  place-items: center;
  min-block-size: 20rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* min-inline-size: 0 on the stack and the card: a grid item's automatic
   minimum size is its min-content width, and the nowrap tab titles are wide
   enough to push that past the specified width — which propagates all the way
   out and makes the whole page scroll sideways on a phone. */
.tab-stack,
.tab-card {
  min-inline-size: 0;
}

.tab-card {
  grid-area: 1 / 1;
  inline-size: min(68%, 17rem);
  transform:
    translate(
      calc((var(--x) + var(--fan) * var(--fx)) * 1%),
      calc((var(--y) + var(--fan) * var(--fy)) * 1%)
    )
    rotate(calc((var(--r) + var(--fan) * var(--fr)) * 1deg));
}

/* Depth and the landing animation live on the inner element, so the entry
   keyframes never freeze a transform onto the card and block the fan. */
.tab-card__inner {
  padding: var(--space-3);
  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  opacity: var(--fade);
  filter: blur(calc(var(--blur) * 1px));
}

.tab-card__bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-block-end: var(--space-3);
  border-block-end: 1px solid var(--border);
}

.tab-card__dots {
  display: flex;
  flex: none;
  gap: 3px;
}

.tab-card__dots > span {
  inline-size: 5px;
  block-size: 5px;
  border-radius: var(--radius-full);
  background-color: var(--border-strong);
}

.tab-card__title {
  min-inline-size: 0; /* flex item: lets the title ellipsis instead of pushing */
  overflow: hidden;
  color: var(--text-subtle);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  line-height: var(--leading-snug);
  white-space: nowrap;
  text-overflow: ellipsis;
}

.tab-card__lines {
  display: grid;
  gap: var(--space-2);
  padding-block-start: var(--space-3);
}

.tab-card__lines > span {
  block-size: 6px;
  border-radius: var(--radius-full);
  background-color: var(--surface-raised-alt);
}

.tab-card__lines > span:nth-child(2) { inline-size: 82%; }
.tab-card__lines > span:nth-child(3) { inline-size: 58%; }

@media (min-width: 900px) {
  .tab-stack {
    min-block-size: 23rem;
  }
}

/* --- motion --------------------------------------------------------------
   The cards land off the global reveal: the graphic is a child of the
   section's [data-reveal-stagger], so it gets .is-revealed when the section
   scrolls in and the stack takes that as its cue — no second observer.
   With JS off, or under reduced motion, none of this matches and the stack
   renders scattered and settled, exactly as it ends up. */

@media (prefers-reduced-motion: no-preference) {
  .js .tab-card__inner {
    opacity: 0;
  }

  .js .problem__graphic.is-revealed .tab-card__inner {
    animation: tab-card-land var(--dur-slow) var(--ease-out) calc(var(--i) * 90ms) both;
  }

  /* Fine pointers only — a fan-out that fires on tap is just a card that
     moved when you tried to read it. */
  @media (hover: hover) and (pointer: fine) {
    .tab-card {
      transition: transform 400ms var(--ease-out);
    }

    .tab-stack:hover {
      --fan: 1;
    }
  }
}

@keyframes tab-card-land {
  from { opacity: 0; transform: translateY(28px); }
  to   { opacity: var(--fade); transform: none; }
}


/* ==========================================================================
   FEATURE SECTION — reusable pattern
   Media on one side, copy on the other. Established here by SECTION 04 and
   reused by 05, 07, 08, 09, 10. The section is the glow-lit, centred band;
   the __grid inside it is the two-column layout and carries the reveal (see
   the note in the markup for why the two are kept apart). .is-reversed on the
   section flips the columns from 900px — media LEFT, copy RIGHT — with no
   change to source order, so the copy still leads on a narrow screen.
   ========================================================================== */

/* Full-bleed, so [data-glow] lights the whole band edge to edge — the max-
   content centring lives on the __grid inside, the same split the hero and
   visa sections use. A glow container capped at --max-content would stop the
   orb dead at the content column on a wide screen. */
.feature-section {
  padding-block: var(--section-y);
  /* Backstop: the back frame is offset up-right and the pill sits in the left
     gutter, so both can reach past the grid on a narrow window.
     `clip`, not `hidden` — never a scroll container, and it leaves the block
     axis free so a frame can still bleed past the grid. */
  overflow-x: clip;
}

.feature-section__grid {
  max-width: var(--max-content);
  margin-inline: auto;
  padding-inline: var(--space-5);
  display: grid;
  /* minmax(0, 1fr), not a bare column: a grid track's default minimum is its
     content's min-content size, and the media column's phone frame is wide
     enough to blow that past the viewport, dragging the shared single column —
     and the copy inside it — off the right edge on a narrow screen. The 0
     floor lets the track shrink to the container instead. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-7);
  align-items: center;
}

/* The copy column is capped at the prose measure even inside its 1fr track,
   so a wide viewport doesn't stretch the sub into an unreadable line. */
.feature-section__copy {
  max-width: var(--max-prose);
}

.feature-section__title {
  margin-block-end: var(--space-5);
}

.feature-section__sub {
  max-width: none; /* the column already caps the measure */
  color: var(--text-muted);
  font-size: var(--text-lg);
}

@media (min-width: 900px) {
  .feature-section__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--space-8);
  }

  /* Media is the second child, so pulling it ahead puts it in the left
     column and leaves the copy on the right. */
  .feature-section.is-reversed .feature-section__media {
    order: -1;
  }
}

/* --- feature list --------------------------------------------------------
   The green-check bullet list. Reused by SECTION 05 and the Pro unlock grid.
   The check sits in a tinted disc; the disc is the same accent-tint the visa
   icons use, so the two families of icon read as one system. */

.feature-list {
  display: grid;
  gap: var(--space-3);
  margin-block-start: var(--space-6);
  padding: 0;
  list-style: none;
}

.feature-list__item {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  color: var(--text-secondary);
}

.feature-list__check {
  display: grid;
  place-items: center;
  inline-size: 1.5rem;
  aspect-ratio: 1;
  flex: none;
  /* Optical: nudged down so the disc centres on the cap height, not the line
     box, when a bullet wraps to a second line. */
  margin-block-start: 0.08em;
  border-radius: var(--radius-full);
  background-color: var(--accent-tint);
  color: var(--accent-text);
}

.feature-list__check svg {
  inline-size: 0.85rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}


/* ==========================================================================
   SECTION 04 — CITY EXPLORER
   Two phone frames stacked in one grid cell: the front upright at full
   strength, the back offset up-right and dimmed. They parallax against each
   other on scroll — the back lags to 0.94x while the front tracks the page —
   driven by --parallax-y off the shared rAF loop, transform only. A glassy
   pill floats in the same cell, aligned to the lower-left and drifting on its
   own cycle so it never moves in step with the frames.
   ========================================================================== */

.feature-section__media {
  display: grid;
  justify-items: center;
  min-inline-size: 0;
}

.explore__stage {
  display: grid;
  grid-template: 1fr / 1fr;
  place-items: center;
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.explore__phone {
  grid-area: 1 / 1;
  inline-size: min(17rem, 78%);
}

/* Resting offset composes with the parallax lag: --parallax-y is applied in
   screen pixels (last, after the scale) so the drift stays a true 40px cap
   whatever the frame's scale. */
.explore__phone--back {
  transform:
    translate3d(0, var(--parallax-y, 0px), 0)
    translate(20%, -8%)
    scale(0.92);
  opacity: 0.4;
}

.explore__phone--front {
  transform: translate3d(0, var(--parallax-y, 0px), 0);
}

/* Same cell as the frames, pinned to its lower-left corner and pulled into
   the gutter so it overlaps the phone edge, matching the design. Glass
   surface, so it reads as lifted off the frame behind it. */
.explore__pill {
  grid-area: 1 / 1;
  place-self: end start;
  z-index: 1;
  max-width: none;
  margin-block-end: 12%;
  margin-inline-start: -0.5rem;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-md);
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 600;
  white-space: nowrap;
}

.explore__pill-icon {
  font-size: 1.1em;
  line-height: 1;
}

/* --- motion --------------------------------------------------------------
   The parallax on the frames is JS (transform via --parallax-y); the pill's
   float is CSS. Under reduced motion --parallax-y is never written and this
   keyframe never matches, so every element rests at its authored transform. */

@media (prefers-reduced-motion: no-preference) {
  .explore__pill {
    animation: explore-pill-float 5s var(--ease-in-out) infinite;
  }
}

@keyframes explore-pill-float {
  0%, 100% { transform: translateY(-4px); }
  50%      { transform: translateY(4px); }
}


/* ==========================================================================
   SECTION 05 — YOUR HOUSEHOLD, YOUR NUMBERS
   .feature-section flipped with .is-reversed. The media column holds a phone
   frame and the live widget: stacked below 900px (widget under the phone at
   full width, as the brief asks), then sharing one grid cell above it, with
   the widget lifted over the frame's lower-left corner on a glass surface.
   Same cell, not absolute positioning — the frame stays in flow and sets the
   row height, exactly like the explore stage.
   ========================================================================== */

.household__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-6);
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.household__phone {
  inline-size: min(15rem, 70%);
}

.household-widget {
  inline-size: 100%;
  padding: var(--space-5);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-lg);
}

@media (min-width: 900px) {
  .household__stage {
    grid-template: 1fr / 1fr;
    gap: 0;
    max-inline-size: 30rem;
  }

  /* Behind and offset up-right, so the widget covers the frame's lower half
     and never the screenshot's top — the part worth seeing once the real PNG
     lands. The offset is a transform, so it costs no layout. */
  .household__phone {
    grid-area: 1 / 1;
    place-self: start end;
    inline-size: min(14rem, 55%);
    transform: translate(6%, -3%);
  }

  /* Pulled half a step into the gutter so it overlaps the frame's edge
     rather than sitting flush with it. .feature-section clips the overflow. */
  .household-widget {
    grid-area: 1 / 1;
    place-self: end start;
    z-index: 1;
    inline-size: min(22rem, 90%);
    margin-inline-start: -0.5rem;
  }
}

/* --- controls -------------------------------------------------------------
   Adults and Children share a row of two; Rent spans both, so its two
   segments each get half the widget's inner width and neither label wraps.
   Each label sits above its control rather than beside it — beside it there
   is no width left for the segments at this card size. */

.household-widget__controls {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-4);
}

.household-widget__row {
  display: grid;
  gap: var(--space-2);
  min-inline-size: 0;
}

.household-widget__row--rent {
  grid-column: 1 / -1;
}

.household-widget__label {
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

/* --- stepper --------------------------------------------------------------
   --surface-raised-alt, not --surface-raised: in Daybreak the latter is the
   same white as the card's glass, so the control would vanish into it. */

.stepper {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background-color: var(--surface-raised-alt);
}

.stepper__btn {
  display: grid;
  place-items: center;
  inline-size: 2rem;
  block-size: 2rem;
  flex: none;
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
  background-color: transparent;
  color: var(--text);
  cursor: pointer;
}

.stepper__btn svg {
  inline-size: 0.85rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
}

.stepper__btn:hover {
  border-color: var(--accent);
  color: var(--text-bright);
}

/* Two states, one look. [disabled] is the JS-off state — main.js clears it at
   init. [aria-disabled] is the clamp at 1–6 adults / 0–6 children: the button
   stays focusable there, so holding + up to the ceiling never drops focus to
   the body. --text-subtle rather than opacity, so the glyph keeps its
   contrast against the control in both themes. */
.stepper__btn[disabled],
.stepper__btn[aria-disabled="true"] {
  border-color: var(--border);
  color: var(--text-subtle);
  cursor: default;
}

.stepper__btn[disabled]:hover,
.stepper__btn[aria-disabled="true"]:hover {
  border-color: var(--border);
  color: var(--text-subtle);
}

.stepper__value {
  color: var(--text-bright);
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 600;
  /* Tabular, and floored at two digits' worth: stepping 9 → 10 would
     otherwise resize the control and shift the buttons either side of it. */
  font-variant-numeric: tabular-nums;
  min-inline-size: 2ch;
  text-align: center;
}

/* --- segmented control ---------------------------------------------------- */

.segmented {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-1);
  padding: var(--space-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  background-color: var(--surface-raised-alt);
}

.segmented__btn {
  min-inline-size: 0;
  padding: var(--space-2) var(--space-1);
  border: 1px solid transparent;
  border-radius: var(--radius-full);
  background-color: transparent;
  color: var(--text-muted);
  font-family: var(--font-text);
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: var(--leading-snug);
  white-space: nowrap;
  cursor: pointer;
}

.segmented__btn:hover {
  color: var(--text-bright);
}

.segmented__btn[disabled] {
  color: var(--text-subtle);
  cursor: default;
}

/* After the disabled rule on purpose: with JS off every segment is disabled,
   and the chosen one must still read as chosen. */
.segmented__btn[aria-pressed="true"] {
  background-color: var(--accent);
  color: var(--accent-contrast);
}

/* --- estimate ------------------------------------------------------------- */

.household-estimate {
  margin-block-start: var(--space-5);
  padding: var(--space-4) var(--space-5);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background-color: var(--surface-deep);
}

.household-estimate__label {
  max-width: none;
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.household-estimate__figure {
  max-width: none;
  margin-block-start: var(--space-2);
  color: var(--text-bright);
  font-family: var(--font-display);
  font-size: var(--text-3xl);
  font-weight: 700;
  line-height: var(--leading-tight);
  /* The tween walks through every digit between two figures; proportional
     numerals would jitter the card's width on every frame. */
  font-variant-numeric: tabular-nums;
}

/* The pulse on change. Colour only — no transform, no layout — and the
   transition is only ever declared under no-preference below, so with reduced
   motion the figure swaps state without a fade. */
.household-estimate__figure.is-changing {
  color: var(--accent-text);
}

/* Required by content.md: the figure is demo data, and the caption that says
   so is part of the widget, not an optional flourish. */
.household-estimate__note {
  max-width: none;
  margin-block-start: var(--space-3);
  color: var(--text-subtle);
  font-size: var(--text-xs);
}

/* The polite live region main.js appends to announce a settled figure. Off
   screen but not display:none, which would take it out of the a11y tree. */
.household-widget__status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: no-preference) {
  .stepper__btn,
  .segmented__btn {
    transition:
      background-color var(--dur-fast) var(--ease-in-out),
      border-color var(--dur-fast) var(--ease-in-out),
      color var(--dur-fast) var(--ease-in-out);
  }

  .household-estimate__figure {
    transition: color var(--dur-base) var(--ease-in-out);
  }
}


/* ==========================================================================
   SECTION 06 — VISAS & LONG-TERM STAY
   The one section that has to outrank the rest, and every choice here is
   about weight: a full-bleed deeper surface where the page is otherwise
   --bg, --section-y-hero instead of --section-y (roughly 1.5x), a large
   static green wash under the centred heading, and the biggest type on the
   page after the H1. The heading block is centred at every width; from
   1200px the phone shares its grid cell, overlapping the copy and bleeding
   out past the content column.
   ========================================================================== */

.visa {
  background-color: var(--surface-deep);
  /* The static half of the glow. The cursor orb rides on top of it inside
     the same [data-glow] isolation. */
  background-image: var(--glow-accent);
  background-repeat: no-repeat;
  background-position: 50% 38%;
  background-size: 82rem 62rem;
  border-block: 1px solid var(--border);
  /* Backstop for the phone, which is meant to run past the content column.
     `clip`, not `hidden` — it never becomes a scroll container, so the bleed
     can't hand the page a horizontal scrollbar. */
  overflow-x: clip;
}

.visa__inner {
  max-width: var(--max-content);
  margin-inline: auto;
  padding: var(--section-y-hero) var(--space-5);
}

/* --- background field ---------------------------------------------------
   Compass rings only: the flight path is the hero's moment, and repeating
   it here would read as a second hero rather than a feature section. */

.visa__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.visa__field svg {
  inline-size: 100%;
  block-size: 100%;
}

.visa-field__compass {
  opacity: 0.05;
}

.visa-field__compass > * {
  fill: none;
  stroke: var(--text);
  stroke-width: 1.5;
}

/* --- heading block ------------------------------------------------------- */

.visa__head {
  text-align: center;
}

/* The eyebrow is a <p>, so the global prose measure caps it at 480px and
   leaves it centred inside that box rather than inside the section. */
.visa__head .eyebrow {
  max-width: none;
}

.visa__title {
  max-width: 46rem;
  margin-inline: auto;
  margin-block-end: var(--space-5);
  /* Larger than every other H2 on the page — the token exists for this
     section and the final CTA, and nothing else. */
  font-size: var(--text-h2-hero);
  text-wrap: balance;
}

/* max-width overrides the global prose measure: this is a centred lead
   paragraph under a full-width heading, not a column of body copy. */
.visa__sub {
  max-width: 44rem;
  margin-inline: auto;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

.visa__sub strong {
  color: var(--accent-text);
  font-weight: 600;
}

/* --- phone --------------------------------------------------------------- */

.visa__stage {
  display: grid;
  justify-items: center;
  margin-block-start: var(--space-7);
}

.visa__phone {
  inline-size: min(14rem, 62%);
}

/* --- cards --------------------------------------------------------------- */

.visa__cards {
  display: grid;
  gap: var(--space-5);
  margin: var(--space-8) 0 0;
  padding: 0;
  list-style: none;
}

/* The <li> carries the reveal and the grid cell; the inner element carries
   the surface, the hover lift, and the spotlight. Two elements because the
   reveal owns transform on its target — a hover lift on the same element
   would lose to `.is-revealed { transform: none }` and never fire. */
.visa-card {
  display: grid;
}

.visa-card__inner {
  position: relative; /* containing block for the spotlight layer */
  overflow: hidden;   /* keeps the spotlight inside the rounded corners */
  padding: var(--space-6);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}

.visa-card__icon {
  display: grid;
  place-items: center;
  inline-size: 2.5rem;
  aspect-ratio: 1;
  margin-block-end: var(--space-5);
  border-radius: var(--radius-md);
  background-color: var(--accent-tint);
  color: var(--accent-text);
}

.visa-card__icon svg {
  inline-size: 1.25rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.visa-card__title {
  margin-block-end: var(--space-3);
  font-size: var(--text-xl);
  font-weight: 600;
}

.visa-card__body {
  max-width: none;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* --- kicker and disclaimer ----------------------------------------------- */

.visa__kicker {
  max-width: var(--max-narrow);
  margin: var(--space-8) auto 0;
  color: var(--text-bright);
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 600;
  line-height: var(--leading-snug);
  text-align: center;
  text-wrap: balance;
}

/* Required by content.md, and sized to be read rather than to disappear:
   --text-subtle clears AA on --surface-deep in both themes. */
.visa__disclaimer {
  max-width: 42rem;
  margin: var(--space-4) auto 0;
  color: var(--text-subtle);
  font-size: var(--text-xs);
  text-align: center;
  text-wrap: pretty;
}

@media (min-width: 900px) {
  .visa__cards {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

/* The overlap waits for 1200px. Narrower than that the frame would sit on
   top of the centred heading's own text rather than beside it, and a phone
   over an H2 is a legibility bug, not emphasis. */
@media (min-width: 1200px) {
  .visa__lead {
    display: grid;
    grid-template: 1fr / 1fr;
    align-items: center;
  }

  /* Same cell, so the frame overlaps the copy with no absolute positioning.
     The phone is the taller of the two and sets the row height; the heading
     centres against it, which is the proportion in the design. */
  .visa__head,
  .visa__stage {
    grid-area: 1 / 1;
  }

  .visa__stage {
    justify-self: end;
    justify-items: end;
    margin-block-start: 0;
    /* Out past the content column, and anchored to it rather than to the
       window: the heading is centred on the same column, so the gap between
       the two is the same 48px at 1200px as it is at 2560px. Anchor the
       frame to the viewport instead and that gap grows with the gutter,
       stranding it half a column away from the text on a wide screen.

          gap = --max-content/2 − --space-5 − 46rem/2 + 8rem − 18rem

       so the three numbers that set it are this bleed, the frame's width
       below, and the H2's own measure. Change one and re-check the other
       two. The section clips whatever hangs past the window. */
    margin-inline-end: -8rem;
  }

  .visa__phone {
    inline-size: 18rem;
    transform: rotate(-6deg);
  }
}

/* --- hover and spotlight -------------------------------------------------
   The lift, the green border, and the shadow are the hover state; the
   spotlight is a radial layer that follows the cursor inside the card.
   main.js writes --mx/--my in px from the card's top-left and toggles
   .is-lit; the layer moves on a transform, so nothing here repaints on a
   pointer move. Fine pointers only — a spotlight that lands on tap is just
   a card that changed colour when you tried to read it. */

@media (hover: hover) and (pointer: fine) {
  .visa-card__inner:hover {
    border-color: var(--accent);
    box-shadow: var(--shadow-accent);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .visa-card__inner {
    transition:
      transform var(--dur-base) var(--ease-out),
      border-color var(--dur-base) var(--ease-in-out),
      box-shadow var(--dur-base) var(--ease-in-out);
  }

  /* The section brief asks for 100ms between cards where the global stagger
     applies 80ms. JS writes its delay into --reveal-delay, which the global
     shorthand reads — restating transition-delay at a higher specificity
     replaces it here without touching the shared timing. */
  .js .visa__cards > .visa-card {
    transition-delay: calc(var(--i) * 100ms);
  }

  @media (hover: hover) and (pointer: fine) {
    .visa-card__inner:hover {
      transform: translateY(-6px);
    }

    .visa-card__inner::after {
      content: "";
      position: absolute;
      inset-block-start: 0;
      inset-inline-start: 0;
      inline-size: 22rem;
      aspect-ratio: 1;
      /* Centres the blob on the cursor without a second transform term. */
      margin: -11rem 0 0 -11rem;
      background: radial-gradient(circle, var(--glow-spotlight) 0%, transparent 70%);
      opacity: 0;
      pointer-events: none;
      transform: translate3d(var(--mx, 0), var(--my, 0), 0);
      transition: opacity var(--dur-base) var(--ease-in-out);
    }

    /* Fades in on .is-lit rather than :hover: JS sets the coordinates before
       it adds the class, so a card the pointer merely comes to rest on —
       hovered by the page scrolling under a still cursor — can never light
       up in a corner nobody pointed at. */
    .visa-card__inner.is-lit::after {
      opacity: 1;
      will-change: transform;
    }
  }
}


/* ==========================================================================
   SECTION 07 — COMPARE CITIES
   .feature-section unreversed — media RIGHT. The media column repeats 05's
   composition: phone and card stacked below 900px, sharing one grid cell
   above it with the card lifted over the frame's lower-left corner.

   The comparison graphic is a real table, kept as a table: `display: grid`
   on a table element strips the row and column semantics out of the
   accessibility tree, so the layout is done with table-layout and cell
   widths, and only the insides of a cell are grid.
   ========================================================================== */

.compare__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-6);
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.compare__phone {
  inline-size: min(15rem, 70%);
}

/* --- the card ------------------------------------------------------------
   The surface is the wrapper, not the table: under `border-collapse:
   collapse` a table's own padding is ignored, and a <caption> is laid out
   outside the table's background box. Both belong to the card, so the card
   is its own element.

   Same glass treatment as the household widget, so the two live graphics on
   this page read as one component family. */

.compare-card {
  inline-size: 100%;
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-lg);
}

.compare-table {
  /* Local to this graphic: the brief's 900ms draw and 90ms per-row stagger,
     named once here rather than repeated down the animation rules. The
     will-change cleanup in main.js is timed off the same two numbers. */
  --bar-draw: 900ms;
  --bar-stagger: 90ms;
  /* The three widths the four columns are cut from — the label column takes
     whatever is left. At 320px a four-column table has barely 240px to work
     with, so these start at the tightest that still fits a chip and a
     category name, and open up at 640px. */
  --cell-gap: var(--space-3);
  --city-col: 24%;
  --diff-col: 3.5rem;

  inline-size: 100%;
  /* Fixed, so the column widths below hold whatever the figures are and a
     long category name wraps instead of stealing width from the bars. */
  table-layout: fixed;
  border-collapse: collapse;
  text-align: start;
}

/* Deliberately no padding here: `.compare-table td` out-specifies a bare
   `.compare-cell`, so a padding reset at this level would silently beat
   every per-column value below. Each cell carries its own instead.

   baseline, so a row's label, its two figures, and its chip all sit on one
   line with the bars hanging beneath them — the row reads as a sentence
   rather than as four separately-centred boxes. */
.compare-table th,
.compare-table td {
  font-weight: inherit;
  vertical-align: baseline;
}

/* Required by content.md: the figures are invented, and the line saying so
   is part of the graphic. caption-side keeps it under the rows, where a
   footnote belongs, without taking it out of the table. */
.compare-table__caption {
  caption-side: bottom;
  padding-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-xs);
  text-align: start;
}

/* --- columns --------------------------------------------------------------
   Widths live on the header row because table-layout: fixed reads them from
   the first row alone. The label column takes whatever is left; the two city
   columns are equal, so the bars in them are directly comparable by length,
   which is the whole point of the graphic. */

.compare-table__col-label {
  padding: 0;
}

.compare-table__col-city {
  inline-size: var(--city-col);
  padding: 0 0 var(--space-3) var(--cell-gap);
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.compare-table__col-diff {
  inline-size: var(--diff-col);
  padding: 0;
}

/* Screen-reader names for the two columns with no visible heading. The
   category names and the difference chips both read without one on screen,
   but a table with blank column headers does not. */
.compare-table__sr-only {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- rows ----------------------------------------------------------------- */

/* Tight padding-block on purpose: baseline alignment already separates the
   rows by a bar's height, so a third space token between them would leave
   the card taller than the frame it is meant to sit against. */
.compare-row__label {
  padding: var(--space-2) var(--space-2) var(--space-2) 0;
  color: var(--text-secondary);
  /* One step down from the figures below 640px: at 320px this column has
     about 60px, and --text-sm puts "Groceries" straight through the figure
     beside it. Restored to --text-sm at 640px. */
  font-size: var(--text-xs);
  line-height: var(--leading-snug);
}

.compare-cell {
  padding: var(--space-2) 0 var(--space-2) var(--cell-gap);
}

/* Narrower gutter than the city columns get: the chip is set against the
   right edge, so its left gap is slack rather than alignment, and at 320px
   that slack is worth more to the label column. */
.compare-cell--diff {
  padding-inline-start: var(--space-2);
  text-align: end;
}

.compare-cell__value {
  display: block;
  margin-block-end: var(--space-2);
  color: var(--text-bright);
  font-size: var(--text-sm);
  font-weight: 600;
  /* The two columns sit directly beside each other; proportional digits
     would make €1,240 and €840 look mismatched rather than different. */
  font-variant-numeric: tabular-nums;
}

/* --- bars -----------------------------------------------------------------
   --surface-raised-alt for the track, not --surface-raised: in Daybreak the
   latter is the same white as the card's glass and the unfilled part of the
   track would disappear. Same reasoning as the stepper.

   --v is each bar's share of its own row's larger figure, so every row is
   scaled against itself and a €60 transport line stays readable beside a
   €1,240 rent line. It is a within-row ratio and never a cross-row one —
   which is exactly what that row's chip states in words. */

.compare-bar {
  display: block;
  block-size: 0.375rem;
  border-radius: var(--radius-full);
  background-color: var(--surface-raised-alt);
  overflow: hidden;
}

.compare-bar__fill {
  display: block;
  block-size: 100%;
  border-radius: inherit;
  /* scaleX from the left edge, never width — the draw stays on the
     compositor and costs no layout. */
  transform: scaleX(var(--v, 1));
  transform-origin: left center;
}

/* City A is the baseline and reads neutral; City B is the one being weighed
   and carries the accent. --accent-text, not --accent: raw mint is 1.37:1 on
   Daybreak's track and would all but vanish there, while --accent-text
   resolves to the mint that stays legible in each theme. Both clear 3:1
   against the track in both themes. */
.compare-bar__fill--a {
  background-color: var(--text-subtle);
}

.compare-bar__fill--b {
  background-color: var(--accent-text);
}

/* --- difference chips -----------------------------------------------------
   Green when City B is cheaper, amber when it is pricier. Text and border
   only: a tinted fill behind text this small drops --positive under AA on
   Daybreak's near-white card. The border is the chip's own colour at low
   strength, so each state stays one token rather than two. */

.compare-chip {
  display: inline-block;
  padding: var(--space-1) var(--space-2);
  border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
  border-radius: var(--radius-full);
  font-size: var(--text-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: var(--leading-snug);
  white-space: nowrap;
}

.compare-chip--cheaper {
  color: var(--positive);
}

.compare-chip--pricier {
  color: var(--warning);
}

@media (min-width: 640px) {
  .compare-card {
    padding: var(--space-5);
  }

  .compare-table {
    --cell-gap: var(--space-4);
    --city-col: 26%;
    --diff-col: 4.5rem;
  }

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

@media (min-width: 900px) {
  .compare__stage {
    grid-template: 1fr / 1fr;
    gap: 0;
    max-inline-size: 30rem;
  }

  /* Behind and offset up-right, the same way the household frame sits: the
     card covers the frame's lower half and never the screenshot's top. */
  /* Wider and lifted further than the household frame: this card is a
     five-row table rather than three controls, so the frame has to stand
     taller than it to still read as the thing being sat on. */
  .compare__phone {
    grid-area: 1 / 1;
    place-self: start end;
    inline-size: min(15rem, 58%);
    transform: translate(8%, -6%);
  }

  /* Pulled half a step into the gutter so it overlaps the frame's edge
     rather than sitting flush with it. .feature-section clips the overflow. */
  .compare-card {
    grid-area: 1 / 1;
    place-self: end start;
    z-index: 1;
    inline-size: min(23rem, 92%);
    margin-inline-start: -0.5rem;
  }
}

/* --- the draw -------------------------------------------------------------
   Behind `.js` and no-preference both, like the global reveal: with JS off
   or reduced motion on, no rule here matches, every bar renders at its full
   --v, every chip is opaque, and main.js starts no observer.

   Each chip waits out its own row's bar — one --bar-draw after that row's
   stagger — so the number lands as the answer to a comparison the visitor
   has just watched resolve. */

@media (prefers-reduced-motion: no-preference) {
  .js .compare-bar__fill {
    transform: scaleX(0);
    transition: transform var(--bar-draw) var(--ease-out)
                calc(var(--row, 0) * var(--bar-stagger));
  }

  .js .compare-chip {
    opacity: 0;
    transition: opacity var(--dur-base) var(--ease-in-out)
                calc(var(--row, 0) * var(--bar-stagger) + var(--bar-draw));
  }

  .js .compare-table.is-drawn .compare-bar__fill {
    transform: scaleX(var(--v, 1));
  }

  .js .compare-table.is-drawn .compare-chip {
    opacity: 1;
  }
}


/* ==========================================================================
   SECTION 08 — SAVED LISTS
   .feature-section flipped with .is-reversed — media LEFT. The media column
   repeats 05's composition: phone and graphic stacked below 900px, sharing
   one grid cell above it with the cards lifted over the frame's lower-left
   corner.

   The graphic is three glass cards fanned into a stack: each one shifted a
   step further along the inline axis and overlapping the card above it, so
   the group reads as a shortlist rather than as three separate rows. Hover
   lifts one out of the stack.

   Every heart is a real toggle button whose filled state is driven by
   aria-pressed alone. The sequenced fill is layered on top of that as a start
   state in the motion block at the bottom of this section, so the resting
   look and the animated one can never disagree.
   ========================================================================== */

.saved__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-6);
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.saved__phone {
  inline-size: min(15rem, 70%);
}

.saved-shortlist {
  inline-size: 100%;
}

/* The inline padding is the room the fan needs: the last card is shifted two
   steps, so reserving two steps here keeps the whole stack inside the column
   instead of relying on .feature-section's overflow clip to hide the edge. */
.saved-cards {
  --stack-shift: 0.85rem;
  /* Matches SAVED_STEP_MS in main.js: the gap between one heart filling and
     the next. Every delay in the motion block is a multiple of it. */
  --heart-step: 300ms;

  display: grid;
  margin: 0;
  padding: 0 calc(var(--stack-shift) * 2) 0 0;
  list-style: none;
}

/* --- the cards ------------------------------------------------------------
   Same glass treatment as the household widget and the compare card, one
   step down in radius and shadow because these are small and there are three
   of them. --radius-md, not --radius-lg: at this height --radius-lg eats the
   card's own corners.

   The overlap is a negative block margin rather than a gap, and each card
   sits one z-index above the one before it, so the fan falls forward the way
   a hand of cards does. */

.saved-card {
  /* The ring below is an ::after on this box. */
  position: relative;
  z-index: calc(var(--card, 0) + 1);
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-md);
  transform: translate3d(calc(var(--card, 0) * var(--stack-shift)), 0, 0);
}

.saved-card + .saved-card {
  margin-block-start: calc(var(--space-2) * -1);
}

/* :focus-within as well as :hover — tabbing to a card's heart has to bring
   that card out of the stack too, or the focus ring lands under the card in
   front of it. */
.saved-card:hover,
.saved-card:focus-within {
  z-index: 10;
  border-color: var(--border-strong);
  box-shadow: var(--shadow-lg);
  transform: translate3d(
    calc(var(--card, 0) * var(--stack-shift)),
    -0.375rem,
    0
  );
}

.saved-card__flag {
  font-size: 1.25rem;
  line-height: 1;
}

.saved-card__name {
  color: var(--text-bright);
  font-size: var(--text-sm);
  font-weight: 600;
}

.saved-card__cost {
  color: var(--text-bright);
  font-size: var(--text-sm);
  font-weight: 600;
  /* Three figures in a column: proportional digits would leave €1,980 and
     €2,560 looking mismatched rather than different. */
  font-variant-numeric: tabular-nums;
}

.saved-shortlist__caption {
  /* Required by content.md: the figures on these cards are invented. */
  margin-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-xs);
}

/* --- the heart ------------------------------------------------------------
   Two paths on one silhouette: the outline is stroked and always there, the
   fill is the same path painted solid and revealed on top of it. So the
   saved state is a transform and an opacity, never a colour change on a
   shape that was already visible.

   [disabled] is the JS-off state — main.js clears it at init. Unlike the
   stepper, nothing here dims: the filled heart is content in that state, not
   a control waiting to be used. Only the cursor and the hover give way. */

.saved-card__heart {
  display: grid;
  place-items: center;
  inline-size: 2rem;
  block-size: 2rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-full);
  background-color: transparent;
  color: var(--text-subtle);
  cursor: pointer;
}

.saved-card__heart[disabled] {
  cursor: default;
}

.saved-card__heart:hover:not([disabled]) {
  background-color: var(--surface-raised-alt);
  color: var(--text-secondary);
}

.saved-heart {
  display: block;
  inline-size: 1.15rem;
}

.saved-heart svg {
  inline-size: 100%;
}

.saved-heart__outline {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linejoin: round;
}

/* --accent-text, not --accent: raw mint is barely 1.4:1 on Daybreak's near-
   white card, while --accent-text resolves to the mint that stays legible in
   every theme. transform-box: fill-box so the scale pivots on the heart
   rather than on the SVG viewport's origin. */
.saved-heart__fill {
  fill: var(--accent-text);
  transform-box: fill-box;
  transform-origin: center;
  opacity: 1;
  transform: scale(1);
}

.saved-card__heart[aria-pressed="false"] .saved-heart__fill {
  opacity: 0;
  transform: scale(0.4);
}

/* The ring the sequence flashes around a card as its heart lands. Present at
   every moment and transparent until then, so nothing is inserted mid-
   animation. inset: -1px puts it over the card's own border rather than
   inside it, which would read as a second, thinner frame. */
.saved-card::after {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: 1;
  border: 1px solid var(--accent);
  border-radius: inherit;
  opacity: 0;
  pointer-events: none;
}

@media (min-width: 900px) {
  .saved__stage {
    grid-template: 1fr / 1fr;
    gap: 0;
    max-inline-size: 30rem;
  }

  /* Behind and offset up-right, the same way the household frame sits: the
     stack covers the frame's lower half and never the screenshot's top. */
  .saved__phone {
    grid-area: 1 / 1;
    place-self: start end;
    inline-size: min(14rem, 55%);
    transform: translate(6%, -3%);
  }

  /* Pulled half a step into the gutter so it overlaps the frame's edge
     rather than sitting flush with it. .feature-section clips the overflow. */
  .saved-shortlist {
    grid-area: 1 / 1;
    place-self: end start;
    z-index: 1;
    inline-size: min(22rem, 90%);
    margin-inline-start: -0.5rem;
  }
}

/* --- the sequence ---------------------------------------------------------
   Behind `.js` and no-preference both, like the global reveal: with JS off or
   reduced motion on, not one rule here matches. Every heart then renders from
   aria-pressed alone — which the markup authors as pressed — so the fallback
   the brief asks for is simply the resting state, and main.js starts no
   observer.

   Two classes, because they answer different questions. `.is-filled` says the
   hearts belong filled now and stays for good. `.is-sequencing` says the
   scripted run is still playing, and carries the per-card delays with it;
   main.js takes it off once the last card has landed, so a visitor's own tap
   on the third heart answers at once instead of waiting out a 600ms delay
   that was only ever meant for the intro. */

@media (prefers-reduced-motion: no-preference) {
  .saved-card {
    transition:
      transform var(--dur-fast) var(--ease-out),
      border-color var(--dur-fast) var(--ease-out),
      box-shadow var(--dur-fast) var(--ease-out);
  }

  .js .saved-heart__fill {
    transition:
      opacity var(--dur-fast) var(--ease-out),
      transform var(--dur-fast) var(--ease-out);
  }

  .js .saved-cards:not(.is-filled) .saved-heart__fill {
    opacity: 0;
    transform: scale(0.4);
  }

  .js .saved-cards.is-sequencing .saved-heart__fill {
    transition-delay: calc(var(--card, 0) * var(--heart-step));
  }

  .js .saved-cards.is-sequencing .saved-heart {
    animation: saved-heart-pop var(--dur-fast) var(--ease-out)
               calc(var(--card, 0) * var(--heart-step));
  }

  /* will-change here rather than in main.js: a pseudo-element is out of
     reach from script, and the hint leaves with the class the moment the
     sequence is over, which is the same lifetime JS gives the icons. */
  .js .saved-cards.is-sequencing .saved-card::after {
    animation: saved-card-ring var(--dur-slow) var(--ease-in-out)
               calc(var(--card, 0) * var(--heart-step));
    will-change: opacity;
  }
}

/* 1 → 1.3 → 1, peaking just after the fill has appeared underneath it. */
@keyframes saved-heart-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.3); }
  100% { transform: scale(1); }
}

@keyframes saved-card-ring {
  0%   { opacity: 0; }
  25%  { opacity: 1; }
  100% { opacity: 0; }
}


/* ==========================================================================
   SECTION 09 — BUDGET CALCULATOR
   .feature-section unreversed — media RIGHT. The media column repeats the
   composition 05, 07 and 08 established: phone and card stacked below 900px,
   sharing one grid cell above it with the card lifted over the frame's
   lower-left corner. Same glass surface as the household widget and the
   compare card, so all three live graphics read as one component family.

   The control is a real <input type="range"> with its track and thumb
   redrawn. The filled part of the track is a gradient stop at --fill, not a
   second element: one property to update, and nothing that can drift out of
   line with the thumb. --fill is authored in the markup so the resting look
   survives with JS off, and rewritten by main.js on input.

   Row order is maintained by main.js and animated with FLIP — see the block
   at the bottom of this section.
   ========================================================================== */

.budget__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-6);
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.budget__phone {
  inline-size: min(15rem, 70%);
}

.budget-calc {
  /* Local to this graphic: the track geometry, named once so the thumb's
     centring offset below is derived from it rather than guessed. */
  --track-h: 0.5rem;
  --thumb: 1.25rem;
  /* The FLIP travel time. main.js times its will-change cleanup off the same
     number. */
  --flip-dur: 400ms;

  inline-size: 100%;
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-lg);
}

@media (min-width: 640px) {
  .budget-calc {
    padding: var(--space-5);
  }
}

@media (min-width: 900px) {
  .budget__stage {
    grid-template: 1fr / 1fr;
    gap: 0;
    max-inline-size: 30rem;
  }

  /* Behind and offset up-right, the same way the household and compare
     frames sit: the card covers the frame's lower half and never the
     screenshot's top. */
  .budget__phone {
    grid-area: 1 / 1;
    place-self: start end;
    inline-size: min(15rem, 58%);
    transform: translate(8%, -6%);
  }

  /* Pulled half a step into the gutter so it overlaps the frame's edge
     rather than sitting flush with it. .feature-section clips the overflow. */
  .budget-calc {
    grid-area: 1 / 1;
    place-self: end start;
    z-index: 1;
    inline-size: min(23rem, 92%);
    margin-inline-start: -0.5rem;
  }
}

/* --- readout --------------------------------------------------------------
   The label is a real <label for>, so the figure beside it and the slider
   below it are one labelled control rather than three loose pieces. */

.budget-calc__readout {
  display: grid;
  gap: var(--space-1);
  margin-block-end: var(--space-2);
}

.budget-calc__label {
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.budget-calc__figure {
  color: var(--text-bright);
  font-family: var(--font-display);
  font-size: var(--text-3xl);
  font-weight: 600;
  line-height: var(--leading-tight);
  /* Tabular: the figure steps through every value between 500 and 6,000 as
     the thumb moves, and proportional digits would make it shimmer. */
  font-variant-numeric: tabular-nums;
}

/* --- slider ---------------------------------------------------------------
   appearance: none strips the native control; everything visible below is
   drawn by the two track pseudo-elements and the two thumb pseudo-elements.
   They cannot be combined into one selector list — an unrecognised
   pseudo-element invalidates the whole rule in every browser — so each engine
   gets its own copy.

   The input box is 2.5rem tall for the pointer target, well over the 24px
   WCAG 2.5.8 floor, while the track it paints inside stays --track-h.

   --surface-raised-alt for the unfilled track, not --surface-raised: in
   Daybreak the latter is the same white as the card's glass and the empty
   part of the track would disappear. Same reasoning as the stepper and the
   compare bars. --accent-text for the filled part rather than raw --accent,
   which is 1.37:1 on Daybreak's track. */

.budget-slider {
  inline-size: 100%;
  block-size: 2.5rem;
  margin: 0;
  padding: 0;
  background-color: transparent;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.budget-slider::-webkit-slider-runnable-track {
  block-size: var(--track-h);
  border-radius: var(--radius-full);
  background-image: linear-gradient(
    to right,
    var(--accent-text) 0 var(--fill, 0%),
    var(--surface-raised-alt) var(--fill, 0%) 100%
  );
}

.budget-slider::-moz-range-track {
  block-size: var(--track-h);
  border-radius: var(--radius-full);
  background-image: linear-gradient(
    to right,
    var(--accent-text) 0 var(--fill, 0%),
    var(--surface-raised-alt) var(--fill, 0%) 100%
  );
}

/* Firefox centres the thumb on the track for us; WebKit lays it out from the
   track's top edge, so it needs half the difference back. */
.budget-slider::-webkit-slider-thumb {
  inline-size: var(--thumb);
  block-size: var(--thumb);
  margin-block-start: calc((var(--track-h) - var(--thumb)) / 2);
  border: 2px solid var(--accent-text);
  border-radius: var(--radius-full);
  background-color: var(--text-bright);
  box-shadow: var(--shadow-sm);
  -webkit-appearance: none;
  appearance: none;
}

.budget-slider::-moz-range-thumb {
  inline-size: var(--thumb);
  block-size: var(--thumb);
  border: 2px solid var(--accent-text);
  border-radius: var(--radius-full);
  background-color: var(--text-bright);
  box-shadow: var(--shadow-sm);
}

/* --- city rows ------------------------------------------------------------
   Two lines below 640px — name over cost, chip alongside — because at 320px
   a single line has no room for a city name, a figure and "Out of reach".
   From 640px the card is wide enough to lay all three out on one line. */

.budget-cities {
  display: grid;
  gap: var(--space-1);
  margin-block-start: var(--space-4);
  padding: 0;
  list-style: none;
}

/* Named areas rather than auto-placement: the chip has to span both rows in
   the stacked layout, and a `grid-row: 1 / -1` would resolve -1 against the
   explicit grid — which has no rows — instead of against the implicit one. */
.budget-city {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "name chip"
    "cost chip";
  align-items: center;
  gap: 0 var(--space-3);
  padding: var(--space-2);
  border-radius: var(--radius-md);
}

.budget-city__name {
  grid-area: name;
  color: var(--text-bright);
  font-size: var(--text-sm);
  font-weight: 600;
}

.budget-city__cost {
  grid-area: cost;
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}

.budget-chip {
  grid-area: chip;
  padding: var(--space-1) var(--space-2);
  border: 1px solid color-mix(in srgb, currentColor 40%, transparent);
  border-radius: var(--radius-full);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: var(--leading-snug);
  /* Floored at the widest of the three labels and centred, so a row crossing
     from "Tight" to "Comfortable" changes colour and word without also
     changing the width of the column every other row is measured against. */
  min-inline-size: 6.5rem;
  text-align: center;
  white-space: nowrap;
}

/* Text and border only, no tinted fill: at this size a fill drops --positive
   under AA on Daybreak's near-white card. The border is the chip's own colour
   at low strength, so each state stays one token rather than two. */

.budget-chip--comfortable {
  color: var(--positive);
}

.budget-chip--tight {
  color: var(--warning);
}

/* --danger-text, not --danger: in Daybreak --danger is coral, which is a fill
   colour and fails AA as text. See note D in tokens.css. */
.budget-chip--out {
  color: var(--danger-text);
}

@media (min-width: 640px) {
  .budget-city {
    grid-template-columns: minmax(0, 1fr) auto auto;
    grid-template-areas: "name cost chip";
  }

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

.budget-calc__caption {
  margin-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-xs);
}

/* The polite live region main.js appends. It carries the three counts and
   never the budget itself — the range announces its own value on every step.
   Off screen but not display:none, which would take it out of the a11y tree.
   Same treatment as the household widget's. */
.budget-calc__status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- the re-sort ----------------------------------------------------------
   FLIP: main.js reorders the list, then puts each moved row back where it
   came from with an inline transform and adds .is-flipping to transition it
   to zero. Only the transition lives here — the transforms are inline and
   transient, and nothing in this block runs unless a row actually moved.

   Behind `.js` and no-preference both, like every other effect on the page.
   Under reduced motion the class is never added and the rows simply appear in
   their new order, which is the correct final state either way. */

@media (prefers-reduced-motion: no-preference) {
  .js .budget-city.is-flipping {
    transition: transform var(--flip-dur) var(--ease-out);
  }
}


/* ==========================================================================
   SECTION 10 — TRENDS OVER TIME
   .feature-section flipped with .is-reversed — media LEFT. The media column
   repeats 05's composition: phone and card stacked below 900px, sharing one
   grid cell above it with the card lifted over the frame's lower-left corner.
   Same glass surface as the household widget, the compare card and the budget
   calculator, so all four live graphics read as one component family.

   The chart is one inline SVG on a 320 × 176 view box. Everything in it is
   authored at its finished position — the whole graphic is the resting state,
   and the draw is a start state layered on top of it in the motion block at
   the bottom of this section. So the chart with JS off, the chart under
   reduced motion, and the last frame of the animation are the same picture by
   construction, and none of them can be a blank box.

   Every stroke in the SVG carries `vector-effect: non-scaling-stroke`. The
   view box is scaled to whatever width the card gets — roughly 0.9× on a
   360px phone and 1.2× on a wide screen — and without it a hairline grid
   would be a hairline on one and a visible rule on the other.
   ========================================================================== */

.trends__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-6);
  inline-size: 100%;
  max-inline-size: 26rem;
  min-inline-size: 0;
}

.trends__phone {
  inline-size: min(15rem, 70%);
}

.trend-card {
  inline-size: 100%;
  margin: 0;
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-lg);
}

@media (min-width: 640px) {
  .trend-card {
    padding: var(--space-5);
  }
}

@media (min-width: 900px) {
  .trends__stage {
    grid-template: 1fr / 1fr;
    gap: 0;
    max-inline-size: 30rem;
  }

  /* Behind and offset up-right, the same way the household, compare and
     budget frames sit: the card covers the frame's lower half and never the
     screenshot's top. */
  .trends__phone {
    grid-area: 1 / 1;
    place-self: start end;
    inline-size: min(15rem, 58%);
    transform: translate(8%, -6%);
  }

  /* Pulled half a step into the gutter so it overlaps the frame's edge
     rather than sitting flush with it. .feature-section clips the overflow. */
  .trend-card {
    grid-area: 1 / 1;
    place-self: end start;
    z-index: 1;
    inline-size: min(23rem, 92%);
    margin-inline-start: -0.5rem;
  }
}

/* --- card head ------------------------------------------------------------
   The label names the series; the delta states in words what the line says in
   shape, so the direction of travel survives a glance that never reaches the
   chart. Both are demo figures — see the caption. */

/* Wraps as two whole lines, never mid-label: at 320px the card has about
   240px of content and the two strings together want more than that, and a
   label broken across two lines under a delta sitting on the first one reads
   as three things rather than two. Both children are nowrap, so the flex line
   breaks between them instead. */
.trend-card__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-1) var(--space-3);
  margin-block-end: var(--space-4);
}

.trend-card__label {
  max-width: none; /* the card already caps the measure */
  white-space: nowrap;
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.trend-card__delta {
  color: var(--accent-text);
  font-size: var(--text-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.trend-card__delta span {
  color: var(--text-subtle);
  font-weight: 400;
}

.trend-card__caption {
  /* Required by content.md: the whole series is invented. */
  margin-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-xs);
}

/* --- the plot box ---------------------------------------------------------
   main.js gives this box a tabindex and the arrow-key stepping, so it needs a
   focus ring of its own — the global :focus-visible rule covers real controls
   only, and this is a grouped readout rather than a control. The offset keeps
   the ring off the chart's own edges. */

.trend-chart__plot {
  border-radius: var(--radius-sm);
}

.trend-chart__plot:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 4px;
}

/* The polite live region main.js appends. It carries the stepped-to point and
   only ever fires from the keyboard — a region that also spoke on pointermove
   would talk continuously through a hover. Off screen but not display:none,
   which would take it out of the a11y tree. */
.trend-chart__status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.trend-chart {
  inline-size: 100%;
  block-size: auto;
}

/* Not drawn — the series in the markup, read by main.js for its coordinates
   and its labels. display:none costs nothing to paint and leaves every
   attribute readable. */
.trend-chart__nodes {
  display: none;
}

/* --- grid and axis --------------------------------------------------------
   Decorative hairlines, so --border rather than --border-strong (see note G
   in tokens.css). The baseline is the one line the series is measured
   against, so it gets the stronger token at reduced opacity — present, but
   never louder than the data. */

.trend-chart__grid line {
  stroke: var(--border);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.trend-chart__baseline {
  stroke: var(--border-strong);
  stroke-opacity: 0.45;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.trend-chart__axis {
  fill: var(--text-subtle);
  font-family: var(--font-text);
  font-size: 10px;
  font-weight: 600;
}

/* --- area and line --------------------------------------------------------
   --accent-text for the stroke, not --accent: raw mint is 1.37:1 on
   Daybreak's near-white card and the line would all but vanish there, while
   --accent-text resolves to the mint that stays legible in every theme. Same
   reasoning as the compare bars.

   The area keeps raw --accent, because it is a wash and not a mark: it is
   read as the region under the line, never as a shape with an edge, and at
   0.28 alpha the darker Daybreak mint would sit on the card as a smear. */

.trend-chart__area {
  fill: url(#trend-area-fill);
}

.trend-chart__stop--top {
  stop-color: var(--accent);
  stop-opacity: 0.28;
}

.trend-chart__stop--bottom {
  stop-color: var(--accent);
  stop-opacity: 0;
}

.trend-chart__line {
  fill: none;
  stroke: var(--accent-text);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* --- dot, crosshair, tooltip ----------------------------------------------
   The dot does two jobs: it rides the tip of the line as it draws, and it
   marks the active point once a visitor is hovering or stepping. One element
   for both, so the two states can never be on screen at the same time.

   main.js moves the dot, the crosshair and the chip with inline transforms,
   which override the transform attributes authored on them. Transform only —
   nothing here changes a geometry attribute. */

.trend-chart__dot-halo {
  fill: var(--accent);
  opacity: 0.22;
}

.trend-chart__dot-core {
  fill: var(--accent-text);
}

.trend-chart__crosshair {
  stroke: var(--border-strong);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  vector-effect: non-scaling-stroke;
  /* Only ever visible while a pointer or the keyboard is on the chart. */
  opacity: 0;
}

.trend-chart.is-tracking .trend-chart__crosshair {
  opacity: 1;
}

.trend-chart__tip-bg {
  fill: var(--surface-raised-alt);
  stroke: var(--border);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.trend-chart__tip-month {
  fill: var(--text-muted);
  font-family: var(--font-text);
  font-size: 11px;
  font-weight: 600;
}

.trend-chart__tip-value {
  fill: var(--text-bright);
  font-family: var(--font-text);
  font-size: 15px;
  font-weight: 700;
  /* The chip is a fixed box and the value swaps under the cursor: without
     tabular figures, €1,720 and €1,980 would shift the string inside it. */
  font-variant-numeric: tabular-nums;
}

/* --- the draw -------------------------------------------------------------
   Behind `.js` and no-preference both, like every other effect on the page.
   With JS off or reduced motion on, not one rule here matches: the line is
   solid, the area and the chip are at full opacity, and the dot is on the
   last point — which is exactly where the animation would have left them, so
   main.js starts no observer.

   Both classes are added by main.js and neither is ever removed. .is-drawing
   says the run has begun and brings the dot on; .is-drawn says the line has
   arrived and releases the area and the chip.

   stroke-dashoffset is the one property on this page that is animated and is
   neither transform nor opacity — it is the technique the section calls for,
   and there is no compositor-only way to reveal a stroke along its own
   length. It is confined to a single path, runs once, and repaints nothing
   outside the chart's own box. No will-change goes with it: the property is
   not composited, so the hint would buy a layer and no speed. */

@media (prefers-reduced-motion: no-preference) {
  /* pathLength="1" on the path makes this dash pair exact whatever the real
     geometry measures. main.js writes the offset inline frame by frame, then
     adds .is-drawn — which stops this rule matching — and clears it. */
  .js .trend-chart:not(.is-drawn) .trend-chart__line {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
  }

  .js .trend-chart:not(.is-drawing) .trend-chart__dot {
    opacity: 0;
  }

  .js .trend-chart__area,
  .js .trend-chart__tip {
    transition: opacity var(--dur-slow) var(--ease-out);
  }

  .js .trend-chart:not(.is-drawn) .trend-chart__area,
  .js .trend-chart:not(.is-drawn) .trend-chart__tip {
    opacity: 0;
  }
}


/* ==========================================================================
   SECTION 11 — BUILT THE WAY YOU LIKE IT
   Centred heading block, then a wide row: the phone on one side, the theme
   picker and the two stat pills on the other, stacking phone-first below
   900px.

   The cross-fade is pure CSS via the shared .theme-shot layers — see the
   media-slot block above. Nothing here observes, measures, or waits for a
   decode.
   ========================================================================== */

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

.themes__inner {
  max-width: var(--max-content);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

/* --- heading block ------------------------------------------------------- */

.themes__head {
  text-align: center;
}

/* The eyebrow and the sub are both <p>, so both are capped at the global
   prose measure and would sit centred inside a 480px box rather than inside
   the section. Same override the visa heading block makes. */
.themes__head .eyebrow {
  max-width: none;
}

.themes__title {
  max-width: 40rem;
  margin-inline: auto;
  margin-block-end: var(--space-5);
  text-wrap: balance;
}

.themes__sub {
  max-width: 44rem;
  margin-inline: auto;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

/* --- stage --------------------------------------------------------------- */

.themes__stage {
  display: grid;
  justify-items: center;
  gap: var(--space-7);
  margin-block-start: var(--space-8);
}

.themes__device {
  inline-size: min(16rem, 70%);
}

@media (min-width: 900px) {
  .themes__stage {
    /* The phone sizes itself; the panel takes a capped column beside it, and
       the pair is centred as a unit rather than spread across the full
       content width — a two-swatch picker pushed to the far edge would read
       as two unrelated things. */
    grid-template-columns: auto minmax(0, 24rem);
    justify-content: center;
    align-items: center;
    gap: var(--space-8);
  }

  .themes__device {
    inline-size: 16rem;
  }
}

/* --- panel --------------------------------------------------------------- */

.themes__panel {
  display: grid;
  gap: var(--space-5);
  inline-size: 100%;
  max-inline-size: 24rem;
  padding: var(--space-5);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  box-shadow: var(--shadow-md);
}

/* Same rule as the header toggle: without a script these buttons do nothing,
   so they are not rendered and not in the tab order. The phone above still
   shows whichever theme was resolved, and the pills below are unaffected. */
.themes__picker {
  display: none;
}

.js .themes__picker {
  display: grid;
  gap: var(--space-3);
}

.themes__picker-label {
  max-width: none;
  color: var(--text-label);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.theme-picker {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
}

/* --- swatch --------------------------------------------------------------
   Styled off [aria-checked], not off the .is-active class main.js also sets:
   the attribute is authored in the markup, so the ring is correct on the
   first paint rather than one script away. */

.theme-swatch {
  flex: 1 1 7rem;
  display: grid;
  gap: var(--space-2);
  padding: var(--space-2);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background-color: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: var(--leading-snug);
  text-align: center;
  cursor: pointer;
}

.theme-swatch:hover {
  border-color: var(--accent);
  color: var(--text-bright);
}

.theme-swatch[aria-checked="true"] {
  border-color: var(--accent);
  /* The ring, drawn outside the border so the two read as one 2px edge
     rather than a thickened border that shifts the chip inside it. */
  box-shadow: 0 0 0 1px var(--accent);
  color: var(--text-bright);
}

/* --- swatch chip ---------------------------------------------------------
   THE ONE PLACE LAYER 1 IS REFERENCED DIRECTLY.

   Every other rule in this file uses the semantic tokens, which by design
   resolve to whatever theme is currently on. A swatch has to preview a theme
   that is NOT currently on — --bg inside the Daybreak chip would paint it in
   Nightfall's background while Nightfall is active, and the two swatches
   would be identical. Layer 1 is theme-independent by definition, so it is
   the only correct source here. Adding a theme means adding its three raw
   values below, nothing else. */

.theme-swatch__chip {
  display: grid;
  /* Background gets the most room, accent the least — the proportions of a
     real screen, not three equal stripes. */
  grid-template-rows: 1.2fr 1fr 0.5fr;
  block-size: 3.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.theme-swatch[data-set-theme="nightfall"] .theme-swatch__band--bg      { background-color: var(--ink-950); }
.theme-swatch[data-set-theme="nightfall"] .theme-swatch__band--surface { background-color: var(--ink-800); }
.theme-swatch[data-set-theme="nightfall"] .theme-swatch__band--accent  { background-color: var(--mint-500); }

.theme-swatch[data-set-theme="daybreak"]  .theme-swatch__band--bg      { background-color: var(--paper-25); }
.theme-swatch[data-set-theme="daybreak"]  .theme-swatch__band--surface { background-color: var(--white); }
.theme-swatch[data-set-theme="daybreak"]  .theme-swatch__band--accent  { background-color: var(--mint-500); }

/* --- stat pills ---------------------------------------------------------- */

.themes__stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

.themes__stat {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  /* Tight enough that the pair sits on one line inside the panel's 24rem at
     every width from 900px up, and wraps to two rows below it. */
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  background-color: var(--surface-raised);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  white-space: nowrap;
}

.themes__stat strong {
  color: var(--text-bright);
  font-weight: 700;
}

.themes__stat-icon {
  display: grid;
  place-items: center;
  inline-size: 1.15rem;
  flex: none;
  color: var(--accent-text);
}

.themes__stat-icon svg {
  inline-size: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* --- motion --------------------------------------------------------------
   Colour and border changes are the hover state proper; the lift is the only
   thing reduced motion takes away. */

@media (prefers-reduced-motion: no-preference) {
  .theme-swatch {
    transition:
      border-color var(--dur-fast) var(--ease-in-out),
      box-shadow var(--dur-fast) var(--ease-in-out),
      color var(--dur-fast) var(--ease-in-out),
      transform var(--dur-fast) var(--ease-out);
  }

  .theme-swatch:hover {
    transform: translateY(-2px);
  }
}


/* ==========================================================================
   SECTION 12 — NOMADCHECK PRO
   A single glass card on a quiet --bg band. The section deliberately carries
   no surface of its own — the card is the only raised thing in it, so it
   lifts without needing to be brighter than anything else on the page.

   THE GRADIENT BORDER degrades in three steps, brightest first:

     1. mask-composite + @property  animated ring, hue sweeping on a 12s loop
     2. mask-composite only         the same ring, frozen at its start angle
     3. neither                     the plain --border hairline underneath

   The ring is a masked ::before rather than the padding-box/border-box
   background trick content.md suggests, for one reason: this card is real
   glass. A border-box gradient layer sits *behind* the whole card, so a
   translucent --surface-glass fill would let the green sweep wash across the
   card's interior, not just its edge. Masking to a 1px ring keeps the
   gradient on the edge and leaves the interior free to blur what is behind
   the page instead.

   The hairline in step 3 is why .pro__card carries a real border: the ring
   sits exactly on top of it, so the card never renders edgeless.
   ========================================================================== */

/* Registering the angle is what makes it animatable — an unregistered custom
   property is an untyped token and jumps between keyframes rather than
   interpolating. Browsers without @property fall to the literal declaration
   on .pro__card::before below, which is step 2 above. */
@property --pro-border-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 315deg;
}

.pro {
  position: relative;
  padding-block: var(--section-y);
  overflow-x: clip; /* backstop for the field's slice; never a scroll container */
}

.pro__inner {
  max-width: var(--max-content);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

/* --- background field ---------------------------------------------------- */

.pro__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.pro__field svg {
  inline-size: 100%;
  block-size: 100%;
}

/* Fainter than the visa section's 0.05: there the field is the only thing
   behind the copy, here it also has to stay out from under a glass card. */
.pro-field__compass {
  opacity: 0.04;
  fill: none;
  stroke: var(--text);
  stroke-width: 1.5;
}

/* The great-circle path, dashed rather than solid so it reads as the brand
   motif at this opacity instead of a stray hairline. */
.pro-field__path {
  opacity: 0.16;
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 2 14;
}

/* --- the card ------------------------------------------------------------ */

.pro__card {
  position: relative; /* containing block for the gradient ring */
  /* Wider than --max-narrow: at 800px the H2 breaks across two lines and the
     unlock columns get tight. This is the width the design sets, and it is
     the width that keeps the title on one line at 1280px. */
  max-width: 59rem;
  margin-inline: auto;
  padding: var(--space-7) var(--space-5) var(--space-6);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  background-color: var(--surface-glass);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  /* Depth plus the soft green outer glow. Both are tokens, and --shadow-accent
     is already tuned per theme — it is heavier in Daybreak, where the same
     spread over a light background barely registers. */
  box-shadow: var(--shadow-lg), var(--shadow-accent);
  text-align: center;
}

@media (min-width: 640px) {
  .pro__card {
    padding: var(--space-8) var(--space-7) var(--space-7);
  }
}

/* The ring. `padding` sets its thickness; the two masks — one clipped to the
   content box, one to the whole box — cancel each other everywhere except
   that padding, leaving a 1px band on the edge.

   No z-index: as a positioned descendant it paints above the card's own
   background, which is what puts it over the hairline. It paints above the
   card's content too, but it only ever covers the outermost 1px, where the
   card's padding guarantees there is nothing to cover. pointer-events stops
   it from eating a click on the way past. */
@supports ((-webkit-mask-composite: xor) or (mask-composite: exclude)) {
  .pro__card::before {
    /* The static fallback for browsers with no @property: the ring renders
       at this angle and stays there. Same declaration the animation below
       takes over when it can. */
    --pro-border-angle: 315deg;

    content: "";
    position: absolute;
    inset: 0;
    padding: 1px;
    border-radius: inherit;
    /* One bright arc, centred on the start angle and fading out both ways —
       green to nothing, per the brief. Interpolation to `transparent` is
       premultiplied, so the fade stays green rather than dipping through
       grey. */
    background: conic-gradient(
      from var(--pro-border-angle),
      var(--accent),
      transparent 35%,
      transparent 65%,
      var(--accent)
    );
    /* `black` is an alpha channel here, not a colour — a mask reads opacity
       and discards hue, so this never needs to be a theme token. */
    -webkit-mask:
      linear-gradient(black 0 0) content-box,
      linear-gradient(black 0 0);
    -webkit-mask-composite: xor;
    mask:
      linear-gradient(black 0 0) content-box,
      linear-gradient(black 0 0);
    mask-composite: exclude;
    pointer-events: none;
  }

  /* No will-change: this repaints the ring every frame for as long as the
     card exists, and a permanent compositor layer buys nothing back. */
  @media (prefers-reduced-motion: no-preference) {
    .pro__card::before {
      animation: pro-border-spin 12s linear infinite;
    }
  }
}

@keyframes pro-border-spin {
  to {
    --pro-border-angle: 675deg; /* 315 + 360 */
  }
}

/* --- heading block ------------------------------------------------------- */

/* The eyebrow is a pill here rather than the bare label the rest of the page
   uses — it is the first thing inside the card and needs an edge to sit on.
   .eyebrow still supplies the type; this only adds the chip and the spacing.
   max-width clears the global prose measure, which would otherwise centre a
   480px box inside the card instead of the pill inside the card. */
.pro__eyebrow {
  display: inline-block;
  max-width: none;
  margin-block-end: var(--space-5);
  padding: var(--space-1) var(--space-4);
  border: 1px solid var(--hub-border);
  border-radius: var(--radius-full);
}

/* No measure cap: the title is meant to run on one line at desktop, and
   text-wrap: balance handles the narrower widths where it cannot. */
.pro__title {
  margin-block-end: var(--space-4);
  text-wrap: balance;
}

.pro__sub {
  max-width: 42rem;
  margin-inline: auto;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

/* --- unlock grid --------------------------------------------------------- */

.pro__unlocks {
  display: grid;
  gap: var(--space-5) var(--space-6);
  margin: var(--space-7) 0 0;
  padding: 0;
  list-style: none;
  text-align: start;
}

@media (min-width: 640px) {
  .pro__unlocks {
    grid-template-columns: 1fr 1fr;
  }
}

/* Two rows, two columns: the icon spans both rows so the label and the body
   share one text column and the body stays aligned under the label rather
   than wrapping back under the icon. */
.pro-unlock {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: var(--space-3);
  align-content: start;
}

.pro-unlock__icon {
  grid-row: 1 / span 2;
  display: grid;
  place-items: center;
  inline-size: 1.5rem;
  aspect-ratio: 1;
  /* Optical: pins the badge to the label's cap height rather than its line
     box, so it reads as level with the text instead of slightly high. */
  margin-block-start: 0.15em;
  border-radius: var(--radius-sm);
  background-color: var(--accent-tint);
  color: var(--accent-text);
}

.pro-unlock__icon svg {
  inline-size: 0.85rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.pro-unlock__label {
  max-width: none;
  color: var(--text-bright);
  font-weight: 600;
  line-height: var(--leading-snug);
}

.pro-unlock__body {
  max-width: none;
  margin-block-start: var(--space-1);
  color: var(--text-muted);
  font-size: var(--text-sm);
  text-wrap: pretty;
}

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

.pro__cta {
  margin-block-start: var(--space-7);
}

/* Which of the two branches shows is decided entirely here, on `.js` — set
   by the blocking script in <head> before first paint. Nothing is swapped
   after load, so neither branch can flash and neither can shift layout.
   Default (no JS): the pair, correct on every platform. */
.pro__cta-single {
  display: none;
}

.pro__cta-pair {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-3);
}

.js .pro__cta-single {
  display: inline-flex;
}

.js .pro__cta-pair {
  display: none;
}

/* --text-subtle clears AA on --surface-glass in both themes; --text-label
   does not once the glass is over --bg. */
.pro__microcopy {
  max-width: none;
  margin-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-sm);
}


/* ==========================================================================
   SECTION 13 — FAQ
   A native <details>/<summary> accordion. Nothing here needs JS, and nothing
   here needs ARIA: the element pair already exposes the expanded state, the
   keyboard interaction, and the label relationship.

   THE OPEN/CLOSE ANIMATION. A closed <details> does not render its content at
   all, so a transition on the content itself never gets a chance to run on
   the way closed — the box is gone the moment `open` is dropped.
   ::details-content is the pseudo-element wrapping that content, and it is
   what makes the transition possible: `content-visibility` animated with
   `allow-discrete` holds the content on screen for the length of the close.

   Height is animated as `grid-template-rows: 0fr -> 1fr` rather than a height
   in pixels, so it works at whatever height the answer happens to be. That is
   a layout property and the site's usual rule is transform/opacity only, but
   the alternatives here are a hardcoded max-height (wrong at every width) or
   a JS measure-and-set (a JS dependency on a section built not to need one).
   It runs on eight small rows, only ever one at a time, only on a click.

   Browsers with no ::details-content get no animation and open instantly.
   The whole block is inside a reduced-motion guard for the same reason: under
   reduced motion the accordion falls back to plain native behaviour, with no
   clipped row to go wrong.

   The rows are transparent when closed and pick up --surface when open, so
   the open row lifts off the page background — that is the tint. The chevron
   turning accent-green carries the rest of the state.
   ========================================================================== */

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

.faq__inner {
  /* The brief's 800px, which is --max-narrow. */
  max-width: var(--max-narrow);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

.faq__title {
  margin-block-end: var(--space-7);
  text-align: center;
  text-wrap: balance;
}

.faq__list {
  display: grid;
  gap: var(--space-3);
}

/* --- one row -------------------------------------------------------------- */

.faq-item {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.faq-item:hover {
  background-color: var(--surface);
}

.faq-item[open] {
  background-color: var(--surface);
  box-shadow: var(--shadow-sm);
}

/* --- the question row ----------------------------------------------------- */

/* `display: flex` is what removes the disclosure triangle in Chrome; the two
   marker rules cover Firefox and older Safari. All three are needed. */
.faq-item__summary {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-4) var(--space-5);
  list-style: none;
  cursor: pointer;
}

/* Kept as two rules, not one selector list: a browser that does not know
   ::-webkit-details-marker throws out the whole list it appears in, taking
   the ::marker reset with it. `content` rather than `display` because
   ::marker only accepts a small set of properties and display is not one. */
.faq-item__summary::marker {
  content: "";
}

.faq-item__summary::-webkit-details-marker {
  display: none;
}

/* <summary> is not in the global :focus-visible rule's :where() list — that
   one covers a, button and the form elements. The row is a control, so it
   gets the same ring. */
.faq-item__summary:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-md);
}

.faq-item__q {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  font-weight: 600;
  line-height: var(--leading-snug);
  color: var(--text);
}

.faq-item[open] .faq-item__q,
.faq-item__summary:hover .faq-item__q {
  color: var(--text-bright);
}

/* --- the chevron ---------------------------------------------------------- */

/* Fixed box so the flex row's second column never changes width, and
   margin-block-start optically centres it on the first line of the question
   rather than on the whole block, which matters once a question wraps. */
.faq-item__chevron {
  flex: none;
  inline-size: 1.125rem;
  block-size: 1.125rem;
  margin-block-start: 0.15rem;
  fill: none;
  stroke: var(--text-subtle);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.faq-item__summary:hover .faq-item__chevron {
  stroke: var(--text);
}

.faq-item[open] .faq-item__chevron {
  stroke: var(--accent-text);
  transform: rotate(180deg);
}

/* --- the answer ----------------------------------------------------------- */

/* Padding lives on the answer, not on the animated wrapper, so it collapses
   with the text instead of leaving a gap behind at 0fr. */
.faq-item__answer {
  padding: 0 var(--space-5) var(--space-5);
}

/* The row is 800px wide because that is the rhythm the section wants; the
   text inside it is capped shorter because ~750px of body copy is past a
   comfortable measure. */
.faq-item__answer p {
  max-width: 38rem;
  color: var(--text-secondary);
  line-height: var(--leading-body);
}

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

@media (prefers-reduced-motion: no-preference) {
  .faq-item {
    transition:
      background-color var(--dur-fast) var(--ease-in-out),
      box-shadow var(--dur-fast) var(--ease-in-out);
  }

  .faq-item__q {
    transition: color var(--dur-fast) var(--ease-in-out);
  }

  .faq-item__chevron {
    transition:
      stroke var(--dur-fast) var(--ease-in-out),
      transform var(--dur-base) var(--ease-in-out);
  }

  .faq-item::details-content {
    display: grid;
    grid-template-rows: 0fr;
    /* Clips the answer while the row track is smaller than it. */
    overflow: hidden;
    transition:
      grid-template-rows var(--dur-base) var(--ease-in-out),
      content-visibility var(--dur-base) allow-discrete;
  }

  .faq-item[open]::details-content {
    grid-template-rows: 1fr;
  }

  /* Grid items refuse to shrink below their content by default, which would
     hold the row open at full height and defeat the 0fr. */
  .faq-item__answer {
    min-block-size: 0;
  }
}


/* ==========================================================================
   SECTION 14 — FINAL CTA
   The hero's bookend, and the only other place --text-h2-hero is used. Three
   things carry that: a full-bleed --surface-deep band where the FAQ above it
   is plain --bg, --section-y-hero padding (roughly 1.5x the page's usual), and
   the great-circle motif at nearly twice the hero's weight instead of the
   hero's dotted 45%.

   Everything is centred and there is no media, so the section is one column
   at every width — the only breakpoint is the button pair going side by side.
   ========================================================================== */

.final-cta {
  --arc-draw: 2000ms;

  background-color: var(--surface-deep);
  /* The static half of the glow, as on the visa band. The cursor orb rides on
     top of it inside the same [data-glow] isolation. */
  background-image: var(--glow-accent);
  background-repeat: no-repeat;
  background-position: 50% 55%;
  background-size: 82rem 62rem;
  border-block-start: 1px solid var(--border);
  /* `clip`, not `hidden` — the field bleeds past the content column and this
     must never become a scroll container. */
  overflow-x: clip;
  text-align: center;
}

.final-cta__inner {
  max-width: var(--max-narrow);
  margin-inline: auto;
  padding: var(--section-y-hero) var(--space-5);
}

/* --- background field ---------------------------------------------------
   The hero's compass and flight path, mirrored: the compass moves from right
   of centre to left, and the arc climbs leftward instead of rightward. Behind
   the content but inside the [data-glow] isolation, so the cursor orb lights
   it too. */

.final-cta__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  display: grid;
  align-content: center;
  overflow: hidden;
  pointer-events: none;
}

/* Mobile-first, and the one place this field parts company with the hero's.
   The hero is a tall box, so its SVG can cover; this band is a wide strip, and
   covering it at 390px would crop a 1440-wide viewBox down to its middle
   quarter — which is the flattest part of the arc, so the motif and both
   endpoint dots would be gone exactly where the section has the least else in
   it. Sizing to the width instead fits the whole sweep, dots included, and the
   grid above centres the resulting strip in the gap under the copy. */
.final-cta__field svg {
  inline-size: 100%;
  block-size: auto;
}

/* Past the point where covering keeps the arc intact, the SVG goes back to
   filling the band and `slice` takes over — the arc then spans the full width
   at full height, as it does in the hero. */
@media (min-width: 900px) {
  .final-cta__field svg {
    block-size: 100%;
  }
}

/* A shade over the hero's 6%: this band is a deeper surface, so the same
   opacity would read fainter than it does up top. */
.cta-field__compass {
  opacity: 0.07;
}

.cta-field__compass > * {
  fill: none;
  stroke: var(--text);
  stroke-width: 1.5;
}

/* Solid and a shade heavier than the hero's 3px arc — this is the bookend, and
   a dash pattern cannot also carry a dashoffset draw. Below the hero's 45%
   despite that: solid reads far stronger than dotted at the same value, and
   this arc passes behind the copy rather than beside it. */
.cta-field__path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 3.5;
  stroke-linecap: round;
  opacity: 0.38;
}

.cta-field__node {
  fill: var(--accent);
  opacity: 0.85;
  /* Scaling a circle needs its own box as the origin; without this the
     transform resolves against the whole SVG and the dot flies off. */
  transform-box: fill-box;
  transform-origin: center;
}

/* --- copy ---------------------------------------------------------------- */

.final-cta__title {
  font-size: var(--text-h2-hero);
  text-wrap: balance;
}

.final-cta__sub {
  /* Wider than the global --max-prose: two lines here, not four. */
  max-width: 34rem;
  margin-block-start: var(--space-5);
  margin-inline: auto;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

.final-cta__actions {
  display: grid;
  gap: var(--space-3);
  margin-block-start: var(--space-7);
}

@media (min-width: 640px) {
  .final-cta__actions {
    grid-auto-flow: column;
    justify-content: center;
  }
}

/* --- the draw -------------------------------------------------------------
   Behind `.js` and no-preference both. With JS off or reduced motion on not
   one rule here matches: the arc is solid at full length and both dots are
   lit — exactly where the animation would have left them — so main.js starts
   no observer.

   .is-drawing is added by main.js on entry and runs the stroke out over
   --arc-draw; .is-lit follows on transitionend and starts the dots breathing.
   Neither is ever removed.

   stroke-dashoffset is the one property here that is neither transform nor
   opacity. It is the technique the section calls for, and there is no
   compositor-only way to reveal a stroke along its own length. It runs once,
   on a single path, and repaints nothing outside this band. No will-change
   goes with it: the property is not composited, so the hint would buy a layer
   and no speed. */

@media (prefers-reduced-motion: no-preference) {
  .js .cta-field__path {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--arc-draw) var(--ease-in-out);
  }

  .js .final-cta.is-drawing .cta-field__path {
    stroke-dashoffset: 0;
  }

  /* The dots wait out the whole draw rather than lighting with it, so the arc
     reads as travelling between two points that are already there. */
  .js .cta-field__node {
    opacity: 0;
    transition: opacity var(--dur-slow) var(--ease-out);
  }

  /* The pulse is delayed by the length of that fade. Starting both at once
     would let the keyframes' own opacity win from frame one and snap the dots
     on instead of bringing them up. */
  .js .final-cta.is-lit .cta-field__node {
    opacity: 0.85;
    animation: cta-node-pulse 3s var(--ease-in-out) var(--dur-slow) infinite;
  }

  /* Offset so the two ends breathe out of step, as the hero's phones do. */
  .js .final-cta.is-lit .cta-field__node + .cta-field__node {
    animation-delay: calc(var(--dur-slow) + 1.5s);
  }
}

@keyframes cta-node-pulse {
  0%, 100% { opacity: 0.85; transform: scale(1); }
  50%      { opacity: 0.45; transform: scale(1.45); }
}


/* ==========================================================================
   SECTION 15 — FOOTER
   Brand column plus three link columns, collapsing to one column below 640px,
   then sitting beside the brand from 900px. The link columns are auto-flowed
   rather than given fixed tracks, so the Contact column — hidden until its
   URLs exist — costs no empty track while it is away and needs no CSS change
   when it comes back.
   ========================================================================== */

.site-footer__inner {
  max-width: var(--max-content);
  margin-inline: auto;
  padding: var(--space-8) var(--space-5) var(--space-7);
}

.site-footer__top {
  display: grid;
  gap: var(--space-7);
}

.site-footer__logo img {
  /* width/height on the element hold the ratio, so overriding one dimension
     here still reserves the right box before the SVG arrives. */
  inline-size: 9.5rem;
  block-size: auto;
}

.site-footer__tagline {
  max-width: 20rem;
  margin-block-start: var(--space-4);
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.site-footer__columns {
  display: grid;
  gap: var(--space-6);
}

.site-footer__heading {
  margin-block-end: var(--space-4);
  color: var(--text-label);
  font-family: var(--font-text);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.site-footer__list {
  display: grid;
  gap: var(--space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The underline grows from the left as a background-size change on a
   one-pixel gradient strip, which is why text-decoration is off entirely —
   the global a:hover underline would appear instantly alongside it. */
.site-footer__list a {
  color: var(--text-muted);
  font-size: var(--text-sm);
  text-decoration: none;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% 1px;
}

.site-footer__list a:hover,
.site-footer__list a:focus-visible {
  color: var(--text-bright);
  text-decoration: none;
  background-size: 100% 1px;
}

@media (prefers-reduced-motion: no-preference) {
  .site-footer__list a {
    transition:
      background-size var(--dur-fast) var(--ease-in-out),
      color var(--dur-fast) var(--ease-in-out);
  }
}

.site-footer__bottom {
  display: grid;
  gap: var(--space-3);
  margin-block-start: var(--space-7);
  padding-block-start: var(--space-5);
  border-block-start: 1px solid var(--border);
}

.site-footer__copyright {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* Set at the copyright's weight, not the disclaimer's: this is an attribution
   the dataset is owed, not fine print. */
.site-footer__source {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.site-footer__disclaimer {
  max-width: var(--max-narrow);
  color: var(--text-subtle);
  font-size: var(--text-xs);
}

@media (min-width: 640px) {
  .site-footer__columns {
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: var(--space-5);
  }

  /* Copyright left, attribution right, disclaimer across both. Below this the
     bottom bar stays the single stacked column it is by default. */
  .site-footer__bottom {
    grid-template-columns: 1fr auto;
  }

  .site-footer__source {
    justify-self: end;
  }

  .site-footer__disclaimer {
    grid-column: 1 / -1;
  }
}

@media (min-width: 900px) {
  .site-footer__top {
    grid-template-columns: 2fr 3fr;
    gap: var(--space-6);
  }
}


/* ==========================================================================
   SCROLL REVEAL
   The hidden start state is applied only when JS is running (`.js` is set by
   the blocking script in <head>, before first paint) and only when motion is
   welcome. With JS off or reduced motion on, none of this matches and the
   content renders in its final state — no observers, no loops, no flash.
   ========================================================================== */

@media (prefers-reduced-motion: no-preference) {
  .js [data-reveal],
  .js [data-reveal-stagger] > * {
    opacity: 0;
    transform: translateY(24px);
    transition:
      opacity var(--dur-slow) var(--ease-out) var(--reveal-delay, 0ms),
      transform var(--dur-slow) var(--ease-out) var(--reveal-delay, 0ms);
  }

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


/* ==========================================================================
   CURSOR-FOLLOW GLOW
   main.js injects .glow-layer into every [data-glow] section, on fine
   pointers only. Nothing here renders without it.
   ========================================================================== */

[data-glow] {
  position: relative;
  /* Contains the blend so it lights this section's background and no other,
     and lets the layer sit at z-index -1 above that background but under the
     section's own content. */
  isolation: isolate;
}

.glow-layer {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  border-radius: inherit;
}

.glow-orb {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 34rem;
  aspect-ratio: 1;
  /* Centres the blob on the cursor without a second transform term. */
  margin: -17rem 0 0 -17rem;
  background: var(--glow-accent);
  mix-blend-mode: screen;
  opacity: 0;
  transition: opacity var(--dur-base) var(--ease-in-out);
}

.glow-orb.is-visible {
  opacity: 1;
}


/* ==========================================================================
   COUNT-UP NUMBERS
   Tabular figures so a number counting up holds its width — a proportional
   digit change mid-animation is layout shift.
   ========================================================================== */

[data-count-to] {
  font-variant-numeric: tabular-nums;
}


/* ==========================================================================
   EYEBROW
   The small uppercase kicker above a heading. Used by every section on the
   index page and by both standalone page types below, so it lives on its own
   rather than inside any one section's block.
   ========================================================================== */

.eyebrow {
  font-size: var(--text-sm);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--accent-text);
  margin-bottom: var(--space-3);
}


/* ==========================================================================
   LEGAL PAGES — privacy.html, terms.html
   --------------------------------------------------------------------------
   One prose column at --max-narrow, no media, no motion beyond the shared
   scroll reveal. Both pages are the same layout with different headings, so
   everything here is on .legal-* and neither page has a rule of its own.

   The global `p { max-width: var(--max-prose) }` is a 480px measure meant for
   copy sat beside a screenshot. Here the column itself is the measure, so the
   body cancels it — otherwise every paragraph would stop well short of the
   rule and the placeholders below it.
   ========================================================================== */

.legal {
  max-width: var(--max-narrow);
  margin-inline: auto;
  padding: var(--section-y) var(--space-5);
}

/* One rule for the whole column rather than a max-width on each descendant:
   every measure on these pages is the column, without exception. */
.legal p,
.legal li {
  max-width: none;
}

/* --- page head ----------------------------------------------------------- */

.legal__title {
  /* Not --text-h1: at 4.5rem a policy heading shouts louder than anything the
     index page says. This is the page's h1 all the same. */
  font-size: var(--text-h2);
  text-wrap: balance;
}

.legal__updated {
  margin-block-start: var(--space-4);
  color: var(--text-subtle);
  font-size: var(--text-sm);
}

.legal__lede {
  margin-block-start: var(--space-5);
  color: var(--text-secondary);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

/* --- contents ------------------------------------------------------------
   A plain ordered list of in-page anchors. Two columns from 640px, where the
   column is wide enough that a one-column list would run longer than the
   first section it points at. */

.legal__toc {
  margin-block-start: var(--space-7);
  padding: var(--space-5);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background-color: var(--surface);
}

.legal__toc-heading {
  margin-block-end: var(--space-4);
  color: var(--text-label);
  font-family: var(--font-text);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}

.legal__toc-list {
  display: grid;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-sm);
}

.legal__toc-list a {
  color: var(--text-muted);
}

.legal__toc-list a:hover,
.legal__toc-list a:focus-visible {
  color: var(--text-bright);
}

@media (min-width: 640px) {
  .legal__toc-list {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-2) var(--space-5);
  }
}

/* --- body ---------------------------------------------------------------- */

.legal__body {
  margin-block-start: var(--space-8);
}

.legal__section + .legal__section {
  margin-block-start: var(--space-7);
}

/* The header is fixed, so an anchor jump would otherwise land the heading
   underneath it. Clears the bar at its scrolled height plus a line of air. */
.legal__section {
  scroll-margin-block-start: calc(var(--header-h) + var(--space-4));
}

.legal__section h2 {
  font-size: var(--text-2xl);
}

.legal__section h3 {
  margin-block-start: var(--space-5);
  font-size: var(--text-lg);
  color: var(--text);
}

.legal__section > * + * {
  margin-block-start: var(--space-4);
}

.legal__section p {
  color: var(--text-secondary);
}

.legal__list {
  display: grid;
  gap: var(--space-2);
  margin: 0;
  padding-inline-start: var(--space-5);
  color: var(--text-secondary);
}

/* --- placeholder ---------------------------------------------------------
   Scaffolding, same visual language as .media-slot__placeholder: dashed
   border, mono type, muted. Every one of these is replaced with real drafted
   text before either page is linked from anywhere but the footer. Nothing on
   these pages is legal advice until they are.
   Deleted one at a time as the real clauses arrive; when the last one goes,
   this rule goes with it. */

.legal__placeholder {
  padding: var(--space-4);
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius-md);
  background-color: var(--surface);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: var(--leading-body);
  /* A bracketed marker with no spaces can outrun a 360px column. */
  overflow-wrap: anywhere;
}


/* ==========================================================================
   ERROR PAGE — 404.html
   --------------------------------------------------------------------------
   The final CTA's band, reduced to one column of copy and one button: a
   full-bleed --surface-deep field with the great-circle motif behind it, tall
   enough to hold the viewport on its own so the footer starts below the fold.

   The motif is the page's whole argument. The arc leaves its origin and the
   destination it is heading for is a hollow ring rather than a lit dot —
   a route with nothing at the far end of it.
   ========================================================================== */

.error-page {
  --arc-draw: 2000ms;

  display: grid;
  align-content: center;
  /* svh, not vh: on a phone the URL bar would otherwise push the button under
     the fold on first paint and pull it back on the first scroll. */
  min-block-size: calc(100svh - var(--header-h));
  background-color: var(--surface-deep);
  background-image: var(--glow-accent);
  background-repeat: no-repeat;
  background-position: 50% 50%;
  background-size: 82rem 62rem;
  /* `clip`, not `hidden` — the field bleeds past the content column and this
     must never become a scroll container. */
  overflow-x: clip;
  text-align: center;
}

.error-page__inner {
  max-width: var(--max-narrow);
  margin-inline: auto;
  padding: var(--section-y) var(--space-5);
}

.error-page__title {
  font-size: var(--text-h2-hero);
  text-wrap: balance;
}

.error-page__sub {
  max-width: 34rem;
  margin-block-start: var(--space-5);
  margin-inline: auto;
  color: var(--text-muted);
  font-size: var(--text-lg);
  text-wrap: pretty;
}

.error-page__actions {
  display: grid;
  justify-content: center;
  margin-block-start: var(--space-7);
}

/* --- background field ----------------------------------------------------
   Behind the copy but inside the [data-glow] isolation, so the cursor orb
   lights it too. Same weights as the final CTA's field. */

.error-page__field {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

/* Fills the band in both axes and lets the SVG's own `meet` fit the viewBox
   inside that box, which is the one thing the hero and the final CTA do not
   do — they both `slice`, because both are bands of a known, roughly fixed
   shape. This one is not: it is 100svh, so it is 390x740 on a phone and
   1920x860 on a laptop, and `slice` loses one end of the arc at each
   extreme. The whole motif is the message here — an arc that stops and a
   destination that isn't there — so no width may crop either endpoint away.
   The cost is a margin of plain band at the sides on a wide, short window,
   which the glow behind it covers. */
.error-page__field svg {
  inline-size: 100%;
  block-size: 100%;
}

.error-field__compass {
  opacity: 0.07;
}

.error-field__compass > * {
  fill: none;
  stroke: var(--text);
  stroke-width: 1.5;
}

.error-field__path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 3.5;
  stroke-linecap: round;
  opacity: 0.38;
}

/* The origin: solid, the same dot the final CTA lights. */
.error-field__node {
  fill: var(--accent);
  opacity: 0.85;
  /* Scaling a circle needs its own box as the origin; without this the
     transform resolves against the whole SVG and the dot flies off. */
  transform-box: fill-box;
  transform-origin: center;
}

/* The destination that isn't there: hollow, dashed, and dimmer than the
   origin it was drawn from. */
.error-field__node--lost {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.5;
  stroke-dasharray: 5 6;
  opacity: 0.5;
}

/* --- the draw -------------------------------------------------------------
   CSS only. The final CTA's arc waits on an IntersectionObserver because it
   starts below the fold; this one is on screen at load, so it needs no JS at
   all and 404.html starts no observer of its own.

   `forwards` leaves the arc exactly where the reduced-motion state already
   has it, so the two agree: drawn, solid, origin lit, destination hollow.
   Under reduced motion not one rule in here matches.

   stroke-dashoffset is the one property here that is neither transform nor
   opacity, as in SECTION 14 — there is no compositor-only way to reveal a
   stroke along its own length. It runs once, on one path. */

@media (prefers-reduced-motion: no-preference) {
  .error-field__path {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    animation: error-arc-draw var(--arc-draw) var(--ease-in-out) 200ms forwards;
  }

  /* Both endpoints wait out the draw rather than lighting with it, so the arc
     reads as travelling towards something instead of between two things. */
  .error-field__node {
    opacity: 0;
    animation: error-node-in var(--dur-slow) var(--ease-out)
               calc(var(--arc-draw) + 200ms) forwards;
  }

  .error-field__node--lost {
    animation-name: error-node-in-lost;
  }
}

@keyframes error-arc-draw {
  to { stroke-dashoffset: 0; }
}

@keyframes error-node-in {
  to { opacity: 0.85; }
}

@keyframes error-node-in-lost {
  to { opacity: 0.5; }
}
