/* =====================================================================
   QUIET LIGHT — Minimalist editorial / gallery calm
   Flora & Hanny · 5 December 2026
   ===================================================================== */

/* ------------------------------------------------------------------ *
   0. Self-hosted fonts
   woff2 served from /fonts via the Worker's ASSETS binding — no third-party
   CDN, so no UA-sniffing / blocking / cache inconsistency (Google serves
   Edge-on-Android a slower static .ttf; self-hosting gives every browser the
   same woff2). These are VARIABLE fonts: one file per subset spans the whole
   weight range, so a single @font-face with a font-weight RANGE covers every
   weight in use (Fraunces 300-500, Schibsted 400-500). font-display: block
   holds text invisible briefly so the real font paints first. unicode-ranges
   match Google's subsets, so latin-ext is fetched only when an accented glyph
   (José, Müller, …) actually appears.
 * ------------------------------------------------------------------ */
@font-face {
  font-family: "Fraunces";
  font-style: normal;
  font-weight: 300 500;
  font-display: block;
  src: url("/fonts/fraunces-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Fraunces";
  font-style: normal;
  font-weight: 300 500;
  font-display: block;
  src: url("/fonts/fraunces-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Schibsted Grotesk";
  font-style: normal;
  font-weight: 400 500;
  font-display: block;
  src: url("/fonts/schibsted-grotesk-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Schibsted Grotesk";
  font-style: normal;
  font-weight: 400 500;
  font-display: block;
  src: url("/fonts/schibsted-grotesk-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ------------------------------------------------------------------ *
   1. Tokens
 * ------------------------------------------------------------------ */
:root {
  /* palette */
  --paper:      #FBFAF7;
  --paper-2:    #F4F2EC;   /* faint tonal block */
  --ink:        #1A1A18;
  --ink-soft:   #2C2B27;
  --grey:       #6B675E;   /* warm grey */
  --grey-line:  #D8D4CA;   /* hairline */
  --grey-faint: #E7E3DA;
  --sage:       #8A9A7B;   /* muted sage accent */
  --sage-deep:  #5E6B53;   /* contrast-safe on paper: 5.4:1 (WCAG AA for small text) */

  /* type */
  --serif: "Fraunces", Georgia, "Times New Roman", serif;
  --sans:  "Schibsted Grotesk", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  /* rhythm */
  --gutter: clamp(1.25rem, 6vw, 8rem);
  --stack:  clamp(4.5rem, 12vh, 9rem);   /* vertical section rhythm */
  --measure: 34rem;

  /* motion */
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --slow: 1.2s;
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: 4.75rem;   /* anchored sections clear the fixed quick-nav */
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-weight: 400;
  font-size: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
  line-height: 1.7;
  letter-spacing: 0.005em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* `clip` (not `hidden`) so the body doesn't become a scroll container — that
     was breaking position:sticky for the hero. Same visual effect. */
  overflow-x: clip;
}

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

h1, h2, h3 {
  font-family: var(--serif);
  font-weight: 300;
  font-optical-sizing: auto;
  line-height: 1.06;
  letter-spacing: -0.01em;
  margin: 0;
}

p { margin: 0; }

a { color: inherit; }

::selection { background: rgba(138, 154, 123, 0.28); color: var(--ink); }

/* Focus — visible, calm */
:focus-visible {
  outline: 2px solid var(--sage-deep);
  outline-offset: 3px;
  border-radius: 1px;
}

.skip-link {
  position: absolute;
  left: 1rem; top: -4rem;
  z-index: 100;
  background: var(--ink);
  color: var(--paper);
  padding: 0.6rem 1rem;
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: none;
  transition: top 0.2s var(--ease);
}
.skip-link:focus { top: 1rem; }

/* ------------------------------------------------------------------ *
   3. Shared decor language
 * ------------------------------------------------------------------ */

/* Small-caps eyebrow */
.eyebrow {
  font-family: var(--sans);
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--sage-deep);
  margin: 0 0 1.25rem;
}

/* Hairline divider */
.hairline {
  border: 0;
  height: 1px;
  background: var(--grey-line);
  margin: 0 0 var(--stack);
  width: 100%;
}
.hairline--center {
  width: clamp(3rem, 8vw, 6rem);
  margin: 0 auto var(--stack);
  background: var(--sage);
}

/* ------------------------------------------------------------------ *
   4. Layout primitives
 * ------------------------------------------------------------------ */
.section {
  padding-inline: var(--gutter);
  padding-block: var(--stack);
  max-width: 78rem;
  margin-inline: auto;
}
.section:first-of-type { padding-top: clamp(5rem, 14vh, 10rem); }
/* First section inside the floating-RSVP band (the intro) keeps that top air. */
.rsvp-layout__main .section:first-of-type { padding-top: clamp(5rem, 14vh, 10rem); }

/* ------------------------------------------------------------------ *
   5. HERO
 * ------------------------------------------------------------------ */
/* A postcard. Without motion (no-JS / reduced-motion) the card shows a settled
   even paper mat + hairline frame. With .can-scrub the card lands with a thin
   border and JS scrubs the mat open as the pinned hero scrolls (section 3, JS). */
.hero {
  position: relative;
  min-height: 100svh;
  background: var(--paper);
  color: var(--paper);
  isolation: isolate;
  /* `clip` instead of `hidden`: same clipping for the image bleed but doesn't
     make .hero a scrollport (which was preventing the child sticky from pinning
     to the viewport — sticky was sticking to hero.top instead of top:0). */
  overflow: clip;
}
.can-scrub .hero { height: 160vh; min-height: 0; }   /* scroll room to scrub */

.hero__sticky {
  position: relative;
  min-height: 100svh;
  display: flex;
  box-sizing: border-box;
  padding: clamp(1rem, 4vw, 3rem);          /* no-JS / reduced-motion: paper margin */
}
.can-scrub .hero__sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  min-height: 0;
  padding: 0;                                /* landing: card fills the frame; JS scrubs up */
}

.hero__card {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  --mat: clamp(0.85rem, 1.8vw, 1.35rem);     /* revealed / static border — equal on all sides */
  --mat-b: var(--mat);                        /* bottom band matches the sides — plain card shape */
  --mat-t: var(--mat);                        /* top band; JS scrubs it OPEN WIDER than the sides to hold the caption (static/no-JS: equals the sides) */
  --bleed: 0px;                              /* at landing we extend the image past the card */
  /* The revealed postcard's caption tone — BOTH the year and the place share the
     timeline year's sage-deep (the timeline splits year=sage-deep / heading=ink;
     here the two are unified into one tone so the strip reads as a single mark). */
  --hero-cap-ink: var(--sage-deep);
  background: var(--paper);
  border: 1px solid var(--grey-line);        /* hairline frame, no shadow */
}
.can-scrub .hero__card { --mat: 0; --mat-b: 0; --mat-t: 0; --bleed: 2vw; }   /* landing: image bleeds 2% past the card; JS scrubs the mat open */

.hero__media {
  position: absolute;
  /* The image always lives at "mat from card edge", minus the bleed so at landing
     it extends past the card's 1px transparent border (no paper rim leaking).
     JS scrubs the mats up and --bleed down together; the TOP mat (--mat-t) opens
     wider than the sides to make room for the caption strip above the photo. */
  inset:
    calc(var(--mat-t) - var(--bleed))
    calc(var(--mat) - var(--bleed))
    calc(var(--mat-b) - var(--bleed));
  overflow: hidden;
  isolation: isolate;
}
.hero__img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  /* Anchor on the paddle-boarders (near the bottom of the frame) so they are
     always visible — short/wide viewports crop from the TOP instead. */
  object-position: 50% 100%;
  transform: scale(1.08);                  /* static crop — never animated, so no will-change */
  filter: saturate(0.92) contrast(1.02);
}
/* Corner glow only — a soft darkening anchored at the bottom-left where the
   lockup sits, fading to nothing before it reaches the type's top/right edges
   so the rest of the photo keeps its full colour (no full-frame veil).
   --veil-w / --veil-h are the ellipse's horizontal / vertical reach; they're
   tapered per breakpoint below because the lockup spans nearly the full width
   on a phone but only the left ~40% on a wide screen. */
.hero__veil {
  position: absolute; inset: 0;
  --veil-w: 125%;     /* phones: glow reaches across the full width */
  --veil-h: 70%;
  background: radial-gradient(
    ellipse var(--veil-w) var(--veil-h) at left bottom,
    rgba(20,20,18,0.50) 0%,
    rgba(20,20,18,0.30) 32%,
    rgba(20,20,18,0.10) 60%,
    rgba(20,20,18,0) 82%
  );
}
@media (min-width: 640px)  { .hero__veil { --veil-w: 92%; --veil-h: 62%; } }
@media (min-width: 820px)  { .hero__veil { --veil-w: 70%; --veil-h: 56%; } }
@media (min-width: 1280px) { .hero__veil { --veil-w: 56%; --veil-h: 52%; } }

/* Caption strip living in the (enlarged) top mat — fades in as the scrub opens
   the postcard, mirroring a timeline photo's caption row. Deliberately the SAME
   type SIZE as the timeline (decoration, NOT scaled up to the big hero), and a
   single faded tone for BOTH labels so it stays a quiet keepsake mark. JS drives
   the opacity off the scrub progress; the 0 default means no-JS / reduced-motion
   never show it (there is no scrub there). */
.hero__cap {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: var(--mat-t);                       /* exactly the top mat band; year/place centre in it */
  box-sizing: border-box;
  padding: 0 var(--mat);                       /* side padding = side mat, so year/place align to the photo's edges */
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  font-family: var(--sans); font-size: 0.68rem; font-weight: 500;
  line-height: 1.2; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--hero-cap-ink);
  opacity: 0;                                  /* JS scrubs this in; hidden by default */
  pointer-events: none;
  z-index: 2;
}
.hero__cap-year { margin: 0; }
.hero__cap-place { margin: 0; text-align: right; }

.hero__inner {
  position: absolute;
  /* The copy lives inside .hero__media, which bleeds `--bleed` (2vw at the
     full-bleed landing) past the card edges on every side. Add that bleed back
     to all three insets so the text keeps a fixed gap from the card corner
     instead of riding the image — otherwise on wide viewports the constant 2vw
     bleed outruns the capped clamp()s and the copy drifts toward, then past,
     the left and bottom edges. `--bleed` is 0 in no-JS/reduced-motion and
     decays to 0 as the scrub starts, so this only affects the landing. */
  left: calc(clamp(1.25rem, 5vw, 3.5rem) + var(--bleed));
  right: calc(clamp(1.25rem, 5vw, 3.5rem) + var(--bleed));
  bottom: calc(clamp(1.75rem, 10vh, 5.5rem) + var(--bleed));
  max-width: 40rem;
  z-index: 2;
}

.hero__eyebrow {
  color: rgba(251, 250, 247, 0.82);
  margin-bottom: 1.5rem;
}

.hero__title {
  font-weight: 300;
  font-size: clamp(3rem, 10.5vw, 7rem);
  line-height: 0.96;
  letter-spacing: 0.02em;        /* settles toward this on load */
  margin: 0 0 1.75rem;
  text-shadow: 0 1px 30px rgba(20, 20, 18, 0.35);
}
.hero__amp {
  display: inline-block;
  font-style: normal;
  font-weight: 300;
  color: inherit;                /* full white, matching the names */
  /* Pull the ampersand in tight against "Flora" — the literal space character
     between the inline-block spans already sits at the title's letter-spacing,
     and the original 0.12em padding stacked on top of it read as a gap. */
  margin: 0 -0.12em;
}
.hero__name { display: inline-block; }
/* Lock the lockup to two lines ("Flora &" / "Hanny") so the scrub never reflows
   the title from one line to two as the card width changes. */
.hero__title .hero__name:last-child { display: block; }

.hero__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.85rem;
  max-width: 30rem;
  font-family: var(--sans);
  font-size: clamp(0.82rem, 0.8rem + 0.3vw, 1rem);
  letter-spacing: 0.04em;
  color: rgba(251, 250, 247, 0.92);
}
.hero__rule {
  display: inline-block;
  width: 2.25rem; height: 1px;
  background: rgba(251, 250, 247, 0.6);
}
/* Mobile (≤820px — the hero's portrait-crop / photo-only standard): ALWAYS break
   the venue onto its own second row (flex-basis:100% forces the wrap at any width),
   so the date line stays short and the lockup never reaches across the photo onto
   the paddle-boarders. The inline hairline separator is meaningless once the two
   are stacked, so it's dropped here. */
