/* =====================================================================
   The 406: Montana — marketing site
   =====================================================================

   Tokens are lifted verbatim from the app's globals.css so the site and
   the product cannot drift apart. If a colour changes there, change it
   here; there is no build step joining them.

   PERFORMANCE RULES OBSERVED THROUGHOUT — the brief asked for heavy
   motion without lag, and these are the four things that buy it:

     1. Only `transform` and `opacity` are ever animated. Both are
        composited on the GPU, so they never trigger layout or paint.
        Animating `top`, `height`, `margin` or `filter` on scroll is what
        makes sites like this stutter.
     2. Nothing animates that is not on screen. Reveals are driven by
        IntersectionObserver and unobserved once fired.
     3. Parallax reads scroll position ONCE per frame, in one
        requestAnimationFrame loop, and writes a CSS custom property. No
        element has its own scroll listener.
     4. `will-change` is applied narrowly and never to more than a
        handful of elements — it reserves a compositor layer, and a page
        that reserves fifty is slower than one that reserves none.

   ===================================================================== */

:root {
  /*
   * The navigation bar's height, in one place.
   *
   * It was the literal 66 in site.css and again as `--docs-nav` in
   * docs.css, in two files that nothing kept in step — change the bar and
   * the documentation's top padding quietly stops matching it. docs.css
   * now derives its value from this one.
   */
  --nav-h: 66px;
  color-scheme: dark;

  /* --- app tokens, Montana (the default) --------------------------- */
  /*
   * Blue ground, gold accent — the state's colours, and the same values
   * the app's globals.css carries on its own :root. The app expresses
   * these as space-separated channels because Tailwind needs to take them
   * apart for opacity modifiers; this site has no Tailwind and no such
   * requirement, so they stay as plain hex here. Same colours, two
   * notations, and neither file is the wrong one.
   *
   * Gold measures 11.4:1 on this blue, which is why it can be used as
   * text here and cannot be in the light theme below.
   */
  --bg: #0b1633;
  --card: #111e42;
  --surface-2: #16264f;
  --border: #1e2f5c;
  --primary: #ffc72c;
  --on-primary: #0b1633;
  --primary-text: #ffc72c;
  --text: #f2f5ff;
  --text-dim: #9aa8c7;

  /* --- site-only ------------------------------------------------- */
  --max: 1180px;
  --gutter: clamp(1.25rem, 5vw, 3rem);

  /* Motion. One curve, three durations — same discipline as the app. */
  --ease: cubic-bezier(0.2, 0, 0, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --tap: 120ms;
  --move: 260ms;
  --enter: 720ms;

  --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;

  /*
   * PHOTOGRAPH SLOTS.
   *
   * Drop your own frames into assets/img/ with these names and they
   * appear. Until then each slot falls back to a gradient, so the site
   * looks finished rather than broken while it is empty. See README.md
   * for the recommended dimensions.
   *
   * THREE, not nine. There were six more here — --img-album-1 through -6,
   * for the album parallax grid that was cut when the page came down from
   * twelve sections to six. Nothing has referenced them since. They were
   * removed rather than left in place because a token list is read as a
   * list of what the page needs, and six phantom entries put six
   * photographs on a shot list that the site has no slot for.
   *
   * NOW ONE, not three, for the same reason. The hero and the two scene
   * photographs became real <img> elements so a CDN could resize and
   * re-encode them per device — nothing can optimise a URL written inside
   * a stylesheet. Their filenames live in index.html now.
   *
   * The one that remains is the thumbnail inside the drawn phone in
   * "Come in". It is decorative, it is about 200px wide, and it is inside
   * a mockup rather than being a photograph the page is showing you — a
   * background is the right tool for it and optimising it would save
   * nothing worth the markup.
   *
   * THE SHOT LIST HAS NOT CHANGED. Still three photographs, still the same
   * three names, still in assets/img/. See README.md for dimensions.
   */
  --img-exif: url('../img/scene-exif.jpg');
}

:root[data-theme='light'] {
  color-scheme: light;

  --bg: #faf9f7;
  --card: #ffffff;
  --surface-2: #f2efec;
  --border: #e2ded8;
  --primary: #ffc72c;
  --on-primary: #000000;
  /*
   * Gold as TEXT is a different problem from gold as a fill, and a worse
   * one than orange was. #ffc72c on this paper is 1.48:1 — not marginal,
   * effectively invisible — and this colour carries every eyebrow and
   * inline link on the page. The deep gold measures 5.94:1, clears AA,
   * and still reads as gold rather than brown. Same reasoning and the
   * same value as the app's light theme.
   */
  --primary-text: #7a5c00;
  --text: #17161a;
  --text-dim: #63605c;
}

/* --------------------------------------------------------------- reset */

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

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

a {
  color: inherit;
  text-decoration: none;
}

h1,
h2,
h3,
p,
figure,
blockquote {
  margin: 0;
}

::selection {
  background: var(--primary);
  color: var(--on-primary);
}

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

/* Screen-reader-only, for the skip link and icon labels. */
.sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.skip {
  position: fixed;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 200;
  padding: 0.6rem 1rem;
  border-radius: 999px;
  background: var(--primary);
  color: var(--on-primary);
  font: 600 0.75rem/1 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  transform: translateY(-200%);
  transition: transform var(--move) var(--ease);
}

.skip:focus {
  transform: translateY(0);
}

/* ------------------------------------------------------ type + layout */

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

.eyebrow {
  font: 600 clamp(0.62rem, 1.5vw, 0.7rem) / 1 var(--font-mono);
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--primary-text);
}

.display {
  font-size: clamp(2.4rem, 7.5vw, 5.5rem);
  line-height: 1.02;
  letter-spacing: -0.03em;
  font-weight: 700;
  text-wrap: balance;
}

.h2 {
  font-size: clamp(1.9rem, 4.6vw, 3.2rem);
  line-height: 1.06;
  letter-spacing: -0.025em;
  font-weight: 700;
  text-wrap: balance;
}

.h3 {
  font-size: clamp(1.15rem, 2.2vw, 1.4rem);
  line-height: 1.25;
  letter-spacing: -0.015em;
  font-weight: 650;
}

.lede {
  font-size: clamp(1.02rem, 1.9vw, 1.3rem);
  line-height: 1.55;
  color: var(--text-dim);
  max-width: 34ch;
  text-wrap: pretty;
}

.lede--wide {
  max-width: 52ch;
}

.mono {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-dim);
}

section {
  position: relative;
}

/*
 * Tightened from clamp(5rem, 14vh, 10rem).
 *
 * That was tuned for six alternating full-bleed sections that each needed
 * air around them to read as separate scenes. The page is four blocks
 * now, two of them bordered cards, so the borders do the separating and
 * the padding only has to stop things touching.
 */
.pad {
  padding-block: clamp(3.5rem, 9vh, 6.5rem);
}

.rule {
  height: 1px;
  background: var(--border);
  border: 0;
  margin: 0;
}

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

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  padding: 0.95rem 1.6rem;
  border-radius: 999px;
  border: 1px solid transparent;
  font: 700 0.72rem/1 var(--font-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  cursor: pointer;
  white-space: nowrap;
  transition:
    transform var(--tap) var(--ease),
    opacity var(--tap) var(--ease),
    border-color var(--tap) var(--ease),
    color var(--tap) var(--ease),
    background-color var(--tap) var(--ease);
}