@media (max-width: 820px) {
  .hero__meta { row-gap: 0.4rem; }
  .hero__rule { display: none; }
  .hero__meta > span:last-child { flex-basis: 100%; }
}

/* ------------------------------------------------------------------ *
   6. INTRO
 * ------------------------------------------------------------------ */
.intro { text-align: center; }
.intro__lead {
  font-size: clamp(1.6rem, 1.1rem + 2.6vw, 3rem);
  font-weight: 300;
  line-height: 1.22;
  letter-spacing: -0.012em;
  max-width: 26ch;
  margin: 0 auto;
  color: var(--ink-soft);
  text-wrap: balance;
}
/* ------------------------------------------------------------------ *
   7. STORY + DIPTYCH (secondary centerpiece)
 * ------------------------------------------------------------------ */
.story__head { margin-bottom: clamp(2.5rem, 7vh, 4.5rem); }
.story__title {
  font-size: clamp(2.4rem, 1.5rem + 4vw, 5rem);
  margin-bottom: 0.6rem;
}

/* Timeline — a single hairline rail down the left. Each photo's framed LEFT
   edge sits ON the rail, so the connector line and the photo frames read as one
   continuous left border; the rail inks sage as you scroll. No per-beat dots
   (the frame is the beat indicator); only the final "arrival" beat keeps a dot. */
.timeline { position: relative; }
.timeline__track { position: relative; }
.timeline__spine {
  position: absolute; top: 0; bottom: 0; left: 0;   /* on the photos' shared left edge */
  width: 1px;                                /* no-JS fallback spans the track; JS sets exact top/height */
  background: var(--grey-line);
  z-index: 1;                                /* sits on the frames' left edge */
}
.timeline__fill {
  position: absolute; top: 0; left: 0; width: 1px; height: 100%;
  background: var(--sage);
  /* JS scrubs scaleY 0→1 (see tlWrite). Transform is composited, so the rail
     fills without a per-frame layout/paint — unlike the old animated height. */
  transform: scaleY(0); transform-origin: top center;
  transition: transform 0.18s linear;
  will-change: transform;
}
.timeline__beats {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column;
  gap: clamp(3.75rem, 10vh, 6.5rem);          /* +35% breathing room between beats */
}

/* A beat = a bordered "plate": a caption row (year left, heading right) above a
   matted photo. Each plate's left border sits ON the rail (.timeline__spine), so
   the connector and the frames read as one continuous left edge. */
.beat { position: relative; }
.beat__media {
  position: relative; margin: 0;
  max-width: 36rem;                          /* full width on phones */
  padding: clamp(0.5rem, 1.2vw, 0.8rem);     /* the paper mat */
  background: var(--paper);
  border: 1px solid var(--grey-line);        /* left edge = the rail */
}
/* Caption above the photo: year on the left, heading on the right (both labels). */
.beat__cap {
  display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
  margin: 0 0 clamp(0.5rem, 1.2vw, 0.8rem);
  font-family: var(--sans); font-size: 0.68rem; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
}
.beat__year { flex: none; margin: 0; color: var(--sage-deep); }
.beat__heading {
  margin: 0; text-align: right;
  font-family: var(--sans); font-weight: 500; font-size: 0.68rem;
  letter-spacing: 0.16em; text-transform: uppercase; line-height: 1.25;
  color: var(--ink-soft);
}
.beat__media img {
  display: block; width: 100%;
  aspect-ratio: 1600 / 1066; object-fit: cover;
  filter: saturate(0.92);
}
/* No per-beat dots; the final arrival beat keeps a single dot on the rail. */
.beat__node { display: none; }
.beat--arrival { padding-left: 1.6rem; }     /* clear the dot, which sits on the rail */
.beat--arrival .beat__node--final {
  display: block; position: absolute;
  left: 0; top: 0.6em;                        /* JS re-centres it on the arrival title */
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--paper); border: 1px solid var(--sage-deep);
  /* Centre the dot on the rail centre (the rail is 1px wide at left:0 ⇒ centre 0.5px),
     and lift it above the spine/fill so the line ends visibly below it. */
  transform: translate(calc(-50% + 0.5px), -50%);
  z-index: 3;
  transition: background 0.4s var(--ease), transform 0.4s var(--ease);
}
.beat--arrival.is-active .beat__node--final {
  background: var(--sage-deep);
  transform: translate(calc(-50% + 0.5px), -50%) scale(1.5);
}
.beat__arrival-title {
  font-family: var(--serif); font-weight: 300;
  font-size: clamp(2.2rem, 1.5rem + 3.4vw, 4.25rem);
  line-height: 1.05; letter-spacing: -0.01em;
  margin: 0;
}

@media (max-width: 640px) {
  .beat__media { max-width: none; }
}

/* The details body (facts/schedule/venue) follows just under that final beat. */
.story   { padding-bottom: clamp(1.25rem, 3.5vh, 2.5rem); }
.details { padding-top:    clamp(1.25rem, 3.5vh, 2.5rem); }

/* ------------------------------------------------------------------ *
   8. DETAILS
 * ------------------------------------------------------------------ */
/* "When": date + dress stacked, the run-of-show beside them; full-width top and
   bottom hairlines wrap the whole block (so no separate divider before "where"). */
.details__when {
  display: grid; gap: 0; align-items: start;
  border-top: 1px solid var(--grey-line);
  border-bottom: 1px solid var(--grey-line);
  padding-block: clamp(1.4rem, 3.5vh, 2.2rem);
  margin-bottom: clamp(2.5rem, 6vh, 4rem);    /* breathing room before the map */
}
/* Single column (default): the schedule reads as the next row beneath the
   facts, so it picks up the same hairline-divider rhythm. The dress fact
   restores its bottom padding (overridden from .fact:last-child) so the gap
   above and below the divider matches the date/dress gap above. */
.details__when > .schedule {
  border-top: 1px solid var(--grey-line);
  padding-top: 1.2rem;
}
.details__when .fact:last-child { padding-bottom: 1.2rem; }
@container content (min-width: 32rem) {
  .details__when {
    grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
    gap: clamp(2.5rem, 6vw, 4.5rem);
  }
  /* Two columns: the schedule sits beside the facts, not under them. */
  .details__when > .schedule { border-top: 0; padding-top: 0; }
  .details__when .fact:last-child { padding-bottom: 0; }
}

/* Date + dress: always stacked, eyebrow ABOVE the value for width. The outer
   lines live on .details__when; here we keep only the divider between the two
   facts, and zero the first/last padding so "Date" lines up with "10:30am". */
.facts { margin: 0; display: flex; flex-direction: column; }
.fact {
  display: flex; flex-direction: column; gap: 0.45rem;
  padding-block: 1.2rem;
}
.fact:first-child { padding-top: 0; }
.fact:last-child  { padding-bottom: 0; }
.fact:not(:last-child) { border-bottom: 1px solid var(--grey-line); }
.fact__label {
  font-family: var(--sans);
  font-size: 0.68rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--sage-deep);
}
.fact__value {
  font-family: var(--serif);
  font-weight: 300;
  font-size: clamp(1.15rem, 1rem + 0.6vw, 1.5rem);
  line-height: 1.3;
  color: var(--ink);
  margin: 0;
}
.fact__note {
  display: block;
  font-family: var(--sans);
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  color: var(--grey);
  margin-top: 0.4rem;
}

/* The day — a simple vertical run-of-show: time → moment, no rules or nodes.
   In .details__when it is the right-hand column; its top spacing comes from the
   grid gap, so it carries no margin of its own. */
.schedule {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 0.7rem; max-width: 32rem;
}
.schedule li { display: grid; grid-template-columns: 7rem 1fr; gap: 1rem; align-items: baseline; }
.schedule__time {
  font-family: var(--sans); font-size: 0.78rem; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--sage-deep); white-space: nowrap;
}
.schedule__moment {
  font-family: var(--serif); font-weight: 300;
  font-size: clamp(1.05rem, 1rem + 0.4vw, 1.35rem);
  line-height: 1.2; color: var(--ink);
}
@media (max-width: 600px) { .schedule li { grid-template-columns: 6rem 1fr; gap: 0.85rem; } }

/* ------------------------------------------------------------------ *
   9. WHERE — getting there (folded into the details section)
 * ------------------------------------------------------------------ */
.venue__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(2rem, 5vw, 3.5rem);
  align-items: stretch;
}
/* Two columns (map | facts) only when the content column is wide enough that the
   map won't get squashed — wide desktop, or mobile once the sticky card is gone.
   In the squeezed ~821-1100px band (card present) and in portrait, it stacks. */
@container content (min-width: 39rem) {
  /* Match the schedule timetable's columns so the address column (right) lands
     at the same width as the schedule column above it. The map/text gap is held
     to ~half the schedule's gap, so the two columns sit a little closer. */
  .venue__grid {
    grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
    gap: clamp(1.25rem, 3vw, 2.25rem);
  }
}
/* Map slot — holds a live MapLibre map (CARTO basemap, the mapcn engine),
   with a static CSS locator as the no-JS / pre-init / fallback state. */
.venue__map {
  position: relative;
  display: block;
  margin: 0;
  min-height: clamp(15rem, 40vw, 22rem);
  background: #FFFEF7; /* skeleton land tone — no flash before the AVIF loads */
  border: 1px solid var(--grey-line);
  color: var(--ink);
  overflow: hidden;
  transition: border-color 0.4s var(--ease);
}
/* Narrow / single-column (mobile): give the map more presence so the off-centre
   "MegaBox" landmark label clears the frame. Must stay AFTER the base .venue__map
   rule above — equal specificity, so source order decides which min-height wins. */
@media (max-width: 819px) {
  .venue__map { min-height: clamp(22.8rem, 60vw, 32.4rem); }
}
.venue__map:hover { border-color: var(--sage); }

/* Static silhouette locator (the no-JS / no-WebGL state + pre-paint skeleton): an
   optimised AVIF/WebP of the venue's block, tinted to the paper palette. object-fit
   "cover" covers the figure and re-crops at each breakpoint, keeping the centre in
   view. Sits above the loading canvas; removed once .is-mapped. */