.btn:active {
  transform: scale(0.97);
}

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

.btn--primary:hover {
  opacity: 0.9;
}

.btn--ghost {
  border-color: var(--border);
  color: var(--text);
  background: transparent;
}

.btn--ghost:hover {
  border-color: var(--primary);
  color: var(--primary-text);
}

.btn svg {
  width: 1.05em;
  height: 1.05em;
  flex: none;
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

/* ----------------------------------------------------------------- nav */

/*
 * A NORMAL BAR, SOLID FROM THE FIRST PIXEL.
 *
 * It used to be transparent and gain a background on scroll, driven by
 * `data-stuck` and an IntersectionObserver watching a sentinel. That
 * belonged to a one-page site whose bar floated over a full-bleed hero.
 * With real pages behind the links it is furniture, and furniture that
 * fades in as you scroll is furniture you cannot find.
 *
 * `data-stuck` is no longer set by anything. The selector is kept below
 * so an older cached page — or the docs pages, which shipped the
 * attribute hard-coded — renders identically rather than losing its
 * background.
 */
.nav {
  /*
   * FIXED, NOT STICKY, and that is not a leftover.
   *
   * Sticky was tried and reverted: it puts the bar in the flow, and
   * every page on this site already reserves space for a bar that is
   * out of it. docs.css offsets its content by `--docs-nav`, and the
   * hero is `min-height: 100svh` on the assumption that the bar floats
   * over it. Going sticky doubles the first and adds a 66px strip below
   * the fold on the second.
   */
  position: fixed;
  inset-inline: 0;
  top: 0;
  z-index: 100;
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  border-bottom: 1px solid var(--border);
}

/* Retained for pages still shipping the attribute. Same result. */
.nav[data-stuck='true'] {
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  border-bottom-color: var(--border);
}

.nav__inner {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  height: var(--nav-h);
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  font-size: 1.05rem;
  margin-right: auto;
}

.brand__mark {
  width: 26px;
  height: 26px;
  flex: none;
  color: var(--primary);
}

.nav__links {
  display: none;
  gap: 1.75rem;
}

.nav__links a {
  font: 600 0.72rem/1 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-dim);
  transition: color var(--tap) var(--ease);
  white-space: nowrap;
}

.nav__links a:hover {
  color: var(--text);
}

/*
 * The page you are on.
 *
 * Set in the markup rather than by script, so it is correct before
 * JavaScript runs and correct with JavaScript off. Colour alone would
 * fail for a reader who cannot distinguish it, so the underline carries
 * the same information.
 */
.nav__links a[aria-current='page'],
.nav__menu a[aria-current='page'] {
  color: var(--primary-text);
  text-decoration: underline;
  text-underline-offset: 0.4em;
  text-decoration-thickness: 1px;
}

/*
 * THE MENU BUTTON, AND THE GAP IT FILLS.
 *
 * `.nav__links` is display:none below 900px and until now NOTHING
 * replaced it — on a phone the bar was a wordmark, a theme toggle and
 * one call to action, and the documentation could not be reached from
 * the top of the page at all.
 */
.nav__toggle {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
  transition: color var(--tap) var(--ease), border-color var(--tap) var(--ease);
}

.nav__toggle:hover {
  color: var(--primary-text);
  border-color: var(--primary);
}

/*
 * Three lines drawn with one element and two shadows rather than three
 * spans, so the markup stays a single empty span.
 */
.nav__bars,
.nav__bars::before,
.nav__bars::after {
  display: block;
  width: 16px;
  height: 1.5px;
  border-radius: 2px;
  background: currentColor;
}

.nav__bars {
  position: relative;
}

.nav__bars::before,
.nav__bars::after {
  content: '';
  position: absolute;
  inset-inline-start: 0;
}

.nav__bars::before {
  top: -5px;
}

.nav__bars::after {
  top: 5px;
}

.nav__toggle[aria-expanded='true'] .nav__bars {
  background: transparent;
}

.nav__toggle[aria-expanded='true'] .nav__bars::before {
  top: 0;
  transform: rotate(45deg);
}

.nav__toggle[aria-expanded='true'] .nav__bars::after {
  top: 0;
  transform: rotate(-45deg);
}

/*
 * The panel. `hidden` is toggled alongside this by site.js, so a closed
 * menu is out of the accessibility tree rather than merely invisible —
 * a screen reader must not be able to tab into a menu nobody opened.
 */
.nav__menu {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.5rem var(--gutter) 1rem;
  border-top: 1px solid var(--border);
  background: var(--bg);
}

.nav__menu[hidden] {
  display: none;
}

.nav__menu a {
  padding: 0.85rem 0.25rem;
  font: 600 0.78rem/1 var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  border-bottom: 1px solid var(--border);
}

.nav__menu a:last-child {
  border-bottom: 0;
}

.nav__menu a:hover {
  color: var(--text);
}

.nav__menu-cta {
  margin-top: 0.6rem;
  color: var(--primary-text) !important;
}

.nav__actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.icon-btn {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
  transition: color var(--tap) var(--ease), border-color var(--tap) var(--ease);
}

.icon-btn:hover {
  color: var(--primary-text);
  border-color: var(--primary);
}

.icon-btn svg {
  width: 17px;
  height: 17px;
}

@media (min-width: 900px) {
  .nav__links {
    display: flex;
  }

  /* The row is showing, so the disclosure button and its panel are not
     just hidden — they are irrelevant. site.js also closes the panel on
     this breakpoint so aria-expanded cannot be left saying "true" about
     something nobody can see. */
  .nav__toggle,
  .nav__menu {
    display: none !important;
  }
}

/*
 * Below the breakpoint the primary call to action lives in the menu
 * panel instead of the bar. Two buttons plus a wordmark plus a toggle
 * does not fit 360px, and "Enter The 406" is the longest label on
 * the site.
 */
@media (max-width: 899px) {
  .nav__cta {
    display: none;
  }
}

/* ---------------------------------------------------------------- hero */

/*
 * CENTRED, NOT BOTTOM-PINNED.
 *
 * This was `align-items: end`, and .hero__body carried a
 * `clamp(6rem, 16vh, 9rem)` top padding on top of that. The two together
 * meant the copy was pushed to the bottom of a full-height section AND
 * held off the top of it, so the empty band between the navigation and
 * the first line of text was whatever was left over — 224px on a 900px
 * screen, 389px on a 1080px one. It grew with the window, which is why it
 * looked worse on a desktop than anywhere it was designed.
 *
 * Centring makes the space above and below the copy the same, and the
 * padding below now only has to clear the scroll cue.
 */
.hero {
  position: relative;
  /*
   * NOT a full 100svh any more.
   *
   * A full-viewport hero costs a whole screen of scrolling before the
   * first fact, and this page used to have a prose section underneath it
   * saying much what the first card now says. At 82svh the photograph
   * still dominates and the top of the panel is visible on a laptop
   * without scrolling, which is what stops the page feeling long.
   */
  min-height: 82svh;
  display: grid;
  align-items: center;
  overflow: clip;
  isolation: isolate;
}