.venue__map-art {
  position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%; display: block;
  object-fit: cover; object-position: center;
}

/* Get directions — kept as a real link to Google Maps, pinned above the map. */
.venue__map-cta {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
  display: block;
  padding: 0.8rem 1.1rem;
  font-family: var(--sans); font-size: 0.68rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--sage-deep); text-decoration: none;
  border-top: 1px solid var(--grey-line);
  background: rgba(251, 250, 247, 0.82);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
  transition: background 0.3s var(--ease), color 0.3s var(--ease);
}
.venue__map-cta span { display: inline-block; transition: transform 0.3s var(--ease); }
.venue__map-cta:hover,
.venue__map-cta:focus-visible { background: var(--paper); color: var(--ink); }
.venue__map-cta:hover span { transform: translateX(4px); }

/* Live map: canvas mount under the chrome; fallback art removed once mapped. */
.venue__map-canvas { position: absolute; inset: 0; z-index: 0; }
/* Warm the cool CARTO basemap toward the paper palette. Scoped to the GL canvas
   so the sage marker and controls (siblings of the canvas) keep true colors.
   Tunable — this is the main on-brand lever. */
.venue__map-canvas .maplibregl-canvas {
  filter: sepia(0.16) saturate(0.72) brightness(1.04) hue-rotate(-6deg);
}
.venue__map.is-mapped .venue__map-art { display: none; }

/* Brand marker — a sage dot with a soft halo (echoes the fallback pin) sitting
   in a larger transparent hit area, plus a ripple ring that pulses on hover. */
.venue__marker {
  position: relative;
  width: 26px; height: 26px;
  pointer-events: none;             /* ripple is driven by hovering the whole frame */
}
.venue__marker::before,
.venue__marker::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 12px; height: 12px; margin: -6px 0 0 -6px;
  border-radius: 50%;
}
.venue__marker::before {            /* the dot */
  background: var(--sage);
  box-shadow: 0 0 0 6px rgba(138, 154, 123, 0.18), 0 1px 4px rgba(26, 26, 24, 0.28);
}
.venue__marker::after {             /* ripple ring — idle until the frame is hovered */
  border: 1px solid var(--sage);
  opacity: 0; transform: scale(1);
  pointer-events: none;
}
/* Same trigger as the border lighting green: hovering anywhere on the map frame. */
.venue__map:hover .venue__marker::after { animation: venue-ripple 1.7s var(--ease) infinite; }
@keyframes venue-ripple {
  0%   { opacity: 0.5; transform: scale(1); }
  100% { opacity: 0;   transform: scale(3.4); }
}
@media (prefers-reduced-motion: reduce) {
  .venue__map:hover .venue__marker::after { animation: none; }
}

/* "The Glasshall" — an on-brand sage label beside the focus dot. */
.venue__marker-label {
  font-family: var(--serif); font-weight: 500; font-size: 0.9rem;
  color: var(--sage); white-space: nowrap; pointer-events: none;
  text-shadow: 0 0 3px var(--paper), 0 0 3px var(--paper), 0 0 7px var(--paper);
}
/* "MegaBox" — a quiet grey landmark label, like the basemap's POI labels. */
.venue__poi {
  font-family: var(--sans); font-weight: 500; font-size: 0.7rem;
  letter-spacing: 0.04em; color: var(--grey); white-space: nowrap;
  pointer-events: none;
  text-shadow: 0 0 3px var(--paper), 0 0 3px var(--paper);
}

/* MapLibre chrome, quieted to the paper/ink system. */
.venue__map .maplibregl-ctrl-group {
  background: var(--paper); border: 1px solid var(--grey-line);
  border-radius: 0; box-shadow: none; overflow: hidden;
}
.venue__map .maplibregl-ctrl-group button + button { border-top: 1px solid var(--grey-line); }
.venue__map .maplibregl-ctrl-group button:hover { background: var(--paper-2); }
.venue__map .maplibregl-ctrl button .maplibregl-ctrl-icon { opacity: 0.66; }
/* Attribution kept (license requirement), small/quiet, lifted clear of the CTA bar. */
.venue__map .maplibregl-ctrl-attrib {
  background: rgba(251, 250, 247, 0.78);
  font-family: var(--sans); font-size: 0.62rem;
}
.venue__map .maplibregl-ctrl-attrib a { color: var(--grey); }
.venue__map .maplibregl-ctrl-bottom-left,
.venue__map .maplibregl-ctrl-bottom-right { bottom: 2.7rem; }
/* Cooperative-gesture hint ("use ctrl + scroll" / "two fingers"), quieted. */
.venue__map .maplibregl-cooperative-gesture-screen {
  background: rgba(26, 26, 24, 0.42);
  font-family: var(--sans); font-weight: 400; letter-spacing: 0.02em;
}
.venue__map .maplibregl-cooperative-gesture-screen .maplibregl-desktop-message,
.venue__map .maplibregl-cooperative-gesture-screen .maplibregl-mobile-message {
  background: var(--paper); color: var(--ink);
  border: 1px solid var(--grey-line); border-radius: 0;
  font-size: 0.78rem; padding: 0.6rem 0.9rem;
}

.venue__facts {
  margin: 0; align-self: start;
  border-top: 1px solid var(--grey-line);
}
.venue__fact {
  display: flex; flex-direction: column; gap: 0.4rem;
  padding: 1.4rem 0; border-bottom: 1px solid var(--grey-line);
}
.venue__fact dt {
  font-family: var(--sans); font-size: 0.68rem; letter-spacing: 0.2em;
  text-transform: uppercase; color: var(--sage-deep);
}
.venue__fact dd {
  margin: 0; font-family: var(--serif); font-weight: 300;
  font-size: clamp(1.05rem, 0.98rem + 0.4vw, 1.3rem);
  line-height: 1.45; color: var(--ink);
}

/* ------------------------------------------------------------------ *
   10. GALLERY
 * ------------------------------------------------------------------ */
.gallery__head {
  margin-bottom: clamp(2.5rem, 6vh, 4rem);
  display: flex; flex-wrap: wrap; align-items: flex-end;
  justify-content: center; gap: 1.25rem 1.5rem;
  text-align: center;
}
.gallery__head-text { min-width: 0; }
.gallery__title {
  font-size: clamp(2.2rem, 1.5rem + 3.4vw, 4.25rem);
  margin-bottom: 0;
}

/* shared frame */
.frame { margin: 0; overflow: hidden; background: var(--paper-2); list-style: none; }
.frame img {
  width: 100%; height: 100%; object-fit: cover;
  filter: saturate(0.92);
  transition: transform 1.4s var(--ease);
}
.frame:hover img { transform: scale(1.03); }

/* Variant chosen by viewport, not a toggle (HY-6 follow-up): grid on phones
   (avoids the tall pinned section), pinned scroll on wider screens. */
[data-variant-panel="grid"]   { display: block; }
[data-variant-panel="scroll"] { display: none; }
@media (min-width: 720px) {
  [data-variant-panel="grid"]   { display: none; }
  [data-variant-panel="scroll"] { display: block; }
}

/* Variant A — grid: a single calm column on phones (<720px). */
.gallery__grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: 1fr;
  gap: clamp(0.8rem, 2vw, 1.4rem);
}
.gallery__grid .frame--tall img { aspect-ratio: 1066 / 1600; }
.gallery__grid .frame--wide img { aspect-ratio: 1600 / 1066; }