/*
 * KEN BURNS.
 *
 * A pure CSS keyframe on transform only — 28 seconds, alternating, so it
 * never snaps back to the start. The layer is scaled up 12% at rest so
 * the drift can never expose an edge.
 *
 * The gradient sits UNDER the photograph in the same declaration, so the
 * slot degrades gracefully: no hero.jpg means a deep charcoal wash with a
 * warm cast rather than an empty black box.
 */
.hero__media {
  position: absolute;
  inset: -6%;
  z-index: -2;
  /*
   * Gradients only. The photograph is a real <img> inside this element —
   * see .hero__img and the comment in index.html. A CDN cannot optimise a
   * URL written inside a stylesheet, so as a background it was one
   * full-size JPEG for every visitor regardless of their screen.
   *
   * These stay as the backdrop: the slot has no photograph in it yet, and
   * a failed load falls back to this rather than a broken-file icon.
   */
  background-image: radial-gradient(120% 90% at 22% 12%, #3a2415 0%, transparent 55%),
    radial-gradient(100% 80% at 82% 88%, #1a2430 0%, transparent 60%),
    linear-gradient(160deg, #16130f 0%, #0b0b0b 70%);
  background-size: cover;
  background-position: center;
  will-change: transform;
  animation: ken-burns 28s var(--ease) infinite alternate;
}

/*
 * Stop the pan once the hero has scrolled away.
 *
 * site.js adds this with an IntersectionObserver. An `infinite` animation
 * does not stop on its own — it keeps the compositor doing work on a layer
 * nobody can see, for as long as the tab is open, all the way down a page
 * this long. Pausing rather than removing means it resumes exactly where
 * it left off if the reader scrolls back up, with no jump.
 */
.hero__media.is-paused {
  animation-play-state: paused;
  will-change: auto;
}

/*
 * The hero photograph. Fills the panned element, so it pans with it.
 *
 * [hidden] is set by site.js when the file fails to load, which reveals
 * the gradients on .hero__media behind it. The slot has no photograph yet,
 * so that is the state the site is in today.
 */
.hero__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.hero__img[hidden] {
  display: none;
}

/*
 * The empty-slot backdrop, in light theme.
 *
 * The gradients on .hero__media are deep browns and near-blacks because
 * they were built to sit UNDERNEATH a photograph, tinting its edges. With
 * no photograph in the slot they are the whole hero — and on the light
 * theme that is a large dark smudge across the top of an otherwise pale
 * page, with the headline going dark-on-dark through the middle of it.
 *
 * This is not new and was not introduced by moving the photograph into an
 * <img>: the same smudge is in the pre-existing build, because the JPEG
 * has never existed. It only reads as "the theme is broken" because the
 * light theme is where a dark blob has nowhere to hide.
 *
 * Warm greys at the same stops, so the composition is identical and only
 * the values change. THE MOMENT hero.jpg EXISTS this is almost entirely
 * covered and stops mattering — it is the empty state, not the design.
 */
:root[data-theme='light'] .hero__media {
  background-image: radial-gradient(120% 90% at 22% 12%, #e6dcd2 0%, transparent 55%),
    radial-gradient(100% 80% at 82% 88%, #d8dee6 0%, transparent 60%),
    linear-gradient(160deg, #f2efec 0%, var(--bg) 70%);
}

/*
 * Same reasoning for the two photograph slots further down. Theirs is a
 * bordered rectangle rather than a full-bleed backdrop, so it reads as a
 * deliberate placeholder either way — but a near-black panel on a pale
 * page still looks like a loading failure rather than an empty frame.
 */
:root[data-theme='light'] .scene__photo {
  background-image: linear-gradient(150deg, #ece5dd 0%, #dfe3e8 60%, #eceaea 100%);
}

:root[data-theme='light'] .scene__photo::after {
  background: linear-gradient(to top, rgb(0 0 0 / 0.18), transparent 55%);
}

@keyframes ken-burns {
  from {
    transform: scale(1.06) translate3d(-1.2%, 0.6%, 0);
  }
  to {
    transform: scale(1.16) translate3d(1.4%, -1.4%, 0);
  }
}

/* Legibility scrim. Two stops, so the top of the frame stays open. */
.hero__scrim {
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    linear-gradient(to top, var(--bg) 2%, color-mix(in srgb, var(--bg) 55%, transparent) 34%, transparent 72%),
    linear-gradient(to bottom, color-mix(in srgb, var(--bg) 70%, transparent) 0%, transparent 30%);
}

.hero__body {
  padding-block: clamp(6rem, 16vh, 9rem) clamp(3.5rem, 9vh, 6rem);
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
  width: 100%;
}

.hero .display {
  margin-top: 1.1rem;
  max-width: 20ch;
}

.hero .lede {
  margin-top: 1.4rem;
  max-width: 44ch;
}

.hero .btn-row {
  margin-top: 2.2rem;
}

.hero__meta {
  margin-top: 2.6rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 1.4rem;
}

/* The camera-data line, used as a signature flourish. */
.exif-line {
  font: 400 0.68rem/1.7 var(--font-mono);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.scroll-cue {
  position: absolute;
  left: 50%;
  bottom: 1.4rem;
  translate: -50% 0;
  width: 1px;
  height: 46px;
  background: linear-gradient(to bottom, transparent, var(--primary));
  transform-origin: top;
  animation: cue 2.4s var(--ease) infinite;
}

@keyframes cue {
  0%,
  100% {
    transform: scaleY(0.3);
    opacity: 0.25;
  }
  50% {
    transform: scaleY(1);
    opacity: 1;
  }
}

/* ------------------------------------------------------------- reveals */

/*
 * The one reveal primitive.
 *
 * Elements start shifted and transparent; JS adds .in when they cross
 * into view and then stops watching them. `--d` staggers siblings
 * without a class per position.
 */
.rise {
  opacity: 0;
  transform: translate3d(0, 26px, 0);
  transition:
    opacity var(--enter) var(--ease),
    transform var(--enter) var(--ease);
  transition-delay: var(--d, 0ms);
}

.rise.in {
  opacity: 1;
  transform: none;
}

.rise--left {
  transform: translate3d(-26px, 0, 0);
}

.rise--scale {
  transform: scale(0.94);
}

/* A word-by-word headline reveal. Spans are wrapped by JS. */
.word {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, 0.5em, 0);
  transition:
    opacity 620ms var(--ease),
    transform 620ms var(--ease);
}

.in > .word,
.in .word {
  opacity: 1;
  transform: none;
}

/* =================================================== interest + ideas */

.interest-band {
  margin-top: clamp(3rem, 8vh, 5rem);
  padding-block: clamp(3.5rem, 9vh, 6rem);
  text-align: center;
  background:
    radial-gradient(70% 120% at 50% 0%, color-mix(in srgb, var(--primary) 9%, transparent), transparent 65%),
    var(--bg);
  border-block: 1px solid var(--border);
}

.interest__tally {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.1rem;
  margin-bottom: 1.6rem;
}

.interest__num {
  font: 700 clamp(2.6rem, 8vw, 4.6rem) / 1 var(--font-mono);
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--primary-text);
}

/*
 * The placeholder, before a real figure arrives. [data-loaded] is set by
 * the fetch callback.
 *
 * Not just a dimmed version of the live number: an em-dash set at 4.6rem
 * and 50% opacity renders as a stray horizontal rule floating above the
 * label, which looks like a layout fault rather than a pending value. It
 * drops to roughly a third of the size and gains tracking so it reads as
 * a deliberate "nothing here yet".
 */
.interest:not([data-loaded]) .interest__num {
  font-size: clamp(1.4rem, 3vw, 1.9rem);
  letter-spacing: 0.3em;
  color: var(--text-dim);
  opacity: 0.55;
  /* Keeps the block from collapsing and shifting the label upward. */
  line-height: 1.9;
}

.interest__label {
  font: 600 0.68rem/1 var(--font-mono);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.interest-cta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.85rem;
  justify-content: center;
  align-items: stretch;
}

.interest-cta--tight {
  margin-top: 2rem;
}

/*
 * The big one.
 *
 * It is the largest control on the page because it is the only thing
 * being asked of a visitor who is not ready to sign up for anything —
 * one tap, no form, no account. Everything about it should say that it
 * costs nothing.
 */
.btn-big {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  padding: 1.25rem 2.4rem;
  border: 0;
  border-radius: 999px;
  background: var(--primary);
  color: var(--on-primary);
  font: 700 clamp(0.8rem, 1.6vw, 0.95rem) / 1 var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  box-shadow: 0 14px 40px -12px color-mix(in srgb, var(--primary) 60%, transparent);
  transition:
    transform var(--tap) var(--ease),
    box-shadow var(--move) var(--ease),
    opacity var(--tap) var(--ease),
    background-color var(--move) var(--ease),
    color var(--move) var(--ease);
}

.btn-big svg {
  width: 1.2em;
  height: 1.2em;
  flex: none;
}

.btn-big:hover {
  transform: translateY(-2px);
  box-shadow: 0 20px 50px -12px color-mix(in srgb, var(--primary) 70%, transparent);
}

.btn-big:active {
  transform: scale(0.975);
}

.btn-big--sm {
  padding: 1rem 1.9rem;
  font-size: 0.8rem;
}

.btn-big[data-busy='true'] {
  opacity: 0.65;
  cursor: progress;
}

/*
 * Waiting on a backend.
 *
 * Outlined rather than solid orange, so it does not sit on the page looking
 * like the primary action and then decline to be one. Still full size —
 * it holds its place in the layout, and it is obvious what it will be.
 */
.btn-big[data-pending='true'] {
  background: transparent;
  color: var(--text-dim);
  border: 1px dashed var(--border);
  box-shadow: none;
  cursor: default;
  transform: none;
}

.btn-big[data-pending='true']:hover {
  transform: none;
  box-shadow: none;
}

/*
 * Already counted.
 *
 * Deliberately not just a disabled grey button — it becomes a quiet
 * confirmation with a filled heart, so the state reads as "done" rather
 * than "unavailable".
 */
.btn-big[data-voted='true'] {
  background: transparent;
  color: var(--primary-text);
  border: 1px solid color-mix(in srgb, var(--primary) 55%, transparent);
  box-shadow: none;
  cursor: default;
  transform: none;
}

.btn-big[data-voted='true'] svg {
  fill: currentColor;
}

/* Matches the big button's height so the pair sit level. */
.btn--tall {
  padding-block: 1.25rem;
}

.interest-cta--tight .btn--tall {
  padding-block: 1rem;
}

.interest__note {
  margin-top: 1.25rem;
  color: var(--text-dim);
}

.interest--inline {
  margin-top: 1.4rem;
}

.interest__num--sm {
  font-size: 1rem;
  letter-spacing: 0;
  color: var(--primary-text);
}

/* --------------------------------------------------------- the dialog */

.sheet {
  padding: 0;
  border: 0;
  background: transparent;
  max-width: min(560px, calc(100vw - 2rem));
  width: 100%;
}

.sheet::backdrop {
  background: rgb(0 0 0 / 0.72);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

.sheet__inner {
  border: 1px solid var(--border);
  border-radius: 20px;
  background: var(--card);
  padding: clamp(1.4rem, 4vw, 2rem);
  color: var(--text);
  max-height: 88svh;
  overflow-y: auto;
}

.sheet[open] .sheet__inner {
  animation: sheet-in var(--move) var(--ease) both;
}

@keyframes sheet-in {
  from {
    opacity: 0;
    transform: translate3d(0, 14px, 0) scale(0.985);
  }
}

.sheet__head {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 1rem;
}

.sheet__blurb {
  margin-top: 0.9rem;
  color: var(--text-dim);
  font-size: 0.95rem;
}

.field {
  display: block;
  margin-top: 1.25rem;
}

.field__label {
  display: block;
  margin-bottom: 0.45rem;
  font: 600 0.64rem/1.4 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.field textarea,
.field input {
  width: 100%;
  border-radius: 0.7rem;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  padding: 0.8rem 0.9rem;
  /* 16px exactly. Anything smaller and iOS Safari zooms the whole page on
     focus and never zooms back out — the app documents the same rule. */
  font-size: 16px;
  font-family: inherit;
  line-height: 1.5;
  outline: none;
  transition: border-color var(--move) var(--ease);
  -webkit-appearance: none;
  appearance: none;
}

.field textarea {
  resize: vertical;
  min-height: 120px;
}

.field textarea:focus,
.field input:focus {
  border-color: var(--primary);
}

.field__hint {
  display: block;
  margin-top: 0.4rem;
  text-align: right;
  font: 400 0.64rem/1 var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}

.sheet__status {
  min-height: 1.4rem;
  margin-top: 1rem;
  font-size: 0.9rem;
  color: var(--primary-text);
}

.sheet__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  justify-content: flex-end;
  margin-top: 0.6rem;
}

/* A link that has no destination yet renders as this instead. */
.is-soon {
  opacity: 0.55;
  cursor: default;
  pointer-events: none;
}

/* --------------------------------------------------------- manifesto */

.manifesto {
  text-align: center;
}

.manifesto .h2 {
  max-width: 24ch;
  margin-inline: auto;
}

.manifesto .lede {
  margin: 1.6rem auto 0;
  max-width: 46ch;
}

/* The struck-through word — video, which this app does not do. */
.struck {
  position: relative;
  color: var(--text-dim);
  white-space: nowrap;
}

.struck::after {
  content: '';
  position: absolute;
  left: -0.04em;
  right: -0.04em;
  top: 55%;
  height: 0.07em;
  background: var(--primary);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 720ms var(--ease) 320ms;
}

.in .struck::after {
  transform: scaleX(1);
}

/*
 * The horizontal-video note.
 *
 * It sits directly under a struck-through "short video", qualifying it,
 * so it has to read as a different KIND of statement than the paragraph
 * above — otherwise it is one more line of manifesto and the exception
 * gets lost in the argument. A bordered card with a tag does that
 * without shouting.
 *
 * Left-aligned inside a centred section, deliberately: centred prose at
 * this length is harder to read, and the block is centred as a whole so
 * the section's symmetry survives.
 */
/*
 * Tag ABOVE the copy, not beside it.
 *
 * Side by side, the tag took a fixed 110px out of a card the section's
 * rhythm caps at 46ch — the sentence wrapped at about 33 characters into
 * six short lines with a column of empty space under the tag beside them.
 * Stacked, the copy gets the full width and the tag reads as an eyebrow,
 * which is the same pattern every other block on this page uses.
 */
.soon-note {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.75rem;
  max-width: 46ch;
  margin: 2.2rem auto 0;
  padding: 1.2rem 1.4rem;
  border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border));
  border-radius: 16px;
  background: color-mix(in srgb, var(--primary) 6%, var(--card));
  text-align: left;
}

.soon-note__tag {
  flex: none;
  padding: 0.34rem 0.62rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--primary) 18%, transparent);
  color: var(--primary-text);
  font: 700 0.58rem/1 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  white-space: nowrap;
}

.soon-note__body {
  color: var(--text-dim);
  font-size: 0.95rem;
  line-height: 1.6;
}

.soon-note__body strong {
  color: var(--text);
}

/*
 * The claim, on its own line above the explanation.
 *
 * It was a run-in lead — bold text sharing the first line with the copy
 * that qualifies it — which reads as emphasis rather than as a statement.
 * This is the sentence somebody is meant to leave with, so it gets the
 * size and the line to itself and the paragraph underneath becomes the
 * footnote it actually is.
 */
.soon-note__lead {
  display: block;
  margin-bottom: 0.5rem;
  font-size: 1.12rem;
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--text);
}

/* ------------------------------------------------------- sticky scenes */

/*
 * THE IPHONE-AD EFFECT.
 *
 * A tall outer section holds a sticky viewport-height stage. As the outer
 * section scrolls past, JS writes a 0→1 progress value onto it as `--p`,
 * and the layers inside read that value. Because the stage is `sticky`
 * rather than `fixed`, it never fights the page's own scrolling and it
 * needs no scroll-jacking — the wheel behaves exactly as the reader
 * expects, which is the difference between this and the versions of this
 * effect that feel broken.
 */
/*
 * The two photograph sections. Ordinary sections — NOT sticky.
 *
 * These used to pin: `.scene` was 140svh (200 before that, 260 before
 * that) with a sticky 100svh stage, so the stage held still while the
 * page scrolled underneath it. Every version of that number was a
 * compromise between letting the reveal play out and not stranding the
 * reader, and every version had the same problem — pinned means you turn
 * the wheel and nothing on screen moves. It was reported as "lag every
 * time you hit a section, you can't scroll when the mouse is over a
 * photo". The page scrolled perfectly the whole time; it just had nothing
 * to show for it, and the photograph was where the eye was, so the
 * photograph got the blame.
 *
 * Removed at the owner's request rather than tuned again. Phones already
 * rendered these as plain sections — that override is now simply the
 * behaviour everywhere, which is why the max-width:899px block below no
 * longer needs to say anything about scenes.
 *
 * The scroll-linked machinery went with it: no --p custom property, no
 * scroll listener, no requestAnimationFrame loop, no IntersectionObserver
 * for parallax. The two layers that used to fade in against scroll
 * position (.exif-card, .restop-flag) now carry the ordinary `rise` class
 * that every other block on this page uses.
 */
.scene {
  padding-block: clamp(5rem, 14vh, 10rem);
}

.scene__stage {
  display: grid;
  place-items: center;
}

.scene__grid {
  display: grid;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: center;
  width: 100%;
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

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

  .scene__grid--flip .scene__copy {
    order: 2;
  }
}

/* The photograph. It no longer drifts — see the note on .scene. */
.scene__photo {
  position: relative;
  aspect-ratio: 4 / 5;
  border-radius: 18px;
  overflow: hidden;
  /*
   * The gradient stays HERE, on the frame, with the photograph as an <img>
   * on top of it.
   *
   * Two reasons. The photographs are not shot yet, so this is what the
   * slot shows in the meantime and it has to look deliberate rather than
   * broken. And when they do exist, a JPEG that fails to load — a bad
   * deploy, a slow connection given up on — falls back to this rather than
   * to a browser's broken-file icon.
   */
  background-image: linear-gradient(150deg, #2b2118 0%, #12161b 60%, #0d0d0d 100%);
  background-size: cover;
  background-position: center;
  border: 1px solid var(--border);
  /* clamp keeps the drift from ever pulling the image off its own frame */
}

/*
 * The photograph itself, filling its frame.
 *
 * width/height are on the element so the browser reserves the right box
 * before the file arrives — without them the caption below jumps down the
 * moment it loads, which is the single most irritating thing a page can
 * do while somebody is reading it.
 *
 * [hidden] is set by site.js when an image fails to load, revealing the
 * gradient on the frame behind it.
 */
.scene__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.scene__img[hidden] {
  display: none;
}

.scene__photo::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgb(0 0 0 / 0.55), transparent 55%);
}

/*
 * The EXIF strip, revealed under the photograph as the scene plays.
 *
 * This mirrors what the app itself does — the camera data sits directly
 * under the frame, not at the bottom of the card — so the site is
 * demonstrating the product rather than describing it.
 */
.exif-card {
  position: relative;
  z-index: 1;
  margin-top: -1px;
  padding: 1rem 1.15rem;
  border: 1px solid var(--border);
  border-top: 0;
  border-radius: 0 0 18px 18px;
  background: var(--card);
  /* Entrance comes from the `rise` class in the markup now, not from
   * scroll position. See the note on .scene above. */
}

.exif-card__row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
}

.exif-card__row span {
  font: 400 0.66rem/1.7 var(--font-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.exif-card__row b {
  color: var(--text);
  font-weight: 500;
}

/* The credit chip that travels with a photograph. */
.credit-chip {
  position: absolute;
  left: 0.9rem;
  top: 0.9rem;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.45rem 0.8rem;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.55);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  /* White on a dark scrim over a photograph is correct in both themes —
     the app makes the same call and documents it. Not themed. */
  color: #fff;
  font: 500 0.64rem/1 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.credit-chip svg {
  width: 13px;
  height: 13px;
  opacity: 0.8;
}

/* Restop banner that slides in on the credit scene. */
.restop-flag {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
  /* Entrance comes from `rise` in the markup, as above. */
}

.restop-flag svg {
  width: 15px;
  height: 15px;
  color: var(--primary);
}

/* --------------------------------------------------------- feature grid */

.grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
}

.card {
  padding: 1.6rem;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--card);
  transition: border-color var(--move) var(--ease), transform var(--move) var(--ease);
}

.card:hover {
  border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
  transform: translateY(-3px);
}

.card__icon {
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  border-radius: 10px;
  background: color-mix(in srgb, var(--primary) 14%, transparent);
  color: var(--primary-text);
  margin-bottom: 1rem;
}