/* Variant B — pinned horizontal scroll: vertical scroll drives horizontal motion */
.hscroll { position: relative; }
.hscroll__sticky { display: flex; flex-direction: column; justify-content: center; }
.hscroll__track {
  list-style: none; margin: 0;
  display: flex; align-items: center;
  gap: clamp(0.8rem, 2vw, 1.5rem);
  width: max-content;
  padding-inline: var(--gutter);
  will-change: transform;
}
.hscroll__item { flex: 0 0 auto; height: 64vh; overflow: hidden; background: var(--paper-2); }
.hscroll__item img { height: 100%; width: auto; object-fit: cover; filter: saturate(0.92); display: block; }
.hscroll__progress { height: 1px; background: var(--grey-line); margin: clamp(1.5rem, 4vh, 2.5rem) var(--gutter) 0; }
.hscroll__bar { display: block; height: 1px; width: 0; background: var(--sage); }

/* JS-on, motion-ok: pin the viewport and pan the track with page scroll */
.hscroll.is-pinned .hscroll__sticky { position: sticky; top: 0; height: 100svh; overflow: hidden; }
.hscroll.is-pinned .hscroll__progress { position: absolute; left: 0; right: 0; bottom: 8vh; margin: 0 var(--gutter); }

/* Default (no JS) + reduced-motion: a calm horizontal swipe row, no pinning */
.hscroll:not(.is-pinned) .hscroll__sticky { overflow-x: auto; scrollbar-width: thin; }
.hscroll:not(.is-pinned) .hscroll__track { transform: none !important; }
.hscroll:not(.is-pinned) .hscroll__item { height: clamp(16rem, 48vw, 28rem); }
.hscroll:not(.is-pinned) .hscroll__progress { display: none; }

/* Closing frame — the relocated "now" photo, a full frame that ends the gallery
   and leads into the RSVP. Shown whole (no crop), centred. */
.gallery__close { margin: clamp(2.75rem, 8vh, 5.5rem) auto 0; max-width: 60rem; }
.gallery__close img {
  display: block; width: 100%; height: auto;
  filter: saturate(0.94);
}
.gallery__close-cap {
  margin: 1.15rem auto 0;
  max-width: 34ch;
  text-align: center;
  text-wrap: balance;
  font-family: var(--serif); font-weight: 300;
  font-size: clamp(1.05rem, 0.95rem + 0.5vw, 1.4rem);
  line-height: 1.45;
  color: var(--ink-soft);
  letter-spacing: 0.01em;
}
/* In the single-column grid (<720px) the closer flows as the final frame —
   same width and gap as the grid, so the gallery + closer read as one component. */
@media (max-width: 719.98px) {
  .gallery__close { margin-top: clamp(0.8rem, 2vw, 1.4rem); max-width: none; }
  .gallery__close-cap { text-align: left; }
}

@media (max-width: 640px) { .gallery__head { align-items: flex-start; } }

/* ------------------------------------------------------------------ *
   11. RSVP
 * ------------------------------------------------------------------ */
/* The RSVP form lives in the floating card / bottom sheet (sections 11b, 12c),
   so it is always one narrow column. */
.rsvp__form { display: grid; gap: 1.4rem; }
/* The JS toggles `hidden` on submit/edit; without this the class `display:grid`
   would win over the UA `[hidden]` rule and the form would stay visible under the
   thank-you state (same fix as `.rsvp-sheet[hidden]`). */
.rsvp__form[hidden] { display: none; }
/* Honeypot: in the DOM but off-screen for people. NOT display:none — some bots
   skip display:none fields. */
.rsvp__hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.field { display: flex; flex-direction: column; gap: 0.6rem; }

.field label,
.field legend {
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sage-deep);
  padding: 0;
}
/* "OPTIONAL" badge — right-aligned on the label row, same eyebrow voice as
   other labels (caps + tracking), at a softer secondary opacity. */
.field label { display: flex; justify-content: space-between; align-items: baseline; }
.field__opt { color: inherit; font-weight: inherit; opacity: 0.45; }

.field input[type="text"],
.field select,
.field textarea {
  font-family: var(--serif);
  font-weight: 300;
  font-size: 1.15rem;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--grey-line);
  padding: 0.7rem 0.1rem;
  min-height: 44px;            /* comfortable touch target on phones */
  border-radius: 0;
  transition: border-color 0.35s var(--ease);
}
.field input::placeholder,
.field textarea::placeholder { color: var(--grey); opacity: 1; font-style: normal; }
.field input:focus,
.field select:focus,
.field textarea:focus {
  outline: none;
  border-bottom-color: var(--sage);
}
.field select {
  appearance: none;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--grey) 50%),
    linear-gradient(135deg, var(--grey) 50%, transparent 50%);
  background-position: right 4px center, right 0 center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 1.5rem;
  cursor: pointer;
}

/* Name area (who's replying) + RSVP selector area: two stacked sections.
   No section divider — whitespace and the eyebrow on the choices block carry
   the break. Inside each section we use tighter, explicit gaps. */
.rsvp__name-area {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;                                /* tight rhythm inside the block */
}

/* Partner-name input — collapses until the checkbox is checked. Same input
   treatment as Your Name: own border-bottom underline, no special trick. */
.rsvp__partner-input {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.35s var(--ease);
}
.rsvp__partner-input-inner { overflow: hidden; min-height: 0; }
.rsvp__name-area:has(#rsvp-partner:checked) .rsvp__partner-input {
  grid-template-rows: 1fr;
}
.rsvp__partner-input input {
  width: 100%;
  font-family: var(--serif);
  font-weight: 300;
  font-size: 1.15rem;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--grey-line);
  padding: 0.7rem 0.1rem;
  min-height: 44px;
  border-radius: 0;
  transition: border-color 0.35s var(--ease);
}
.rsvp__partner-input input::placeholder { color: var(--grey); opacity: 1; }
.rsvp__partner-input input:focus { outline: none; border-bottom-color: var(--sage); }

/* RSVP selector area: anchored by a small eyebrow label, then the radios. */
.rsvp__choices { display: flex; flex-direction: column; }
.rsvp__choices-label {
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sage-deep);
  margin-bottom: 0.45rem;
}

/* One choice-row spec applies wherever .choice appears — partner checkbox and
   both radios share identical alignment, font, and row height. */
.choice {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  cursor: pointer;
  font-family: var(--sans);
  font-weight: 500;
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1;                             /* collapse line-box to glyph
                                                 height so flex centres the
                                                 cap-band on the box */
  color: var(--sage-deep);
  padding-block: 0.6rem;
}
.choice--check .choice__box { border-radius: 3px; }
.choice--check .choice__box::after { border-radius: 1px; }
/* Small kerning nudge: the body sans renders a hair high in the line-box; a 2px
   top margin on the text drops it onto the box's visual centre. */
.choice__text { margin-top: 0.125rem; }
.choice input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.choice__box {
  flex: none;
  width: 1.05rem; height: 1.05rem;
  border: 1px solid var(--grey);
  border-radius: 50%;
  position: relative;
  transition: border-color 0.25s var(--ease);
}
.choice__box::after {
  content: "";
  position: absolute; inset: 3px;
  border-radius: 50%;
  background: var(--sage);
  transform: scale(0);
  transition: transform 0.25s var(--ease);
}
.choice input:checked + .choice__box { border-color: var(--sage); }
.choice input:checked + .choice__box::after { transform: scale(1); }
.choice input:focus-visible + .choice__box {
  outline: 2px solid var(--sage-deep);
  outline-offset: 3px;
}