.card__icon svg {
  width: 18px;
  height: 18px;
}

.card p {
  margin-top: 0.55rem;
  color: var(--text-dim);
  font-size: 0.95rem;
}

/*
 * The album strip lived here — six frames each drifting at its own rate,
 * the last thing on the page reading the --p custom property. The section
 * that held it was cut when the page came down from twelve sections to
 * six, and nothing has referenced .albums since. Removed along with the
 * parallax system that drove it.
 */

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

/*
 * The app, drawn in HTML and CSS rather than screenshotted.
 *
 * A screenshot goes stale the day the UI changes and costs a 400KB
 * request; this is a few hundred bytes of markup, stays sharp at any
 * density, and animates.
 */
.phone {
  width: min(300px, 78vw);
  margin-inline: auto;
  border-radius: 42px;
  border: 1px solid var(--border);
  background: var(--bg);
  padding: 10px;
  box-shadow: 0 40px 90px -30px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(255 255 255 / 0.03) inset;
}

.phone__screen {
  border-radius: 34px;
  overflow: hidden;
  background: var(--bg);
  border: 1px solid var(--border);
}

.phone__bar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.7rem 0.85rem;
  border-bottom: 1px solid var(--border);
}

.phone__bar span {
  font: 600 0.58rem/1 var(--font-mono);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.phone__notch {
  width: 44px;
  height: 4px;
  border-radius: 999px;
  background: var(--border);
  margin: 0 auto 0.55rem;
}

.phone__photo {
  aspect-ratio: 4 / 3;
  background-image: var(--img-exif), linear-gradient(150deg, #2c2318, #101317);
  background-size: cover;
  background-position: center;
}

.phone__exif {
  padding: 0.6rem 0.8rem;
  border-bottom: 1px solid var(--border);
  font: 400 0.55rem/1.7 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.phone__actions {
  display: flex;
  gap: 1.1rem;
  padding: 0.65rem 0.8rem 0.9rem;
  color: var(--text-dim);
}

.phone__actions svg {
  width: 17px;
  height: 17px;
}

.phone__actions em {
  font: 500 0.6rem/1 var(--font-mono);
  font-style: normal;
  align-self: center;
}

.phone__act {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}

.phone__act--live {
  color: var(--primary-text);
}

/* -------------------------------------------------------------- genres */

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 1.6rem;
}

.chip {
  padding: 0.45rem 0.85rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  font-size: 0.82rem;
  color: var(--text-dim);
  transition: border-color var(--tap) var(--ease), color var(--tap) var(--ease);
}

.chip:hover {
  border-color: var(--primary);
  color: var(--primary-text);
}

/* ------------------------------------------------------------- shelves */

/*
 * The five browse categories.
 *
 * `auto-fit` with a floor rather than a fixed five across: five tiles is
 * an awkward number that leaves a widow on almost every breakpoint if the
 * count is hard-coded. Left to reflow it lands 5 / 3 / 2 / 1 as the window
 * narrows, and the row is centred so a short final line sits under the
 * middle of the ones above rather than hanging off the left edge.
 */
.shelf-grid {
  display: grid;
  gap: 0.9rem;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  margin-top: 2.6rem;
}

.shelf {
  padding: 1.4rem 1.3rem;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--card);
  transition:
    border-color var(--tap) var(--ease),
    background-color var(--tap) var(--ease);
}

.shelf:hover {
  border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
  background: color-mix(in srgb, var(--primary) 5%, var(--card));
}

.shelf img {
  display: block;
  width: 34px;
  height: 34px;
  margin-bottom: 0.9rem;
}

.shelf b {
  display: block;
  font-size: 1rem;
  color: var(--text);
}

.shelf p {
  margin-top: 0.4rem;
  font-size: 0.88rem;
  line-height: 1.55;
  color: var(--text-dim);
}

/* -------------------------------------------------------------- ethics */

.ethics {
  background: var(--card);
  border-block: 1px solid var(--border);
}

.ledger {
  display: grid;
  gap: 0;
  margin-top: 2.4rem;
  border: 1px solid var(--border);
  border-radius: 16px;
  overflow: hidden;
  background: var(--bg);
}

.ledger__row {
  display: grid;
  grid-template-columns: 26px 1fr;
  gap: 0.9rem;
  align-items: start;
  padding: 1.1rem 1.25rem;
  border-bottom: 1px solid var(--border);
}

.ledger__row:last-child {
  border-bottom: 0;
}

.ledger__row svg {
  width: 18px;
  height: 18px;
  margin-top: 0.15rem;
  color: var(--primary-text);
}

.ledger__row b {
  font-weight: 600;
}

.ledger__row p {
  color: var(--text-dim);
  font-size: 0.93rem;
  margin-top: 0.2rem;
}

/* ------------------------------------------------------------- roadmap */

.roadmap {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  margin-top: 2.6rem;
}

.roadmap__item {
  padding: 1.5rem;
  border: 1px dashed var(--border);
  border-radius: 16px;
}

.roadmap__tag {
  display: inline-block;
  padding: 0.28rem 0.7rem;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--primary) 50%, transparent);
  color: var(--primary-text);
  font: 600 0.58rem/1 var(--font-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  margin-bottom: 0.9rem;
}

.roadmap__item p {
  color: var(--text-dim);
  font-size: 0.93rem;
  margin-top: 0.5rem;
}

/* ----------------------------------------------------------- community */

.community {
  text-align: center;
}

/*
 * One uniform grid. Every destination is the same tile.
 *
 * Discord used to be a large feature card sitting beside these, which made
 * the two most important links on the page two different sizes and two
 * different shapes. `auto-fit` with a minimum keeps the tiles identical at
 * every width and reflows the count instead of resizing them — so nine links
 * and three links both look deliberate.
 */
.socials {
  display: grid;
  gap: 0.7rem;
  /*
   * A capped range, not 1fr.
   *
   * With `1fr` the tiles stretch to fill the row, so two filled-in links
   * became two 384px slabs and nine became small squares — the size changed
   * with the count, which is the opposite of consistent. Bounded between
   * 150 and 190px they stay the same tile whether there are two or nine,
   * and the row centres whatever is left.
   */
  grid-template-columns: repeat(auto-fit, minmax(150px, 190px));
  justify-content: center;
  max-width: 800px;
  margin-inline: auto;
}

.social {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  /* Fixed height, not padding-derived: a one-word label and a two-word one
     must not produce tiles of different heights. */
  min-height: 58px;
  padding-inline: 0.9rem;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--card);
  font: 600 0.68rem/1 var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
  transition:
    color var(--tap) var(--ease),
    border-color var(--tap) var(--ease),
    background-color var(--tap) var(--ease),
    transform var(--tap) var(--ease);
}

.social:hover {
  color: var(--text);
  border-color: var(--brand, var(--primary));
  background: color-mix(in srgb, var(--brand, var(--primary)) 10%, var(--card));
  transform: translateY(-2px);
}

.social svg {
  width: 19px;
  height: 19px;
  flex: none;
  color: var(--brand, var(--primary-text));
}

/* A tile with no destination yet. Same size, so the grid does not reflow
   the day the URL is added — see .is-soon for the shared treatment. */
.social.is-soon {
  border-style: dashed;
}

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

.cta {
  text-align: center;
  overflow: clip;
  isolation: isolate;
}

.cta__glow {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: radial-gradient(60% 60% at 50% 108%, color-mix(in srgb, var(--primary) 22%, transparent), transparent 70%);
}

.cta .btn-row {
  justify-content: center;
  margin-top: 2.2rem;
}

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


/* =====================================================================
   THE FEATURES PANEL
   =====================================================================

   One grid, replacing five alternating photo-and-prose sections. Those
   needed three photographs and only one of them has ever existed —
   scene-exif.jpg and scene-credit.jpg were live 404s on the deployed
   site for as long as the sections referencing them were on it.

   auto-fit with a 17rem floor rather than a fixed column count, so the
   grid reflows from three columns to two to one without a breakpoint of
   its own. minmax(0, 1fr) rather than a bare fraction: a grid track's
   default minimum is auto, which refuses to shrink below its longest
   word and pushes the row wider than its container.
   ===================================================================== */