.btn {
  justify-self: stretch;
  justify-content: center;
  margin-top: 0.75rem;
  display: inline-flex;
  align-items: center;
  gap: 0.75rem;
  font-family: var(--sans);
  font-size: 0.74rem;
  font-weight: 500;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--ink);
  padding: 1rem 2.4rem;
  cursor: pointer;
  border-radius: 0;
  transition: background 0.4s var(--ease), color 0.4s var(--ease);
}
.btn:hover { background: var(--sage-deep); border-color: var(--sage-deep); }
/* .btn's display: inline-flex would win over the UA [hidden] rule and leave
   the button taking layout space — same fix as .rsvp__form[hidden]. */
.btn[hidden] { display: none; }
/* Disabled / submitting — the button reads as inert: muted to a soft grey,
   no hover lift, not-allowed cursor. Both :disabled (the JS sets it during
   the network round-trip) and [aria-busy="true"] (for any caller that prefers
   that signal) collapse to the same look. */
.btn:disabled,
.btn[aria-busy="true"] {
  background: var(--grey-line);
  border-color: var(--grey-line);
  color: var(--paper);
  cursor: not-allowed;
  pointer-events: none;
}

.rsvp__hint {
  grid-column: 1 / -1;
  margin: 0;
  font-size: 0.82rem;
  color: #A8503C;
  letter-spacing: 0.02em;
}

/* thank-you state — editorial column, capped at 40ch. Default: left-aligned
   text, block left-flush. This is what the desktop dock (narrow column) and
   portrait mobile (~≤420px sheet where the primary essentially fills the
   width) both want. The landscape-mobile band below overrides this when
   the sheet gets wide enough that left-flush leaves visible dead space. */
.rsvp__thanks { text-align: left; max-width: 40ch; }
.rsvp__thanks-title {
  font-family: var(--serif);
  font-style: normal;
  font-weight: 300;
  font-size: clamp(1.8rem, 1.3rem + 2vw, 3rem);
  margin-bottom: 1rem;
  /* The name is user input and the title font is viewport-scaled while the desktop
     card is narrow (~254px); let a long single-token name break instead of spilling. */
  overflow-wrap: break-word;
}
.rsvp__thanks-body {
  color: var(--grey);
  margin-bottom: 1.75rem;
  /* Prefer pretty-wrapping on the body so the last line isn't a lone
     trailing word — graceful fallback to normal wrap on older browsers. */
  text-wrap: pretty;
}

.link-btn {
  background: none; border: 0;
  font-family: var(--sans);
  font-size: 0.74rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sage-deep);
  cursor: pointer;
  padding: 0 0 0.35rem;
  border-bottom: 1px solid var(--grey-line);
  transition: border-color 0.3s var(--ease);
}
.link-btn:hover { border-color: var(--sage); }

/* Thank-you actions — declined branch (no primary) defaults to left-flush.
   The attending branch — primary visible — centers the column so the
   secondary sits under the primary's text-center. The primary fills its
   column either way, so its centered "ADD TO CALENDAR" label IS the
   column's visual center; pulling "Edit my reply" to that same axis
   reads as a button pair, not a left-aligned link orphaned under a CTA. */
.rsvp__thanks-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 1.1rem; }
.rsvp__thanks-actions[hidden] { display: none; }
.rsvp__thanks-actions:has(.rsvp__thanks-primary:not([hidden])) { align-items: center; }
/* The primary inherits .btn — neutralise the top margin .btn carries for the
   form's submit row (the flex gap already handles spacing here). Width 100%
   fills narrow columns (desktop dock ~254px, iPhone sheet ~330px); max-width
   keeps it button-shaped on wide sheets where the editorial column would
   otherwise stretch it past the comfortable CTA width. */
.rsvp__thanks-primary { margin-top: 0; width: 100%; max-width: 22rem; }

/* Landscape mobile — the bottom sheet between ~420px and the 820px desktop
   handover. Wide enough that the primary no longer fills the sheet, narrow
   enough that the dock isn't shown yet. Pin the whole editorial composition
   to the primary's 22rem width and center it: heading + body text-align
   center, block centered horizontally, actions centered. Applies to BOTH
   attending and declined — declined had the same dead-space problem when
   the sheet got this wide, just with a smaller body weight. The band is
   bounded above by 820px because beyond that the desktop dock takes over
   (narrow right column, default left-align is correct there). */
@media (min-width: 420px) and (max-width: 820px) {
  .rsvp__thanks {
    text-align: center;
    max-width: 22rem;          /* match primary so heading + body wrap in step */
    margin-inline: auto;
  }
  .rsvp__thanks-actions { align-items: center; }
}

/* ------------------------------------------------------------------ *
   11b. RSVP layout — two-column band + the floating (sticky) card
        Left column = intro / story / details; right column = the card,
        which sticks while the band is in view and scrolls away above the
        gallery. Collapses to one column at <=820px.
 * ------------------------------------------------------------------ */
.rsvp-layout {
  max-width: 78rem;
  margin-inline: auto;
  /* Cap the layout's inline padding tighter than the global --gutter so the
     content column doesn't shrink as the viewport widens past ~1250px; extra
     viewport width just becomes the outer margin. */
  padding-inline: clamp(1.25rem, 6vw, 5rem);
}
/* The content column is a query CONTAINER: its inner layouts (details, getting
   there) respond to the space AVAILABLE — which is narrow when the sticky RSVP
   card sits beside it (~821-1100px) and wide again once the card is gone
   (<=820px) or the viewport is large. This is what lets the same content read as
   "portrait" when squeezed and "landscape" when roomy. See docs/design-notes.md. */
.rsvp-layout__main { min-width: 0; container-type: inline-size; container-name: content; }

/* Inside the band, .section's page-container styles are redundant — the band
   owns horizontal layout. Keep only the vertical rhythm. */
.rsvp-layout__main .section {
  max-width: none;
  margin-inline: 0;
  padding-inline: 0;
}

/* The card (also the no-JS mobile fallback, rendered in place). */
.rsvp-dock {
  background: var(--paper);
  border: 1px solid var(--grey-line);
  padding: clamp(1.5rem, 2.5vw, 2rem);
}
/* The desktop dock head now carries only the "RSVP" eyebrow — the H2 title
   moved into the form so it hides in the thank-you state. The eyebrow stays
   for both states (form + thanks) as the section's standing label. */
.rsvp-dock__head { margin-bottom: 1.6rem; }
.rsvp-dock__eyebrow { display: block; }

/* "Will you join us?" — lives inside .rsvp__form so it disappears when the
   form is hidden in favour of the thank-you state. Same visual treatment the
   dock title used to have, plus a tighter top so the eyebrow above it sits
   close (the form's grid gap would otherwise push it down). */
.rsvp__title {
  font-family: var(--serif); font-weight: 300;
  font-size: clamp(1.7rem, 1.4rem + 1vw, 2.1rem);
  line-height: 1.05; margin: 0;
}