.panel__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 1px;
  margin-top: 3rem;
  /*
   * The 1px gap plus a border on every card would double up into 2px
   * seams between neighbours. The grid's own background shows through
   * the gap instead, so every seam is one hairline whatever the column
   * count resolves to.
   */
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  list-style: none;
  padding: 0;
}

.panel__card {
  background: var(--card);
  padding: 1.9rem 1.6rem;
}

.panel__card svg {
  width: 22px;
  height: 22px;
  color: var(--primary-text);
}

.panel__card h3 {
  margin-top: 1rem;
  font-size: 1.02rem;
  font-weight: 650;
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.panel__card p {
  margin-top: 0.6rem;
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.65;
}

/*
 * The honest strip.
 *
 * Everything named in it is written and switched off. It is a distinct
 * block rather than three more cards precisely so it cannot be read as
 * part of the grid above — the previous copy described tips, the vault
 * and a membership in the present tense, and one of those has never
 * taken a payment.
 */
.panel__soon {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.9rem;
  margin-top: 2rem;
  padding: 1.4rem 1.6rem;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
}

.panel__soon-tag {
  flex: none;
  font: 600 0.66rem/1 var(--font-mono);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--primary-text);
}

.panel__soon-body {
  flex: 1 1 22rem;
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.7;
}

.panel__soon-body strong {
  color: var(--text);
  font-weight: 600;
}

/* =====================================================================
   THE ORIGIN SECTION — why 406
   ===================================================================== */
/*
 * A CENTRED COLUMN WITH A READABLE MEASURE.
 *
 * `.lede` is 34ch by default — right for a line sitting beside a
 * photograph, and much too narrow for the only prose block left on the
 * page. At 34ch these two paragraphs ran as a tall thin ribbon with the
 * heading twice its width above it and dead space either side.
 *
 * 58ch is wider than .lede--wide because this is body copy somebody
 * actually reads through rather than a standfirst they scan.
 */
.origin__wrap {
  max-width: 52rem;
}

.origin__body {
  margin-top: 1.6rem;
}

.origin__body .lede {
  margin-top: 1.1rem;
  max-width: 58ch;
}

.origin__sig {
  margin-top: 2rem;
  font: 600 0.76rem/1.6 var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.origin__sig span {
  color: var(--primary-text);
}

/* =====================================================================
   INTERIOR PAGES  (contact, and anything else that is not the landing)
   =====================================================================

   The bar is fixed, so every page that is not the hero has to reserve
   its height. `--nav-h` is the single source for that number; see the
   note beside it.
   ===================================================================== */
.page {
  padding-block: calc(var(--nav-h) + 3rem) 5rem;
}

/*
 * THE MEASURE GOES ON THE CHILDREN, NOT THE WRAP.
 *
 * This was `max-width: 44rem` on the element itself, which also carries
 * `.wrap` — and `.wrap` sets `margin-inline: auto`. The result was a
 * 44rem block centred in the viewport while the form and the note cards
 * below it stayed flush against the gutter: 288px of indent on the
 * heading and none on anything else. Measured before this comment
 * existed.
 *
 * Constraining the children keeps the wrap full width, so every left
 * edge on the page lines up, and still holds the prose to a readable
 * measure.
 */
.page__head > * {
  max-width: 44rem;
}

.h1 {
  margin-top: 1rem;
  font-size: clamp(2.1rem, 5.5vw, 3.1rem);
  line-height: 1.06;
  letter-spacing: -0.03em;
  font-weight: 700;
}

.page__head .lede {
  margin-top: 1.2rem;
}

/* =====================================================================
   FORMS
   =====================================================================

   `.formfield`, NOT `.field`. The suggestion dialog further up this file
   already owns `.field`, `.field__label` and `.field__hint`, and its
   hint is a right-aligned monospace character counter — which is right
   for a counter and wrong for a sentence of help text under an input.

   Reusing the name put the contact form's hints in mono, flush right.
   They are separate components that happen to both contain fields, so
   they get separate names rather than one of them quietly inheriting the
   other's decisions.
   ===================================================================== */
.form {
  margin-top: 2.6rem;
  max-width: 38rem;
  display: grid;
  gap: 1.4rem;
}

.formfield {
  display: grid;
  gap: 0.5rem;
}

.formfield__label {
  font: 600 0.68rem/1 var(--font-mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.formfield__control {
  width: 100%;
  padding: 0.85rem 0.95rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-size: 0.95rem;
  transition: border-color var(--tap) var(--ease);
}

.formfield__control:focus-visible {
  outline: none;
  border-color: var(--primary);
}

textarea.formfield__control {
  resize: vertical;
  min-height: 9rem;
  line-height: 1.6;
}

/*
 * The select's own arrow is left in place deliberately. Replacing it
 * means appearance:none plus a background image, and the replacement
 * does not follow the platform on a phone — where this control is a
 * native picker sheet and should look like one.
 */
select.formfield__control {
  cursor: pointer;
}

.formfield__hint {
  color: var(--text-dim);
  font-size: 0.82rem;
  line-height: 1.55;
}

.formfield__hint[hidden] {
  display: none;
}

/*
 * The status line. It is a live region, so it must stay in the layout
 * whether or not it has anything to say — a display:none live region
 * announces nothing when it fills.
 */
.form__status {
  min-height: 1.4rem;
  color: var(--primary-text);
  font-size: 0.9rem;
  line-height: 1.5;
}

.form__status:empty {
  min-height: 0;
}

.form__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
}

.form__fallback {
  margin-top: 2.6rem;
  padding: 1.4rem 1.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--text-dim);
  line-height: 1.7;
}

.form__fallback a {
  color: var(--primary-text);
}

/* ------------------------------------------------------- note cards */
.notecards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
  gap: 1px;
  margin-top: 4rem;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.notecard {
  background: var(--card);
  padding: 1.6rem;
}

.notecard h2 {
  font-size: 0.95rem;
  font-weight: 650;
  letter-spacing: -0.01em;
}

.notecard p {
  margin-top: 0.6rem;
  color: var(--text-dim);
  font-size: 0.88rem;
  line-height: 1.65;
}

.notecard a {
  color: var(--primary-text);
}

.notecard a:hover {
  text-decoration: underline;
}

.footer {
  border-top: 1px solid var(--border);
  padding-block: 3.5rem 2.5rem;
}

/*
 * A SHAPE, NOT FOUR EQUAL SLABS.
 *
 * This was `repeat(auto-fit, minmax(min(100%, 190px), 1fr))`, which gave
 * the brand block — a logo, a sentence and a row of icons — exactly the
 * same width as a list of four words, and let the whole thing reflow to a
 * different column count at sizes nobody chose.
 *
 * The brand column now takes a defined share and the three link columns
 * split what is left, so the footer has a deliberate rhythm at every
 * width. `minmax(0, …)` on the link columns rather than a bare fraction:
 * a grid track's default minimum is `auto`, which refuses to shrink below
 * its longest word and pushes the row wider than its container.
 */
/*
 * TWO BLOCKS, NOT FOUR COLUMNS.
 *
 * The old grid was `minmax(0, 1.6fr) repeat(3, minmax(0, 1fr))` over a
 * brand block and three link lists — an "app" column of three
 * coming-soon rows, an "Explore" column of in-page anchors, and a
 * seven-row "Legal & contact". The anchors went with the sections they
 * pointed at, and the store rows said a third time what the navigation
 * and the closing section already say.
 *
 * What is left is where else this exists, and the small print. The
 * brand block takes the larger share; the two short lists split what is
 * left of it and sit under the brand at phone width.
 */
.footer__inner {
  display: grid;
  gap: 2.4rem 3rem;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
  align-items: start;
}

.footer__links {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 2rem;
}

@media (max-width: 760px) {
  .footer__inner {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 420px) {
  .footer__links {
    grid-template-columns: minmax(0, 1fr);
  }
}

.footer__blurb {
  color: var(--text-dim);
  font-size: 0.9rem;
  line-height: 1.6;
  margin-top: 0.9rem;
  max-width: 34ch;
}

/*
 * The social row.
 *
 * Icon-only tiles rather than a list of names, because the number of
 * configured profiles changes and a COLUMN of one link reads as a fault
 * while a ROW of one icon reads as a fact. `flex-wrap` so nine of them
 * wrap into two tidy lines instead of forcing the column wider.
 */
.footer__socials {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 1.5rem;
}

.footer__social {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--card);
  color: var(--text-dim);
  transition:
    color var(--tap) var(--ease),
    border-color var(--tap) var(--ease),
    background-color var(--tap) var(--ease),
    transform var(--tap) var(--ease);
}

.footer__social svg {
  width: 17px;
  height: 17px;
}

.footer__social:hover {
  color: var(--brand, var(--primary-text));
  border-color: var(--brand, var(--primary));
  background: color-mix(in srgb, var(--brand, var(--primary)) 12%, var(--card));
  transform: translateY(-2px);
}

/*
 * "Built by" — the photographer's own addresses.
 *
 * Deliberately quieter than the link columns: this is provenance, not
 * navigation. The label sits on its own line above the links so the two
 * addresses read as a pair rather than as a sentence broken by a comma.
 */
.footer__by {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
  margin-top: 1.5rem;
}

.footer__by-label {
  flex-basis: 100%;
  font: 600 0.62rem/1 var(--font-mono);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.footer__by a {
  font-size: 0.88rem;
  color: var(--text-dim);
}

.footer__by a:hover {
  color: var(--primary-text);
}

/*
 * A footer row waiting on a URL.
 *
 * .is-soon on its own only sets opacity and pointer-events — it was
 * written for the buttons and social tiles, which bring their own type.
 * site.js copies the anchor's class onto the span that replaces it, and
 * these anchors have no class, so without this rule an unreleased store
 * row rendered at the body's font size beside its 0.93rem neighbours.
 */
.footer li .is-soon {
  display: inline-block;
  font-size: 0.93rem;
  color: var(--text-dim);
}

.footer h4 {
  margin: 0 0 0.9rem;
  font: 600 0.62rem/1 var(--font-mono);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--text-dim);
}

.footer ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.55rem;
}

.footer a {
  color: var(--text);
  font-size: 0.93rem;
  transition: color var(--tap) var(--ease);
}

.footer a:hover {
  color: var(--primary-text);
}

/*
 * A button that has to sit in a list of links without looking like a
 * stranger.
 *
 * "Send a suggestion" opens a dialog rather than going anywhere, so it is
 * a <button> — using an <a href="#"> for it would put a fake destination
 * in the status bar and break middle-click and "open in new tab" in ways
 * that are worse than the styling problem this solves.
 *
 * Every declaration below is undoing a browser default that a <button>
 * brings and an <a> does not: the border, the background, the padding, the
 * centred alignment, and the font, which does NOT inherit on form controls.
 */
.footer__linkbtn {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  color: var(--text);
  font-family: inherit;
  font-size: 0.93rem;
  line-height: inherit;
  text-align: left;
  cursor: pointer;
  transition: color var(--tap) var(--ease);
}

.footer__linkbtn:hover {
  color: var(--primary-text);
}

/*
 * The legal note in the base row.
 *
 * It used .exif-line — uppercase mono with wide tracking, which is the
 * treatment this site reserves for camera settings. Sitting beside the
 * plain-text copyright it read as a banner competing for attention rather
 * than as the quiet note it is. Same size and colour as its neighbour now.
 */
.footer__note {
  color: var(--text-dim);
  font-size: 0.82rem;
}

.footer__base {
  margin-top: 3rem;
  padding-top: 1.6rem;
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  gap: 0.8rem 1.5rem;
  align-items: center;
  justify-content: space-between;
}

.footer__base p {
  color: var(--text-dim);
  font-size: 0.85rem;
}

/* ================================================================
   REDUCED MOTION

   Everything above is decoration. Somebody who has asked their
   operating system for less movement gets the finished state of every
   animation immediately — nothing drifts, nothing pans, nothing waits
   to be scrolled into existence. The site must be complete without
   any of it, and this block is the proof that it is.
   ================================================================ */

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

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

  .rise,
  .word {
    opacity: 1 !important;
    transform: none !important;
  }

  .struck::after {
    transform: scaleX(1) !important;
  }

  .hero__media {
    animation: none !important;
    transform: scale(1.06) !important;
  }

  .scene {
    height: auto !important;
  }

  .scene__stage {
    position: static !important;
    height: auto !important;
    padding-block: clamp(4rem, 10vh, 7rem);
  }

  .scene__photo,
  .albums__frame {
    transform: none !important;
    will-change: auto !important;
  }

  .exif-card,
  .restop-flag {
    opacity: 1 !important;
    transform: none !important;
  }

  .scroll-cue {
    display: none;
  }
}

/*
 * Narrow screens get the scenes as ordinary stacked sections too.
 *
 * A 260svh sticky stage on a phone means three screens of scrolling for
 * one idea, and the parallax it buys is invisible at that width.
 */
@media (max-width: 899px) {
  /* The scene overrides that used to live here are gone: the scenes are
     plain sections at every width now, so there is nothing to undo. */
  .scene__photo {
    aspect-ratio: 3 / 4;
  }

  /*
   * Blur-behind is the most expensive thing on this page on a phone.
   *
   * `backdrop-filter` makes the compositor resample everything underneath
   * the bar every time anything moves — and this bar is fixed, so that is
   * every frame of every scroll. On iOS in particular it is the difference
   * between a scroll that tracks the finger and one that does not.
   *
   * The bar exists to keep the navigation legible over the page. A more
   * opaque background does that on its own; the blur was a refinement that
   * costs more than it returns at this size.
   */
  .nav[data-stuck='true'] {
    background: color-mix(in srgb, var(--bg) 94%, transparent);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /*
   * No Ken Burns on a phone.
   *
   * A 28-second infinite transform on a full-bleed background keeps a
   * viewport-sized composited layer alive for the entire visit — memory
   * and battery spent on a drift most people never consciously see, on
   * the devices least able to spare either. The photograph is still the
   * first thing on screen; it just holds still.
   */
  .hero__media {
    animation: none;
    will-change: auto;
    transform: scale(1.06);
  }
}