/* Desktop: two columns; the card sticks on the right. */
@media (min-width: 821px) {
  .rsvp-layout {
    display: grid;
    /* Right column is a FIXED rem-width (not vw-based) so the left column never
       shrinks as the viewport widens past the layout's max-width (78rem) —
       extra width just widens the outer margins. Gap capped for the same reason. */
    grid-template-columns: minmax(0, 1fr) 20rem;
    gap: clamp(2rem, 4vw, 3rem);
    /* default align-items: stretch — the aside fills the row height so the
       sticky card can travel the full band. */
  }
  /* The intro shares the band's first row with the card, so left-align it
     (it stays centered at <=820px, where there is no card beside it). */
  .rsvp-layout__main .intro { text-align: left; }
  .rsvp-layout__main .intro__lead { margin-inline: 0; max-width: 30ch; }
  .rsvp-dock {
    position: sticky;
    top: 5.25rem;                          /* clears the fixed top bar */
    margin-top: clamp(5rem, 14vh, 10rem);  /* start beside the intro's content */
    max-height: calc(100svh - 6.75rem);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}

/* Mobile/tablet: one column. With JS the card is hidden (the bottom sheet is
   used); without JS it renders in place after the details. */
@media (max-width: 820px) {
  .rsvp-layout__aside { margin-top: var(--stack); }
  .js .rsvp-layout__aside { display: none; }
}

/* ------------------------------------------------------------------ *
   12. CLOSING
 * ------------------------------------------------------------------ */
.closing {
  text-align: center;
  padding: clamp(5rem, 14vh, 9rem) var(--gutter) clamp(3.5rem, 9vh, 6rem);
  max-width: 78rem;
  margin-inline: auto;
}
/* Where the fixed reserve bar overlays the page (<=820px), add clearance so the
   footer content and its whitespace sit above the bar and stay balanced. */
@media (max-width: 820px) {
  .closing { padding-bottom: calc(clamp(3.5rem, 9vh, 6rem) + 4rem + env(safe-area-inset-bottom, 0px)); }
}
.closing__names {
  font-size: clamp(2.6rem, 1.8rem + 4vw, 5.5rem);
  font-weight: 300;
  letter-spacing: 0.01em;
  margin-bottom: 1.5rem;
}
.closing__meta {
  font-family: var(--sans);
  font-size: 0.8rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--grey);
}
.closing__dot { color: var(--sage); margin: 0 0.5rem; }
/* Mobile: break date and venue onto their own lines so neither is widowed.
   Desktop keeps the single middot-separated line. */
@media (max-width: 600px) {
  .closing__when,
  .closing__where { display: block; }
  .closing__dot { display: none; }
}
.closing__sign {
  margin-top: 2.5rem;
  font-family: var(--serif);
  font-style: normal;
  font-weight: 300;
  font-size: 1.15rem;
  color: var(--ink-soft);
}
/* Closing footer's bird is both a sign-off flourish AND the "back to top" link.
   Visual styling lives on the <a> so the SVG inherits via currentColor; sage at
   reduced opacity keeps it subtle, brightening on hover/focus as the affordance. */
.closing__top {
  display: inline-block;
  margin-top: 2.25rem;
  color: var(--sage);
  text-decoration: none;
  opacity: 0.7;
  transition: color 0.3s var(--ease), opacity 0.3s var(--ease);
}
.closing__top:hover,
.closing__top:focus-visible { color: var(--sage-deep); opacity: 1; }
.closing__mark {
  display: block;
  width: auto;
  height: clamp(1.6rem, 1.3rem + 0.8vw, 2.25rem);
  fill: currentColor;
}

/* ------------------------------------------------------------------ *
   12b. Quiet quick-nav (top bar) — supporting, fades in after the hero
 * ------------------------------------------------------------------ */
.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.8rem clamp(1rem, 4vw, 2.5rem);
  background: rgba(251, 250, 247, 0.75);   /* slightly more translucent */
  -webkit-backdrop-filter: blur(10px) saturate(1.1);
  backdrop-filter: blur(10px) saturate(1.1);
  border-bottom: 1px solid var(--grey-line);
  opacity: 0;
  transform: translateY(-100%);
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
  pointer-events: none;
}
.topbar.is-stuck { opacity: 1; transform: none; pointer-events: auto; }

/* Bird logomark — replaces the "Flora & Hanny" wordmark in the top bar. The
   <use> reference inherits fill from currentColor, so the mark picks up the
   link's text colour (ink, sage-deep on hover). */
.topbar__brand {
  display: inline-flex;
  align-items: center;
  color: var(--ink);
  text-decoration: none;
  transition: color 0.3s var(--ease);
}
.topbar__brand:hover { color: var(--sage-deep); }
.topbar__brand-mark {
  display: block;
  width: auto;
  height: clamp(1.65rem, 1.5rem + 0.4vw, 2rem);
  fill: currentColor;
}
.topbar__nav { display: flex; align-items: center; gap: clamp(0.9rem, 2.5vw, 1.75rem); }
.topbar__link {
  position: relative;
  font-family: var(--sans);
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--grey);
  text-decoration: none;
  padding: 0.5rem 0;            /* larger tap target without growing the text */
  transition: color 0.3s var(--ease);
}
/* hover + current-section underline (sage hairline), in the page's decor language */
.topbar__link:not(.topbar__link--cta)::after {
  content: "";
  position: absolute; left: 0; right: 0; bottom: 0.32rem; height: 1px;
  background: var(--sage);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.3s var(--ease);
}
.topbar__link:hover { color: var(--ink); }
.topbar__link:hover::after,
.topbar__link.is-here::after { transform: scaleX(1); }
.topbar__link.is-here { color: var(--ink); }
.topbar__link--cta {
  color: var(--ink);
  border: 1px solid var(--grey-line);
  padding: 0.6rem 1.1rem;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease), background 0.3s var(--ease);
}
.topbar__link--cta:hover,
.topbar__link--cta.is-here {
  border-color: var(--sage);
  color: var(--sage-deep);
  background: rgba(138, 154, 123, 0.07);
}
/* Attention — solid black while the floating RSVP card is fully off-screen (JS
   toggles .is-attention). Catches the eye once the form has scrolled away. */
.topbar__link--cta.is-attention {
  color: var(--paper);
  background: var(--ink);
  border-color: var(--ink);
}
.topbar__link--cta.is-attention:hover {
  color: var(--paper);
  background: var(--sage-deep);
  border-color: var(--sage-deep);
}
/* Below 820px the floating reserve bar replaces the top bar entirely (see 12c). */

/* Demoted functional sub-line under a narrative headline */
.section__sub {
  font-family: var(--sans);
  font-size: 0.95rem;
  letter-spacing: 0.01em;
  color: var(--grey);
  margin: 0;
}

/* ------------------------------------------------------------------ *
   12c. Floating RSVP — reserve bar + bottom sheet (mobile / tablet)
        Progressive enhancement (JS toggles .is-visible / .is-open). At
        <=820px this replaces the top bar; above it, neither floating piece
        shows. Without JS, nothing here appears and the inline card is used.
 * ------------------------------------------------------------------ */

/* The top bar and the floating reserve bar are mutually exclusive. */
@media (max-width: 820px) { .topbar { display: none; } }

/* ---- Reserve bar ---- */
.rsvp-bar { display: none; }            /* desktop + no-JS: never shown */

@media (max-width: 820px) {
  .js .rsvp-bar {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    z-index: 50;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.7rem clamp(1rem, 4vw, 1.5rem);
    padding-bottom: calc(0.7rem + env(safe-area-inset-bottom, 0px));
    background: rgba(251, 250, 247, 0.8);    /* slightly more translucent */
    -webkit-backdrop-filter: blur(10px) saturate(1.1);
    backdrop-filter: blur(10px) saturate(1.1);
    border-top: 1px solid var(--grey-line);
    opacity: 0;
    visibility: hidden;                  /* keeps controls out of the tab order while hidden */
    transform: translateY(100%);
    transition:
      opacity 0.4s var(--ease),
      transform 0.4s var(--ease),
      visibility 0.4s var(--ease);
  }
  .js .rsvp-bar.is-visible { opacity: 1; visibility: visible; transform: none; }
}

.rsvp-bar__info {
  display: flex; flex-direction: column; gap: 0.1rem;
  min-width: 0;                          /* truncate the text instead of shoving the CTA */
  padding: 0.35rem 0;
  text-decoration: none;
  color: var(--ink);
}
.rsvp-bar__date {
  font-family: var(--serif); font-weight: 400;
  font-size: 1.02rem; line-height: 1.15; letter-spacing: 0.01em;
  white-space: nowrap;
}
.rsvp-bar__where {
  font-family: var(--sans); font-size: 0.72rem; letter-spacing: 0.03em;
  color: var(--grey);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  transition: color 0.3s var(--ease);
}
.rsvp-bar__info:hover .rsvp-bar__where,
.rsvp-bar__info:focus-visible .rsvp-bar__where { color: var(--sage-deep); }
@media (max-width: 360px) { .rsvp-bar__where { display: none; } }

.rsvp-bar__cta {
  flex: none;
  font-family: var(--sans); font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--paper); background: var(--ink);
  border: 1px solid var(--ink); border-radius: 0;
  padding: 0.85rem 1.8rem;
  cursor: pointer;
  transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
}
.rsvp-bar__cta:hover { background: var(--sage-deep); border-color: var(--sage-deep); }

/* ---- Bottom sheet ---- */
.rsvp-sheet[hidden] { display: none; }
.rsvp-sheet {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: flex-end;  /* panel rests at the bottom */
}
.rsvp-sheet__scrim {
  position: absolute; inset: 0;
  background: rgba(20, 20, 18, 0.46);
  opacity: 0;
  transition: opacity 0.35s var(--ease);
}
.rsvp-sheet.is-open .rsvp-sheet__scrim { opacity: 1; }

.rsvp-sheet__panel {
  position: relative;
  width: 100%;
  max-height: 92dvh;                      /* size to the form; grow toward full-height only if it must scroll */
  overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  background: var(--paper);
  border-top: 1px solid var(--grey-line);
  padding: 0.75rem clamp(1.25rem, 6vw, 2rem)
           calc(1.5rem + env(safe-area-inset-bottom, 0px));
  transform: translateY(100%);           /* square top corners — faithful to the system */
  transition: transform 0.4s var(--ease);
}
.rsvp-sheet.is-open .rsvp-sheet__panel { transform: none; }

/* The grabber is a real affordance: drag it down (or tap it) to dismiss the
   sheet. The visible pill is a ::before so the <span> itself can be a larger,
   comfortable touch target. ✕ / Esc / scrim remain the keyboard + AT close path. */
.rsvp-sheet__handle {
  display: block;
  width: fit-content;                    /* only the grabber zone is interactive, not the whole top strip */
  margin: 0 auto 0.75rem;
  padding: 1rem 2.25rem;                 /* generous grab / tap-to-close target around the pill */
  background: none;
  border: 0;
  cursor: grab;
  touch-action: none;                    /* the handle owns the vertical gesture (drag-to-dismiss) */
  -webkit-tap-highlight-color: transparent;
}
.rsvp-sheet__handle::before {
  content: "";
  display: block;
  width: 2.25rem; height: 4px;
  margin-inline: auto;
  background: var(--grey-line);
  border-radius: 2px;                    /* the one soft affordance */
  transition: background-color 0.25s var(--ease), width 0.25s var(--ease);
}
.rsvp-sheet__handle:hover::before { background: var(--grey); width: 2.6rem; }
.rsvp-sheet__handle:active { cursor: grabbing; }
.rsvp-sheet.is-dragging .rsvp-sheet__handle { cursor: grabbing; }
.rsvp-sheet.is-dragging .rsvp-sheet__handle::before { background: var(--grey); }
/* While dragging, panel + scrim track the finger 1:1 (no transition lag). */
.rsvp-sheet.is-dragging .rsvp-sheet__panel,
.rsvp-sheet.is-dragging .rsvp-sheet__scrim { transition: none; }

/* No more sheet-head wrapper — the form's own H2 ("Will you join us?") is the
   sheet's title now, and goes away in the thank-you state. The close button
   floats over the panel's top-right corner so it stays reachable in either
   state without owning a separate header row. */
.rsvp-sheet__close {
  position: absolute; top: 0.4rem; right: clamp(0.75rem, 3vw, 1.25rem);
  z-index: 2;
  width: 2.75rem; height: 2.75rem;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--serif); font-size: 1.7rem; line-height: 1;
  color: var(--grey); background: none; border: 0; cursor: pointer;
  transition: color 0.3s var(--ease);
}
.rsvp-sheet__close:hover { color: var(--ink); }

/* The form keeps its own styling once moved in; just neutralise wrapper margin. */
.rsvp-sheet__body > .rsvp__interactive { margin: 0; }

/* ------------------------------------------------------------------ *
   13. Motion — reveals (blur-up + fade-up)
 * ------------------------------------------------------------------ */
/* Reveals are an ENHANCEMENT: hidden only when JS is present (.js on <html>).
   Without JS the content renders fully visible. Prose gets a soft blur-up;
   dense/functional blocks get a crisp fade+rise (.reveal--plain, no blur). */
/* No static will-change here: promoting every reveal to its own GPU layer up
   front (there are ~17) wastes memory and can slow scrolling. app.js adds the
   hint just before each element reveals and drops it on transitionend. */
.js .reveal,
.js .reveal--plain { opacity: 0; }
.js .reveal {
  transform: translateY(22px);
  filter: blur(6px);
  transition:
    opacity var(--slow) var(--ease),
    transform var(--slow) var(--ease),
    filter var(--slow) var(--ease);
  transition-delay: var(--reveal-delay, 0ms);
}
.js .reveal--plain {
  transform: translateY(12px);
  transition:
    opacity 0.7s var(--ease),
    transform 0.7s var(--ease);
  transition-delay: var(--reveal-delay, 0ms);
}
.reveal.is-in,
.reveal--plain.is-in {
  opacity: 1;
  transform: none;
  filter: none;
  /* Settled = transform:none and (via app.js) will-change cleared, so .beat is
     no longer a stacking context — the timeline year chip's z-index reliably
     sits above the rail (not a 1px coincidence). */
}

/* Hero title settle on load — the two names arrive, the ampersand joins last */
.js .hero__title [data-settle] {
  opacity: 0;
  filter: blur(10px);
  transform: translateY(0.3em);
  transition:
    opacity 1s var(--ease),
    filter 1s var(--ease),
    transform 1s var(--ease);
}
.is-loaded .hero__title [data-settle] {
  opacity: 1;
  filter: blur(0);
  transform: none;
}
.is-loaded .hero__name:nth-child(1) { transition-delay: 0.15s; }
.is-loaded .hero__name:nth-child(3) { transition-delay: 0.30s; }
.is-loaded .hero__amp                { transition-delay: 0.46s; }

.js .hero__eyebrow, .js .hero__meta {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 1s var(--ease) 0.7s, transform 1s var(--ease) 0.7s;
}
.is-loaded .hero__eyebrow,
.is-loaded .hero__meta { opacity: 1; transform: none; }
.is-loaded .hero__meta { transition-delay: 0.96s; }

/* ------------------------------------------------------------------ *
   14. Reduced motion
 * ------------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .reveal, .reveal--plain,
  .hero__title [data-settle],
  .hero__eyebrow, .hero__meta {
    opacity: 1 !important; transform: none !important; filter: none !important;
  }
  .hero__img { transform: scale(1.02) !important; }
  .frame:hover img { transform: none; }
  .topbar { transition: none; }
}
